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.
- package/CHANGELOG.md +54 -0
- package/README.md +21 -10
- package/package.json +6 -3
- package/skills/pi-browser-harness/SKILL.md +30 -1
- package/src/cdp/commands.ts +260 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +31 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +375 -265
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +90 -82
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -37
- package/src/domains/console.ts +48 -4
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +75 -30
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +64 -0
- package/src/domains/form.ts +115 -112
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +16 -12
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +32 -51
- package/src/domains/keyboard.ts +38 -34
- package/src/domains/navigate.ts +16 -26
- package/src/domains/network.ts +61 -13
- package/src/domains/page.ts +25 -10
- package/src/domains/readpage/read-page.ts +74 -20
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -90
- package/src/domains/scroll.ts +10 -12
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -21
- package/src/domains/setup.ts +1 -13
- package/src/domains/snapshot.ts +36 -280
- package/src/domains/tabs.ts +15 -16
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -4
- package/src/index.ts +23 -69
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/prompt.ts +22 -122
- package/src/registry.ts +33 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +48 -18
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/paths.ts +11 -1
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/src/domains/snapshot.ts
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
362
|
-
if (!
|
|
363
|
-
|
|
364
|
-
|
|
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(
|
|
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
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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");
|
package/src/domains/tabs.ts
CHANGED
|
@@ -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
|
|
57
|
-
if (!ti.success) return
|
|
58
|
-
const info =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/domains/viewport.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import { type 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
|
|
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
|
|
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: {
|