@oh-my-pi/pi-coding-agent 14.5.12 → 14.5.14

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 (112) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/commit/pipeline.ts +4 -3
  5. package/src/config/model-equivalence.ts +49 -16
  6. package/src/config/model-registry.ts +100 -25
  7. package/src/config/model-resolver.ts +29 -15
  8. package/src/config/settings-schema.ts +20 -6
  9. package/src/config/settings.ts +9 -8
  10. package/src/config.ts +18 -6
  11. package/src/eval/backend.ts +43 -0
  12. package/src/eval/eval.lark +43 -0
  13. package/src/eval/index.ts +5 -0
  14. package/src/eval/js/context-manager.ts +717 -0
  15. package/src/eval/js/executor.ts +131 -0
  16. package/src/eval/js/index.ts +46 -0
  17. package/src/eval/js/prelude.ts +2 -0
  18. package/src/eval/js/prelude.txt +84 -0
  19. package/src/eval/js/tool-bridge.ts +124 -0
  20. package/src/eval/parse.ts +337 -0
  21. package/src/{ipy → eval/py}/executor.ts +2 -180
  22. package/src/{ipy → eval/py}/gateway-coordinator.ts +2 -2
  23. package/src/eval/py/index.ts +58 -0
  24. package/src/{ipy → eval/py}/kernel.ts +9 -45
  25. package/src/{ipy → eval/py}/prelude.py +39 -227
  26. package/src/eval/types.ts +48 -0
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +8 -10
  29. package/src/extensibility/extensions/types.ts +2 -3
  30. package/src/internal-urls/docs-index.generated.ts +5 -5
  31. package/src/lsp/client.ts +9 -0
  32. package/src/lsp/index.ts +395 -0
  33. package/src/lsp/types.ts +15 -4
  34. package/src/main.ts +35 -14
  35. package/src/mcp/manager.ts +22 -0
  36. package/src/mcp/oauth-flow.ts +1 -1
  37. package/src/memories/index.ts +1 -1
  38. package/src/modes/acp/acp-event-mapper.ts +1 -1
  39. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  40. package/src/modes/components/login-dialog.ts +1 -1
  41. package/src/modes/components/oauth-selector.ts +2 -1
  42. package/src/modes/components/tool-execution.ts +3 -4
  43. package/src/modes/controllers/command-controller.ts +28 -8
  44. package/src/modes/controllers/input-controller.ts +4 -4
  45. package/src/modes/controllers/selector-controller.ts +2 -1
  46. package/src/modes/interactive-mode.ts +4 -5
  47. package/src/modes/rpc/rpc-client.ts +9 -0
  48. package/src/modes/rpc/rpc-mode.ts +6 -0
  49. package/src/modes/rpc/rpc-types.ts +9 -0
  50. package/src/modes/types.ts +3 -3
  51. package/src/modes/utils/ui-helpers.ts +2 -2
  52. package/src/prompts/system/system-prompt.md +3 -3
  53. package/src/prompts/tools/eval.md +92 -0
  54. package/src/prompts/tools/lsp.md +7 -3
  55. package/src/sdk.ts +64 -35
  56. package/src/session/agent-session.ts +152 -46
  57. package/src/session/messages.ts +1 -1
  58. package/src/slash-commands/builtin-registry.ts +1 -1
  59. package/src/system-prompt.ts +34 -66
  60. package/src/task/agents.ts +4 -5
  61. package/src/task/executor.ts +5 -9
  62. package/src/tools/archive-reader.ts +9 -3
  63. package/src/tools/browser/launch.ts +22 -0
  64. package/src/tools/browser/readable.ts +11 -6
  65. package/src/tools/browser/registry.ts +25 -244
  66. package/src/tools/browser/render.ts +1 -1
  67. package/src/tools/browser/tab-protocol.ts +101 -0
  68. package/src/tools/browser/tab-supervisor.ts +429 -0
  69. package/src/tools/browser/tab-worker-entry.ts +21 -0
  70. package/src/tools/browser/tab-worker.ts +1006 -0
  71. package/src/tools/browser.ts +17 -32
  72. package/src/tools/checkpoint.ts +2 -2
  73. package/src/tools/{python.ts → eval.ts} +324 -315
  74. package/src/tools/exit-plan-mode.ts +1 -1
  75. package/src/tools/image-gen.ts +2 -2
  76. package/src/tools/index.ts +62 -100
  77. package/src/tools/read.ts +0 -6
  78. package/src/tools/recipe/runners/pkg.ts +34 -32
  79. package/src/tools/renderers.ts +2 -2
  80. package/src/tools/resolve.ts +7 -2
  81. package/src/tools/todo-write.ts +0 -1
  82. package/src/tools/tool-timeouts.ts +2 -2
  83. package/src/tools/write.ts +8 -1
  84. package/src/utils/markit.ts +15 -7
  85. package/src/utils/tools-manager.ts +5 -5
  86. package/src/web/scrapers/crossref.ts +3 -3
  87. package/src/web/scrapers/devto.ts +1 -1
  88. package/src/web/scrapers/discourse.ts +5 -5
  89. package/src/web/scrapers/firefox-addons.ts +1 -1
  90. package/src/web/scrapers/flathub.ts +2 -2
  91. package/src/web/scrapers/gitlab.ts +1 -1
  92. package/src/web/scrapers/go-pkg.ts +2 -2
  93. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  94. package/src/web/scrapers/mastodon.ts +9 -9
  95. package/src/web/scrapers/mdn.ts +11 -7
  96. package/src/web/scrapers/pub-dev.ts +1 -1
  97. package/src/web/scrapers/rawg.ts +3 -3
  98. package/src/web/scrapers/readthedocs.ts +1 -1
  99. package/src/web/scrapers/spdx.ts +1 -1
  100. package/src/web/scrapers/stackoverflow.ts +2 -2
  101. package/src/web/scrapers/types.ts +53 -39
  102. package/src/web/scrapers/w3c.ts +1 -1
  103. package/src/web/search/index.ts +5 -5
  104. package/src/web/search/provider.ts +121 -39
  105. package/src/web/search/providers/gemini.ts +4 -4
  106. package/src/web/search/render.ts +2 -2
  107. package/src/ipy/modules.ts +0 -144
  108. package/src/prompts/tools/python.md +0 -57
  109. package/src/tools/browser/vm.ts +0 -792
  110. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  111. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  112. /package/src/{ipy → eval/py}/runtime.ts +0 -0
@@ -0,0 +1,1006 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import * as vm from "node:vm";
5
+ import { Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
6
+ import type { HTMLElement } from "linkedom";
7
+ import type {
8
+ Browser,
9
+ Dialog,
10
+ ElementHandle,
11
+ HTTPResponse,
12
+ KeyInput,
13
+ Page,
14
+ SerializedAXNode,
15
+ Target,
16
+ } from "puppeteer-core";
17
+ import { resizeImage } from "../../utils/image-resize";
18
+ import { resolveToCwd } from "../path-utils";
19
+ import { formatScreenshot } from "../render-utils";
20
+ import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors";
21
+ import {
22
+ applyStealthPatches,
23
+ applyViewport,
24
+ BROWSER_PROTOCOL_TIMEOUT_MS,
25
+ DEFAULT_VIEWPORT,
26
+ loadPuppeteerInWorker,
27
+ } from "./launch";
28
+ import { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./readable";
29
+ import type {
30
+ Observation,
31
+ ObservationEntry,
32
+ ReadyInfo,
33
+ RunErrorPayload,
34
+ RunResultOk,
35
+ ScreenshotResult,
36
+ SessionSnapshot,
37
+ Transport,
38
+ WorkerInbound,
39
+ WorkerInitPayload,
40
+ } from "./tab-protocol";
41
+
42
+ declare global {
43
+ interface Element extends HTMLElement {}
44
+ function getComputedStyle(element: Element): Record<string, unknown>;
45
+ var innerWidth: number;
46
+ var innerHeight: number;
47
+ var document: {
48
+ elementFromPoint(x: number, y: number): Element | null;
49
+ };
50
+ }
51
+
52
+ const INTERACTIVE_AX_ROLES = new Set([
53
+ "button",
54
+ "link",
55
+ "textbox",
56
+ "combobox",
57
+ "listbox",
58
+ "option",
59
+ "checkbox",
60
+ "radio",
61
+ "switch",
62
+ "tab",
63
+ "menuitem",
64
+ "menuitemcheckbox",
65
+ "menuitemradio",
66
+ "slider",
67
+ "spinbutton",
68
+ "searchbox",
69
+ "treeitem",
70
+ ]);
71
+
72
+ const LEGACY_SELECTOR_PREFIXES = ["p-aria/", "p-text/", "p-xpath/", "p-pierce/"] as const;
73
+
74
+ type DialogPolicy = "accept" | "dismiss";
75
+ type DragTarget = string | { readonly x: number; readonly y: number };
76
+ type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string };
77
+
78
+ interface ScreenshotOptions {
79
+ selector?: string;
80
+ fullPage?: boolean;
81
+ save?: string;
82
+ silent?: boolean;
83
+ }
84
+
85
+ interface TabApi {
86
+ readonly name: string;
87
+ readonly page: Page;
88
+ readonly signal?: AbortSignal;
89
+ url(): string;
90
+ title(): Promise<string>;
91
+ goto(
92
+ url: string,
93
+ opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2" },
94
+ ): Promise<void>;
95
+ observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise<Observation>;
96
+ screenshot(opts?: ScreenshotOptions): Promise<ScreenshotResult>;
97
+ extract(format?: ReadableFormat): Promise<ReadableResult | null>;
98
+ click(selector: string): Promise<void>;
99
+ type(selector: string, text: string): Promise<void>;
100
+ fill(selector: string, value: string): Promise<void>;
101
+ press(key: KeyInput, opts?: { selector?: string }): Promise<void>;
102
+ scroll(deltaX: number, deltaY: number): Promise<void>;
103
+ drag(from: DragTarget, to: DragTarget): Promise<void>;
104
+ waitFor(selector: string): Promise<ElementHandle>;
105
+ evaluate<TResult, TArgs extends unknown[]>(
106
+ fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
107
+ ...args: TArgs
108
+ ): Promise<TResult>;
109
+ scrollIntoView(selector: string): Promise<void>;
110
+ select(selector: string, ...values: string[]): Promise<string[]>;
111
+ uploadFile(selector: string, ...filePaths: string[]): Promise<void>;
112
+ waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise<string>;
113
+ waitForResponse(
114
+ pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise<boolean>),
115
+ opts?: { timeout?: number },
116
+ ): Promise<HTTPResponse>;
117
+ id(n: number): Promise<ElementHandle>;
118
+ }
119
+
120
+ function normalizeSelector(selector: string): string {
121
+ if (!selector) return selector;
122
+ if (selector.startsWith("p-") && !LEGACY_SELECTOR_PREFIXES.some(prefix => selector.startsWith(prefix))) {
123
+ throw new ToolError(
124
+ `Unsupported selector prefix. Use CSS or puppeteer query handlers (aria/, text/, xpath/, pierce/). Got: ${selector}`,
125
+ );
126
+ }
127
+ if (selector.startsWith("p-text/")) return `text/${selector.slice("p-text/".length)}`;
128
+ if (selector.startsWith("p-xpath/")) return `xpath/${selector.slice("p-xpath/".length)}`;
129
+ if (selector.startsWith("p-pierce/")) return `pierce/${selector.slice("p-pierce/".length)}`;
130
+ if (selector.startsWith("p-aria/")) {
131
+ const rest = selector.slice("p-aria/".length);
132
+ const nameMatch = rest.match(/\[\s*name\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\]]+))\s*\]/);
133
+ const name = nameMatch?.[1] ?? nameMatch?.[2] ?? nameMatch?.[3];
134
+ if (name) return `aria/${name.trim()}`;
135
+ return `aria/${rest}`;
136
+ }
137
+ return selector;
138
+ }
139
+
140
+ function isInteractiveNode(node: SerializedAXNode): boolean {
141
+ if (INTERACTIVE_AX_ROLES.has(node.role)) return true;
142
+ return (
143
+ node.checked !== undefined ||
144
+ node.pressed !== undefined ||
145
+ node.selected !== undefined ||
146
+ node.expanded !== undefined ||
147
+ node.focused === true
148
+ );
149
+ }
150
+
151
+ function asElementHandle(handle: unknown): ElementHandle | null {
152
+ return handle ? (handle as ElementHandle) : null;
153
+ }
154
+
155
+ function cloneSafe(value: unknown): unknown {
156
+ if (value === undefined) return undefined;
157
+ try {
158
+ structuredClone(value);
159
+ return value;
160
+ } catch {}
161
+ try {
162
+ return JSON.parse(JSON.stringify(value)) as unknown;
163
+ } catch {}
164
+ return String(value);
165
+ }
166
+
167
+ function errorPayload(error: unknown): RunErrorPayload {
168
+ if (error instanceof ToolAbortError) {
169
+ return { name: error.name, message: error.message, stack: error.stack, isToolError: false, isAbort: true };
170
+ }
171
+ if (error instanceof ToolError) {
172
+ return { name: error.name, message: error.message, stack: error.stack, isToolError: true, isAbort: false };
173
+ }
174
+ if (error instanceof Error) {
175
+ return { name: error.name, message: error.message, stack: error.stack, isToolError: false, isAbort: false };
176
+ }
177
+ return { name: "Error", message: String(error), isToolError: false, isAbort: false };
178
+ }
179
+
180
+ async function targetIdForTarget(target: Target): Promise<string> {
181
+ const raw = target as unknown as { _targetId?: unknown };
182
+ if (typeof raw._targetId === "string") return raw._targetId;
183
+ const session = await target.createCDPSession();
184
+ try {
185
+ const info = (await session.send("Target.getTargetInfo")) as { targetInfo?: { targetId?: string } };
186
+ if (info.targetInfo?.targetId) return info.targetInfo.targetId;
187
+ throw new ToolError("Target id unavailable from CDP target info");
188
+ } finally {
189
+ await session.detach().catch(() => undefined);
190
+ }
191
+ }
192
+
193
+ async function targetIdForPage(page: Page): Promise<string> {
194
+ return await targetIdForTarget(page.target());
195
+ }
196
+
197
+ async function collectObservationEntries(
198
+ core: WorkerCore,
199
+ node: SerializedAXNode,
200
+ entries: ObservationEntry[],
201
+ options: { viewportOnly: boolean; includeAll: boolean },
202
+ ): Promise<void> {
203
+ if (options.includeAll || isInteractiveNode(node)) {
204
+ const handle = await node.elementHandle();
205
+ if (handle) {
206
+ let inViewport = true;
207
+ if (options.viewportOnly) {
208
+ try {
209
+ inViewport = await handle.isIntersectingViewport();
210
+ } catch {
211
+ inViewport = false;
212
+ }
213
+ }
214
+ if (inViewport) {
215
+ const id = core.nextElementId();
216
+ const states: string[] = [];
217
+ if (node.disabled) states.push("disabled");
218
+ if (node.checked !== undefined) states.push(`checked=${String(node.checked)}`);
219
+ if (node.pressed !== undefined) states.push(`pressed=${String(node.pressed)}`);
220
+ if (node.selected !== undefined) states.push(`selected=${String(node.selected)}`);
221
+ if (node.expanded !== undefined) states.push(`expanded=${String(node.expanded)}`);
222
+ if (node.required) states.push("required");
223
+ if (node.readonly) states.push("readonly");
224
+ if (node.multiselectable) states.push("multiselectable");
225
+ if (node.multiline) states.push("multiline");
226
+ if (node.modal) states.push("modal");
227
+ if (node.focused) states.push("focused");
228
+ core.cacheElement(id, handle as ElementHandle);
229
+ entries.push({
230
+ id,
231
+ role: node.role,
232
+ name: node.name,
233
+ value: node.value,
234
+ description: node.description,
235
+ keyshortcuts: node.keyshortcuts,
236
+ states,
237
+ });
238
+ } else {
239
+ await handle.dispose();
240
+ }
241
+ }
242
+ }
243
+ for (const child of node.children ?? []) {
244
+ await collectObservationEntries(core, child, entries, options);
245
+ }
246
+ }
247
+
248
+ async function resolveActionableQueryHandlerClickTarget(handles: ElementHandle[]): Promise<ElementHandle | null> {
249
+ const candidates: Array<{
250
+ handle: ElementHandle;
251
+ rect: { x: number; y: number; w: number; h: number };
252
+ ownedProxy?: ElementHandle;
253
+ }> = [];
254
+ for (const handle of handles) {
255
+ let clickable: ElementHandle = handle;
256
+ let clickableProxy: ElementHandle | null = null;
257
+ try {
258
+ const proxy = await handle.evaluateHandle(el => {
259
+ const target =
260
+ (el as Element).closest(
261
+ 'a,button,[role="button"],[role="link"],input[type="button"],input[type="submit"]',
262
+ ) ?? el;
263
+ return target;
264
+ });
265
+ clickableProxy = asElementHandle(proxy.asElement());
266
+ if (clickableProxy) clickable = clickableProxy;
267
+ } catch {}
268
+ try {
269
+ const intersecting = await clickable.isIntersectingViewport();
270
+ if (!intersecting) continue;
271
+ const rect = (await clickable.evaluate(el => {
272
+ const r = (el as Element).getBoundingClientRect();
273
+ return { x: r.left, y: r.top, w: r.width, h: r.height };
274
+ })) as { x: number; y: number; w: number; h: number };
275
+ if (rect.w < 1 || rect.h < 1) continue;
276
+ candidates.push({ handle: clickable, rect, ownedProxy: clickableProxy ?? undefined });
277
+ } catch {
278
+ } finally {
279
+ if (clickableProxy && clickableProxy !== handle && clickable !== clickableProxy) {
280
+ await clickableProxy.dispose().catch(() => undefined);
281
+ }
282
+ }
283
+ }
284
+ if (!candidates.length) return null;
285
+ candidates.sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x);
286
+ const winner = candidates[0]?.handle ?? null;
287
+ for (let i = 1; i < candidates.length; i++) {
288
+ const candidate = candidates[i]!;
289
+ if (candidate.ownedProxy) await candidate.ownedProxy.dispose().catch(() => undefined);
290
+ }
291
+ return winner;
292
+ }
293
+
294
+ async function isClickActionable(handle: ElementHandle): Promise<ActionabilityResult> {
295
+ return (await handle.evaluate(el => {
296
+ const element = el as HTMLElement;
297
+ const style = globalThis.getComputedStyle(element);
298
+ if (style.display === "none") return { ok: false as const, reason: "display:none" };
299
+ if (style.visibility === "hidden") return { ok: false as const, reason: "visibility:hidden" };
300
+ if (style.pointerEvents === "none") return { ok: false as const, reason: "pointer-events:none" };
301
+ if (Number(style.opacity) === 0) return { ok: false as const, reason: "opacity:0" };
302
+ const r = element.getBoundingClientRect();
303
+ if (r.width < 1 || r.height < 1) return { ok: false as const, reason: "zero-size" };
304
+ const left = Math.max(0, Math.min(globalThis.innerWidth, r.left));
305
+ const right = Math.max(0, Math.min(globalThis.innerWidth, r.right));
306
+ const top = Math.max(0, Math.min(globalThis.innerHeight, r.top));
307
+ const bottom = Math.max(0, Math.min(globalThis.innerHeight, r.bottom));
308
+ if (right - left < 1 || bottom - top < 1) return { ok: false as const, reason: "off-viewport" };
309
+ const x = Math.floor((left + right) / 2);
310
+ const y = Math.floor((top + bottom) / 2);
311
+ const topEl = globalThis.document.elementFromPoint(x, y);
312
+ if (!topEl) return { ok: false as const, reason: "elementFromPoint-null" };
313
+ if (topEl === element || element.contains(topEl) || (topEl as Element).contains(element))
314
+ return { ok: true as const, x, y };
315
+ return { ok: false as const, reason: "obscured" };
316
+ })) as ActionabilityResult;
317
+ }
318
+
319
+ async function clickQueryHandlerText(
320
+ page: Page,
321
+ selector: string,
322
+ timeoutMs: number,
323
+ signal?: AbortSignal,
324
+ ): Promise<void> {
325
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
326
+ const clickSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
327
+ const start = Date.now();
328
+ let lastSeen = 0;
329
+ let lastReason: string | null = null;
330
+ while (Date.now() - start < timeoutMs) {
331
+ throwIfAborted(clickSignal);
332
+ const handles = (await untilAborted(clickSignal, () => page.$$(selector))) as ElementHandle[];
333
+ try {
334
+ lastSeen = handles.length;
335
+ const target = await resolveActionableQueryHandlerClickTarget(handles);
336
+ if (!target) {
337
+ lastReason = handles.length ? "no-visible-candidate" : "no-matches";
338
+ await Bun.sleep(100);
339
+ continue;
340
+ }
341
+ const actionability = await isClickActionable(target);
342
+ if (!actionability.ok) {
343
+ lastReason = actionability.reason;
344
+ await Bun.sleep(100);
345
+ continue;
346
+ }
347
+ try {
348
+ await untilAborted(clickSignal, () => target.click());
349
+ return;
350
+ } catch (err) {
351
+ lastReason = err instanceof Error ? err.message : String(err);
352
+ await Bun.sleep(100);
353
+ }
354
+ } finally {
355
+ await Promise.all(handles.map(async handle => handle.dispose().catch(() => undefined)));
356
+ }
357
+ }
358
+ throw new ToolError(
359
+ `Timed out clicking ${selector} (seen ${lastSeen} matches; last reason: ${lastReason ?? "unknown"}). ` +
360
+ "If there are multiple matching elements, use observe + tab.id() or a more specific selector.",
361
+ );
362
+ }
363
+
364
+ export class WorkerCore {
365
+ #transport: Transport;
366
+ #browser?: Browser;
367
+ #page?: Page;
368
+ #targetId?: string;
369
+ #elementCache = new Map<number, ElementHandle>();
370
+ #elementCounter = 0;
371
+ #active?: { id: string; ac: AbortController };
372
+ #unsub: () => void;
373
+ #mode?: WorkerInitPayload["mode"];
374
+ #dialogPolicy?: DialogPolicy;
375
+ #dialogHandler?: (dialog: Dialog) => void;
376
+
377
+ constructor(transport: Transport) {
378
+ this.#transport = transport;
379
+ this.#unsub = this.#transport.onMessage(msg => {
380
+ void this.#handleMessage(msg as WorkerInbound);
381
+ });
382
+ }
383
+
384
+ nextElementId(): number {
385
+ this.#elementCounter += 1;
386
+ return this.#elementCounter;
387
+ }
388
+
389
+ cacheElement(id: number, handle: ElementHandle): void {
390
+ this.#elementCache.set(id, handle);
391
+ }
392
+
393
+ async #handleMessage(msg: WorkerInbound): Promise<void> {
394
+ switch (msg.type) {
395
+ case "init":
396
+ await this.#init(msg.payload);
397
+ return;
398
+ case "run":
399
+ await this.#run(msg);
400
+ return;
401
+ case "abort":
402
+ if (this.#active?.id === msg.id) this.#active.ac.abort(new ToolAbortError());
403
+ return;
404
+ case "close":
405
+ await this.#close();
406
+ return;
407
+ }
408
+ }
409
+
410
+ async #init(payload: WorkerInitPayload): Promise<void> {
411
+ try {
412
+ this.#mode = payload.mode;
413
+ const puppeteer = await loadPuppeteerInWorker(payload.safeDir);
414
+ this.#browser = await puppeteer.connect({
415
+ browserWSEndpoint: payload.browserWSEndpoint,
416
+ defaultViewport: null,
417
+ protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
418
+ });
419
+ if (payload.mode === "headless") {
420
+ this.#page = await this.#browser.newPage();
421
+ await applyStealthPatches(this.#browser, this.#page, { browserSession: null, override: null });
422
+ await applyViewport(this.#page, payload.viewport);
423
+ if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs);
424
+ if (payload.url) {
425
+ await this.#page.goto(payload.url, {
426
+ waitUntil: payload.waitUntil ?? "networkidle2",
427
+ timeout: payload.timeoutMs,
428
+ });
429
+ }
430
+ } else {
431
+ this.#page = await this.#findAttachedPage(payload.targetId);
432
+ if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs);
433
+ }
434
+ this.#targetId = await targetIdForPage(this.#page);
435
+ this.#transport.send({ type: "ready", info: await this.#currentReadyInfo() });
436
+ } catch (error) {
437
+ this.#transport.send({ type: "init-failed", error: errorPayload(error) });
438
+ }
439
+ }
440
+
441
+ async #findAttachedPage(targetId: string): Promise<Page> {
442
+ if (!this.#browser) throw new ToolError("Browser is not connected");
443
+ for (const target of this.#browser.targets()) {
444
+ if ((await targetIdForTarget(target).catch(() => "")) !== targetId) continue;
445
+ const page = await target.page();
446
+ if (!page) break;
447
+ return page;
448
+ }
449
+ throw new ToolError(`Target ${targetId} is no longer available on the attached browser`);
450
+ }
451
+
452
+ async #currentReadyInfo(): Promise<ReadyInfo> {
453
+ const page = this.#requirePage();
454
+ const targetId = this.#targetId ?? (await targetIdForPage(page));
455
+ this.#targetId = targetId;
456
+ return {
457
+ url: page.url(),
458
+ title: await page.title().catch(() => undefined),
459
+ viewport: page.viewport() ?? DEFAULT_VIEWPORT,
460
+ targetId,
461
+ };
462
+ }
463
+
464
+ #applyDialogPolicy(policy: DialogPolicy): void {
465
+ const page = this.#requirePage();
466
+ if (this.#dialogPolicy === policy && this.#dialogHandler) return;
467
+ if (this.#dialogHandler) page.off("dialog", this.#dialogHandler);
468
+ const handler = (dialog: Dialog): void => {
469
+ const action = policy === "accept" ? dialog.accept() : dialog.dismiss();
470
+ void action.catch(err =>
471
+ this.#log("debug", "Dialog auto-handler failed", {
472
+ policy,
473
+ error: err instanceof Error ? err.message : String(err),
474
+ }),
475
+ );
476
+ };
477
+ page.on("dialog", handler);
478
+ this.#dialogPolicy = policy;
479
+ this.#dialogHandler = handler;
480
+ }
481
+
482
+ async #postReadyInfo(): Promise<void> {
483
+ try {
484
+ this.#transport.send({ type: "ready", info: await this.#currentReadyInfo() });
485
+ } catch (error) {
486
+ this.#log("debug", "Failed to refresh tab info", {
487
+ error: error instanceof Error ? error.message : String(error),
488
+ });
489
+ }
490
+ }
491
+
492
+ async #run(msg: Extract<WorkerInbound, { type: "run" }>): Promise<void> {
493
+ if (this.#active) {
494
+ this.#transport.send({
495
+ type: "result",
496
+ id: msg.id,
497
+ ok: false,
498
+ error: errorPayload(new ToolError("Tab worker is busy")),
499
+ });
500
+ return;
501
+ }
502
+ const timeoutSignal = AbortSignal.timeout(msg.timeoutMs);
503
+ const ac = new AbortController();
504
+ const signal = AbortSignal.any([timeoutSignal, ac.signal]);
505
+ this.#active = { id: msg.id, ac };
506
+ const displays: RunResultOk["displays"] = [];
507
+ const screenshots: ScreenshotResult[] = [];
508
+ try {
509
+ throwIfAborted(signal);
510
+ const page = this.#requirePage();
511
+ const browser = this.#requireBrowser();
512
+ const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, displays, screenshots);
513
+ const ctx = vm.createContext({
514
+ page,
515
+ browser,
516
+ tab: tabApi,
517
+ display: (value: unknown): void => this.#display(displays, value),
518
+ assert: (cond: unknown, text?: string): void => {
519
+ if (!cond) throw new ToolError(text ?? "Assertion failed");
520
+ },
521
+ wait: (ms: number): Promise<void> => Bun.sleep(ms),
522
+ console: this.#console(),
523
+ setTimeout,
524
+ clearTimeout,
525
+ setInterval,
526
+ clearInterval,
527
+ queueMicrotask,
528
+ Promise,
529
+ URL,
530
+ URLSearchParams,
531
+ TextEncoder,
532
+ TextDecoder,
533
+ Buffer,
534
+ });
535
+ const wrapped = `(async () => {\n${msg.code}\n})()`;
536
+ const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers<never>();
537
+ const onCancel = (): void => {
538
+ rejectCancel(
539
+ timeoutSignal.aborted
540
+ ? new ToolError(`Browser code execution timed out after ${msg.timeoutMs}ms`)
541
+ : new ToolAbortError(),
542
+ );
543
+ };
544
+ if (signal.aborted) onCancel();
545
+ else signal.addEventListener("abort", onCancel, { once: true });
546
+ try {
547
+ const returnValue = await Promise.race([
548
+ vm.runInContext(wrapped, ctx, {
549
+ filename: `browser-run-${msg.id}.js`,
550
+ lineOffset: -1,
551
+ }) as Promise<unknown>,
552
+ cancelRejection,
553
+ ]);
554
+ await this.#postReadyInfo();
555
+ this.#transport.send({
556
+ type: "result",
557
+ id: msg.id,
558
+ ok: true,
559
+ payload: { displays, returnValue: cloneSafe(returnValue), screenshots },
560
+ });
561
+ } finally {
562
+ signal.removeEventListener("abort", onCancel);
563
+ }
564
+ } catch (error) {
565
+ this.#transport.send({ type: "result", id: msg.id, ok: false, error: errorPayload(error) });
566
+ } finally {
567
+ if (this.#active?.id === msg.id) this.#active = undefined;
568
+ }
569
+ }
570
+
571
+ #createTabApi(
572
+ name: string,
573
+ timeoutMs: number,
574
+ signal: AbortSignal,
575
+ session: SessionSnapshot,
576
+ displays: RunResultOk["displays"],
577
+ screenshots: ScreenshotResult[],
578
+ ): TabApi {
579
+ const page = this.#requirePage();
580
+ return {
581
+ name,
582
+ page,
583
+ signal,
584
+ url: () => page.url(),
585
+ title: () => page.title(),
586
+ goto: async (url, opts) => {
587
+ this.#clearElementCache();
588
+ await untilAborted(signal, () =>
589
+ page.goto(url, { waitUntil: opts?.waitUntil ?? "networkidle2", timeout: timeoutMs }),
590
+ );
591
+ },
592
+ observe: opts => this.#collectObservation({ ...opts, signal }),
593
+ screenshot: async opts => await this.#captureScreenshot(session, displays, screenshots, signal, opts),
594
+ extract: async (format = "markdown") => {
595
+ const html = (await untilAborted(signal, () => page.content())) as string;
596
+ return extractReadableFromHtml(html, page.url(), format);
597
+ },
598
+ click: async selector => {
599
+ const resolved = normalizeSelector(selector);
600
+ if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, timeoutMs, signal);
601
+ else await untilAborted(signal, () => page.locator(resolved).setTimeout(timeoutMs).click());
602
+ },
603
+ type: async (selector, text) => {
604
+ const handle = (await untilAborted(signal, () =>
605
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
606
+ )) as ElementHandle;
607
+ try {
608
+ await untilAborted(signal, () => handle.type(text, { delay: 0 }));
609
+ } finally {
610
+ await handle.dispose();
611
+ }
612
+ },
613
+ fill: async (selector, value) => {
614
+ await untilAborted(signal, () =>
615
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).fill(value),
616
+ );
617
+ },
618
+ press: async (key, opts) => {
619
+ const selector = opts?.selector;
620
+ if (selector) await untilAborted(signal, () => page.focus(normalizeSelector(selector)));
621
+ await untilAborted(signal, () => page.keyboard.press(key));
622
+ },
623
+ scroll: async (deltaX, deltaY) => {
624
+ await untilAborted(signal, () => page.mouse.wheel({ deltaX, deltaY }));
625
+ },
626
+ drag: async (from, to) => await this.#drag(from, to, signal),
627
+ waitFor: async selector =>
628
+ (await untilAborted(signal, () =>
629
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
630
+ )) as ElementHandle,
631
+ evaluate: async (fn, ...args) =>
632
+ (await untilAborted(signal, () =>
633
+ typeof fn === "string" ? page.evaluate(fn) : page.evaluate(fn as (...a: unknown[]) => unknown, ...args),
634
+ )) as never,
635
+ scrollIntoView: async selector => {
636
+ const handle = (await untilAborted(signal, () =>
637
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
638
+ )) as ElementHandle;
639
+ try {
640
+ await untilAborted(signal, () =>
641
+ handle.evaluate(el => {
642
+ const target = el as unknown as {
643
+ scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void;
644
+ };
645
+ target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
646
+ }),
647
+ );
648
+ } finally {
649
+ await handle.dispose().catch(() => undefined);
650
+ }
651
+ },
652
+ select: async (selector, ...values) => await this.#select(selector, values, timeoutMs, signal),
653
+ uploadFile: async (selector, ...filePaths) =>
654
+ await this.#uploadFile(selector, filePaths, timeoutMs, signal, session),
655
+ waitForUrl: async (pattern, opts) => await this.#waitForUrl(pattern, opts?.timeout ?? timeoutMs, signal),
656
+ waitForResponse: async (pattern, opts) =>
657
+ await this.#waitForResponse(pattern, opts?.timeout ?? timeoutMs, signal),
658
+ id: async id => await this.#resolveCachedHandle(id),
659
+ };
660
+ }
661
+
662
+ async #collectObservation(options: {
663
+ includeAll?: boolean;
664
+ viewportOnly?: boolean;
665
+ signal?: AbortSignal;
666
+ }): Promise<Observation> {
667
+ const page = this.#requirePage();
668
+ this.#clearElementCache();
669
+ const includeAll = options.includeAll ?? false;
670
+ const viewportOnly = options.viewportOnly ?? false;
671
+ const snapshot = (await untilAborted(options.signal, () =>
672
+ page.accessibility.snapshot({ interestingOnly: !includeAll }),
673
+ )) as SerializedAXNode | null;
674
+ if (!snapshot) throw new ToolError("Accessibility snapshot unavailable");
675
+ const entries: ObservationEntry[] = [];
676
+ await collectObservationEntries(this, snapshot, entries, { includeAll, viewportOnly });
677
+ const scroll = (await untilAborted(options.signal, () =>
678
+ page.evaluate(() => {
679
+ const win = globalThis as unknown as {
680
+ scrollX: number;
681
+ scrollY: number;
682
+ innerWidth: number;
683
+ innerHeight: number;
684
+ document: { documentElement: { scrollWidth: number; scrollHeight: number } };
685
+ };
686
+ const doc = win.document.documentElement;
687
+ return {
688
+ x: win.scrollX,
689
+ y: win.scrollY,
690
+ width: win.innerWidth,
691
+ height: win.innerHeight,
692
+ scrollWidth: doc.scrollWidth,
693
+ scrollHeight: doc.scrollHeight,
694
+ };
695
+ }),
696
+ )) as Observation["scroll"];
697
+ return {
698
+ url: page.url(),
699
+ title: (await untilAborted(options.signal, () => page.title())) as string,
700
+ viewport: page.viewport() ?? DEFAULT_VIEWPORT,
701
+ scroll,
702
+ elements: entries,
703
+ };
704
+ }
705
+
706
+ async #captureScreenshot(
707
+ session: SessionSnapshot,
708
+ displays: RunResultOk["displays"],
709
+ screenshots: ScreenshotResult[],
710
+ signal: AbortSignal | undefined,
711
+ opts: ScreenshotOptions = {},
712
+ ): Promise<ScreenshotResult> {
713
+ const page = this.#requirePage();
714
+ const fullPage = opts.selector ? false : (opts.fullPage ?? false);
715
+ let buffer: Buffer;
716
+ if (opts.selector) {
717
+ const handle = (await untilAborted(signal, () =>
718
+ page.$(normalizeSelector(opts.selector!)),
719
+ )) as ElementHandle | null;
720
+ if (!handle) throw new ToolError("Screenshot selector did not resolve to an element");
721
+ try {
722
+ buffer = (await untilAborted(signal, () => handle.screenshot({ type: "png" }))) as Buffer;
723
+ } finally {
724
+ await handle.dispose().catch(() => undefined);
725
+ }
726
+ } else {
727
+ buffer = (await untilAborted(signal, () => page.screenshot({ type: "png", fullPage }))) as Buffer;
728
+ }
729
+ const resized = await resizeImage(
730
+ { type: "image", data: buffer.toBase64(), mimeType: "image/png" },
731
+ { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
732
+ );
733
+ const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
734
+ const dest =
735
+ explicitPath ??
736
+ (session.browserScreenshotDir
737
+ ? path.join(
738
+ session.browserScreenshotDir,
739
+ `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.png`,
740
+ )
741
+ : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`));
742
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
743
+ const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
744
+ const savedBuffer = saveFullRes ? buffer : resized.buffer;
745
+ const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
746
+ await Bun.write(dest, savedBuffer);
747
+ const info: ScreenshotResult = {
748
+ dest,
749
+ mimeType: savedMimeType,
750
+ bytes: savedBuffer.length,
751
+ width: resized.width,
752
+ height: resized.height,
753
+ };
754
+ screenshots.push(info);
755
+ if (!opts.silent) {
756
+ const lines = formatScreenshot({
757
+ saveFullRes,
758
+ savedMimeType,
759
+ savedByteLength: savedBuffer.length,
760
+ dest,
761
+ resized,
762
+ });
763
+ displays.push({ type: "text", text: lines.join("\n") });
764
+ displays.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
765
+ }
766
+ return info;
767
+ }
768
+
769
+ async #drag(from: DragTarget, to: DragTarget, signal: AbortSignal): Promise<void> {
770
+ const page = this.#requirePage();
771
+ const resolveDragPoint = async (
772
+ target: DragTarget,
773
+ role: "from" | "to",
774
+ ): Promise<{ x: number; y: number; handle?: ElementHandle }> => {
775
+ if (typeof target === "string") {
776
+ const handle = (await untilAborted(signal, () =>
777
+ page.$(normalizeSelector(target)),
778
+ )) as ElementHandle | null;
779
+ if (!handle) throw new ToolError(`Drag ${role} selector did not resolve: ${target}`);
780
+ const box = (await untilAborted(signal, () => handle.boundingBox())) as {
781
+ x: number;
782
+ y: number;
783
+ width: number;
784
+ height: number;
785
+ } | null;
786
+ if (!box) {
787
+ await handle.dispose().catch(() => undefined);
788
+ throw new ToolError(`Drag ${role} element has no bounding box (likely not visible): ${target}`);
789
+ }
790
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2, handle };
791
+ }
792
+ if (
793
+ target !== null &&
794
+ typeof target === "object" &&
795
+ typeof (target as { x: unknown }).x === "number" &&
796
+ typeof (target as { y: unknown }).y === "number"
797
+ ) {
798
+ return { x: (target as { x: number }).x, y: (target as { y: number }).y };
799
+ }
800
+ throw new ToolError(
801
+ `Drag ${role} must be a selector string or { x: number, y: number } point. Got: ${typeof target}`,
802
+ );
803
+ };
804
+ const start = await resolveDragPoint(from, "from");
805
+ let end: { x: number; y: number; handle?: ElementHandle } | undefined;
806
+ try {
807
+ end = await resolveDragPoint(to, "to");
808
+ await untilAborted(signal, () => page.mouse.move(start.x, start.y));
809
+ await untilAborted(signal, () => page.mouse.down());
810
+ await untilAborted(signal, () => page.mouse.move(end!.x, end!.y, { steps: 12 }));
811
+ await untilAborted(signal, () => page.mouse.up());
812
+ } finally {
813
+ if (start.handle) await start.handle.dispose().catch(() => undefined);
814
+ if (end?.handle) await end.handle.dispose().catch(() => undefined);
815
+ }
816
+ }
817
+
818
+ async #select(selector: string, values: string[], timeoutMs: number, signal: AbortSignal): Promise<string[]> {
819
+ const page = this.#requirePage();
820
+ const handle = (await untilAborted(signal, () =>
821
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
822
+ )) as ElementHandle;
823
+ try {
824
+ return (await untilAborted(signal, () =>
825
+ handle.evaluate((el, vals) => {
826
+ interface SelectOption {
827
+ value: string;
828
+ selected: boolean;
829
+ }
830
+ interface SelectLike {
831
+ tagName: string;
832
+ options: ArrayLike<SelectOption>;
833
+ dispatchEvent: (event: unknown) => boolean;
834
+ }
835
+ const select = el as unknown as SelectLike;
836
+ if (!select || select.tagName !== "SELECT") throw new Error("tab.select() requires a <select> element");
837
+ const EventCtor = (
838
+ globalThis as unknown as { Event: new (type: string, init?: { bubbles: boolean }) => unknown }
839
+ ).Event;
840
+ const wanted = new Set(vals as string[]);
841
+ const selected: string[] = [];
842
+ for (let i = 0; i < select.options.length; i++) {
843
+ const opt = select.options[i] as SelectOption;
844
+ opt.selected = wanted.has(opt.value);
845
+ if (opt.selected) selected.push(opt.value);
846
+ }
847
+ select.dispatchEvent(new EventCtor("input", { bubbles: true }));
848
+ select.dispatchEvent(new EventCtor("change", { bubbles: true }));
849
+ return selected;
850
+ }, values),
851
+ )) as string[];
852
+ } finally {
853
+ await handle.dispose().catch(() => undefined);
854
+ }
855
+ }
856
+
857
+ async #uploadFile(
858
+ selector: string,
859
+ filePaths: string[],
860
+ timeoutMs: number,
861
+ signal: AbortSignal,
862
+ session: SessionSnapshot,
863
+ ): Promise<void> {
864
+ if (!filePaths.length) throw new ToolError("tab.uploadFile() requires at least one file path");
865
+ const page = this.#requirePage();
866
+ const handle = (await untilAborted(signal, () =>
867
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
868
+ )) as ElementHandle;
869
+ try {
870
+ const absolute = filePaths.map(filePath => resolveToCwd(filePath, session.cwd));
871
+ const upload = handle as unknown as { uploadFile: (...paths: string[]) => Promise<void> };
872
+ const tagName = (await untilAborted(signal, () =>
873
+ handle.evaluate(el => (el as unknown as { tagName: string }).tagName),
874
+ )) as string;
875
+ if (tagName !== "INPUT")
876
+ throw new ToolError(
877
+ `tab.uploadFile() requires an <input type="file"> element (got <${tagName.toLowerCase()}>)`,
878
+ );
879
+ await untilAborted(signal, () => upload.uploadFile(...absolute));
880
+ } finally {
881
+ await handle.dispose().catch(() => undefined);
882
+ }
883
+ }
884
+
885
+ async #waitForUrl(pattern: string | RegExp, timeout: number, signal: AbortSignal): Promise<string> {
886
+ const page = this.#requirePage();
887
+ const isRegex = pattern instanceof RegExp;
888
+ const matcher = isRegex ? pattern.source : pattern;
889
+ const flags = isRegex ? pattern.flags : "";
890
+ await untilAborted(signal, () =>
891
+ page.waitForFunction(
892
+ (m: string, isRe: boolean, fl: string) => {
893
+ const url = (globalThis as unknown as { location: { href: string } }).location.href;
894
+ return isRe ? new RegExp(m, fl).test(url) : url.includes(m);
895
+ },
896
+ { timeout, polling: 200 },
897
+ matcher,
898
+ isRegex,
899
+ flags,
900
+ ),
901
+ );
902
+ return page.url();
903
+ }
904
+
905
+ async #waitForResponse(
906
+ pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise<boolean>),
907
+ timeout: number,
908
+ signal: AbortSignal,
909
+ ): Promise<HTTPResponse> {
910
+ const page = this.#requirePage();
911
+ const predicate: (response: HTTPResponse) => boolean | Promise<boolean> =
912
+ typeof pattern === "function"
913
+ ? pattern
914
+ : pattern instanceof RegExp
915
+ ? response => pattern.test(response.url())
916
+ : response => response.url().includes(pattern);
917
+ return (await untilAborted(signal, () => page.waitForResponse(predicate, { timeout }))) as HTTPResponse;
918
+ }
919
+
920
+ async #resolveCachedHandle(id: number): Promise<ElementHandle> {
921
+ const handle = this.#elementCache.get(id);
922
+ if (!handle) throw new ToolError(`Unknown element id ${id}. Run tab.observe() to refresh the element list.`);
923
+ try {
924
+ const isConnected = (await handle.evaluate(el => el.isConnected)) as boolean;
925
+ if (!isConnected) {
926
+ this.#clearElementCache();
927
+ throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
928
+ }
929
+ } catch (err) {
930
+ if (err instanceof ToolError) throw err;
931
+ this.#clearElementCache();
932
+ throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
933
+ }
934
+ return handle;
935
+ }
936
+
937
+ #display(displays: RunResultOk["displays"], value: unknown): void {
938
+ if (value === undefined || value === null) return;
939
+ if (
940
+ typeof value === "object" &&
941
+ value !== null &&
942
+ "type" in (value as Record<string, unknown>) &&
943
+ (value as { type?: unknown }).type === "image"
944
+ ) {
945
+ const img = value as { data?: unknown; mimeType?: unknown };
946
+ if (typeof img.data === "string" && typeof img.mimeType === "string") {
947
+ displays.push({ type: "image", data: img.data, mimeType: img.mimeType });
948
+ return;
949
+ }
950
+ }
951
+ if (typeof value === "string") {
952
+ displays.push({ type: "text", text: value });
953
+ return;
954
+ }
955
+ try {
956
+ displays.push({ type: "text", text: JSON.stringify(value, null, 2) });
957
+ } catch {
958
+ displays.push({ type: "text", text: String(value) });
959
+ }
960
+ }
961
+
962
+ #console(): Pick<Console, "log" | "debug" | "warn" | "error"> {
963
+ return {
964
+ log: (...args: unknown[]) => this.#log("debug", args.map(String).join(" ")),
965
+ debug: (...args: unknown[]) => this.#log("debug", args.map(String).join(" ")),
966
+ warn: (...args: unknown[]) => this.#log("warn", args.map(String).join(" ")),
967
+ error: (...args: unknown[]) => this.#log("error", args.map(String).join(" ")),
968
+ };
969
+ }
970
+
971
+ #clearElementCache(): void {
972
+ if (this.#elementCache.size === 0) {
973
+ this.#elementCounter = 0;
974
+ return;
975
+ }
976
+ const handles = [...this.#elementCache.values()];
977
+ this.#elementCache.clear();
978
+ this.#elementCounter = 0;
979
+ for (const handle of handles) void handle.dispose().catch(() => undefined);
980
+ }
981
+
982
+ async #close(): Promise<void> {
983
+ this.#unsub();
984
+ this.#clearElementCache();
985
+ const page = this.#page;
986
+ if (this.#dialogHandler && page && !page.isClosed()) page.off("dialog", this.#dialogHandler);
987
+ if (this.#mode === "headless" && page && !page.isClosed()) await page.close().catch(() => undefined);
988
+ if (this.#browser?.connected) this.#browser.disconnect();
989
+ this.#transport.send({ type: "closed" });
990
+ this.#transport.close();
991
+ }
992
+
993
+ #requirePage(): Page {
994
+ if (!this.#page) throw new ToolError("Tab worker is not initialized");
995
+ return this.#page;
996
+ }
997
+
998
+ #requireBrowser(): Browser {
999
+ if (!this.#browser) throw new ToolError("Tab worker is not initialized");
1000
+ return this.#browser;
1001
+ }
1002
+
1003
+ #log(level: "debug" | "warn" | "error", msg: string, meta?: Record<string, unknown>): void {
1004
+ this.#transport.send({ type: "log", level, msg, meta });
1005
+ }
1006
+ }