@oh-my-pi/pi-coding-agent 10.3.2 → 10.6.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 (58) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/package.json +16 -11
  3. package/src/capability/index.ts +9 -0
  4. package/src/cli/update-cli.ts +2 -5
  5. package/src/config/settings-schema.ts +39 -1
  6. package/src/cursor.ts +1 -1
  7. package/src/extensibility/custom-tools/wrapper.ts +9 -33
  8. package/src/extensibility/extensions/wrapper.ts +18 -31
  9. package/src/extensibility/hooks/tool-wrapper.ts +6 -16
  10. package/src/extensibility/tool-proxy.ts +25 -0
  11. package/src/index.ts +1 -0
  12. package/src/ipy/executor.ts +107 -3
  13. package/src/ipy/gateway-coordinator.ts +0 -4
  14. package/src/ipy/kernel.ts +65 -175
  15. package/src/main.ts +17 -0
  16. package/src/mcp/render.ts +10 -226
  17. package/src/modes/components/tool-execution.ts +83 -96
  18. package/src/modes/controllers/input-controller.ts +38 -0
  19. package/src/modes/interactive-mode.ts +13 -0
  20. package/src/patch/index.ts +1 -0
  21. package/src/prompts/system/system-prompt.md +5 -2
  22. package/src/prompts/tools/ask.md +6 -9
  23. package/src/prompts/tools/browser.md +26 -0
  24. package/src/prompts/tools/grep.md +4 -8
  25. package/src/prompts/tools/task.md +29 -4
  26. package/src/sdk.ts +21 -0
  27. package/src/session/session-manager.ts +1 -0
  28. package/src/task/executor.ts +5 -47
  29. package/src/tools/ask.ts +60 -71
  30. package/src/tools/bash.ts +1 -0
  31. package/src/tools/browser.ts +1138 -0
  32. package/src/tools/find.ts +11 -2
  33. package/src/tools/grep.ts +111 -107
  34. package/src/tools/index.ts +4 -0
  35. package/src/tools/json-tree.ts +231 -0
  36. package/src/tools/notebook.ts +1 -0
  37. package/src/tools/puppeteer/00_stealth_tampering.txt +63 -0
  38. package/src/tools/puppeteer/01_stealth_activity.txt +20 -0
  39. package/src/tools/puppeteer/02_stealth_hairline.txt +11 -0
  40. package/src/tools/puppeteer/03_stealth_botd.txt +384 -0
  41. package/src/tools/puppeteer/04_stealth_iframe.txt +81 -0
  42. package/src/tools/puppeteer/05_stealth_webgl.txt +75 -0
  43. package/src/tools/puppeteer/06_stealth_screen.txt +72 -0
  44. package/src/tools/puppeteer/07_stealth_fonts.txt +97 -0
  45. package/src/tools/puppeteer/08_stealth_audio.txt +51 -0
  46. package/src/tools/puppeteer/09_stealth_locale.txt +46 -0
  47. package/src/tools/puppeteer/10_stealth_plugins.txt +206 -0
  48. package/src/tools/puppeteer/11_stealth_hardware.txt +8 -0
  49. package/src/tools/puppeteer/12_stealth_codecs.txt +40 -0
  50. package/src/tools/puppeteer/13_stealth_worker.txt +74 -0
  51. package/src/tools/python.ts +1 -0
  52. package/src/tools/ssh.ts +1 -0
  53. package/src/tools/todo-write.ts +1 -0
  54. package/src/tools/write.ts +1 -0
  55. package/src/web/search/index.ts +15 -4
  56. package/src/web/search/providers/jina.ts +76 -0
  57. package/src/web/search/render.ts +3 -1
  58. package/src/web/search/types.ts +2 -2
@@ -0,0 +1,1138 @@
1
+ import * as path from "node:path";
2
+ import { Readability } from "@mozilla/readability";
3
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
+ import { StringEnum } from "@oh-my-pi/pi-ai";
5
+ import { logger, untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { type Static, Type } from "@sinclair/typebox";
7
+ import { JSDOM, VirtualConsole } from "jsdom";
8
+ import type { Browser, CDPSession, ElementHandle, KeyInput, Page, SerializedAXNode } from "puppeteer";
9
+ import puppeteer from "puppeteer";
10
+ import { renderPromptTemplate } from "../config/prompt-templates";
11
+ import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
12
+ import type { ToolSession } from "../sdk";
13
+ import { htmlToBasicMarkdown } from "../web/scrapers/types";
14
+ import type { OutputMeta } from "./output-meta";
15
+ import { resolveToCwd } from "./path-utils";
16
+ import stealthTamperingScript from "./puppeteer/00_stealth_tampering.txt" with { type: "text" };
17
+ import stealthActivityScript from "./puppeteer/01_stealth_activity.txt" with { type: "text" };
18
+ import stealthHairlineScript from "./puppeteer/02_stealth_hairline.txt" with { type: "text" };
19
+ import stealthBotdScript from "./puppeteer/03_stealth_botd.txt" with { type: "text" };
20
+ import stealthIframeScript from "./puppeteer/04_stealth_iframe.txt" with { type: "text" };
21
+ import stealthWebglScript from "./puppeteer/05_stealth_webgl.txt" with { type: "text" };
22
+ import stealthScreenScript from "./puppeteer/06_stealth_screen.txt" with { type: "text" };
23
+ import stealthFontsScript from "./puppeteer/07_stealth_fonts.txt" with { type: "text" };
24
+ import stealthAudioScript from "./puppeteer/08_stealth_audio.txt" with { type: "text" };
25
+ import stealthLocaleScript from "./puppeteer/09_stealth_locale.txt" with { type: "text" };
26
+ import stealthPluginsScript from "./puppeteer/10_stealth_plugins.txt" with { type: "text" };
27
+ import stealthHardwareScript from "./puppeteer/11_stealth_hardware.txt" with { type: "text" };
28
+ import stealthCodecsScript from "./puppeteer/12_stealth_codecs.txt" with { type: "text" };
29
+ import stealthWorkerScript from "./puppeteer/13_stealth_worker.txt" with { type: "text" };
30
+ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
31
+ import { toolResult } from "./tool-result";
32
+
33
+ const DEFAULT_TIMEOUT_SECONDS = 30;
34
+ const MAX_TIMEOUT_SECONDS = 120;
35
+ const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1.25 };
36
+ const STEALTH_IGNORE_DEFAULT_ARGS = [
37
+ "--disable-extensions",
38
+ "--disable-default-apps",
39
+ "--disable-component-extensions-with-background-pages",
40
+ ];
41
+ const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
42
+ const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
43
+ const INTERACTIVE_AX_ROLES = new Set([
44
+ "button",
45
+ "link",
46
+ "textbox",
47
+ "combobox",
48
+ "listbox",
49
+ "option",
50
+ "checkbox",
51
+ "radio",
52
+ "switch",
53
+ "tab",
54
+ "menuitem",
55
+ "menuitemcheckbox",
56
+ "menuitemradio",
57
+ "slider",
58
+ "spinbutton",
59
+ "searchbox",
60
+ "treeitem",
61
+ ]);
62
+
63
+ /**
64
+ * Stealth init scripts for Puppeteer.
65
+ */
66
+
67
+ type PuppeteerCdpClient = {
68
+ send: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
69
+ };
70
+
71
+ type UserAgentOverride = {
72
+ userAgent: string;
73
+ platform: string;
74
+ acceptLanguage: string;
75
+ userAgentMetadata: {
76
+ brands: Array<{ brand: string; version: string }>;
77
+ fullVersion: string;
78
+ platform: string;
79
+ platformVersion: string;
80
+ architecture: string;
81
+ model: string;
82
+ mobile: boolean;
83
+ };
84
+ };
85
+
86
+ function resolvePageClient(page: Page): PuppeteerCdpClient | null {
87
+ const pageWithClient = page as Page & {
88
+ _client?: (() => PuppeteerCdpClient) | PuppeteerCdpClient;
89
+ };
90
+ if (!pageWithClient._client) return null;
91
+ return typeof pageWithClient._client === "function" ? pageWithClient._client() : pageWithClient._client;
92
+ }
93
+
94
+ const puppeteerGetArgsSchema = Type.Array(
95
+ Type.Object({
96
+ selector: Type.String({ description: "CSS selector for the target element" }),
97
+ attribute: Type.Optional(Type.String({ description: "Attribute name (get_attribute)" })),
98
+ }),
99
+ { description: "Batch arguments for get_* actions", minItems: 1 },
100
+ );
101
+
102
+ const browserSchema = Type.Object({
103
+ action: StringEnum(
104
+ [
105
+ "open",
106
+ "goto",
107
+ "observe",
108
+ "click",
109
+ "click_id",
110
+ "type",
111
+ "type_id",
112
+ "fill",
113
+ "fill_id",
114
+ "press",
115
+ "scroll",
116
+ "drag",
117
+ "wait_for_selector",
118
+ "evaluate",
119
+ "get_text",
120
+ "get_html",
121
+ "get_attribute",
122
+ "extract_readable",
123
+ "screenshot",
124
+ "close",
125
+ ],
126
+ { description: "Action to perform" },
127
+ ),
128
+ url: Type.Optional(Type.String({ description: "URL to navigate to (goto)" })),
129
+ selector: Type.Optional(Type.String({ description: "CSS selector for the target element" })),
130
+ element_id: Type.Optional(Type.Number({ description: "Element ID from observe" })),
131
+ include_all: Type.Optional(Type.Boolean({ description: "Include non-interactive nodes in observe" })),
132
+ viewport_only: Type.Optional(Type.Boolean({ description: "Limit observe output to elements in the viewport" })),
133
+ args: Type.Optional(puppeteerGetArgsSchema),
134
+ script: Type.Optional(Type.String({ description: "JavaScript to evaluate (evaluate)" })),
135
+ text: Type.Optional(Type.String({ description: "Text to type (type)" })),
136
+ value: Type.Optional(Type.String({ description: "Value to set (fill)" })),
137
+ attribute: Type.Optional(Type.String({ description: "Attribute name to read (get_attribute)" })),
138
+ key: Type.Optional(Type.String({ description: "Keyboard key to press (press)" })),
139
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 30)" })),
140
+ wait_until: Type.Optional(
141
+ StringEnum(["load", "domcontentloaded", "networkidle0", "networkidle2"], {
142
+ description: "Navigation wait condition (goto)",
143
+ }),
144
+ ),
145
+ full_page: Type.Optional(Type.Boolean({ description: "Capture full page screenshot (screenshot)" })),
146
+ format: Type.Optional(
147
+ StringEnum(["png", "jpeg", "text", "markdown"], {
148
+ description: "Output format (screenshot: png/jpeg, extract_readable: text/markdown)",
149
+ }),
150
+ ),
151
+ quality: Type.Optional(Type.Number({ description: "JPEG quality 0-100 (screenshot)" })),
152
+ path: Type.Optional(Type.String({ description: "Optional path to save screenshot (relative to cwd)" })),
153
+ viewport: Type.Optional(
154
+ Type.Object({
155
+ width: Type.Number({ description: "Viewport width in pixels" }),
156
+ height: Type.Number({ description: "Viewport height in pixels" }),
157
+ deviceScaleFactor: Type.Optional(Type.Number({ description: "Device scale factor" })),
158
+ }),
159
+ ),
160
+ delta_x: Type.Optional(Type.Number({ description: "Scroll delta X (scroll)" })),
161
+ delta_y: Type.Optional(Type.Number({ description: "Scroll delta Y (scroll)" })),
162
+ from_selector: Type.Optional(Type.String({ description: "Drag start selector (drag)" })),
163
+ to_selector: Type.Optional(Type.String({ description: "Drag end selector (drag)" })),
164
+ });
165
+
166
+ /** Input schema for the Puppeteer tool. */
167
+ export type BrowserParams = Static<typeof browserSchema>;
168
+
169
+ /** Details describing a Puppeteer tool execution result. */
170
+ export interface BrowserToolDetails {
171
+ action: BrowserParams["action"];
172
+ url?: string;
173
+ selector?: string;
174
+ elementId?: number;
175
+ result?: string | string[];
176
+ screenshotPath?: string;
177
+ mimeType?: string;
178
+ bytes?: number;
179
+ viewport?: { width: number; height: number; deviceScaleFactor?: number };
180
+ observation?: Observation;
181
+ readable?: ReadableResult;
182
+ meta?: OutputMeta;
183
+ }
184
+
185
+ export interface ObservationEntry {
186
+ id: number;
187
+ role: string;
188
+ name?: string;
189
+ value?: string | number;
190
+ description?: string;
191
+ keyshortcuts?: string;
192
+ states: string[];
193
+ }
194
+
195
+ export interface Observation {
196
+ url: string;
197
+ title?: string;
198
+ viewport: { width: number; height: number; deviceScaleFactor?: number };
199
+ scroll: {
200
+ x: number;
201
+ y: number;
202
+ width: number;
203
+ height: number;
204
+ scrollWidth: number;
205
+ scrollHeight: number;
206
+ };
207
+ elements: ObservationEntry[];
208
+ }
209
+
210
+ export interface ReadableResult {
211
+ url: string;
212
+ title?: string;
213
+ byline?: string;
214
+ excerpt?: string;
215
+ contentLength: number;
216
+ text?: string;
217
+ markdown?: string;
218
+ }
219
+
220
+ function clampTimeout(timeoutSeconds?: number): number {
221
+ if (timeoutSeconds === undefined) return DEFAULT_TIMEOUT_SECONDS;
222
+ return Math.min(Math.max(timeoutSeconds, 1), MAX_TIMEOUT_SECONDS);
223
+ }
224
+
225
+ function clampQuality(quality?: number): number | undefined {
226
+ if (quality === undefined) return undefined;
227
+ return Math.min(Math.max(quality, 0), 100);
228
+ }
229
+
230
+ function ensureParam<T>(value: T | undefined, name: string, action: string): T {
231
+ if (value === undefined || value === null || value === "") {
232
+ throw new ToolError(`Missing required parameter '${name}' for action '${action}'.`);
233
+ }
234
+ return value;
235
+ }
236
+
237
+ function resolveArtifactsDir(session: ToolSession): string | null {
238
+ return session.getArtifactsDir?.() ?? session.getSessionFile()?.slice(0, -6) ?? null;
239
+ }
240
+
241
+ function formatEvaluateResult(value: unknown): string {
242
+ if (typeof value === "string") return value;
243
+ if (value === undefined) return "undefined";
244
+ try {
245
+ const serialized = JSON.stringify(value, null, 2);
246
+ return serialized ?? "undefined";
247
+ } catch {
248
+ return String(value);
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Puppeteer tool for headless browser automation.
254
+ */
255
+ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
256
+ public readonly name = "puppeteer";
257
+ public readonly label = "Puppeteer";
258
+ public readonly description: string;
259
+ public readonly parameters = browserSchema;
260
+ private readonly session: ToolSession;
261
+ private browser: Browser | null = null;
262
+ private page: Page | null = null;
263
+ private currentHeadless: boolean | null = null;
264
+ private browserSession: CDPSession | null = null;
265
+ private userAgentOverride: UserAgentOverride | null = null;
266
+ private elementIdCounter = 0;
267
+ private readonly elementCache = new Map<number, ElementHandle>();
268
+ private readonly patchedClients = new WeakSet<object>();
269
+
270
+ constructor(session: ToolSession) {
271
+ this.session = session;
272
+ this.description = renderPromptTemplate(browserDescription, {});
273
+ }
274
+
275
+ private async closeBrowser(): Promise<void> {
276
+ await this.clearElementCache();
277
+ if (this.page && !this.page.isClosed()) {
278
+ await this.page.close();
279
+ }
280
+ this.page = null;
281
+ if (this.browser?.connected) {
282
+ await this.browser.close();
283
+ }
284
+ this.browser = null;
285
+ this.browserSession = null;
286
+ this.userAgentOverride = null;
287
+ }
288
+
289
+ private async resetBrowser(params?: BrowserParams): Promise<Page> {
290
+ await this.closeBrowser();
291
+ this.currentHeadless = this.session.settings.get("browser.headless");
292
+ this.browser = await puppeteer.launch({
293
+ headless: this.currentHeadless,
294
+ defaultViewport: DEFAULT_VIEWPORT,
295
+ args: [
296
+ "--no-sandbox",
297
+ "--disable-setuid-sandbox",
298
+ "--disable-blink-features=AutomationControlled",
299
+ `--window-size=${DEFAULT_VIEWPORT.width},${DEFAULT_VIEWPORT.height}`,
300
+ ],
301
+ ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULT_ARGS],
302
+ });
303
+ this.page = await this.browser.newPage();
304
+ await this.applyStealthPatches(this.page);
305
+ await this.applyViewport(this.page, params?.viewport);
306
+ return this.page;
307
+ }
308
+
309
+ private async ensurePage(params?: BrowserParams): Promise<Page> {
310
+ const desiredHeadless = this.session.settings.get("browser.headless");
311
+ if (this.currentHeadless !== null && this.currentHeadless !== desiredHeadless) {
312
+ return this.resetBrowser(params);
313
+ }
314
+ if (this.page && !this.page.isClosed()) {
315
+ return this.page;
316
+ }
317
+ if (!this.browser || !this.browser.isConnected()) {
318
+ return this.resetBrowser(params);
319
+ }
320
+ this.page = await this.browser.newPage();
321
+ await this.applyStealthPatches(this.page);
322
+ await this.applyViewport(this.page, params?.viewport);
323
+ return this.page;
324
+ }
325
+
326
+ private async applyViewport(page: Page, viewport?: BrowserParams["viewport"]): Promise<void> {
327
+ const target = viewport ?? DEFAULT_VIEWPORT;
328
+ await page.setViewport(target);
329
+ }
330
+
331
+ private async clearElementCache(): Promise<void> {
332
+ if (this.elementCache.size === 0) {
333
+ this.elementIdCounter = 0;
334
+ return;
335
+ }
336
+ const handles = Array.from(this.elementCache.values());
337
+ this.elementCache.clear();
338
+ this.elementIdCounter = 0;
339
+ await Promise.all(
340
+ handles.map(async handle => {
341
+ try {
342
+ await handle.dispose();
343
+ } catch {
344
+ return;
345
+ }
346
+ }),
347
+ );
348
+ }
349
+
350
+ private async resolveCachedHandle(id: number): Promise<ElementHandle> {
351
+ const handle = this.elementCache.get(id);
352
+ if (!handle) {
353
+ throw new ToolError(`Unknown element_id ${id}. Run observe to refresh the element list.`);
354
+ }
355
+ try {
356
+ const isConnected = (await handle.evaluate(el => el.isConnected)) as boolean;
357
+ if (!isConnected) {
358
+ await this.clearElementCache();
359
+ throw new ToolError(`Element_id ${id} is stale. Run observe again.`);
360
+ }
361
+ } catch {
362
+ await this.clearElementCache();
363
+ throw new ToolError(`Element_id ${id} is stale. Run observe again.`);
364
+ }
365
+ return handle;
366
+ }
367
+
368
+ private isInteractiveNode(node: SerializedAXNode): boolean {
369
+ if (INTERACTIVE_AX_ROLES.has(node.role)) return true;
370
+ return (
371
+ node.checked !== undefined ||
372
+ node.pressed !== undefined ||
373
+ node.selected !== undefined ||
374
+ node.expanded !== undefined ||
375
+ node.focused === true
376
+ );
377
+ }
378
+
379
+ private async collectObservationEntries(
380
+ node: SerializedAXNode,
381
+ entries: ObservationEntry[],
382
+ options: { viewportOnly: boolean; includeAll: boolean },
383
+ ): Promise<void> {
384
+ if (options.includeAll || this.isInteractiveNode(node)) {
385
+ const handle = await node.elementHandle();
386
+ if (handle) {
387
+ let inViewport = true;
388
+ if (options.viewportOnly) {
389
+ try {
390
+ inViewport = await handle.isIntersectingViewport();
391
+ } catch {
392
+ inViewport = false;
393
+ }
394
+ }
395
+ if (inViewport) {
396
+ const id = ++this.elementIdCounter;
397
+ const states: string[] = [];
398
+ if (node.disabled) states.push("disabled");
399
+ if (node.checked !== undefined) states.push(`checked=${String(node.checked)}`);
400
+ if (node.pressed !== undefined) states.push(`pressed=${String(node.pressed)}`);
401
+ if (node.selected !== undefined) states.push(`selected=${String(node.selected)}`);
402
+ if (node.expanded !== undefined) states.push(`expanded=${String(node.expanded)}`);
403
+ if (node.required) states.push("required");
404
+ if (node.readonly) states.push("readonly");
405
+ if (node.multiselectable) states.push("multiselectable");
406
+ if (node.multiline) states.push("multiline");
407
+ if (node.modal) states.push("modal");
408
+ if (node.focused) states.push("focused");
409
+ this.elementCache.set(id, handle);
410
+ entries.push({
411
+ id,
412
+ role: node.role,
413
+ name: node.name,
414
+ value: node.value,
415
+ description: node.description,
416
+ keyshortcuts: node.keyshortcuts,
417
+ states,
418
+ });
419
+ } else {
420
+ await handle.dispose();
421
+ }
422
+ }
423
+ }
424
+ for (const child of node.children ?? []) {
425
+ await this.collectObservationEntries(child, entries, options);
426
+ }
427
+ }
428
+
429
+ private formatObservation(observation: Observation): string {
430
+ const viewport = `${observation.viewport.width}x${observation.viewport.height}`;
431
+ const scroll = `x=${observation.scroll.x} y=${observation.scroll.y} viewport=${observation.scroll.width}x${observation.scroll.height} doc=${observation.scroll.scrollWidth}x${observation.scroll.scrollHeight}`;
432
+ const lines = [
433
+ `URL: ${observation.url}`,
434
+ observation.title ? `Title: ${observation.title}` : "Title:",
435
+ `Viewport: ${viewport}`,
436
+ `Scroll: ${scroll}`,
437
+ "Elements:",
438
+ ];
439
+ for (const entry of observation.elements) {
440
+ const name = entry.name ? ` "${entry.name}"` : "";
441
+ const value = entry.value !== undefined ? ` value=${JSON.stringify(entry.value)}` : "";
442
+ const description = entry.description ? ` desc=${JSON.stringify(entry.description)}` : "";
443
+ const shortcuts = entry.keyshortcuts ? ` shortcuts=${JSON.stringify(entry.keyshortcuts)}` : "";
444
+ const state = entry.states.length ? ` (${entry.states.join(", ")})` : "";
445
+ lines.push(`${entry.id}. ${entry.role}${name}${value}${description}${shortcuts}${state}`);
446
+ }
447
+ return lines.join("\n");
448
+ }
449
+
450
+ /**
451
+ * Restart the browser to apply changes like headless mode.
452
+ */
453
+ public async restartForModeChange(): Promise<void> {
454
+ await this.resetBrowser();
455
+ }
456
+
457
+ private async applyStealthPatches(page: Page): Promise<void> {
458
+ this.patchSourceUrl(page);
459
+ await this.applyUserAgentOverride(page);
460
+ await this.injectStealthScripts(page);
461
+ }
462
+
463
+ private async applyUserAgentOverride(page: Page): Promise<void> {
464
+ const client = resolvePageClient(page);
465
+ if (!client) return;
466
+ const override = await this.resolveUserAgentOverride(page);
467
+ await this.sendUserAgentOverride(client, override);
468
+ await this.configureUserAgentTargets(override);
469
+ }
470
+
471
+ private async resolveUserAgentOverride(page: Page): Promise<UserAgentOverride> {
472
+ if (this.userAgentOverride) return this.userAgentOverride;
473
+ const rawUserAgent = await page.browser().userAgent();
474
+ let userAgent = rawUserAgent.replace("HeadlessChrome/", "Chrome/");
475
+ if (userAgent.includes("Linux") && !userAgent.includes("Android")) {
476
+ userAgent = userAgent.replace(/\(([^)]+)\)/, "(Windows NT 10.0; Win64; x64)");
477
+ }
478
+
479
+ const uaVersionMatch = userAgent.match(/Chrome\/([\d|.]+)/);
480
+ const fallbackVersionMatch = uaVersionMatch ?? (await page.browser().version()).match(/\/([\d|.]+)/);
481
+ const uaVersion = fallbackVersionMatch?.[1] ?? "0";
482
+ const majorVersion = Number.parseInt(uaVersion.split(".")[0] ?? "0", 10) || 0;
483
+ const isAndroid = userAgent.includes("Android");
484
+ const platform = userAgent.includes("Mac OS X")
485
+ ? "MacIntel"
486
+ : isAndroid
487
+ ? "Android"
488
+ : userAgent.includes("Linux")
489
+ ? "Linux"
490
+ : "Win32";
491
+ const platformFull = userAgent.includes("Mac OS X")
492
+ ? "Mac OS X"
493
+ : isAndroid
494
+ ? "Android"
495
+ : userAgent.includes("Linux")
496
+ ? "Linux"
497
+ : "Windows";
498
+ const platformVersion = userAgent.includes("Mac OS X ")
499
+ ? (userAgent.match(/Mac OS X ([^)]+)/)?.[1] ?? "")
500
+ : userAgent.includes("Android ")
501
+ ? (userAgent.match(/Android ([^;]+)/)?.[1] ?? "")
502
+ : userAgent.includes("Windows ")
503
+ ? (userAgent.match(/Windows .*?([\d|.]+);?/)?.[1] ?? "")
504
+ : "";
505
+ const architecture = isAndroid ? "" : "x86";
506
+ const model = isAndroid ? (userAgent.match(/Android.*?;\s([^)]+)/)?.[1] ?? "") : "";
507
+
508
+ const brandOrders = [
509
+ [0, 1, 2],
510
+ [0, 2, 1],
511
+ [1, 0, 2],
512
+ [1, 2, 0],
513
+ [2, 0, 1],
514
+ [2, 1, 0],
515
+ ];
516
+ const order = brandOrders[majorVersion % brandOrders.length] ?? brandOrders[0];
517
+ const escapedChars = [" ", " ", ";"];
518
+ const greaseyBrand = `${escapedChars[order[0]]}Not${escapedChars[order[1]]}A${escapedChars[order[2]]}Brand`;
519
+ const brands: { brand: string; version: string }[] = [];
520
+ brands[order[0]] = { brand: greaseyBrand, version: "99" };
521
+ brands[order[1]] = { brand: "Chromium", version: String(majorVersion) };
522
+ brands[order[2]] = { brand: "Google Chrome", version: String(majorVersion) };
523
+
524
+ this.userAgentOverride = {
525
+ userAgent,
526
+ platform,
527
+ acceptLanguage: STEALTH_ACCEPT_LANGUAGE,
528
+ userAgentMetadata: {
529
+ brands,
530
+ fullVersion: uaVersion,
531
+ platform: platformFull,
532
+ platformVersion,
533
+ architecture,
534
+ model,
535
+ mobile: isAndroid,
536
+ },
537
+ };
538
+ return this.userAgentOverride;
539
+ }
540
+
541
+ private async configureUserAgentTargets(override: UserAgentOverride): Promise<void> {
542
+ if (!this.browser) return;
543
+ if (!this.browserSession) {
544
+ this.browserSession = await this.browser.target().createCDPSession();
545
+ await this.browserSession.send("Target.setAutoAttach", {
546
+ autoAttach: true,
547
+ waitForDebuggerOnStart: false,
548
+ flatten: true,
549
+ });
550
+ this.browserSession.on("Target.attachedToTarget", async (event: { sessionId: string }) => {
551
+ const connection = this.browserSession?.connection();
552
+ const session = connection?.session(event.sessionId);
553
+ if (!session || !this.userAgentOverride) return;
554
+ await this.sendUserAgentOverride(this.wrapSession(session), this.userAgentOverride);
555
+ });
556
+ }
557
+
558
+ const targets = this.browser.targets();
559
+ await Promise.all(
560
+ targets.map(async target => {
561
+ const session = await target.createCDPSession();
562
+ await this.sendUserAgentOverride(this.wrapSession(session), override);
563
+ }),
564
+ );
565
+ }
566
+
567
+ private wrapSession(session: CDPSession): PuppeteerCdpClient {
568
+ return {
569
+ send: async (method, params) => session.send(method as never, params as never),
570
+ };
571
+ }
572
+
573
+ private async sendUserAgentOverride(client: PuppeteerCdpClient, override: UserAgentOverride): Promise<void> {
574
+ try {
575
+ await client.send("Network.enable");
576
+ } catch {}
577
+ try {
578
+ await client.send("Network.setUserAgentOverride", override);
579
+ } catch (error) {
580
+ logger.debug("Failed to apply Network user agent override", {
581
+ error: error instanceof Error ? error.message : String(error),
582
+ });
583
+ }
584
+ try {
585
+ await client.send("Emulation.setUserAgentOverride", override);
586
+ } catch (error) {
587
+ logger.debug("Failed to apply Emulation user agent override", {
588
+ error: error instanceof Error ? error.message : String(error),
589
+ });
590
+ }
591
+ }
592
+
593
+ private patchSourceUrl(page: Page): void {
594
+ const client = resolvePageClient(page);
595
+ if (!client) return;
596
+ const clientKey = client as object;
597
+ if (this.patchedClients.has(clientKey)) return;
598
+ this.patchedClients.add(clientKey);
599
+ const originalSend = client.send.bind(client);
600
+ client.send = async (method: string, params?: Record<string, unknown>) => {
601
+ const next = async (payload?: Record<string, unknown>) => {
602
+ try {
603
+ return await originalSend(method, payload);
604
+ } catch (error) {
605
+ if (
606
+ error instanceof Error &&
607
+ error.message.includes(
608
+ "Protocol error (Network.getResponseBody): No resource with given identifier found",
609
+ )
610
+ ) {
611
+ return undefined;
612
+ }
613
+ throw error;
614
+ }
615
+ };
616
+ if (!method || !params) {
617
+ return next(params);
618
+ }
619
+ const key =
620
+ method === "Runtime.evaluate"
621
+ ? "expression"
622
+ : method === "Runtime.callFunctionOn"
623
+ ? "functionDeclaration"
624
+ : null;
625
+ if (!key) {
626
+ return next(params);
627
+ }
628
+ const value = params[key];
629
+ if (typeof value !== "string" || !value.includes(PUPPETEER_SOURCE_URL_SUFFIX)) {
630
+ return next(params);
631
+ }
632
+ const patchedParams = { ...params, [key]: value.replace(PUPPETEER_SOURCE_URL_SUFFIX, "") };
633
+ return next(patchedParams);
634
+ };
635
+ }
636
+
637
+ /** Injects stealth scripts that cover common puppeteer detection surfaces. */
638
+ private async injectStealthScripts(page: Page): Promise<void> {
639
+ const scripts = [
640
+ stealthTamperingScript,
641
+ stealthActivityScript,
642
+ stealthHairlineScript,
643
+ stealthBotdScript,
644
+ stealthIframeScript,
645
+ stealthWebglScript,
646
+ stealthScreenScript,
647
+ stealthFontsScript,
648
+ stealthAudioScript,
649
+ stealthLocaleScript,
650
+ stealthPluginsScript,
651
+ stealthHardwareScript,
652
+ stealthCodecsScript,
653
+ stealthWorkerScript,
654
+ ];
655
+
656
+ const joint = scripts
657
+ .map(
658
+ script => `
659
+ try {
660
+ ${script};
661
+ } catch (e) {}
662
+ `,
663
+ )
664
+ .join(";\n");
665
+
666
+ await page.evaluateOnNewDocument(`(() => {
667
+ // Native function cache - captured before any tampering
668
+ const iframe = document.createElement("iframe");
669
+ iframe.style.display = "none";
670
+ document.head.appendChild(iframe);
671
+ const nativeWindow = iframe.contentWindow;
672
+ if (!nativeWindow) return;
673
+
674
+ // Cache pristine native functions
675
+ const Function_toString = nativeWindow.Function.prototype.toString;
676
+ const Object_getOwnPropertyDescriptor = nativeWindow.Object.getOwnPropertyDescriptor;
677
+ const Object_getOwnPropertyDescriptors = nativeWindow.Object.getOwnPropertyDescriptors;
678
+ const Object_getPrototypeOf = nativeWindow.Object.getPrototypeOf;
679
+ const Object_defineProperty = nativeWindow.Object.defineProperty;
680
+ const Object_getOwnPropertyDescriptorOriginal = nativeWindow.Object.getOwnPropertyDescriptor;
681
+ const Object_create = nativeWindow.Object.create;
682
+ const Object_keys = nativeWindow.Object.keys;
683
+ const Object_getOwnPropertyNames = nativeWindow.Object.getOwnPropertyNames;
684
+ const Object_entries = nativeWindow.Object.entries;
685
+ const Object_setPrototypeOf = nativeWindow.Object.setPrototypeOf;
686
+ const Object_assign = nativeWindow.Object.assign;
687
+ const Window_setTimeout = nativeWindow.setTimeout;
688
+ const Math_random = nativeWindow.Math.random;
689
+ const Math_floor = nativeWindow.Math.floor;
690
+ const Math_max = nativeWindow.Math.max;
691
+ const Math_min = nativeWindow.Math.min;
692
+ const Window_Event = nativeWindow.Event;
693
+ const Promise_resolve = nativeWindow.Promise.resolve.bind(nativeWindow.Promise);
694
+ const Window_Blob = nativeWindow.Blob;
695
+ const Window_Proxy = nativeWindow.Proxy;
696
+ const Intl_DateTimeFormat = nativeWindow.Intl.DateTimeFormat;
697
+ const Date_constructor = nativeWindow.Date;
698
+
699
+
700
+ ${joint}
701
+
702
+ document.head.removeChild(iframe);})();`);
703
+ }
704
+
705
+ public async execute(
706
+ _toolCallId: string,
707
+ params: BrowserParams,
708
+ signal?: AbortSignal,
709
+ _onUpdate?: AgentToolUpdateCallback<BrowserToolDetails>,
710
+ _context?: AgentToolContext,
711
+ ): Promise<AgentToolResult<BrowserToolDetails>> {
712
+ try {
713
+ throwIfAborted(signal);
714
+ const timeoutSeconds = clampTimeout(params.timeout);
715
+ const timeoutMs = timeoutSeconds * 1000;
716
+ const details: BrowserToolDetails = { action: params.action };
717
+
718
+ switch (params.action) {
719
+ case "open": {
720
+ const page = await untilAborted(signal, () => this.resetBrowser(params));
721
+ const viewport = page.viewport();
722
+ details.viewport = viewport ?? DEFAULT_VIEWPORT;
723
+ return toolResult(details).text("Opened headless browser session").done();
724
+ }
725
+ case "close": {
726
+ await untilAborted(signal, () => this.closeBrowser());
727
+ return toolResult(details).text("Closed headless browser session").done();
728
+ }
729
+ case "goto": {
730
+ const url = ensureParam(params.url, "url", params.action);
731
+ details.url = url;
732
+ const page = await this.ensurePage(params);
733
+ const waitUntil = params.wait_until ?? "networkidle2";
734
+ await this.clearElementCache();
735
+ await untilAborted(signal, () => page.goto(url, { waitUntil, timeout: timeoutMs }));
736
+ const finalUrl = page.url();
737
+ const title = (await untilAborted(signal, () => page.title())) as string;
738
+ details.url = finalUrl;
739
+ details.result = title;
740
+ return toolResult(details)
741
+ .text(`Navigated to ${finalUrl}${title ? `\nTitle: ${title}` : ""}`)
742
+ .done();
743
+ }
744
+ case "observe": {
745
+ const page = await this.ensurePage(params);
746
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
747
+ const observeSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
748
+ await this.clearElementCache();
749
+ const snapshot = (await untilAborted(observeSignal, () =>
750
+ page.accessibility.snapshot({ interestingOnly: !(params.include_all ?? false) }),
751
+ )) as SerializedAXNode | null;
752
+ if (!snapshot) {
753
+ throw new ToolError("Accessibility snapshot unavailable");
754
+ }
755
+ const entries: ObservationEntry[] = [];
756
+ await this.collectObservationEntries(snapshot, entries, {
757
+ viewportOnly: params.viewport_only ?? false,
758
+ includeAll: params.include_all ?? false,
759
+ });
760
+ const scroll = (await untilAborted(observeSignal, () =>
761
+ page.evaluate(() => {
762
+ const win = globalThis as unknown as {
763
+ scrollX: number;
764
+ scrollY: number;
765
+ innerWidth: number;
766
+ innerHeight: number;
767
+ document: { documentElement: { scrollWidth: number; scrollHeight: number } };
768
+ };
769
+ const doc = win.document.documentElement;
770
+ return {
771
+ x: win.scrollX,
772
+ y: win.scrollY,
773
+ width: win.innerWidth,
774
+ height: win.innerHeight,
775
+ scrollWidth: doc.scrollWidth,
776
+ scrollHeight: doc.scrollHeight,
777
+ };
778
+ }),
779
+ )) as Observation["scroll"];
780
+ const url = page.url();
781
+ const title = (await untilAborted(observeSignal, () => page.title())) as string;
782
+ const viewport = page.viewport() ?? DEFAULT_VIEWPORT;
783
+ const observation: Observation = {
784
+ url,
785
+ title,
786
+ viewport,
787
+ scroll,
788
+ elements: entries,
789
+ };
790
+ details.url = url;
791
+ details.viewport = viewport;
792
+ details.observation = observation;
793
+ details.result = `${entries.length} elements`;
794
+ return toolResult(details).text(this.formatObservation(observation)).done();
795
+ }
796
+ case "click": {
797
+ const selector = ensureParam(params.selector, "selector", params.action);
798
+ details.selector = selector;
799
+ const page = await this.ensurePage(params);
800
+ const locator = page.locator(selector).setTimeout(timeoutMs);
801
+ await untilAborted(signal, () => locator.click());
802
+ return toolResult(details).text(`Clicked ${selector}`).done();
803
+ }
804
+ case "click_id": {
805
+ const elementId = ensureParam(params.element_id, "element_id", params.action);
806
+ details.elementId = elementId;
807
+ const handle = await this.resolveCachedHandle(elementId);
808
+ try {
809
+ await untilAborted(signal, () => handle.click());
810
+ } catch {
811
+ await this.clearElementCache();
812
+ throw new ToolError(`Element_id ${elementId} is stale. Run observe again.`);
813
+ }
814
+ return toolResult(details).text(`Clicked element ${elementId}`).done();
815
+ }
816
+ case "type": {
817
+ const selector = ensureParam(params.selector, "selector", params.action);
818
+ const text = ensureParam(params.text, "text", params.action);
819
+ details.selector = selector;
820
+ const page = await this.ensurePage(params);
821
+ const locator = page.locator(selector).setTimeout(timeoutMs);
822
+ const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
823
+ await untilAborted(signal, () => handle.type(text, { delay: 0 }));
824
+ await handle.dispose();
825
+ return toolResult(details).text(`Typed into ${selector}`).done();
826
+ }
827
+ case "type_id": {
828
+ const elementId = ensureParam(params.element_id, "element_id", params.action);
829
+ const text = ensureParam(params.text, "text", params.action);
830
+ details.elementId = elementId;
831
+ const page = await this.ensurePage(params);
832
+ const handle = await this.resolveCachedHandle(elementId);
833
+ try {
834
+ await untilAborted(signal, () => handle.focus());
835
+ await untilAborted(signal, () => page.keyboard.type(text, { delay: 0 }));
836
+ } catch {
837
+ await this.clearElementCache();
838
+ throw new ToolError(`Element_id ${elementId} is stale. Run observe again.`);
839
+ }
840
+ return toolResult(details).text(`Typed into element ${elementId}`).done();
841
+ }
842
+ case "fill": {
843
+ const selector = ensureParam(params.selector, "selector", params.action);
844
+ const value = ensureParam(params.value, "value", params.action);
845
+ details.selector = selector;
846
+ const page = await this.ensurePage(params);
847
+ const locator = page.locator(selector).setTimeout(timeoutMs);
848
+ await untilAborted(signal, () => locator.fill(value));
849
+ return toolResult(details).text(`Filled ${selector}`).done();
850
+ }
851
+ case "fill_id": {
852
+ const elementId = ensureParam(params.element_id, "element_id", params.action);
853
+ const value = ensureParam(params.value, "value", params.action);
854
+ details.elementId = elementId;
855
+ const handle = await this.resolveCachedHandle(elementId);
856
+ try {
857
+ await untilAborted(signal, () =>
858
+ handle.evaluate((el, inputValue) => {
859
+ const element = el as { value?: string; dispatchEvent: (event: Event) => boolean };
860
+ if (!("value" in element)) {
861
+ throw new Error("Target element is not a form input");
862
+ }
863
+ element.value = String(inputValue);
864
+ element.dispatchEvent(new Event("input", { bubbles: true }));
865
+ element.dispatchEvent(new Event("change", { bubbles: true }));
866
+ }, value),
867
+ );
868
+ } catch {
869
+ await this.clearElementCache();
870
+ throw new ToolError(`Element_id ${elementId} is stale. Run observe again.`);
871
+ }
872
+ return toolResult(details).text(`Filled element ${elementId}`).done();
873
+ }
874
+ case "press": {
875
+ const key = ensureParam(params.key, "key", params.action) as KeyInput;
876
+ const page = await this.ensurePage(params);
877
+ if (params.selector) {
878
+ await untilAborted(signal, () => page.focus(params.selector as string));
879
+ }
880
+ await untilAborted(signal, () => page.keyboard.press(key));
881
+ return toolResult(details).text(`Pressed ${key}`).done();
882
+ }
883
+ case "scroll": {
884
+ const deltaY = ensureParam(params.delta_y, "delta_y", params.action);
885
+ const deltaX = params.delta_x ?? 0;
886
+ const page = await this.ensurePage(params);
887
+ await untilAborted(signal, () => page.mouse.wheel({ deltaX, deltaY }));
888
+ return toolResult(details).text(`Scrolled by ${deltaX}, ${deltaY}`).done();
889
+ }
890
+ case "drag": {
891
+ const fromSelector = ensureParam(params.from_selector, "from_selector", params.action);
892
+ const toSelector = ensureParam(params.to_selector, "to_selector", params.action);
893
+ const page = await this.ensurePage(params);
894
+ const fromHandle = (await untilAborted(signal, () => page.$(fromSelector))) as ElementHandle | null;
895
+ const toHandle = (await untilAborted(signal, () => page.$(toSelector))) as ElementHandle | null;
896
+ if (!fromHandle || !toHandle) {
897
+ throw new ToolError("Drag selectors did not resolve to elements");
898
+ }
899
+ const fromBox = (await untilAborted(signal, () => fromHandle.boundingBox())) as {
900
+ x: number;
901
+ y: number;
902
+ width: number;
903
+ height: number;
904
+ } | null;
905
+ const toBox = (await untilAborted(signal, () => toHandle.boundingBox())) as {
906
+ x: number;
907
+ y: number;
908
+ width: number;
909
+ height: number;
910
+ } | null;
911
+ await fromHandle.dispose();
912
+ await toHandle.dispose();
913
+ if (!fromBox || !toBox) {
914
+ throw new ToolError("Drag elements are not visible");
915
+ }
916
+ const startX = fromBox.x + fromBox.width / 2;
917
+ const startY = fromBox.y + fromBox.height / 2;
918
+ const endX = toBox.x + toBox.width / 2;
919
+ const endY = toBox.y + toBox.height / 2;
920
+ await untilAborted(signal, () => page.mouse.move(startX, startY));
921
+ await untilAborted(signal, () => page.mouse.down());
922
+ await untilAborted(signal, () => page.mouse.move(endX, endY, { steps: 12 }));
923
+ await untilAborted(signal, () => page.mouse.up());
924
+ return toolResult(details).text(`Dragged from ${fromSelector} to ${toSelector}`).done();
925
+ }
926
+ case "wait_for_selector": {
927
+ const selector = ensureParam(params.selector, "selector", params.action);
928
+ details.selector = selector;
929
+ const page = await this.ensurePage(params);
930
+ const locator = page.locator(selector).setTimeout(timeoutMs);
931
+ await untilAborted(signal, () => locator.wait());
932
+ return toolResult(details).text(`Selector ready: ${selector}`).done();
933
+ }
934
+ case "evaluate": {
935
+ const script = ensureParam(params.script, "script", params.action);
936
+ const page = await this.ensurePage(params);
937
+ const value = (await untilAborted(signal, () =>
938
+ page.evaluate((source: string) => {
939
+ const evaluator = new Function(`return (${source});`);
940
+ return evaluator();
941
+ }, script),
942
+ )) as unknown;
943
+ const output = formatEvaluateResult(value);
944
+ details.result = output;
945
+ return toolResult(details).text(output).done();
946
+ }
947
+ case "get_text": {
948
+ const page = await this.ensurePage(params);
949
+ if (params.args?.length) {
950
+ const values = (await Promise.all(
951
+ params.args.map((arg, index) => {
952
+ const selector = ensureParam(arg.selector, `args[${index}].selector`, params.action);
953
+ return untilAborted(signal, () =>
954
+ page.$eval(selector, (el: Element) => (el as HTMLElement).innerText),
955
+ );
956
+ }),
957
+ )) as string[];
958
+ details.result = values;
959
+ return toolResult(details)
960
+ .text(JSON.stringify(values, null, 2))
961
+ .done();
962
+ }
963
+ const selector = ensureParam(params.selector, "selector", params.action);
964
+ details.selector = selector;
965
+ const value = (await untilAborted(signal, () =>
966
+ page.$eval(selector, (el: Element) => (el as HTMLElement).innerText),
967
+ )) as string;
968
+ details.result = value;
969
+ return toolResult(details).text(value).done();
970
+ }
971
+ case "get_html": {
972
+ const page = await this.ensurePage(params);
973
+ if (params.args?.length) {
974
+ const values = (await Promise.all(
975
+ params.args.map((arg, index) => {
976
+ const selector = ensureParam(arg.selector, `args[${index}].selector`, params.action);
977
+ return untilAborted(signal, () =>
978
+ page.$eval(selector, (el: Element) => (el as HTMLElement).innerHTML),
979
+ );
980
+ }),
981
+ )) as string[];
982
+ details.result = values;
983
+ return toolResult(details)
984
+ .text(JSON.stringify(values, null, 2))
985
+ .done();
986
+ }
987
+ const selector = ensureParam(params.selector, "selector", params.action);
988
+ details.selector = selector;
989
+ const value = (await untilAborted(signal, () =>
990
+ page.$eval(selector, (el: Element) => (el as HTMLElement).innerHTML),
991
+ )) as string;
992
+ details.result = value;
993
+ return toolResult(details).text(value).done();
994
+ }
995
+ case "get_attribute": {
996
+ const page = await this.ensurePage(params);
997
+ if (params.args?.length) {
998
+ const values = (await Promise.all(
999
+ params.args.map((arg, index) => {
1000
+ const selector = ensureParam(arg.selector, `args[${index}].selector`, params.action);
1001
+ const attribute = ensureParam(arg.attribute, `args[${index}].attribute`, params.action);
1002
+ return untilAborted(signal, () =>
1003
+ page.$eval(selector, (el: Element) => (el as HTMLElement).getAttribute(String(attribute))),
1004
+ );
1005
+ }),
1006
+ )) as string[];
1007
+ details.result = values;
1008
+ return toolResult(details)
1009
+ .text(JSON.stringify(values, null, 2))
1010
+ .done();
1011
+ }
1012
+ const selector = ensureParam(params.selector, "selector", params.action);
1013
+ const attribute = ensureParam(params.attribute, "attribute", params.action);
1014
+ details.selector = selector;
1015
+ const value = (await untilAborted(signal, () =>
1016
+ page.$eval(
1017
+ selector,
1018
+ (el: { getAttribute: (name: string) => string | null }, attr: string) =>
1019
+ el.getAttribute(String(attr)),
1020
+ attribute,
1021
+ ),
1022
+ )) as string | null;
1023
+ const output = value ?? "";
1024
+ details.result = output;
1025
+ return toolResult(details).text(output).done();
1026
+ }
1027
+ case "extract_readable": {
1028
+ const page = await this.ensurePage(params);
1029
+ const format = params.format ?? "text";
1030
+ if (format !== "text" && format !== "markdown") {
1031
+ throw new ToolError("extract_readable format must be text or markdown");
1032
+ }
1033
+ const html = (await untilAborted(signal, () => page.content())) as string;
1034
+ const url = page.url();
1035
+ const virtualConsole = new VirtualConsole();
1036
+ virtualConsole.on("jsdomError", error => {
1037
+ if (error?.message?.includes("Could not parse CSS stylesheet")) return;
1038
+ logger.debug("JSDOM error during readable extraction", {
1039
+ error: error instanceof Error ? error.message : String(error),
1040
+ });
1041
+ });
1042
+ const dom = new JSDOM(html, { url, virtualConsole });
1043
+ const reader = new Readability(dom.window.document);
1044
+ const article = reader.parse();
1045
+ dom.window.close();
1046
+ if (!article) {
1047
+ throw new ToolError("Readable content not found");
1048
+ }
1049
+ const markdown = format === "markdown" ? htmlToBasicMarkdown(article.content ?? "") : undefined;
1050
+ const text = format === "text" ? (article.textContent ?? "") : undefined;
1051
+ const readable: ReadableResult = {
1052
+ url,
1053
+ title: article.title ?? undefined,
1054
+ byline: article.byline ?? undefined,
1055
+ excerpt: article.excerpt ?? undefined,
1056
+ contentLength: article.length ?? article.textContent?.length ?? 0,
1057
+ text,
1058
+ markdown,
1059
+ };
1060
+ details.url = url;
1061
+ details.readable = readable;
1062
+ details.result = format === "markdown" ? (markdown ?? "") : (text ?? "");
1063
+ return toolResult(details)
1064
+ .text(JSON.stringify(readable, null, 2))
1065
+ .done();
1066
+ }
1067
+ case "screenshot": {
1068
+ const page = await this.ensurePage(params);
1069
+ const format = params.format ?? "png";
1070
+ if (format !== "png" && format !== "jpeg") {
1071
+ throw new ToolError("Screenshot format must be png or jpeg");
1072
+ }
1073
+ const imageFormat = format;
1074
+ const fullPage = params.selector ? false : (params.full_page ?? false);
1075
+ const quality = imageFormat === "jpeg" ? clampQuality(params.quality ?? 80) : undefined;
1076
+ let buffer: Buffer;
1077
+
1078
+ if (params.selector) {
1079
+ const handle = (await untilAborted(signal, () =>
1080
+ page.$(params.selector as string),
1081
+ )) as ElementHandle | null;
1082
+ if (!handle) {
1083
+ throw new ToolError("Screenshot selector did not resolve to an element");
1084
+ }
1085
+ buffer = (await untilAborted(signal, () =>
1086
+ handle.screenshot({ type: imageFormat, quality }),
1087
+ )) as Buffer;
1088
+ await handle.dispose();
1089
+ details.selector = params.selector;
1090
+ } else {
1091
+ buffer = (await untilAborted(signal, () =>
1092
+ page.screenshot({ type: imageFormat, quality, fullPage }),
1093
+ )) as Buffer;
1094
+ }
1095
+
1096
+ const mimeType = imageFormat === "png" ? "image/png" : "image/jpeg";
1097
+ const base64 = buffer.toString("base64");
1098
+ let savedPath: string | undefined;
1099
+ if (params.path) {
1100
+ const resolved = resolveToCwd(params.path, this.session.cwd);
1101
+ const ext = path.extname(resolved);
1102
+ savedPath = ext ? resolved : `${resolved}.${imageFormat}`;
1103
+ await Bun.write(savedPath, buffer);
1104
+ } else {
1105
+ const artifactsDir = resolveArtifactsDir(this.session);
1106
+ if (artifactsDir) {
1107
+ savedPath = path.join(artifactsDir, `puppeteer-${Date.now()}.${imageFormat}`);
1108
+ await Bun.write(savedPath, buffer);
1109
+ }
1110
+ }
1111
+ details.screenshotPath = savedPath;
1112
+ details.mimeType = mimeType;
1113
+ details.bytes = buffer.length;
1114
+
1115
+ const lines = ["Screenshot captured", `Format: ${format}`, `Bytes: ${buffer.length}`];
1116
+ if (savedPath) {
1117
+ lines.push(`Saved: ${savedPath}`);
1118
+ }
1119
+
1120
+ return toolResult(details)
1121
+ .content([
1122
+ { type: "text", text: lines.join("\n") },
1123
+ { type: "image", data: base64, mimeType },
1124
+ ])
1125
+ .done();
1126
+ }
1127
+ default:
1128
+ throw new ToolError(`Unsupported action: ${params.action}`);
1129
+ }
1130
+ } catch (error) {
1131
+ if (error instanceof ToolAbortError) throw error;
1132
+ if (error instanceof Error && error.name === "AbortError") {
1133
+ throw new ToolAbortError();
1134
+ }
1135
+ throw error;
1136
+ }
1137
+ }
1138
+ }