pi-browser-harness 0.10.2 → 0.12.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 (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +375 -265
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -37
  28. package/src/domains/console.ts +48 -4
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
@@ -2,11 +2,23 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { Type } from "typebox";
3
3
  import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
4
  import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
5
- import type { CdpSession } from "../cdp/session";
5
+ import { boxOf } from "./box";
6
+ import { cdpCall } from "./cdp-call";
7
+ import {
8
+ buildTree,
9
+ collectInteractiveTargets,
10
+ countNodes,
11
+ INTERACTIVE_ROLES,
12
+ liveValue,
13
+ type SlimNode,
14
+ stripInternals,
15
+ VALUE_ROLES,
16
+ } from "./ax-tree";
6
17
  import { type Result, err, ok } from "../util/result";
7
18
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
8
19
  import { applyTruncation } from "../util/truncate";
9
20
  import { screenshotPath } from "../util/paths";
21
+ import { asBoolean, asNumber, asRecord, asString } from "../util/guards";
10
22
 
11
23
  const SnapshotArgs = Type.Object({
12
24
  includeScreenshot: Type.Optional(
@@ -26,168 +38,6 @@ const SnapshotArgs = Type.Object({
26
38
  ),
27
39
  });
28
40
 
29
- // Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only.
30
- type AxValue = { value?: unknown };
31
- export type RawAxNode = {
32
- nodeId: string;
33
- parentId?: string;
34
- childIds?: ReadonlyArray<string>;
35
- ignored?: boolean;
36
- role?: AxValue;
37
- name?: AxValue;
38
- value?: AxValue;
39
- description?: AxValue;
40
- properties?: ReadonlyArray<{ name?: string; value?: AxValue }>;
41
- backendDOMNodeId?: number;
42
- };
43
-
44
- type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
45
-
46
- export type SlimNode = {
47
- role: string;
48
- name?: string;
49
- value?: string;
50
- description?: string;
51
- state?: string;
52
- children: SlimNode[];
53
- /** Internal: kept for the post-build box fetch. Stripped before returning to the LLM. */
54
- _backendId?: number;
55
- /** Stable handle (e.g. "e12") for interaction tools. Survives re-renders via backendNodeId. */
56
- ref?: string;
57
- /** Click target — center of the element's bounding box, in viewport CSS pixels. */
58
- box?: Box;
59
- };
60
-
61
- const INTERACTIVE_ROLES = new Set([
62
- "button",
63
- "link",
64
- "textbox",
65
- "checkbox",
66
- "radio",
67
- "combobox",
68
- "menuitem",
69
- "tab",
70
- "switch",
71
- "slider",
72
- "searchbox",
73
- "spinbutton",
74
- ]);
75
-
76
- // Roles whose live DOM value/checked state should override the (often stale)
77
- // AX-tree value. The AX `value` is unreliable for freshly-typed or controlled
78
- // inputs, so for these we read the real DOM property at snapshot time.
79
- const VALUE_ROLES = new Set([
80
- "textbox",
81
- "searchbox",
82
- "spinbutton",
83
- "combobox",
84
- "checkbox",
85
- "radio",
86
- "switch",
87
- "slider",
88
- ]);
89
-
90
- const stringOf = (v: AxValue | undefined): string | undefined => {
91
- const x = v?.value;
92
- if (typeof x === "string" && x !== "") return x;
93
- if (typeof x === "number") return String(x);
94
- return undefined;
95
- };
96
-
97
- const collectState = (props: RawAxNode["properties"]): string | undefined => {
98
- if (!props || props.length === 0) return undefined;
99
- const flags: string[] = [];
100
- for (const p of props) {
101
- const name = p.name;
102
- if (!name) continue;
103
- const raw = p.value?.value;
104
- // Boolean flags worth surfacing in the outline.
105
- if (raw === true && (name === "focused" || name === "required" || name === "disabled" || name === "checked" || name === "expanded" || name === "selected" || name === "pressed" || name === "modal")) {
106
- flags.push(name);
107
- }
108
- if (typeof raw === "number" && name === "level") flags.push(`level ${raw}`);
109
- }
110
- return flags.length > 0 ? flags.join(", ") : undefined;
111
- };
112
-
113
- export const buildTree = (
114
- rawNodes: ReadonlyArray<RawAxNode>,
115
- opts: { interestingOnly: boolean; maxNodes: number },
116
- ): SlimNode[] => {
117
- // Index for parent/child lookups.
118
- const byId = new Map<string, RawAxNode>();
119
- for (const n of rawNodes) byId.set(n.nodeId, n);
120
-
121
- // CDP doesn't mark a single root explicitly; the root nodes are the ones
122
- // whose parentId isn't in the index (typically just one — RootWebArea).
123
- const rootIds: string[] = [];
124
- for (const n of rawNodes) {
125
- if (!n.parentId || !byId.has(n.parentId)) rootIds.push(n.nodeId);
126
- }
127
-
128
- let budget = opts.maxNodes;
129
-
130
- const slim = (node: RawAxNode): SlimNode | undefined => {
131
- if (budget <= 0) return undefined;
132
- if (opts.interestingOnly && node.ignored) {
133
- // Skip the ignored node but recurse through its children — Chrome often
134
- // ignores wrapper divs but the meaningful descendants are still there.
135
- const out: SlimNode[] = [];
136
- for (const cid of node.childIds ?? []) {
137
- const child = byId.get(cid);
138
- if (!child) continue;
139
- const slimChild = slim(child);
140
- if (slimChild) out.push(slimChild);
141
- }
142
- // Hoist children into the parent. We return undefined; the parent's
143
- // recursion will see them by re-walking — but that double-counts. Instead
144
- // we handle hoisting at the parent level. So here just signal "skip me"
145
- // by returning undefined; the parent will hoist by checking ignored flag.
146
- return out.length > 0 ? { role: "_hoist", children: out } : undefined;
147
- }
148
- budget--;
149
- const role = stringOf(node.role) ?? "unknown";
150
- const name = stringOf(node.name);
151
- const value = stringOf(node.value);
152
- const description = stringOf(node.description);
153
- const state = collectState(node.properties);
154
- const children: SlimNode[] = [];
155
- for (const cid of node.childIds ?? []) {
156
- const child = byId.get(cid);
157
- if (!child) continue;
158
- const slimChild = slim(child);
159
- if (!slimChild) continue;
160
- // Hoist any synthetic _hoist nodes' children into this level.
161
- if (slimChild.role === "_hoist") children.push(...slimChild.children);
162
- else children.push(slimChild);
163
- }
164
- const out: SlimNode = { role, children };
165
- if (name !== undefined) out.name = name;
166
- if (value !== undefined) out.value = value;
167
- if (description !== undefined) out.description = description;
168
- if (state !== undefined) out.state = state;
169
- if (node.backendDOMNodeId !== undefined) out._backendId = node.backendDOMNodeId;
170
- return out;
171
- };
172
-
173
- const result: SlimNode[] = [];
174
- for (const rid of rootIds) {
175
- const root = byId.get(rid);
176
- if (!root) continue;
177
- const s = slim(root);
178
- if (!s) continue;
179
- if (s.role === "_hoist") result.push(...s.children);
180
- else result.push(s);
181
- }
182
- return result;
183
- };
184
-
185
- const countNodes = (nodes: ReadonlyArray<SlimNode>): number => {
186
- let n = 0;
187
- for (const node of nodes) n += 1 + countNodes(node.children);
188
- return n;
189
- };
190
-
191
41
  const summarize = (nodes: ReadonlyArray<SlimNode>): string => {
192
42
  const counts = new Map<string, number>();
193
43
  const walk = (ns: ReadonlyArray<SlimNode>): void => {
@@ -197,7 +47,6 @@ const summarize = (nodes: ReadonlyArray<SlimNode>): string => {
197
47
  }
198
48
  };
199
49
  walk(nodes);
200
- // Group landmarks (roles that are top-level structural).
201
50
  const landmarkRoles = new Set(["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region"]);
202
51
  let landmarks = 0;
203
52
  for (const [role, c] of counts) if (landmarkRoles.has(role)) landmarks += c;
@@ -221,8 +70,6 @@ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => {
221
70
  if (n.name) line += ` "${n.name}"`;
222
71
  if (n.value && n.value !== n.name) line += ` = ${JSON.stringify(n.value)}`;
223
72
  if (n.state) line += ` (${n.state})`;
224
- // Surface the stable ref + click coordinates for interactive nodes. Agents
225
- // pass `ref` to interaction tools (survives re-renders); @(x,y) is a fallback.
226
73
  if (n.ref) line += ` [${n.ref}]`;
227
74
  if (n.box && INTERACTIVE_ROLES.has(n.role)) {
228
75
  line += ` @(${n.box.cx},${n.box.cy})`;
@@ -235,61 +82,6 @@ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => {
235
82
  return lines.join("\n");
236
83
  };
237
84
 
238
- /** Strip internal _backendId from a slim tree before returning to callers. */
239
- const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
240
- nodes.map((n) => {
241
- const { _backendId: _bid, ...rest } = n;
242
- return { ...rest, children: stripInternals(n.children) };
243
- });
244
-
245
- /**
246
- * Walk all slim nodes and collect (slimNode, backendId) pairs for interactive
247
- * roles. Caller fetches DOM.getBoxModel for each and writes box back into the
248
- * slim node by reference.
249
- */
250
- export const collectInteractiveTargets = (
251
- nodes: ReadonlyArray<SlimNode>,
252
- ): Array<{ node: SlimNode; backendId: number }> => {
253
- const out: Array<{ node: SlimNode; backendId: number }> = [];
254
- const walk = (ns: ReadonlyArray<SlimNode>): void => {
255
- for (const n of ns) {
256
- if (n._backendId !== undefined && INTERACTIVE_ROLES.has(n.role)) {
257
- out.push({ node: n, backendId: n._backendId });
258
- }
259
- walk(n.children);
260
- }
261
- };
262
- walk(nodes);
263
- return out;
264
- };
265
-
266
- /**
267
- * Read the live DOM value for a form control by backend node id. Resolves the
268
- * node to a JS object then reads the property that matters for its type:
269
- * `.value` for inputs/selects/textareas, `.checked` (→ "checked"/"unchecked")
270
- * for checkboxes/radios. Returns undefined on any failure so the caller falls
271
- * back to the AX value. Mirrors the box-fetch error handling: never throws.
272
- */
273
- const liveValue = async (session: CdpSession, backendId: number): Promise<string | undefined> => {
274
- const resolved = await session.call("DOM.resolveNode", { backendNodeId: backendId });
275
- if (!resolved.success) return undefined;
276
- const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
277
- if (!objectId) return undefined;
278
- const fn = `function () {
279
- if (this.type === "checkbox" || this.type === "radio") return this.checked ? "checked" : "unchecked";
280
- if (typeof this.value === "string") return this.value;
281
- return null;
282
- }`;
283
- const r = await session.call("Runtime.callFunctionOn", {
284
- objectId,
285
- functionDeclaration: fn,
286
- returnByValue: true,
287
- });
288
- if (!r.success) return undefined;
289
- const value = (r.data as { result?: { value?: unknown } }).result?.value;
290
- return typeof value === "string" ? value : undefined;
291
- };
292
-
293
85
  type SnapshotDetails = {
294
86
  nodeCount: number;
295
87
  truncated: boolean;
@@ -306,45 +98,32 @@ export const snapshotTool = defineBrowserTool({
306
98
  "DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy). Every interactive element gets a stable ref shown as [eN] plus click coordinates @(x,y). Pass the ref to browser_click/browser_fill/etc. — refs survive re-renders, coordinates don't. Use this BEFORE deciding whether you need a screenshot. Pair with browser_execute_js for surgical reads of specific element values.",
307
99
  promptSnippet: "Get accessibility-tree snapshot with stable element refs (default for page inspection)",
308
100
  promptGuidelines: [
309
- "DEFAULT — use this whenever you need to know what's on a page, what's clickable, or how the page is structured.",
310
- "Refs come for free: every interactive element shows '[eN]' in the outline. Pass eN as `ref` to browser_click/browser_fill/browser_select_option/browser_focus/browser_upload_file — refs survive re-renders, so prefer them over the '@(x,y)' coordinates (a fallback).",
311
- "DO NOT call browser_screenshot just to understand the page. This tool already gives you structure, labels, states, refs, and click targets.",
312
- "Re-run after a navigation or a major re-render to get fresh refs; a 'ref is stale' error from an interaction tool means you need a new snapshot.",
101
+ "Re-run browser_snapshot after a navigation or a major re-render to get fresh refs.",
313
102
  "Pass includeScreenshot:true ONLY if you also need to verify visual rendering (rare).",
314
103
  "format:'json' returns the raw slim structure (with `ref` and `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
315
104
  ],
316
105
  parameters: SnapshotArgs,
106
+ concurrency: "parallel",
317
107
 
318
108
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
319
109
  const session = client.session();
320
110
 
321
- // 1. AX tree
322
- const axRes = await session.call("Accessibility.getFullAXTree", {});
323
- if (!axRes.success) return err({ kind: "cdp_error", message: axRes.error.message });
324
- const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
111
+ const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {});
112
+ if (!axRes.success) return axRes;
113
+ const rawNodes = axRes.data.nodes;
325
114
 
326
- // 2. Page url/title
327
115
  const piRes = await client.pageInfo();
328
116
  if (!piRes.success) return err({ kind: "cdp_error", message: piRes.error.message });
329
117
  const pageUrl = "dialog" in piRes.data ? "" : piRes.data.url;
330
118
  const pageTitle = "dialog" in piRes.data ? "" : piRes.data.title;
331
119
 
332
- // 3. Slim + format
333
120
  const slim = buildTree(rawNodes, {
334
121
  interestingOnly: args.interestingOnly ?? true,
335
122
  maxNodes: args.maxNodes ?? 1000,
336
123
  });
337
124
 
338
- // 4. Fetch bounding boxes for interactive nodes so the agent can click
339
- // without a screenshot round-trip. Capped budget so a slow page can't
340
- // wedge the call.
341
125
  const targets = collectInteractiveTargets(slim);
342
126
 
343
- // Assign stable refs (e1, e2, …) to every interactive target. Interaction
344
- // tools resolve these refs to backendNodeIds at call time, so they stay
345
- // valid across re-renders. The ref → backendNodeId map (and the per-ref
346
- // signature baseline for the post-mutation diff) is published below, after
347
- // the box/live-value fetch so the signature reflects the live DOM value.
348
127
  const refMap = new Map<string, number>();
349
128
  targets.forEach(({ node, backendId }, i) => {
350
129
  const ref = `e${i + 1}`;
@@ -358,42 +137,18 @@ export const snapshotTool = defineBrowserTool({
358
137
  await Promise.allSettled(
359
138
  targets.map(async ({ node, backendId }) => {
360
139
  if (ac.signal.aborted) return;
361
- const r = await session.call("DOM.getBoxModel", { backendNodeId: backendId });
362
- if (!r.success) return;
363
- // CDP boundary cast: DOM.getBoxModel
364
- const data = r.data as { model?: { content?: ReadonlyArray<number>; width?: number; height?: number } };
365
- const content = data.model?.content;
366
- if (!content || content.length < 8) return;
367
- // content is [x1,y1, x2,y1, x2,y2, x1,y2] in viewport CSS pixels.
368
- const x = content[0]!;
369
- const y = content[1]!;
370
- const width = data.model!.width ?? 0;
371
- const height = data.model!.height ?? 0;
372
- // Skip zero-size and off-viewport boxes — they can't be clicked.
373
- if (width <= 0 || height <= 0 || x < 0 || y < 0) return;
374
- node.box = {
375
- x: Math.round(x),
376
- y: Math.round(y),
377
- width: Math.round(width),
378
- height: Math.round(height),
379
- cx: Math.round(x + width / 2),
380
- cy: Math.round(y + height / 2),
381
- };
382
- // Override the AX value with the live DOM property for form controls.
383
- // The AX `value` is stale for freshly-typed / controlled inputs, so the
384
- // agent can't trust it as a read-back signal. Reading .value/.checked
385
- // from the actual element fixes that.
140
+ const box = await boxOf(client, backendId);
141
+ if (!box.success) return;
142
+ if (box.data.x < 0 || box.data.y < 0) return;
143
+ node.box = box.data;
386
144
  if (ac.signal.aborted || !VALUE_ROLES.has(node.role)) return;
387
- const live = await liveValue(session, backendId);
145
+ const live = await liveValue(client, backendId);
388
146
  if (live !== undefined) node.value = live;
389
147
  }),
390
148
  );
391
149
  clearTimeout(timer);
392
150
  }
393
151
 
394
- // Publish refs to the active tab. The signature (role|name|value|state) is
395
- // the baseline the post-mutation auto-diff compares against, so it must use
396
- // the live values resolved above.
397
152
  const refSig = new Map<string, string>();
398
153
  for (const { node } of targets) {
399
154
  if (node.ref === undefined) continue;
@@ -405,14 +160,12 @@ export const snapshotTool = defineBrowserTool({
405
160
  const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripped, null, 2);
406
161
  const trunc = await applyTruncation(text, "snapshot");
407
162
 
408
- // 4. Optional screenshot — JPEG q=80 to match screenshotTool's default.
409
163
  let shotPath: string | undefined;
410
164
  if (args.includeScreenshot) {
411
- const shot = await session.call("Page.captureScreenshot", { format: "jpeg", quality: 80 });
165
+ const shot = await cdpCall(client, "Page.captureScreenshot", { format: "jpeg", quality: 80 });
412
166
  if (shot.success) {
413
- const data = (shot.data as { data: string }).data;
414
167
  shotPath = screenshotPath(client.namespace, "jpeg");
415
- writeFileSync(shotPath, Buffer.from(data, "base64"));
168
+ writeFileSync(shotPath, Buffer.from(shot.data.data, "base64"));
416
169
  }
417
170
  }
418
171
 
@@ -429,10 +182,17 @@ export const snapshotTool = defineBrowserTool({
429
182
  },
430
183
 
431
184
  renderResult(result, expanded, theme) {
432
- const details = result.details as
433
- | (SnapshotDetails & { summary?: string; ok?: boolean })
434
- | undefined;
435
- if (!details) return new Text(theme.fg("error", "snapshot: no details"), 0, 0);
185
+ const raw = asRecord(result.details);
186
+ if (raw === undefined) return new Text(theme.fg("error", "snapshot: no details"), 0, 0);
187
+ const details = {
188
+ nodeCount: asNumber(raw["nodeCount"]),
189
+ truncated: asBoolean(raw["truncated"]),
190
+ fullOutputPath: asString(raw["fullOutputPath"]),
191
+ screenshotPath: asString(raw["screenshotPath"]),
192
+ url: asString(raw["url"]),
193
+ title: asString(raw["title"]),
194
+ summary: asString(raw["summary"]),
195
+ };
436
196
 
437
197
  const titleLine = details.title ? ` "${details.title}"` : "";
438
198
  const summary = details.summary ?? "";
@@ -449,9 +209,6 @@ export const snapshotTool = defineBrowserTool({
449
209
  return new Markdown(md, 0, 0, getMarkdownTheme());
450
210
  }
451
211
 
452
- // Expanded view: read truncated text from result.content (the spilled file
453
- // is too large to inline) — the same `text` we returned from handler is in
454
- // result.content[0]. Fall back to a placeholder if shape is unexpected.
455
212
  const content = result.content[0];
456
213
  const treeText = content && content.type === "text" ? content.text : "";
457
214
 
@@ -471,7 +228,6 @@ export const snapshotTool = defineBrowserTool({
471
228
 
472
229
  if (!details.screenshotPath) return treeBlock;
473
230
 
474
- // Mirror screenshotTool's width-clamp wrapper for the inline image.
475
231
  try {
476
232
  const buf = readFileSync(details.screenshotPath);
477
233
  const b64 = buf.toString("base64");
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { type Result, err, ok } from "../util/result";
3
3
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { cdpCallBrowser } from "./cdp-call";
4
5
 
5
6
  const ListTabsArgs = Type.Object({
6
7
  includeInternal: Type.Optional(Type.Boolean({ default: true, description: "Include chrome:// pages" })),
@@ -18,12 +19,10 @@ export const listTabsTool = defineBrowserTool({
18
19
  description: "List browser tabs. Defaults to tabs this harness session opened (scope:'owned'). Pass scope:'all' to see the user's other tabs too.",
19
20
  promptSnippet: "List browser tabs",
20
21
  promptGuidelines: [
21
- "Defaults to scope:'owned' — only tabs this session created. Pass scope:'all' to inspect the user's other tabs.",
22
- "browser_switch_tab and browser_close_tab refuse non-owned tabs; use browser_new_tab to open one this session controls.",
23
22
  "Each tab shows its full 32-char hex targetId — use the exact value with browser_switch_tab.",
24
- "Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.",
25
23
  ],
26
24
  parameters: ListTabsArgs,
25
+ concurrency: "parallel",
27
26
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
28
27
  const r = await client.listTabs(args.includeInternal ?? true);
29
28
  if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -50,12 +49,13 @@ export const currentTabTool = defineBrowserTool({
50
49
  promptSnippet: "Get current tab info",
51
50
  promptGuidelines: ["Returns targetId, url, title, and whether the tab is owned by this harness session."],
52
51
  parameters: Type.Object({}),
52
+ concurrency: "parallel",
53
53
  async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
54
54
  const cur = client.current();
55
55
  if (!cur) return err({ kind: "invalid_state", message: "No tab attached." });
56
- const ti = await client.session().callBrowser("Target.getTargetInfo", { targetId: cur.targetId });
57
- if (!ti.success) return err({ kind: "cdp_error", message: ti.error.message });
58
- const info = (ti.data as { targetInfo: { targetId: string; url: string; title: string } }).targetInfo; // CDP boundary cast: Target.getTargetInfo returns { targetInfo: { targetId, url, title } }
56
+ const ti = await cdpCallBrowser(client, "Target.getTargetInfo", { targetId: cur.targetId });
57
+ if (!ti.success) return ti;
58
+ const info = ti.data.targetInfo;
59
59
  const owned = client.owns(info.targetId);
60
60
  return ok({
61
61
  text: `Current tab${owned ? " (owned)" : " (NOT owned by this session)"}:\n ${info.targetId}\n ${info.url}\n ${info.title}`,
@@ -80,12 +80,10 @@ export const switchTabTool = defineBrowserTool({
80
80
  promptGuidelines: [
81
81
  "Get a targetId via browser_list_tabs first.",
82
82
  "Accepts exact targetId or a unique prefix of at least 8 hex characters.",
83
- "Refuses tabs not owned by this session — use browser_new_tab to open a controllable tab instead.",
84
83
  ],
85
84
  parameters: SwitchTabArgs,
85
+ concurrency: "serialized",
86
86
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
87
- // Resolve to a concrete targetId (exact, or unique hex prefix). We resolve
88
- // first so the ownership check sees the canonical id, not the prefix.
89
87
  let resolved = args.targetId;
90
88
  if (!client.owns(resolved)) {
91
89
  const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
@@ -104,7 +102,9 @@ export const switchTabTool = defineBrowserTool({
104
102
  details: { matches: matches.map((t) => ({ targetId: t.targetId, url: t.url })) },
105
103
  });
106
104
  }
107
- resolved = matches[0]!.targetId;
105
+ const first = matches[0];
106
+ if (first === undefined) return err({ kind: "invalid_state", message: "no matching tab" });
107
+ resolved = first.targetId;
108
108
  }
109
109
  if (!client.owns(resolved)) {
110
110
  return err({
@@ -134,11 +134,9 @@ export const newTabTool = defineBrowserTool({
134
134
  label: "Browser New Tab",
135
135
  description: "Open a new tab in the harness's dedicated Chrome window and switch to it. Optionally navigate to a URL.",
136
136
  promptSnippet: "Open a new tab",
137
- promptGuidelines: [
138
- "All tabs opened by this tool live in a single dedicated Chrome window separate from the user's main browsing.",
139
- "Pass url to navigate immediately.",
140
- ],
137
+ promptGuidelines: [],
141
138
  parameters: NewTabArgs,
139
+ concurrency: "serialized",
142
140
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
143
141
  const r = await client.newTab(args.url);
144
142
  if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -161,10 +159,10 @@ export const closeTabTool = defineBrowserTool({
161
159
  description: "Close a tab the harness owns. Refuses tabs not opened by this session.",
162
160
  promptSnippet: "Close an owned tab",
163
161
  promptGuidelines: [
164
- "Only closes tabs this session opened (visible in browser_list_tabs default scope).",
165
162
  "Accepts exact targetId or a unique hex prefix (>=8 chars).",
166
163
  ],
167
164
  parameters: CloseTabArgs,
165
+ concurrency: "serialized",
168
166
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
169
167
  let resolved = args.targetId;
170
168
  if (!client.owns(resolved)) {
@@ -172,7 +170,8 @@ export const closeTabTool = defineBrowserTool({
172
170
  if (isHexPrefix) {
173
171
  const owned = client.ownership().list();
174
172
  const matches = owned.filter((id) => id.startsWith(args.targetId));
175
- if (matches.length === 1) resolved = matches[0]!;
173
+ const first = matches[0];
174
+ if (matches.length === 1 && first !== undefined) resolved = first;
176
175
  else if (matches.length > 1) {
177
176
  return err({
178
177
  kind: "invalid_state",
@@ -0,0 +1,35 @@
1
+ import type { BrowserClient } from "../client";
2
+ import { type Result, err, ok } from "../util/result";
3
+ import type { ToolErr } from "../util/tool";
4
+ import { resolveRefToBox } from "./ref-resolve";
5
+
6
+ export type PointArgs = {
7
+ readonly ref?: string | undefined;
8
+ readonly x?: number | undefined;
9
+ readonly y?: number | undefined;
10
+ };
11
+
12
+ export type ResolvedTarget = {
13
+ readonly x: number;
14
+ readonly y: number;
15
+ readonly label: string;
16
+ };
17
+
18
+ export const resolveTarget = async (
19
+ client: BrowserClient,
20
+ args: PointArgs,
21
+ ): Promise<Result<ResolvedTarget, ToolErr>> => {
22
+ if (args.ref !== undefined) {
23
+ const box = await resolveRefToBox(client, args.ref);
24
+ if (!box.success) return box;
25
+ return ok({
26
+ x: box.data.cx,
27
+ y: box.data.cy,
28
+ label: `[${args.ref}] (${box.data.cx}, ${box.data.cy})`,
29
+ });
30
+ }
31
+ if (args.x !== undefined && args.y !== undefined) {
32
+ return ok({ x: args.x, y: args.y, label: `(${args.x}, ${args.y})` });
33
+ }
34
+ return err({ kind: "invalid_state", message: "Provide either `ref` or both `x` and `y`." });
35
+ };
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
- import { type Result, err, ok } from "../util/result";
2
+ import { type Result, ok } from "../util/result";
3
3
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { cdpCall } from "./cdp-call";
4
5
 
5
6
  const ViewportArgs = Type.Object({
6
7
  width: Type.Integer({ minimum: 100, maximum: 8000, description: "Viewport CSS pixel width" }),
@@ -16,18 +17,18 @@ export const viewportResizeTool = defineBrowserTool({
16
17
  description: "Override the viewport size and device pixel ratio for responsive testing.",
17
18
  promptSnippet: "Resize the viewport (responsive testing)",
18
19
  promptGuidelines: [
19
- "Width/height in CSS pixels (e.g., 375x667 for iPhone SE).",
20
20
  "Pass deviceScaleFactor=2 to simulate retina displays.",
21
21
  ],
22
22
  parameters: ViewportArgs,
23
+ concurrency: "serialized",
23
24
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
24
- const r = await client.session().call("Emulation.setDeviceMetricsOverride", {
25
+ const r = await cdpCall(client, "Emulation.setDeviceMetricsOverride", {
25
26
  width: args.width,
26
27
  height: args.height,
27
28
  deviceScaleFactor: args.deviceScaleFactor ?? 1,
28
29
  mobile: false,
29
30
  });
30
- if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
31
+ if (!r.success) return r;
31
32
  return ok({
32
33
  text: `Viewport set to ${args.width}x${args.height} @${args.deviceScaleFactor ?? 1}x`,
33
34
  details: {