@oh-my-pi/pi-coding-agent 16.4.2 → 16.4.3

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 (122) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/cli.js +17514 -17799
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/commit/agentic/agent.d.ts +1 -0
  5. package/dist/types/config/settings-schema.d.ts +28 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +10 -22
  7. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  8. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  9. package/dist/types/modes/components/status-line/component.d.ts +3 -0
  10. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  11. package/dist/types/modes/interactive-mode.d.ts +8 -0
  12. package/dist/types/modes/types.d.ts +2 -0
  13. package/dist/types/session/agent-session.d.ts +16 -0
  14. package/dist/types/task/executor.d.ts +31 -0
  15. package/dist/types/tools/__tests__/vibe-render.test.d.ts +1 -0
  16. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  17. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  18. package/dist/types/tools/browser/run-output.d.ts +25 -0
  19. package/dist/types/tools/browser/tab-worker.d.ts +16 -0
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/vibe.d.ts +161 -0
  22. package/dist/types/utils/changelog.d.ts +35 -1
  23. package/dist/types/vibe/runtime.d.ts +124 -0
  24. package/dist/types/vibe/state.d.ts +4 -0
  25. package/dist/types/web/search/provider.d.ts +2 -0
  26. package/dist/types/web/search/providers/bing.d.ts +14 -0
  27. package/dist/types/web/search/providers/browser-headers.d.ts +9 -0
  28. package/dist/types/web/search/providers/browser-page.d.ts +32 -0
  29. package/dist/types/web/search/providers/ecosia.d.ts +14 -0
  30. package/dist/types/web/search/providers/google.d.ts +13 -0
  31. package/dist/types/web/search/providers/mojeek.d.ts +14 -0
  32. package/dist/types/web/search/providers/public.d.ts +37 -0
  33. package/dist/types/web/search/providers/startpage.d.ts +14 -0
  34. package/dist/types/web/search/providers/yahoo.d.ts +14 -0
  35. package/dist/types/web/search/types.d.ts +28 -0
  36. package/package.json +15 -18
  37. package/scripts/build-binary.ts +56 -73
  38. package/scripts/bundle-dist.ts +36 -40
  39. package/scripts/compile-binary.ts +68 -0
  40. package/scripts/generate-docs-index.ts +3 -93
  41. package/scripts/legacy-pi-virtual-module.ts +192 -0
  42. package/src/advisor/__tests__/advisor.test.ts +13 -0
  43. package/src/advisor/__tests__/config.test.ts +36 -0
  44. package/src/advisor/config.ts +11 -8
  45. package/src/commit/agentic/agent.ts +4 -0
  46. package/src/commit/agentic/index.ts +46 -21
  47. package/src/edit/index.ts +10 -76
  48. package/src/exec/non-interactive-env.ts +0 -1
  49. package/src/extensibility/plugins/legacy-pi-compat.ts +328 -162
  50. package/src/extensibility/plugins/legacy-pi-virtual-modules.d.ts +4 -0
  51. package/src/internal-urls/docs-index.ts +2 -1
  52. package/src/internal-urls/skill-protocol.ts +1 -1
  53. package/src/lsp/deferred-diagnostics.ts +66 -0
  54. package/src/main.ts +13 -13
  55. package/src/mcp/transports/stdio.test.ts +45 -1
  56. package/src/mcp/transports/stdio.ts +6 -3
  57. package/src/modes/acp/acp-agent.ts +65 -1
  58. package/src/modes/acp/acp-event-mapper.ts +5 -0
  59. package/src/modes/acp/acp-mode.ts +11 -0
  60. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  61. package/src/modes/components/advisor-config.ts +15 -7
  62. package/src/modes/components/move-overlay.ts +2 -3
  63. package/src/modes/components/status-line/component.ts +6 -0
  64. package/src/modes/components/status-line/segments.ts +6 -0
  65. package/src/modes/components/status-line/types.ts +3 -0
  66. package/src/modes/controllers/command-controller.ts +8 -10
  67. package/src/modes/interactive-mode.ts +117 -1
  68. package/src/modes/types.ts +2 -0
  69. package/src/prompts/system/eager-task.md +2 -2
  70. package/src/prompts/system/system-prompt.md +9 -2
  71. package/src/prompts/system/vibe-mode-active.md +23 -0
  72. package/src/prompts/tools/browser.md +3 -3
  73. package/src/prompts/tools/grep.md +1 -1
  74. package/src/prompts/tools/vibe-kill.md +3 -0
  75. package/src/prompts/tools/vibe-list.md +3 -0
  76. package/src/prompts/tools/vibe-send.md +9 -0
  77. package/src/prompts/tools/vibe-spawn.md +10 -0
  78. package/src/prompts/tools/vibe-turn-result.md +19 -0
  79. package/src/prompts/tools/vibe-wait.md +8 -0
  80. package/src/sdk.ts +5 -0
  81. package/src/session/agent-session.ts +103 -13
  82. package/src/session/snapcompact-inline.ts +3 -19
  83. package/src/slash-commands/builtin-registry.ts +24 -8
  84. package/src/task/agents.ts +0 -2
  85. package/src/task/executor.ts +105 -0
  86. package/src/tools/__tests__/vibe-render.test.ts +210 -0
  87. package/src/tools/bash-skill-urls.ts +1 -1
  88. package/src/tools/browser/cmux/cmux-tab.ts +46 -48
  89. package/src/tools/browser/cmux/rpc.ts +50 -0
  90. package/src/tools/browser/run-output.ts +76 -0
  91. package/src/tools/browser/tab-worker.ts +264 -129
  92. package/src/tools/glob.ts +20 -6
  93. package/src/tools/index.ts +1 -0
  94. package/src/tools/read.ts +17 -9
  95. package/src/tools/renderers.ts +6 -0
  96. package/src/tools/vibe.ts +608 -0
  97. package/src/tools/write.ts +15 -2
  98. package/src/utils/changelog.ts +106 -9
  99. package/src/utils/git.ts +0 -1
  100. package/src/utils/title-generator.ts +70 -7
  101. package/src/vibe/runtime.ts +710 -0
  102. package/src/vibe/state.ts +4 -0
  103. package/src/web/search/index.ts +14 -6
  104. package/src/web/search/provider.ts +37 -1
  105. package/src/web/search/providers/bing.ts +197 -0
  106. package/src/web/search/providers/browser-headers.ts +92 -0
  107. package/src/web/search/providers/browser-page.ts +123 -0
  108. package/src/web/search/providers/duckduckgo.ts +13 -34
  109. package/src/web/search/providers/ecosia.ts +178 -0
  110. package/src/web/search/providers/google.ts +193 -0
  111. package/src/web/search/providers/mojeek.ts +206 -0
  112. package/src/web/search/providers/public.ts +201 -0
  113. package/src/web/search/providers/startpage.ts +213 -0
  114. package/src/web/search/providers/yahoo.ts +179 -0
  115. package/src/web/search/types.ts +35 -0
  116. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +0 -10
  117. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +0 -10
  118. package/scripts/generate-legacy-pi-bundled-registry.ts +0 -420
  119. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +0 -1011
  120. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +0 -3430
  121. package/src/internal-urls/docs-index.generated.txt +0 -2
  122. package/src/prompts/agents/plan.md +0 -47
@@ -0,0 +1,4 @@
1
+ /** Vibe mode session-level state, mirroring {@link ../plan-mode/state.ts}. */
2
+ export interface VibeModeState {
3
+ enabled: boolean;
4
+ }
@@ -231,12 +231,20 @@ export async function runSearchQuery(
231
231
  params: SearchQueryParams,
232
232
  options: { authStorage?: AuthStorage; sessionId?: string; signal?: AbortSignal } = {},
233
233
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
234
- const authStorage = options.authStorage ?? (await discoverAuthStorage());
235
- return executeSearch("cli-web-search", params, {
236
- authStorage,
237
- sessionId: options.sessionId,
238
- signal: options.signal,
239
- });
234
+ const createdAuthStorage = options.authStorage ? undefined : await discoverAuthStorage();
235
+ const authStorage = options.authStorage ?? createdAuthStorage;
236
+ if (!authStorage) {
237
+ throw new Error("Failed to initialize authentication storage");
238
+ }
239
+ try {
240
+ return await executeSearch("cli-web-search", params, {
241
+ authStorage,
242
+ sessionId: options.sessionId,
243
+ signal: options.signal,
244
+ });
245
+ } finally {
246
+ createdAuthStorage?.close();
247
+ }
240
248
  }
241
249
 
242
250
  /**
@@ -114,6 +114,41 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
114
114
  label: SEARCH_PROVIDER_LABELS.duckduckgo,
115
115
  load: async () => new (await import("./providers/duckduckgo")).DuckDuckGoProvider(),
116
116
  },
117
+ google: {
118
+ id: "google",
119
+ label: SEARCH_PROVIDER_LABELS.google,
120
+ load: async () => new (await import("./providers/google")).GoogleProvider(),
121
+ },
122
+ bing: {
123
+ id: "bing",
124
+ label: SEARCH_PROVIDER_LABELS.bing,
125
+ load: async () => new (await import("./providers/bing")).BingProvider(),
126
+ },
127
+ yahoo: {
128
+ id: "yahoo",
129
+ label: SEARCH_PROVIDER_LABELS.yahoo,
130
+ load: async () => new (await import("./providers/yahoo")).YahooProvider(),
131
+ },
132
+ ecosia: {
133
+ id: "ecosia",
134
+ label: SEARCH_PROVIDER_LABELS.ecosia,
135
+ load: async () => new (await import("./providers/ecosia")).EcosiaProvider(),
136
+ },
137
+ startpage: {
138
+ id: "startpage",
139
+ label: SEARCH_PROVIDER_LABELS.startpage,
140
+ load: async () => new (await import("./providers/startpage")).StartpageProvider(),
141
+ },
142
+ mojeek: {
143
+ id: "mojeek",
144
+ label: SEARCH_PROVIDER_LABELS.mojeek,
145
+ load: async () => new (await import("./providers/mojeek")).MojeekProvider(),
146
+ },
147
+ public: {
148
+ id: "public",
149
+ label: SEARCH_PROVIDER_LABELS.public,
150
+ load: async () => new (await import("./providers/public")).PublicWebProvider(),
151
+ },
117
152
  };
118
153
 
119
154
  const instanceCache = new Map<SearchProviderId, SearchProvider>();
@@ -180,7 +215,8 @@ export function setExcludedSearchProviders(providers: readonly SearchProviderId[
180
215
  excludedProvIds = new Set(providers);
181
216
  }
182
217
 
183
- function isSearchProviderExcluded(id: SearchProviderId): boolean {
218
+ /** `true` when settings exclude `id` from web search (auto chain and the Public Web fan-out). */
219
+ export function isSearchProviderExcluded(id: SearchProviderId): boolean {
184
220
  return excludedProvIds.has(id);
185
221
  }
186
222
 
@@ -0,0 +1,197 @@
1
+ import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
+ import { parseHTML } from "linkedom";
3
+ import type { SearchResponse, SearchSource } from "../../../web/search/types";
4
+ import { SearchProviderError } from "../../../web/search/types";
5
+ import { clampNumResults } from "../utils";
6
+ import type { SearchParams } from "./base";
7
+ import { SearchProvider } from "./base";
8
+ import { browserFetch } from "./browser-page";
9
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
10
+
11
+ /**
12
+ * Bing's HTML search frontend. A plain GET with browser navigation headers
13
+ * returns a fully server-rendered results page — no JavaScript challenge on
14
+ * the organic path — so we parse it directly without a real browser.
15
+ */
16
+ const BING_HOME_URL = "https://www.bing.com/";
17
+ const BING_SEARCH_URL = "https://www.bing.com/search";
18
+ const DEFAULT_NUM_RESULTS = 10;
19
+ const MAX_NUM_RESULTS = 20;
20
+ const MS_PER_DAY = 86_400_000;
21
+
22
+ /**
23
+ * Recency → Bing `filters=ex1:"…"` freshness codes, as emitted by Bing's own
24
+ * "Any time" dropdown. `year` has no fixed code; the dropdown emits a custom
25
+ * epoch-day range (`ez5_<start>_<end>`, days since 1970-01-01) which
26
+ * {@link recencyToFilters} computes. Bing parses the parameter (the SERP
27
+ * filter UI reflects it) but enforcement is server-side and vantage-dependent.
28
+ */
29
+ const RECENCY_TO_BING_EZ: Record<Exclude<NonNullable<SearchParams["recency"]>, "year">, string> = {
30
+ day: "ez1",
31
+ week: "ez2",
32
+ month: "ez3",
33
+ };
34
+
35
+ /** Snippet containers observed on Bing result blocks, in preference order. */
36
+ const BING_SNIPPET_SELECTORS: readonly string[] = [".b_caption p", "p[class*='b_lineclamp']", ".b_algoSlug"];
37
+
38
+ interface ParsedResult {
39
+ title: string;
40
+ url: string;
41
+ snippet?: string;
42
+ }
43
+
44
+ /** Build the `filters` value for a recency window, mirroring Bing's dropdown URLs. */
45
+ function recencyToFilters(recency: NonNullable<SearchParams["recency"]>): string {
46
+ if (recency === "year") {
47
+ const epochDay = Math.floor(Date.now() / MS_PER_DAY);
48
+ return `ex1:"ez5_${epochDay - 365}_${epochDay}"`;
49
+ }
50
+ return `ex1:"${RECENCY_TO_BING_EZ[recency]}"`;
51
+ }
52
+
53
+ /**
54
+ * Resolve a Bing result href to the underlying target URL.
55
+ *
56
+ * Organic hrefs are usually wrapped as `https://www.bing.com/ck/a?…&u=a1<payload>`
57
+ * where the payload after the literal `a1` prefix is the unpadded base64url
58
+ * encoding of the target URL. Direct external hrefs also occur; Bing-internal
59
+ * links (vertical tabs, ads plumbing) and non-http(s) schemes are rejected.
60
+ */
61
+ function unwrapResultUrl(href: string): string | undefined {
62
+ let url: URL;
63
+ try {
64
+ url = new URL(href, BING_HOME_URL);
65
+ } catch {
66
+ return undefined;
67
+ }
68
+
69
+ if (url.hostname === "bing.com" || url.hostname.endsWith(".bing.com")) {
70
+ if (url.pathname !== "/ck/a") return undefined;
71
+ const wrapped = url.searchParams.get("u");
72
+ if (!wrapped?.startsWith("a1")) return undefined;
73
+ try {
74
+ url = new URL(Buffer.from(wrapped.slice(2), "base64url").toString("utf-8"));
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
81
+ return url.href;
82
+ }
83
+
84
+ function findSnippet(item: Element): string | undefined {
85
+ for (const selector of BING_SNIPPET_SELECTORS) {
86
+ const text = (item.querySelector(selector)?.textContent ?? "").replace(/\s+/g, " ").trim();
87
+ if (text) return text;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ /**
93
+ * Pull organic result blocks out of the page in document order.
94
+ *
95
+ * Each organic hit is an `<li class="b_algo">` with the title link in
96
+ * `h2 > a[href]` (sitelink/attribution anchors live outside the `h2`) and the
97
+ * preview text in one of {@link BING_SNIPPET_SELECTORS}. Ads, answer cards,
98
+ * and the "no results" row use other classes and fall out naturally.
99
+ */
100
+ function parseHtmlResults(html: string): ParsedResult[] {
101
+ const { document } = parseHTML(html);
102
+ const results: ParsedResult[] = [];
103
+ for (const item of document.querySelectorAll("li.b_algo")) {
104
+ const anchor = item.querySelector("h2 a[href]");
105
+ const href = anchor?.getAttribute("href");
106
+ if (!href) continue;
107
+ const url = unwrapResultUrl(href);
108
+ if (!url) continue;
109
+ const title = (anchor?.textContent ?? "").replace(/\s+/g, " ").trim();
110
+ if (!title) continue;
111
+ results.push({ title, url, snippet: findSnippet(item) });
112
+ }
113
+ return results;
114
+ }
115
+
116
+ /**
117
+ * `true` when Bing answered with its CAPTCHA/consent interstitial instead of
118
+ * a results page. The challenge redirects to `/turing/captcha/…`; body
119
+ * markers are only trusted when no organic result block is present so a
120
+ * search *about* CAPTCHAs never trips the detector.
121
+ */
122
+ function isChallengeResponse(html: string, finalUrl: string): boolean {
123
+ if (finalUrl.includes("/turing/captcha")) return true;
124
+ if (html.includes('class="b_algo"')) return false;
125
+ return /turing\/captcha|b_captcha|px-captcha|verify (?:that )?you are (?:a )?human/i.test(html);
126
+ }
127
+
128
+ function buildSearchUrl(params: SearchParams, numResults: number): string {
129
+ const url = new URL(BING_SEARCH_URL);
130
+ url.searchParams.set("q", params.query);
131
+ url.searchParams.set("count", String(numResults));
132
+ url.searchParams.set("mkt", "en-US");
133
+ url.searchParams.set("setlang", "en");
134
+ if (params.recency) url.searchParams.set("filters", recencyToFilters(params.recency));
135
+ return url.href;
136
+ }
137
+
138
+ async function callBingHtml(params: SearchParams, numResults: number): Promise<string> {
139
+ const url = buildSearchUrl(params, numResults);
140
+ const page = await browserFetch(url, {
141
+ fetch: params.fetch ?? fetch,
142
+ signal: withHardTimeout(params.signal),
143
+ referer: BING_HOME_URL,
144
+ });
145
+
146
+ const body = page.html;
147
+ if (isChallengeResponse(body, page.url)) {
148
+ throw new SearchProviderError(
149
+ "bing",
150
+ "Bing blocked the request with a CAPTCHA challenge. Bing throttles automated searches from datacenter/shared-egress IPs; try the duckduckgo or mojeek provider, or configure a credentialed provider such as Brave, Tavily, Exa, or Kagi.",
151
+ 429,
152
+ );
153
+ }
154
+ if (page.status < 200 || page.status >= 300) {
155
+ const classified = classifyProviderHttpError("bing", page.status, body);
156
+ if (classified) throw classified;
157
+ throw new SearchProviderError("bing", `Bing HTML error (${page.status})`, page.status);
158
+ }
159
+
160
+ return body;
161
+ }
162
+
163
+ /** Execute a Bing web search via the server-rendered HTML results page. */
164
+ export async function searchBing(params: SearchParams): Promise<SearchResponse> {
165
+ const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
166
+ const html = await callBingHtml(params, numResults);
167
+ const parsed = parseHtmlResults(html);
168
+
169
+ const sources: SearchSource[] = [];
170
+ const seen = new Set<string>();
171
+ for (const result of parsed) {
172
+ if (seen.has(result.url)) continue;
173
+ seen.add(result.url);
174
+ sources.push({ title: result.title, url: result.url, snippet: result.snippet });
175
+ if (sources.length >= numResults) break;
176
+ }
177
+
178
+ return { provider: "bing", sources };
179
+ }
180
+
181
+ /** Search provider for Bing (no API key required). */
182
+ export class BingProvider extends SearchProvider {
183
+ readonly id = "bing";
184
+ readonly label = "Bing";
185
+
186
+ isAvailable(_authStorage: AuthStorage): boolean {
187
+ return true;
188
+ }
189
+
190
+ isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
191
+ return true;
192
+ }
193
+
194
+ search(params: SearchParams): Promise<SearchResponse> {
195
+ return searchBing(params);
196
+ }
197
+ }
@@ -0,0 +1,92 @@
1
+ import { HeaderGenerator } from "header-generator";
2
+
3
+ // Instantiate the singleton header generator.
4
+ // This matches modern browsers from real-world query statistics
5
+ // and randomizes between Chrome, Firefox, Safari, and Edge.
6
+ const generator = new HeaderGenerator({
7
+ browserListQuery: "last 3 versions",
8
+ devices: ["desktop"],
9
+ operatingSystems: ["windows", "macos", "linux"],
10
+ locales: ["en-US", "en"],
11
+ httpVersion: "2",
12
+ strict: false,
13
+ });
14
+
15
+ // A fallback desktop Mac Chrome navigation fingerprint matching
16
+ // the previous static default setup for deterministic or non-randomized calls.
17
+ const CHROME_FALLBACK_HEADERS: Record<string, string> = {
18
+ Accept:
19
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
20
+ "Accept-Language": "en-US,en;q=0.9",
21
+ "Cache-Control": "max-age=0",
22
+ Priority: "u=0, i",
23
+ "Sec-Ch-Ua": '"Google Chrome";v="149", "Chromium";v="149", ";Not A Brand";v="99"',
24
+ "Sec-Ch-Ua-Mobile": "?0",
25
+ "Sec-Ch-Ua-Platform": '"macOS"',
26
+ "Sec-Fetch-Dest": "document",
27
+ "Sec-Fetch-Mode": "navigate",
28
+ "Sec-Fetch-Site": "none",
29
+ "Sec-Fetch-User": "?1",
30
+ "Upgrade-Insecure-Requests": "1",
31
+ "User-Agent":
32
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
33
+ };
34
+
35
+ function canonicalizeHeaderNames(headers: Record<string, string>): Record<string, string> {
36
+ const canonicalized: Record<string, string> = {};
37
+
38
+ for (const key in headers) {
39
+ const value = headers[key];
40
+ if (value === undefined) continue;
41
+
42
+ // Retain Client Hints (sec-ch-ua*) in their standard lower-case representation
43
+ if (key.startsWith("sec-ch-ua")) {
44
+ canonicalized[key] = value;
45
+ continue;
46
+ }
47
+
48
+ // Retain diagnostics or other HTTP/2 custom lower-case keys
49
+ if (["dnt", "rtt", "ect"].includes(key)) {
50
+ canonicalized[key.toUpperCase()] = value;
51
+ continue;
52
+ }
53
+
54
+ // Retain HTTP/2 specific pseudo headers if any, or general standard casing overrides
55
+ if (key === "te") {
56
+ canonicalized.TE = value;
57
+ continue;
58
+ }
59
+
60
+ // Pascalize words separated by hyphens (e.g. accept-language -> Accept-Language)
61
+ const pascalized = key
62
+ .split("-")
63
+ .map(part => (part[0] ? part[0].toUpperCase() + part.slice(1).toLowerCase() : ""))
64
+ .join("-");
65
+
66
+ canonicalized[pascalized] = value;
67
+ }
68
+
69
+ return canonicalized;
70
+ }
71
+
72
+ /**
73
+ * Build a fresh, internally consistent desktop navigation fingerprint for one HTTP request.
74
+ * By default, this randomizes across valid modern versions of Chrome, Firefox, Edge, and Safari
75
+ * using real-world traffic data. Set `randomized` to `false` when a fetch must preserve a
76
+ * stable Mac Chrome identity.
77
+ */
78
+ export function buildBrowserNavigationHeaders(options?: { randomized?: boolean }): Record<string, string> {
79
+ const randomized = options?.randomized !== false;
80
+ if (!randomized) {
81
+ return { ...CHROME_FALLBACK_HEADERS };
82
+ }
83
+
84
+ try {
85
+ // Generate realistic, consistent headers with the Bayesian generator
86
+ const generated = generator.getHeaders();
87
+ return canonicalizeHeaderNames(generated);
88
+ } catch {
89
+ // Gracefully recover to the robust default profile on unexpected generator errors
90
+ return { ...CHROME_FALLBACK_HEADERS };
91
+ }
92
+ }
@@ -0,0 +1,123 @@
1
+ import type { FetchImpl } from "@oh-my-pi/pi-ai";
2
+ import { untilAborted } from "@oh-my-pi/pi-utils";
3
+ import type { Page } from "puppeteer-core";
4
+ import { applyStealthPatches, applyViewport } from "../../../tools/browser/launch";
5
+ import { acquireBrowser, holdBrowser, releaseBrowser } from "../../../tools/browser/registry";
6
+ import { buildBrowserNavigationHeaders } from "./browser-headers";
7
+ import { SEARCH_HARD_TIMEOUT_MS } from "./utils";
8
+
9
+ /** HTML plus the response status and final URL after redirects or browser navigation. */
10
+ export interface LoadedHtmlPage {
11
+ html: string;
12
+ status: number;
13
+ url: string;
14
+ }
15
+
16
+ interface BrowserFallbackOptions {
17
+ homeUrl?: string;
18
+ ready?: { selector: string; timeoutMs: number };
19
+ afterNavigation?: (page: Page, signal: AbortSignal) => Promise<void>;
20
+ shouldFallback: (page: LoadedHtmlPage) => boolean;
21
+ attempts?: number;
22
+ retryDelayMs?: number;
23
+ }
24
+
25
+ /** Controls a browser-profiled fetch and its optional headless-browser fallback. */
26
+ export interface BrowserFetchOptions {
27
+ fetch?: FetchImpl;
28
+ signal: AbortSignal;
29
+ randomizeHeaders?: boolean;
30
+ referer?: string;
31
+ init?: Omit<RequestInit, "headers" | "signal">;
32
+ headers?: Readonly<Record<string, string>>;
33
+ browser?: BrowserFallbackOptions;
34
+ }
35
+
36
+ async function fetchHtmlPage(url: string, options: BrowserFetchOptions, fetchImpl: FetchImpl): Promise<LoadedHtmlPage> {
37
+ const response = await fetchImpl(url, {
38
+ ...options.init,
39
+ headers: {
40
+ ...buildBrowserNavigationHeaders({ randomized: options.randomizeHeaders }),
41
+ ...(options.referer ? { Referer: options.referer, "Sec-Fetch-Site": "same-origin" } : {}),
42
+ ...options.headers,
43
+ },
44
+ signal: options.signal,
45
+ });
46
+ return { html: await response.text(), status: response.status, url: response.url || url };
47
+ }
48
+
49
+ async function browseHtmlPage(
50
+ url: string,
51
+ options: BrowserFallbackOptions,
52
+ signal: AbortSignal,
53
+ ): Promise<LoadedHtmlPage> {
54
+ const { homeUrl, ready } = options;
55
+ const attempts = Math.max(1, options.attempts ?? 1);
56
+ const handle = await untilAborted(signal, () =>
57
+ acquireBrowser(
58
+ { kind: "headless", headless: true },
59
+ {
60
+ cwd: process.cwd(),
61
+ signal,
62
+ },
63
+ ),
64
+ );
65
+ if (!("browser" in handle)) {
66
+ await releaseBrowser(handle, { kill: false });
67
+ throw new Error("Headless browser acquisition returned a non-Puppeteer browser");
68
+ }
69
+
70
+ holdBrowser(handle);
71
+ let page: Page | undefined;
72
+ try {
73
+ const activePage = await untilAborted(signal, () => handle.browser.newPage());
74
+ page = activePage;
75
+ await applyViewport(activePage);
76
+ await applyStealthPatches(handle.browser, activePage, handle.stealth);
77
+ if (homeUrl) {
78
+ await untilAborted(signal, () =>
79
+ activePage.goto(homeUrl, { waitUntil: "domcontentloaded", timeout: SEARCH_HARD_TIMEOUT_MS }),
80
+ );
81
+ }
82
+ for (let attempt = 0; attempt < attempts; attempt++) {
83
+ if (attempt > 0 && options.retryDelayMs) await Bun.sleep(options.retryDelayMs);
84
+
85
+ const response = await untilAborted(signal, () =>
86
+ activePage.goto(url, { waitUntil: "domcontentloaded", timeout: SEARCH_HARD_TIMEOUT_MS }),
87
+ );
88
+ if (options.afterNavigation) await options.afterNavigation(activePage, signal);
89
+ if (ready) {
90
+ await untilAborted(signal, () =>
91
+ activePage.waitForSelector(ready.selector, { timeout: ready.timeoutMs }).catch(() => null),
92
+ );
93
+ }
94
+ const loaded = {
95
+ html: await untilAborted(signal, () => activePage.content()),
96
+ status: response?.status() ?? 200,
97
+ url: activePage.url(),
98
+ };
99
+ if (!options.shouldFallback(loaded) || attempt === attempts - 1) return loaded;
100
+ }
101
+ throw new Error("Browser fallback exhausted without a response");
102
+ } finally {
103
+ await page?.close().catch(() => undefined);
104
+ await releaseBrowser(handle, { kill: false });
105
+ }
106
+ }
107
+
108
+ /** Fetch with a fresh browser profile, escalating rejected production responses to the stealth browser. */
109
+ export async function browserFetch(url: string, options: BrowserFetchOptions): Promise<LoadedHtmlPage> {
110
+ const fetchImpl = options.fetch ?? fetch;
111
+ let page: LoadedHtmlPage;
112
+ try {
113
+ page = await fetchHtmlPage(url, options, fetchImpl);
114
+ } catch (error) {
115
+ if (options.fetch || !options.browser) throw error;
116
+ return browseHtmlPage(url, options.browser, options.signal);
117
+ }
118
+
119
+ if (!options.browser || options.fetch) return page;
120
+ const isSuccessful = page.status >= 200 && page.status < 300;
121
+ if (isSuccessful && !options.browser.shouldFallback(page)) return page;
122
+ return browseHtmlPage(url, options.browser, options.signal);
123
+ }
@@ -4,6 +4,7 @@ import { SearchProviderError } from "../../../web/search/types";
4
4
  import { clampNumResults } from "../utils";
5
5
  import type { SearchParams } from "./base";
6
6
  import { SearchProvider } from "./base";
7
+ import { browserFetch } from "./browser-page";
7
8
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
8
9
 
9
10
  /**
@@ -28,15 +29,6 @@ const RECENCY_TO_DDG_DF: Record<NonNullable<SearchParams["recency"]>, string> =
28
29
  year: "y",
29
30
  };
30
31
 
31
- /**
32
- * Browser-like UA so DDG serves the standard results page instead of the
33
- * mobile-only or noscript variants. DDG returns HTTP 202 plus an anomaly
34
- * modal when it suspects automation; we surface that as a clear error so
35
- * the orchestrator can fall through to the next provider with context.
36
- */
37
- const BROWSER_USER_AGENT =
38
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
39
-
40
32
  interface ParsedResult {
41
33
  title: string;
42
34
  url: string;
@@ -133,35 +125,22 @@ async function callDuckDuckGoHtml(params: SearchParams): Promise<string> {
133
125
  // Add b: "" parameter as specified in the browser fetch template to match real browser form submission
134
126
  form.set("b", "");
135
127
 
136
- const response = await (params.fetch ?? fetch)(DUCKDUCKGO_HTML_URL, {
137
- method: "POST",
138
- body: form.toString(),
139
- headers: {
140
- Accept:
141
- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
142
- "Accept-Language": "en,en-US;q=0.9",
143
- "Cache-Control": "max-age=0",
144
- "Content-Type": "application/x-www-form-urlencoded",
145
- Priority: "u=0, i",
146
- "Sec-Ch-Ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
147
- "Sec-Ch-Ua-Mobile": "?0",
148
- "Sec-Ch-Ua-Platform": '"macOS"',
149
- "Sec-Fetch-Dest": "document",
150
- "Sec-Fetch-Mode": "navigate",
151
- "Sec-Fetch-Site": "same-origin",
152
- "Sec-Fetch-User": "?1",
153
- "Upgrade-Insecure-Requests": "1",
154
- "User-Agent": BROWSER_USER_AGENT,
155
- Referer: "https://html.duckduckgo.com/",
156
- },
128
+ const page = await browserFetch(DUCKDUCKGO_HTML_URL, {
129
+ fetch: params.fetch ?? fetch,
157
130
  signal: withHardTimeout(params.signal),
131
+ referer: "https://html.duckduckgo.com/",
132
+ init: {
133
+ method: "POST",
134
+ body: form.toString(),
135
+ },
136
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
158
137
  });
159
138
 
160
- const body = await response.text();
161
- if (!response.ok && response.status !== 202) {
162
- const classified = classifyProviderHttpError("duckduckgo", response.status, body);
139
+ const body = page.html;
140
+ if (page.status < 200 || page.status >= 300) {
141
+ const classified = classifyProviderHttpError("duckduckgo", page.status, body);
163
142
  if (classified) throw classified;
164
- throw new SearchProviderError("duckduckgo", `DuckDuckGo HTML error (${response.status})`, response.status);
143
+ throw new SearchProviderError("duckduckgo", `DuckDuckGo HTML error (${page.status})`, page.status);
165
144
  }
166
145
 
167
146
  if (isAnomalyResponse(body)) {