pi-browser-harness 0.3.1 → 0.4.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.
@@ -1,8 +1,9 @@
1
- import { type Result, err, ok } from "../util/result";
2
- import { type CdpError, cdpError } from "./errors";
1
+ import { type Result, ok } from "../util/result";
2
+ import type { CdpError } from "./errors";
3
3
  import type { DialogInfo } from "./types";
4
- import { isInternalUrl } from "./types";
4
+ import type { OwnershipRegistry } from "./ownership";
5
5
  import type { CdpTransport } from "./transport";
6
+ import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
6
7
 
7
8
  export type CdpSession = {
8
9
  attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
@@ -13,13 +14,18 @@ export type CdpSession = {
13
14
  callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
14
15
  takeDialog(): DialogInfo | null;
15
16
  drainPageInfoInvalidations(): boolean;
17
+ drainNetworkBuffer(filter: NetworkFilter): DrainResult;
16
18
  };
17
19
 
18
- export const createCdpSession = (transport: CdpTransport): CdpSession => {
20
+ export const createCdpSession = (
21
+ transport: CdpTransport,
22
+ ownership?: OwnershipRegistry,
23
+ ): CdpSession => {
19
24
  let sessionId: string | null = null;
20
25
  let targetId: string | null = null;
21
26
  let dialog: DialogInfo | null = null;
22
27
  let pageInfoDirty = false;
28
+ const networkBuffer = createNetworkBuffer();
23
29
 
24
30
  let activeConsumer: Promise<void> = Promise.resolve();
25
31
 
@@ -41,6 +47,14 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
41
47
  if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
42
48
  pageInfoDirty = true;
43
49
  }
50
+ if (ev.method === "Target.targetDestroyed" && ownership) {
51
+ const params = ev.params as { targetId?: string } | undefined;
52
+ if (params?.targetId) ownership.remove(params.targetId);
53
+ }
54
+ if (ev.method === "Network.requestWillBeSent") networkBuffer.ingestRequestWillBeSent(ev.params);
55
+ else if (ev.method === "Network.responseReceived") networkBuffer.ingestResponseReceived(ev.params);
56
+ else if (ev.method === "Network.loadingFinished") networkBuffer.ingestLoadingFinished(ev.params);
57
+ else if (ev.method === "Network.loadingFailed") networkBuffer.ingestLoadingFailed(ev.params);
44
58
  }
45
59
  };
46
60
 
@@ -68,7 +82,7 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
68
82
  // Switching to Promise.all over a single WS pipelines the round-trips and
69
83
  // saves ~3× on tab-switch latency. Defer until session.ts has tests.
70
84
  const enableDomains = async (sid: string): Promise<void> => {
71
- for (const d of ["Page", "DOM", "Runtime", "Network"]) {
85
+ for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility"]) {
72
86
  await transport.request(`${d}.enable`, {}, { sessionId: sid });
73
87
  }
74
88
  };
@@ -81,25 +95,51 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
81
95
  // as a clear Error in normal use.
82
96
  return {
83
97
  async attachFirstPage() {
98
+ // Subscribe to Target.* events so we can react to targetDestroyed.
99
+ // Best-effort: failing to enable discovery is not fatal for attach.
100
+ await transport.request("Target.setDiscoverTargets", { discover: true }, { sessionId: null });
101
+
84
102
  const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
85
103
  if (!targets.success) return targets;
86
104
  const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
87
- let pages = data.targetInfos.filter((t) => t.type === "page" && !isInternalUrl(t.url));
88
- if (pages.length === 0) {
89
- const created = await transport.request("Target.createTarget", { url: "about:blank" }, { sessionId: null });
105
+ const allPages = data.targetInfos.filter((t) => t.type === "page");
106
+ // Reconcile the persisted ownership set against live targets — drop dead IDs.
107
+ if (ownership) {
108
+ const live = new Set(allPages.map((p) => p.targetId));
109
+ const survivors = ownership.list().filter((id) => live.has(id));
110
+ if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
111
+ const hw = ownership.harnessWindow();
112
+ if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
113
+ }
114
+
115
+ // Prefer attaching to a tab this session already owns. Falls back to
116
+ // creating a fresh harness-owned tab in a dedicated window — never
117
+ // grabs the user's foreground tab.
118
+ let pickTargetId: string | undefined;
119
+ if (ownership) {
120
+ const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
121
+ pickTargetId = ownedLive[0];
122
+ }
123
+ if (!pickTargetId) {
124
+ const createParams: Record<string, unknown> = { url: "about:blank" };
125
+ if (ownership) createParams["newWindow"] = true;
126
+ const created = await transport.request("Target.createTarget", createParams, { sessionId: null });
90
127
  if (!created.success) return created;
91
128
  const c = created.data as { targetId: string };
92
- pages = [{ targetId: c.targetId, type: "page", url: "about:blank" }];
129
+ pickTargetId = c.targetId;
130
+ if (ownership) {
131
+ ownership.setHarnessWindow(c.targetId);
132
+ ownership.add(c.targetId);
133
+ }
93
134
  }
94
- const first = pages[0];
95
- if (!first) return err(cdpError("invalid_response", "no page targets after creation"));
96
- const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null });
135
+
136
+ const attached = await transport.request("Target.attachToTarget", { targetId: pickTargetId, flatten: true }, { sessionId: null });
97
137
  if (!attached.success) return attached;
98
138
  const a = attached.data as { sessionId: string };
99
139
  sessionId = a.sessionId;
100
- targetId = first.targetId;
140
+ targetId = pickTargetId;
101
141
  await enableDomains(a.sessionId);
102
- return ok({ targetId: first.targetId, sessionId: a.sessionId });
142
+ return ok({ targetId: pickTargetId, sessionId: a.sessionId });
103
143
  },
104
144
  async switchTo(tid) {
105
145
  const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
@@ -110,6 +150,9 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
110
150
  sessionId = a.sessionId;
111
151
  targetId = tid;
112
152
  pageInfoDirty = true;
153
+ // Buffer is page-scoped: on switch, the network history of the previous
154
+ // tab is no longer relevant to what the agent is now looking at.
155
+ networkBuffer.clear();
113
156
  await enableDomains(a.sessionId);
114
157
  return ok(undefined);
115
158
  },
@@ -135,5 +178,8 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
135
178
  pageInfoDirty = false;
136
179
  return dirty;
137
180
  },
181
+ drainNetworkBuffer(filter) {
182
+ return networkBuffer.drain(filter);
183
+ },
138
184
  };
139
185
  };
package/src/cdp/types.ts CHANGED
@@ -23,6 +23,7 @@ export type TabInfo = {
23
23
  readonly targetId: string;
24
24
  readonly title: string;
25
25
  readonly url: string;
26
+ readonly owned?: boolean;
26
27
  };
27
28
 
28
29
  export type PageInfo = {
package/src/client.ts CHANGED
@@ -1,15 +1,25 @@
1
1
  import { type Result, err, ok } from "./util/result";
2
2
  import { safeJs } from "./util/js-template";
3
+ import { type Mutex, createMutex } from "./util/mutex";
3
4
  import { discoverWsUrl } from "./cdp/discovery";
4
5
  import { type CdpError, cdpError } from "./cdp/errors";
5
6
  import type { CdpTransport } from "./cdp/transport";
6
7
  import { createCdpTransport } from "./cdp/transport";
7
8
  import { type CdpSession, createCdpSession } from "./cdp/session";
9
+ import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
8
10
  import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
9
11
 
10
12
  export type BrowserClientOptions = {
11
13
  readonly namespace: string;
12
14
  readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
15
+ readonly initialOwnership?: {
16
+ readonly ownedTargetIds?: ReadonlyArray<string>;
17
+ readonly harnessWindowTargetId?: string;
18
+ };
19
+ readonly onOwnershipChange?: (snapshot: {
20
+ readonly ownedTargetIds: ReadonlyArray<string>;
21
+ readonly harnessWindowTargetId: string | undefined;
22
+ }) => void;
13
23
  };
14
24
 
15
25
  export type BrowserClient = {
@@ -24,9 +34,15 @@ export type BrowserClient = {
24
34
  listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
25
35
  switchTab(targetId: string): Promise<Result<void, CdpError>>;
26
36
  newTab(url?: string): Promise<Result<string, CdpError>>;
37
+ closeTab(targetId: string): Promise<Result<void, CdpError>>;
38
+ owns(targetId: string): boolean;
39
+ ownership(): OwnershipRegistry;
27
40
  current(): { readonly sessionId: string; readonly targetId: string } | null;
28
41
  session(): CdpSession;
29
42
  transport(): CdpTransport;
43
+ /** Returns the shared async mutex that serialized browser tools must acquire
44
+ * before performing mutations. Observation tools should not use this. */
45
+ mutationMutex(): Mutex;
30
46
  };
31
47
 
32
48
  const HEALTH_TTL_MS = 30_000;
@@ -62,7 +78,25 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
62
78
 
63
79
  export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
64
80
  const transport = createCdpTransport();
65
- const session = createCdpSession(transport);
81
+ const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
82
+ if (opts.initialOwnership?.ownedTargetIds !== undefined) {
83
+ ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
84
+ }
85
+ if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
86
+ ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
87
+ }
88
+ const ownership = createOwnershipRegistry(ownershipInit);
89
+ if (opts.onOwnershipChange) {
90
+ const cb = opts.onOwnershipChange;
91
+ ownership.onChange(() => {
92
+ cb({
93
+ ownedTargetIds: ownership.list(),
94
+ harnessWindowTargetId: ownership.harnessWindow(),
95
+ });
96
+ });
97
+ }
98
+ const session = createCdpSession(transport, ownership);
99
+ const mutationMutex = createMutex();
66
100
  let lastHealth = 0;
67
101
  let pageCache: { readonly info: PageInfo; readonly at: number } | null = null;
68
102
  let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
@@ -181,7 +215,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
181
215
  const tabs = data.targetInfos
182
216
  .filter((t) => t.type === "page")
183
217
  .filter((t) => includeInternal || !t.url.startsWith("chrome://"))
184
- .map((t): TabInfo => ({ targetId: t.targetId, title: t.title, url: t.url }));
218
+ .map((t): TabInfo => ({
219
+ targetId: t.targetId,
220
+ title: t.title,
221
+ url: t.url,
222
+ owned: ownership.has(t.targetId),
223
+ }));
224
+ // Reconcile any persisted owned ids that no longer exist as live page targets.
225
+ const live = new Set(tabs.map((t) => t.targetId));
226
+ const owned = ownership.list();
227
+ const survivors = owned.filter((id) => live.has(id));
228
+ if (survivors.length !== owned.length) ownership.replaceAll(survivors);
229
+ const hw = ownership.harnessWindow();
230
+ if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
185
231
  return ok(tabs);
186
232
  };
187
233
 
@@ -199,9 +245,30 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
199
245
  };
200
246
 
201
247
  const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
202
- const created = await session.callBrowser("Target.createTarget", { url: "about:blank" });
248
+ // Verify the recorded harness window still exists. Querying the live
249
+ // target list also gives listTabs's reconciliation a chance to run.
250
+ const tabsResult = await listTabs(true);
251
+ if (!tabsResult.success) return tabsResult;
252
+ const live = new Set(tabsResult.data.map((t) => t.targetId));
253
+ const hw = ownership.harnessWindow();
254
+
255
+ const params: Record<string, unknown> = { url: "about:blank" };
256
+ if (hw && live.has(hw)) {
257
+ // Open as a child of the harness window's seed tab — Chrome places
258
+ // it in the same window, giving us visual grouping for free.
259
+ params["openerId"] = hw;
260
+ } else {
261
+ // Either fresh session or the harness window was closed by the user.
262
+ // Spawn a new dedicated window.
263
+ params["newWindow"] = true;
264
+ }
265
+ const created = await session.callBrowser("Target.createTarget", params);
203
266
  if (!created.success) return created;
204
267
  const c = created.data as { targetId: string };
268
+
269
+ if (params["newWindow"]) ownership.setHarnessWindow(c.targetId);
270
+ ownership.add(c.targetId);
271
+
205
272
  const switched = await switchTab(c.targetId);
206
273
  if (!switched.success) return switched;
207
274
  if (url && url !== "about:blank") {
@@ -212,6 +279,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
212
279
  return ok(c.targetId);
213
280
  };
214
281
 
282
+ const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
283
+ const r = await session.callBrowser("Target.closeTarget", { targetId });
284
+ if (!r.success) return r;
285
+ ownership.remove(targetId);
286
+ return ok(undefined);
287
+ };
288
+
215
289
  const status = (): DaemonStatus => ({
216
290
  alive: transport.state() === "open" && session.current() !== null,
217
291
  sessionId: session.current()?.sessionId ?? null,
@@ -224,9 +298,12 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
224
298
  ensureAlive, status, start, stop,
225
299
  evaluateJs, pageInfo,
226
300
  takeDialog: () => session.takeDialog(),
227
- listTabs, switchTab, newTab,
301
+ listTabs, switchTab, newTab, closeTab,
302
+ owns: (id: string) => ownership.has(id),
303
+ ownership: () => ownership,
228
304
  current: () => session.current(),
229
305
  session: () => session,
230
306
  transport: () => transport,
307
+ mutationMutex: () => mutationMutex,
231
308
  };
232
309
  };
@@ -41,14 +41,15 @@ export const clickTool = defineBrowserTool({
41
41
  name: "browser_click",
42
42
  label: "Browser Click",
43
43
  description:
44
- "Click at viewport CSS-pixel coordinates. Compositor-level click works through iframes, shadow DOM, and cross-origin content. Use browser_screenshot first to find coordinates.",
44
+ "Click at viewport CSS-pixel coordinates. Compositor-level click works through iframes, shadow DOM, and cross-origin content. Get coordinates from browser_snapshot's @(x,y) hints — do not screenshot to find click targets.",
45
45
  promptSnippet: "Click at pixel coordinates (x, y) on the page",
46
46
  promptGuidelines: [
47
- "Use browser_click for all clicks. Coordinates are viewport CSS pixels (not device pixels).",
48
- "Capture a browser_screenshot BEFORE clicking to find the right coordinates.",
49
- "Capture another browser_screenshot AFTER clicking to verify the action worked.",
47
+ "Get (x, y) from browser_snapshot — the outline shows '@(x,y)' for every interactive element. NO screenshot needed.",
48
+ "If the target isn't an interactive role in the snapshot, fall back to browser_execute_js with `el.getBoundingClientRect()` — still NO screenshot.",
49
+ "Coordinates are viewport CSS pixels (not device pixels).",
50
+ "After clicking, verify with browser_snapshot (or browser_execute_js for a specific value). Only screenshot if you need to confirm visual rendering.",
50
51
  "Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content — no selector needed.",
51
- "If a click doesn't register, try setting BH_DEBUG_CLICKS=1 to get annotated screenshots showing exact click positions.",
52
+ "If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots showing exact click positions (debug only).",
52
53
  "For React/Vue components that don't respond to clicks, try browser_dispatch_key to send DOM-level events.",
53
54
  ],
54
55
  parameters: ClickArgs,
package/src/domains/js.ts CHANGED
@@ -3,10 +3,20 @@ import { resolve, isAbsolute } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { createRequire } from "node:module";
5
5
  import { Type } from "typebox";
6
+ import { Markdown, Text } from "@mariozechner/pi-tui";
7
+ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
6
8
  import { type Result, err, ok } from "../util/result";
7
9
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
8
10
  import { applyTruncation } from "../util/truncate";
9
11
 
12
+ const COMPACT_PREVIEW_BYTES = 120;
13
+
14
+ const formatBytes = (n: number): string => {
15
+ if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
16
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
17
+ return `${n} B`;
18
+ };
19
+
10
20
  // AsyncFunction constructor — the documented Node.js mechanism for compiling
11
21
  // arbitrary user-supplied source. Equivalent to `new Function` but produces an
12
22
  // async function we can `await`. The full RCE risk is contained at the source
@@ -29,11 +39,15 @@ export const executeJsTool = defineBrowserTool({
29
39
  name: "browser_execute_js",
30
40
  label: "Browser Execute JS",
31
41
  description:
32
- "Run JavaScript in the page (or a specific iframe target). `return X` gets auto-wrapped in an IIFE for convenience. Always use safeJs / JSON.stringify when interpolating untrusted strings into source.",
33
- promptSnippet: "Execute JS in the page (or iframe)",
42
+ "Surgical reads from the DOM. The cheapest, most precise way to extract a specific element's text, attribute, value, or geometry. Prefer over browser_screenshot for ANY data extraction or coordinate lookup. `return X` is auto-wrapped in an IIFE. Always use safeJs / JSON.stringify when interpolating untrusted strings.",
43
+ promptSnippet: "Execute JS in the page — surgical DOM reads (preferred over screenshot)",
34
44
  promptGuidelines: [
45
+ "DEFAULT for extracting a specific value: `document.querySelector('.price').innerText`, `input.value`, `el.getAttribute('aria-label')`, etc.",
46
+ "For click coordinates not already in browser_snapshot: `JSON.stringify(document.querySelector('SELECTOR').getBoundingClientRect())` — beats a screenshot every time.",
47
+ "For 'is this element visible / enabled / checked / focused', a one-liner here beats a screenshot every time.",
48
+ "DO NOT call browser_screenshot to read a value or check element state. Use this tool.",
35
49
  "`return foo` is auto-wrapped in an IIFE — both `foo` and `(() => foo)()` work.",
36
- "For iframes, get a targetId via browser_execute_js with `Object.values(document.querySelectorAll('iframe'))` then call browser_execute_js with targetId.",
50
+ "For iframes, first run `Object.values(document.querySelectorAll('iframe'))` to find the iframe, then pass its targetId.",
37
51
  "Result must be JSON-serializable (Runtime.evaluate returnByValue=true).",
38
52
  ],
39
53
  parameters: ExecuteJsArgs,
@@ -42,13 +56,58 @@ export const executeJsTool = defineBrowserTool({
42
56
  if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
43
57
  const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data);
44
58
  const truncated = await applyTruncation(valueStr, "js");
59
+ // If the value is JSON, pretty-print for the expanded TUI view. Falls back
60
+ // to the raw string if parse fails (e.g. value is "undefined" sentinel).
61
+ let pretty: string | undefined;
62
+ try {
63
+ const parsed = JSON.parse(valueStr);
64
+ pretty = JSON.stringify(parsed, null, 2);
65
+ } catch {
66
+ pretty = undefined;
67
+ }
45
68
  return ok({
46
69
  text: truncated.text,
47
- details: truncated.fullOutputPath !== undefined
48
- ? { valueLength: valueStr.length, fullOutputPath: truncated.fullOutputPath }
49
- : { valueLength: valueStr.length },
70
+ details: {
71
+ valueLength: valueStr.length,
72
+ // Full value for the expanded renderer. Capped at 200 KB so a giant
73
+ // dump doesn't bloat the conversation transcript on disk.
74
+ full: valueStr.length > 200_000 ? valueStr.slice(0, 200_000) : valueStr,
75
+ ...(pretty !== undefined ? { pretty: pretty.length > 200_000 ? pretty.slice(0, 200_000) : pretty } : {}),
76
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
77
+ },
50
78
  });
51
79
  },
80
+
81
+ renderResult(result, expanded, theme) {
82
+ const details = result.details as
83
+ | { valueLength?: number; full?: string; pretty?: string; fullOutputPath?: string }
84
+ | undefined;
85
+ if (!details) return new Text(theme.fg("error", "execute_js: no details"), 0, 0);
86
+
87
+ const len = details.valueLength ?? 0;
88
+ const full = details.full ?? "";
89
+ const isJson = details.pretty !== undefined;
90
+
91
+ if (!expanded) {
92
+ const preview = full.length > COMPACT_PREVIEW_BYTES ? full.slice(0, COMPACT_PREVIEW_BYTES) + "…" : full;
93
+ const md = [
94
+ `**${formatBytes(len)}** ${isJson ? "JSON" : "value"}`,
95
+ "```",
96
+ preview,
97
+ "```",
98
+ keyHint("app.tools.expand", "to expand"),
99
+ ].join("\n");
100
+ return new Markdown(md, 0, 0, getMarkdownTheme());
101
+ }
102
+
103
+ const body = details.pretty ?? full;
104
+ const fence = isJson ? "```json" : "```";
105
+ const tail = details.fullOutputPath
106
+ ? `\n\nFull value at \`${details.fullOutputPath}\` · ${keyHint("app.tools.expand", "to collapse")}`
107
+ : `\n\n${keyHint("app.tools.expand", "to collapse")}`;
108
+ const md = [`**${formatBytes(len)}** ${isJson ? "JSON" : "value"}`, fence, body, "```", tail].join("\n");
109
+ return new Markdown(md, 0, 0, getMarkdownTheme());
110
+ },
52
111
  });
53
112
 
54
113
  const RunScriptArgs = Type.Object({
@@ -75,7 +75,7 @@ export const openUrlsTool = defineBrowserTool({
75
75
  promptSnippet: "Open multiple URLs in new tabs (parallel)",
76
76
  promptGuidelines: [
77
77
  "Use after web_search to open citations in parallel.",
78
- "After opening, use browser_list_tabs / browser_switch_tab / browser_screenshot to interact.",
78
+ "After opening, use browser_list_tabs / browser_switch_tab to navigate, then browser_snapshot to inspect each tab.",
79
79
  "Use browser_wait_for_load on a tab before extracting data from SPAs.",
80
80
  ],
81
81
  parameters: OpenUrlsArgs,