one-code-extension 0.1.4 → 0.3.0

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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Third-party search backends for `web_search` (pure — no pi imports).
3
+ *
4
+ * `pi-web-search` only speaks the model provider's *own* search API, so on any
5
+ * provider without one (every `openai-completions` model: OpenRouter, Z.ai,
6
+ * Copilot chat, Ollama, DeepSeek direct) `web_search` used to fail outright.
7
+ * This module is the fallback chain that runs there: Brave and Tavily when the
8
+ * user has a key, else Exa's keyless hosted MCP endpoint, which is rate-limited
9
+ * and best-effort and is therefore always labelled as such in the result (and,
10
+ * once per session, to the user — index.ts). Findings §12 has the survey that
11
+ * picked these three and the live probes behind the "keyless" claim.
12
+ *
13
+ * Every backend takes `fetch` as a parameter so the chain is unit-testable
14
+ * without the network; nothing here reads the environment or the file system
15
+ * directly — `resolveChain` is handed `env` and the parsed settings.
16
+ */
17
+
18
+ export interface SearchResult {
19
+ title: string;
20
+ url: string;
21
+ snippet: string;
22
+ }
23
+
24
+ export interface DomainFilters {
25
+ allowed?: string[];
26
+ blocked?: string[];
27
+ }
28
+
29
+ export type BackendName = "brave" | "tavily" | "exa-free";
30
+
31
+ export interface SearchBackend {
32
+ readonly name: BackendName;
33
+ readonly label: string;
34
+ /** True for the keyless route — the result carries the free-tier warning. */
35
+ readonly keyless: boolean;
36
+ search(
37
+ query: string,
38
+ filters: DomainFilters,
39
+ maxResults: number,
40
+ signal: AbortSignal | undefined,
41
+ ): Promise<SearchResult[]>;
42
+ }
43
+
44
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
45
+
46
+ /** `webSearch` block of `~/.onecode/settings.json` (One Code's own key). */
47
+ export interface WebSearchSettings {
48
+ /** Backend order, e.g. `["tavily", "brave", "exa-free"]`. Unknown names are ignored. */
49
+ order?: BackendName[];
50
+ apiKeys?: { brave?: string; tavily?: string };
51
+ }
52
+
53
+ export const BRAVE_KEY_ENV = "BRAVE_SEARCH_API_KEY";
54
+ export const TAVILY_KEY_ENV = "TAVILY_API_KEY";
55
+ export const DEFAULT_MAX_RESULTS = 10;
56
+ export const BACKEND_TIMEOUT_MS = 30_000;
57
+
58
+ const BRAVE_URL = "https://api.search.brave.com/res/v1/web/search";
59
+ const TAVILY_URL = "https://api.tavily.com/search";
60
+ export const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
61
+ const BACKEND_NAMES: readonly BackendName[] = ["brave", "tavily", "exa-free"];
62
+
63
+ /** The one-line settings snippet both warnings point at: the fix, spelled out. */
64
+ export const SETTINGS_SNIPPET = '{ "webSearch": { "apiKeys": { "brave": "<key>" } } }';
65
+ export const SETTINGS_FILE = "~/.onecode/settings.json";
66
+ /** The advice both warnings share: where a key goes and that the keyed backends have free tiers. */
67
+ const KEYLESS_ADVICE =
68
+ `add ${SETTINGS_SNIPPET} (or "tavily") to ${SETTINGS_FILE}, or set ${BRAVE_KEY_ENV} / ${TAVILY_KEY_ENV} in the environment. ` +
69
+ "Brave and Tavily both have free tiers.";
70
+
71
+ /**
72
+ * Model-facing warning attached to every keyless result: the model should know
73
+ * these results are best-effort and what the user can configure instead.
74
+ */
75
+ export const KEYLESS_NOTE =
76
+ "Note: these results came from Exa's free keyless endpoint (rate-limited, best-effort) because no search API key is configured. " +
77
+ `For reliable search the user can ${KEYLESS_ADVICE}`;
78
+
79
+ /** User-facing one-time notice (TUI) the first time a session searches keyless. */
80
+ export const KEYLESS_USER_NOTICE =
81
+ "web_search used Exa's free keyless endpoint (rate-limited, best-effort): this provider has no native web search and no search API key is configured. " +
82
+ `For reliable results ${KEYLESS_ADVICE}`;
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Settings + chain resolution
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /** Parse the `webSearch` value of a settings file leniently: bad shapes become an empty setting, never a throw. */
89
+ export function webSearchSettingsFrom(raw: unknown): WebSearchSettings {
90
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
91
+ const value = raw as Record<string, unknown>;
92
+ const settings: WebSearchSettings = {};
93
+ if (Array.isArray(value.order)) {
94
+ const order = value.order.filter((n): n is BackendName => typeof n === "string" && (BACKEND_NAMES as string[]).includes(n));
95
+ if (order.length) settings.order = order;
96
+ }
97
+ if (value.apiKeys && typeof value.apiKeys === "object" && !Array.isArray(value.apiKeys)) {
98
+ const keys = value.apiKeys as Record<string, unknown>;
99
+ const apiKeys: WebSearchSettings["apiKeys"] = {};
100
+ if (typeof keys.brave === "string" && keys.brave.trim()) apiKeys.brave = keys.brave.trim();
101
+ if (typeof keys.tavily === "string" && keys.tavily.trim()) apiKeys.tavily = keys.tavily.trim();
102
+ if (Object.keys(apiKeys).length) settings.apiKeys = apiKeys;
103
+ }
104
+ return settings;
105
+ }
106
+
107
+ /**
108
+ * The backends that can run right now, in order. Env vars win over settings
109
+ * keys (the ecosystem-wide convention). Default order: keyed backends first
110
+ * (Brave, then Tavily), keyless Exa last — a configured key means the user
111
+ * chose reliability, so the free route is only ever the last resort. A settings
112
+ * `order` reorders the *available* backends; a keyed backend named there
113
+ * without a key is skipped, and anything not named keeps its default place
114
+ * after the named ones.
115
+ */
116
+ export function resolveChain(
117
+ env: Record<string, string | undefined>,
118
+ settings: WebSearchSettings,
119
+ fetchImpl: FetchLike = globalThis.fetch,
120
+ ): SearchBackend[] {
121
+ const braveKey = env[BRAVE_KEY_ENV]?.trim() || settings.apiKeys?.brave;
122
+ const tavilyKey = env[TAVILY_KEY_ENV]?.trim() || settings.apiKeys?.tavily;
123
+ const available = new Map<BackendName, SearchBackend>();
124
+ if (braveKey) available.set("brave", braveBackend(braveKey, fetchImpl));
125
+ if (tavilyKey) available.set("tavily", tavilyBackend(tavilyKey, fetchImpl));
126
+ available.set("exa-free", exaFreeBackend(fetchImpl));
127
+
128
+ const order = [...(settings.order ?? []), ...BACKEND_NAMES];
129
+ const chain: SearchBackend[] = [];
130
+ for (const name of order) {
131
+ const backend = available.get(name);
132
+ if (backend && !chain.includes(backend)) chain.push(backend);
133
+ }
134
+ return chain;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Domain filters (Claude Code's allowed_domains / blocked_domains)
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function hostOf(url: string): string | undefined {
142
+ try {
143
+ return new URL(url).hostname.toLowerCase();
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ }
148
+
149
+ function normalizeDomain(domain: string): string {
150
+ return domain
151
+ .trim()
152
+ .toLowerCase()
153
+ .replace(/^https?:\/\//, "")
154
+ .replace(/^www\./, "")
155
+ .replace(/\/.*$/, "");
156
+ }
157
+
158
+ function hostMatches(host: string, domain: string): boolean {
159
+ const bare = host.replace(/^www\./, "");
160
+ return bare === domain || bare.endsWith(`.${domain}`);
161
+ }
162
+
163
+ function normalizedDomains(list: string[] | undefined): string[] {
164
+ return (list ?? []).map(normalizeDomain).filter(Boolean);
165
+ }
166
+
167
+ /** Keep results whose host is under an allowed domain (when any are given) and under no blocked domain. */
168
+ export function filterByDomains(results: SearchResult[], filters: DomainFilters): SearchResult[] {
169
+ const allowed = normalizedDomains(filters.allowed);
170
+ const blocked = normalizedDomains(filters.blocked);
171
+ if (!allowed.length && !blocked.length) return results;
172
+ return results.filter((result) => {
173
+ const host = hostOf(result.url);
174
+ if (!host) return false;
175
+ if (blocked.some((d) => hostMatches(host, d))) return false;
176
+ return !allowed.length || allowed.some((d) => hostMatches(host, d));
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Express the filters as `site:` operators for engines that take them in the
182
+ * query (Brave, and the provider-native path, which has no filter parameters
183
+ * of its own). Post-filtering still applies afterwards — the operators only
184
+ * make the engine return relevant pages instead of pages we then drop.
185
+ */
186
+ export function withSiteOperators(query: string, filters: DomainFilters): string {
187
+ const allowed = normalizedDomains(filters.allowed);
188
+ const blocked = normalizedDomains(filters.blocked);
189
+ const parts = [query.trim()];
190
+ if (allowed.length === 1) parts.push(`site:${allowed[0]}`);
191
+ else if (allowed.length > 1) parts.push(`(${allowed.map((d) => `site:${d}`).join(" OR ")})`);
192
+ for (const domain of blocked) parts.push(`-site:${domain}`);
193
+ return parts.join(" ");
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Backends
198
+ // ---------------------------------------------------------------------------
199
+
200
+ function timeoutSignal(signal: AbortSignal | undefined): AbortSignal {
201
+ const timeout = AbortSignal.timeout(BACKEND_TIMEOUT_MS);
202
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
203
+ }
204
+
205
+ /** The error body, whole: it lands in the tool result, which the caller persists when large (never a bare slice). */
206
+ async function errorBody(response: Response): Promise<string> {
207
+ try {
208
+ const text = (await response.text()).trim();
209
+ return text ? `: ${text}` : "";
210
+ } catch {
211
+ return "";
212
+ }
213
+ }
214
+
215
+ /** Throw a labelled error for a failed keyed-API response; 401/403 name the env var to check. */
216
+ async function assertOk(response: Response, apiName: string, keyEnv: string): Promise<void> {
217
+ if (response.ok) return;
218
+ const hint = response.status === 401 || response.status === 403 ? ` (check ${keyEnv})` : "";
219
+ throw new Error(`${apiName} HTTP ${response.status}${hint}${await errorBody(response)}`);
220
+ }
221
+
222
+ /** Map a provider's rows onto SearchResult, reading the snippet from `snippetKey`; rows without a url are dropped. */
223
+ function toResults(rows: unknown, snippetKey: string): SearchResult[] {
224
+ if (!Array.isArray(rows)) return [];
225
+ return (rows as Array<Record<string, unknown>>)
226
+ .map((r) => ({
227
+ title: typeof r.title === "string" ? r.title : "",
228
+ url: typeof r.url === "string" ? r.url : "",
229
+ snippet: typeof r[snippetKey] === "string" ? (r[snippetKey] as string) : "",
230
+ }))
231
+ .filter((r) => r.url);
232
+ }
233
+
234
+ /** Brave Search API — `X-Subscription-Token`; `web.results[]` of title/url/description. */
235
+ export function parseBrave(body: unknown): SearchResult[] {
236
+ return toResults((body as { web?: { results?: unknown } } | undefined)?.web?.results, "description");
237
+ }
238
+
239
+ export function braveBackend(apiKey: string, fetchImpl: FetchLike): SearchBackend {
240
+ return {
241
+ name: "brave",
242
+ label: "Brave Search",
243
+ keyless: false,
244
+ async search(query, filters, maxResults, signal) {
245
+ const url = new URL(BRAVE_URL);
246
+ url.searchParams.set("q", withSiteOperators(query, filters));
247
+ url.searchParams.set("count", String(Math.min(maxResults, 20)));
248
+ const response = await fetchImpl(url.toString(), {
249
+ headers: { accept: "application/json", "x-subscription-token": apiKey },
250
+ signal: timeoutSignal(signal),
251
+ });
252
+ await assertOk(response, "Brave Search API", BRAVE_KEY_ENV);
253
+ return filterByDomains(parseBrave(await response.json()), filters);
254
+ },
255
+ };
256
+ }
257
+
258
+ /** Tavily — Bearer key; `results[]` of title/url/content, native include/exclude domains. */
259
+ export function parseTavily(body: unknown): SearchResult[] {
260
+ return toResults((body as { results?: unknown } | undefined)?.results, "content");
261
+ }
262
+
263
+ export function tavilyBackend(apiKey: string, fetchImpl: FetchLike): SearchBackend {
264
+ return {
265
+ name: "tavily",
266
+ label: "Tavily",
267
+ keyless: false,
268
+ async search(query, filters, maxResults, signal) {
269
+ const body: Record<string, unknown> = { query, max_results: Math.min(maxResults, 20) };
270
+ if (filters.allowed?.length) body.include_domains = normalizedDomains(filters.allowed);
271
+ if (filters.blocked?.length) body.exclude_domains = normalizedDomains(filters.blocked);
272
+ const response = await fetchImpl(TAVILY_URL, {
273
+ method: "POST",
274
+ headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${apiKey}` },
275
+ body: JSON.stringify(body),
276
+ signal: timeoutSignal(signal),
277
+ });
278
+ await assertOk(response, "Tavily API", TAVILY_KEY_ENV);
279
+ return filterByDomains(parseTavily(await response.json()), filters);
280
+ },
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Exa's hosted MCP server, keyless. One JSON-RPC `tools/call` of `web_search_exa`
286
+ * (no `initialize` handshake needed — verified live 2026-09-09); the reply is
287
+ * either JSON or a one-message SSE stream, and the tool's text is a list of
288
+ * blocks separated by `---`:
289
+ *
290
+ * Title: …\nURL: …\nPublished: …\nAuthor: …\nHighlights:\n…
291
+ */
292
+ export function parseExaMcpText(text: string): SearchResult[] {
293
+ const results: SearchResult[] = [];
294
+ for (const block of text.split(/\n\s*---\s*\n/)) {
295
+ const title = block.match(/^Title:\s*(.*)$/m)?.[1]?.trim() ?? "";
296
+ const url = block.match(/^URL:\s*(\S+)/m)?.[1]?.trim() ?? "";
297
+ if (!url) continue;
298
+ const highlights = block.split(/^Highlights:\s*$/m)[1] ?? "";
299
+ const snippet = highlights
300
+ .split("\n")
301
+ .map((line) => line.trim())
302
+ .filter((line) => line && line !== "...")
303
+ .join(" ")
304
+ .replace(/\s+/g, " ")
305
+ .trim();
306
+ results.push({ title, url, snippet });
307
+ }
308
+ return results;
309
+ }
310
+
311
+ /** The JSON-RPC message out of an MCP response body (plain JSON, or the last `data:` line of SSE). */
312
+ export function parseMcpResponseBody(body: string, contentType: string): unknown {
313
+ const trimmed = body.trim();
314
+ if (!contentType.includes("text/event-stream")) return JSON.parse(trimmed);
315
+ const dataLines = trimmed
316
+ .split("\n")
317
+ .filter((line) => line.startsWith("data:"))
318
+ .map((line) => line.slice(5).trim())
319
+ .filter(Boolean);
320
+ if (!dataLines.length) throw new Error("Exa MCP returned an event stream with no data");
321
+ return JSON.parse(dataLines[dataLines.length - 1]);
322
+ }
323
+
324
+ export function exaFreeBackend(fetchImpl: FetchLike): SearchBackend {
325
+ let nextId = 1;
326
+ return {
327
+ name: "exa-free",
328
+ label: "Exa (free keyless endpoint)",
329
+ keyless: true,
330
+ async search(query, filters, maxResults, signal) {
331
+ const response = await fetchImpl(EXA_MCP_URL, {
332
+ method: "POST",
333
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
334
+ body: JSON.stringify({
335
+ jsonrpc: "2.0",
336
+ id: nextId++,
337
+ method: "tools/call",
338
+ params: { name: "web_search_exa", arguments: { query, numResults: Math.min(maxResults, 20) } },
339
+ }),
340
+ signal: timeoutSignal(signal),
341
+ });
342
+ if (response.status === 429) {
343
+ throw new Error("Exa free endpoint rate-limited the request (HTTP 429); retry shortly or configure a keyed backend");
344
+ }
345
+ if (!response.ok) throw new Error(`Exa MCP HTTP ${response.status}${await errorBody(response)}`);
346
+ const message = parseMcpResponseBody(await response.text(), response.headers.get("content-type") ?? "") as {
347
+ error?: { message?: string };
348
+ result?: { isError?: boolean; content?: Array<{ type: string; text?: string }> };
349
+ };
350
+ if (message.error) throw new Error(`Exa MCP error: ${message.error.message ?? JSON.stringify(message.error)}`);
351
+ const text = (message.result?.content ?? [])
352
+ .filter((c) => c.type === "text" && typeof c.text === "string")
353
+ .map((c) => c.text as string)
354
+ .join("\n");
355
+ if (message.result?.isError) throw new Error(`Exa MCP tool error: ${text || "(no detail)"}`);
356
+ return filterByDomains(parseExaMcpText(text), filters);
357
+ },
358
+ };
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Chain execution + result text
363
+ // ---------------------------------------------------------------------------
364
+
365
+ export interface ChainOutcome {
366
+ backend: SearchBackend;
367
+ results: SearchResult[];
368
+ /** `label: error` for every backend tried before this one. */
369
+ failures: string[];
370
+ }
371
+
372
+ /**
373
+ * Try each backend in order until one returns; a backend that throws is
374
+ * recorded and the next one runs. A cancellation stops the whole chain. When
375
+ * every backend fails the error names each attempt so the fix is visible.
376
+ */
377
+ export async function runChain(
378
+ chain: SearchBackend[],
379
+ query: string,
380
+ filters: DomainFilters,
381
+ maxResults: number,
382
+ signal: AbortSignal | undefined,
383
+ ): Promise<ChainOutcome> {
384
+ const failures: string[] = [];
385
+ for (const backend of chain) {
386
+ if (signal?.aborted) throw new Error("Search was cancelled.");
387
+ try {
388
+ const results = await backend.search(query, filters, maxResults, signal);
389
+ return { backend, results, failures };
390
+ } catch (error) {
391
+ if (signal?.aborted) throw new Error("Search was cancelled.");
392
+ failures.push(`${backend.label}: ${(error as Error).message}`);
393
+ }
394
+ }
395
+ throw new Error(`Every search backend failed:\n- ${failures.join("\n- ")}`);
396
+ }
397
+
398
+ /**
399
+ * Claude Code's WebSearch returns "result blocks with titles and URLs"; this is
400
+ * that shape, with the backend named so the model can report its source and,
401
+ * on the keyless route, the configuration note.
402
+ */
403
+ export function formatSearchResults(query: string, outcome: ChainOutcome): string {
404
+ const lines: string[] = [];
405
+ lines.push(`Search results for "${query}" (via ${outcome.backend.label}):`);
406
+ if (outcome.failures.length) lines.push(`Fell back after: ${outcome.failures.join("; ")}`);
407
+ if (outcome.backend.keyless) lines.push(KEYLESS_NOTE);
408
+ lines.push("");
409
+ if (!outcome.results.length) {
410
+ lines.push("No results.");
411
+ } else {
412
+ outcome.results.forEach((result, index) => {
413
+ lines.push(`${index + 1}. ${result.title || result.url}`);
414
+ lines.push(` ${result.url}`);
415
+ if (result.snippet) lines.push(` ${result.snippet}`);
416
+ lines.push("");
417
+ });
418
+ lines.push('After answering from these results, end with a "Sources:" list of the URLs you used as markdown links.');
419
+ }
420
+ return lines.join("\n").trimEnd();
421
+ }
@@ -1,33 +1,168 @@
1
1
  /**
2
2
  * web extension — Claude Code's WebSearch role.
3
3
  *
4
- * `web_search` comes from the community `pi-web-search` package, which calls the
5
- * *current model provider's own* search API (OpenAI/Codex, Anthropic, Gemini) —
6
- * no third-party key, and as close to Claude Code's server-side search as an
7
- * extension can get. It also registers Gemini-only `url_context`.
4
+ * `web_search` is registered here with Claude Code's schema (`query`,
5
+ * `allowed_domains`, `blocked_domains`) and routes by what the session can do:
8
6
  *
9
- * WebFetch lives in `extensions/web-fetch` (our own); this file only owns search.
7
+ * 1. Provider-native search through the community `pi-web-search` package
8
+ * when the current model's provider has one (Anthropic, OpenAI/Codex,
9
+ * Gemini, xAI) — no third-party key, and as close to Claude Code's
10
+ * server-side search as an extension can get.
11
+ * 2. Otherwise the third-party chain in backends.ts: Brave, then Tavily when
12
+ * the user has a key (env var or `webSearch.apiKeys` in
13
+ * ~/.onecode/settings.json), and Exa's keyless hosted endpoint as the last
14
+ * resort — labelled in every result and announced once to the user, so a
15
+ * best-effort free route never passes for a configured one.
16
+ *
17
+ * pi-web-search's own registration still runs first: it wires the Gemini-only
18
+ * `url_context` tool and the active-tools sync that hides it on non-Gemini
19
+ * models. Its `web_search` is then overridden by ours — pi keeps one tool per
20
+ * name per extension (`extension.tools.set(name, …)`), so a second
21
+ * `registerTool` from the same extension replaces the vendor's entry.
10
22
  *
11
- * Note: pi-web-search drives `setActiveTools` to hide `url_context` on non-Gemini
12
- * models. That composes with our deferral only because `tool-search` loads first
13
- * and therefore deactivates deferred tools before this package snapshots the
14
- * active set. Keep that ordering.
23
+ * WebFetch lives in `extensions/web-fetch` (our own); this file only owns search.
15
24
  */
16
25
 
17
26
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
- import webSearch from "pi-web-search/src/index.ts";
27
+ import { homedir } from "node:os";
28
+ import { Type } from "typebox";
29
+ import webSearchPackage from "pi-web-search/src/index.ts";
30
+ import { getProviderKind } from "pi-web-search/src/api.ts";
31
+ import { webSearch as nativeWebSearch } from "pi-web-search/src/web_search.ts";
32
+ import { getWebSearchModel } from "pi-web-search/src/utils.ts";
33
+ import { readJsonFile } from "../lib/atomic-write.ts";
19
34
  import { DEFER_CHANNEL } from "../lib/deferred.ts";
35
+ import { oneCodeSettingsPath } from "../lib/one-code-settings.ts";
36
+ import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
37
+ import { ccToolRenderers } from "../lib/tui-render.ts";
38
+ import {
39
+ DEFAULT_MAX_RESULTS,
40
+ formatSearchResults,
41
+ KEYLESS_USER_NOTICE,
42
+ resolveChain,
43
+ runChain,
44
+ webSearchSettingsFrom,
45
+ withSiteOperators,
46
+ type WebSearchSettings,
47
+ } from "./backends.ts";
48
+
49
+ /** `webSearch` from ~/.onecode/settings.json, read leniently — a bad file skips the setting, never breaks the tool. */
50
+ function readWebSearchSettings(): WebSearchSettings {
51
+ return webSearchSettingsFrom(readJsonFile<{ webSearch?: unknown }>(oneCodeSettingsPath(homedir()))?.webSearch);
52
+ }
53
+
54
+ /** Declared up front: pi infers `details` from the first return it sees. */
55
+ interface SearchDetails {
56
+ query?: string;
57
+ backend?: string;
58
+ keyless?: boolean;
59
+ resultCount?: number;
60
+ failures?: string[];
61
+ native?: boolean;
62
+ error?: unknown;
63
+ }
20
64
 
21
65
  export default function webExtension(pi: ExtensionAPI) {
22
- webSearch(pi);
66
+ // url_context + its active-tools sync, and the vendor web_search we override below.
67
+ webSearchPackage(pi);
68
+
69
+ // Once per session: the user is told the first time a search goes keyless.
70
+ let keylessNoticeShown = false;
23
71
 
24
- // pi-web-search reports failures ("Failed: …" text, details.error set)
25
- // without isError, so a weak model can read a failure as a successful
26
- // search with odd content. Stamp isError here rather than patching the
27
- // vendor package.
72
+ pi.registerTool({
73
+ name: "web_search",
74
+ label: "Web Search",
75
+ ...ccToolRenderers<{ query?: string }>("Web Search", { title: (args) => args?.query }),
76
+ description:
77
+ "Search the web. Returns result blocks with titles and URLs.\n\n" +
78
+ "- `allowed_domains` / `blocked_domains` filter results (enforced on Brave/Tavily/Exa; with provider-native search they become `site:` operators in the query, best-effort).\n" +
79
+ '- After answering from results, end with a "Sources:" list of the URLs you used as markdown links.\n' +
80
+ "- Uses the model provider's own search when it has one; otherwise a configured search API (Brave, Tavily) or, with no key, a free rate-limited endpoint — the result names which.",
81
+ parameters: Type.Object({
82
+ query: Type.String({ minLength: 2, description: "The search query to use" }),
83
+ allowed_domains: Type.Optional(Type.Array(Type.String(), { description: "Only include search results from these domains" })),
84
+ blocked_domains: Type.Optional(Type.Array(Type.String(), { description: "Never include search results from these domains" })),
85
+ }),
86
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
87
+ const filters = { allowed: params.allowed_domains, blocked: params.blocked_domains };
88
+
89
+ // 1. Provider-native search (pi-web-search) when the current model — or a
90
+ // web-search.json model — supports it. Its result text is passed through;
91
+ // it reports failures as "Failed: …" text with details.error and no
92
+ // isError, which a weak model can read as a successful search with odd
93
+ // content, so isError is stamped here.
94
+ const nativeModel = await getWebSearchModel(ctx);
95
+ if (nativeModel) {
96
+ const result = await nativeWebSearch(
97
+ toolCallId,
98
+ { query: withSiteOperators(params.query, filters) },
99
+ signal ?? new AbortController().signal,
100
+ onUpdate,
101
+ ctx,
102
+ );
103
+ // pi-web-search exposes no domain parameters and returns a synthesized
104
+ // answer (nothing to post-filter), so the filters ride as `site:`
105
+ // operators only — say so, rather than let the model assume enforcement.
106
+ const hasFilters = Boolean(params.allowed_domains?.length || params.blocked_domains?.length);
107
+ const content =
108
+ hasFilters && !result.details?.error
109
+ ? [
110
+ ...result.content,
111
+ { type: "text" as const, text: "(Domain filters were applied as `site:` operators in the query — best-effort with provider-native search; verify the sources' hosts.)" },
112
+ ]
113
+ : result.content;
114
+ return {
115
+ ...result,
116
+ content,
117
+ details: { ...result.details, query: params.query, native: true, backend: `${nativeModel.provider}/${nativeModel.id}` } as SearchDetails,
118
+ isError: Boolean(result.isError) || Boolean(result.details?.error),
119
+ };
120
+ }
121
+
122
+ // 2. Third-party chain.
123
+ const chain = resolveChain(process.env, readWebSearchSettings());
124
+ onUpdate?.({
125
+ content: [{ type: "text", text: `Searching ${chain[0]?.label ?? "the web"} for "${params.query}"...` }],
126
+ details: { query: params.query } as SearchDetails,
127
+ });
128
+ try {
129
+ const outcome = await runChain(chain, params.query, filters, DEFAULT_MAX_RESULTS, signal);
130
+ if (outcome.backend.keyless && !keylessNoticeShown && ctx.hasUI) {
131
+ keylessNoticeShown = true;
132
+ ctx.ui.notify(KEYLESS_USER_NOTICE, "warning");
133
+ }
134
+ // Bounded by result count, but a provider's error body (quoted in
135
+ // "Fell back after") or long snippets can still be large: persist, never slice.
136
+ const text = persistIfLarge(formatSearchResults(params.query, outcome), { dir: sessionResultsDir(ctx), id: toolCallId });
137
+ return {
138
+ content: [{ type: "text", text }],
139
+ details: {
140
+ query: params.query,
141
+ backend: outcome.backend.name,
142
+ keyless: outcome.backend.keyless,
143
+ resultCount: outcome.results.length,
144
+ failures: outcome.failures.length ? outcome.failures : undefined,
145
+ } as SearchDetails,
146
+ };
147
+ } catch (error) {
148
+ const message = (error as Error).message;
149
+ const text = persistIfLarge(
150
+ `web_search failed on ${ctx.model?.provider}/${ctx.model?.id} (no native web search on this provider).\n${message}`,
151
+ { dir: sessionResultsDir(ctx), id: toolCallId },
152
+ );
153
+ return {
154
+ content: [{ type: "text", text }],
155
+ details: { query: params.query, error: message } as SearchDetails,
156
+ isError: true,
157
+ };
158
+ }
159
+ },
160
+ });
161
+
162
+ // url_context reports failures ("Failed: …" text, details.error set) without
163
+ // isError. Stamp it here rather than patching the vendor package.
28
164
  pi.on("tool_result", (event) => {
29
- if (event.toolName !== "web_search" && event.toolName !== "url_context") return;
30
- if (event.isError) return;
165
+ if (event.toolName !== "url_context" || event.isError) return;
31
166
  const details = event.details as { error?: unknown } | undefined;
32
167
  if (details?.error) return { isError: true };
33
168
  });
@@ -36,8 +171,30 @@ export default function webExtension(pi: ExtensionAPI) {
36
171
  name: "web_search",
37
172
  keywords: ["web", "search", "internet", "google", "news", "lookup", "online", "current"],
38
173
  });
39
- pi.events.emit(DEFER_CHANNEL, {
40
- name: "url_context",
41
- keywords: ["url", "analyze page", "gemini"],
42
- });
174
+
175
+ // url_context only works on a Gemini-compatible provider — pi-web-search's
176
+ // own gate (getProviderKind === "google") deactivates the tool on anything
177
+ // else, and its execute() there returns "url_context currently requires a
178
+ // Google Gemini-compatible model". Registering it as deferred unconditionally
179
+ // still advertised it in the names-only reminder (and as a defer_loading
180
+ // definition) on every provider, so a non-Gemini model would load it and burn
181
+ // two rounds discovering it cannot run (TOOL-FIDELITY-REVIEW-2026-09-07 M5).
182
+ // Mirror pi-web-search's gate: register it once the first Gemini model is
183
+ // seen. The one-shot flag keeps later session_tree/model_select events from
184
+ // re-emitting (each emit past session_start takes the DEFER handler's
185
+ // late-arrival path — deactivate + reschedule the listing rewrite — which is a
186
+ // no-op once url_context is already registered).
187
+ let urlContextDeferred = false;
188
+ const deferUrlContextIfGemini = (model: { provider?: string; api?: string } | undefined) => {
189
+ // ctx.model is undefined until a model resolves (session_start can fire
190
+ // first), and getProviderKind dereferences model.provider without a guard —
191
+ // so an undefined model must short-circuit here, as pi-web-search's own
192
+ // `!!model && getProviderKind(...)` does.
193
+ if (urlContextDeferred || !model || getProviderKind(model) !== "google") return;
194
+ urlContextDeferred = true;
195
+ pi.events.emit(DEFER_CHANNEL, { name: "url_context", keywords: ["url", "analyze page", "gemini"] });
196
+ };
197
+ pi.on("session_start", (_event, ctx) => deferUrlContextIfGemini(ctx.model));
198
+ pi.on("session_tree", (_event, ctx) => deferUrlContextIfGemini(ctx.model));
199
+ pi.on("model_select", (event) => deferUrlContextIfGemini(event.model));
43
200
  }