pi-browser-harness 0.6.0 → 0.8.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.
@@ -2,6 +2,7 @@ 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
6
  import { type Result, err, ok } from "../util/result";
6
7
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
8
  import { applyTruncation } from "../util/truncate";
@@ -27,7 +28,7 @@ const SnapshotArgs = Type.Object({
27
28
 
28
29
  // Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only.
29
30
  type AxValue = { value?: unknown };
30
- type RawAxNode = {
31
+ export type RawAxNode = {
31
32
  nodeId: string;
32
33
  parentId?: string;
33
34
  childIds?: ReadonlyArray<string>;
@@ -42,7 +43,7 @@ type RawAxNode = {
42
43
 
43
44
  type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
44
45
 
45
- type SlimNode = {
46
+ export type SlimNode = {
46
47
  role: string;
47
48
  name?: string;
48
49
  value?: string;
@@ -51,6 +52,8 @@ type SlimNode = {
51
52
  children: SlimNode[];
52
53
  /** Internal: kept for the post-build box fetch. Stripped before returning to the LLM. */
53
54
  _backendId?: number;
55
+ /** Stable handle (e.g. "e12") for interaction tools. Survives re-renders via backendNodeId. */
56
+ ref?: string;
54
57
  /** Click target — center of the element's bounding box, in viewport CSS pixels. */
55
58
  box?: Box;
56
59
  };
@@ -70,6 +73,20 @@ const INTERACTIVE_ROLES = new Set([
70
73
  "spinbutton",
71
74
  ]);
72
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
+
73
90
  const stringOf = (v: AxValue | undefined): string | undefined => {
74
91
  const x = v?.value;
75
92
  if (typeof x === "string" && x !== "") return x;
@@ -93,7 +110,7 @@ const collectState = (props: RawAxNode["properties"]): string | undefined => {
93
110
  return flags.length > 0 ? flags.join(", ") : undefined;
94
111
  };
95
112
 
96
- const buildTree = (
113
+ export const buildTree = (
97
114
  rawNodes: ReadonlyArray<RawAxNode>,
98
115
  opts: { interestingOnly: boolean; maxNodes: number },
99
116
  ): SlimNode[] => {
@@ -204,8 +221,9 @@ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => {
204
221
  if (n.name) line += ` "${n.name}"`;
205
222
  if (n.value && n.value !== n.name) line += ` = ${JSON.stringify(n.value)}`;
206
223
  if (n.state) line += ` (${n.state})`;
207
- // Surface click coordinates for interactive nodes — agents can pass these
208
- // straight to browser_click without a screenshot round-trip.
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
+ if (n.ref) line += ` [${n.ref}]`;
209
227
  if (n.box && INTERACTIVE_ROLES.has(n.role)) {
210
228
  line += ` @(${n.box.cx},${n.box.cy})`;
211
229
  }
@@ -229,7 +247,7 @@ const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
229
247
  * roles. Caller fetches DOM.getBoxModel for each and writes box back into the
230
248
  * slim node by reference.
231
249
  */
232
- const collectInteractiveTargets = (
250
+ export const collectInteractiveTargets = (
233
251
  nodes: ReadonlyArray<SlimNode>,
234
252
  ): Array<{ node: SlimNode; backendId: number }> => {
235
253
  const out: Array<{ node: SlimNode; backendId: number }> = [];
@@ -245,6 +263,33 @@ const collectInteractiveTargets = (
245
263
  return out;
246
264
  };
247
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
+
248
293
  type SnapshotDetails = {
249
294
  nodeCount: number;
250
295
  truncated: boolean;
@@ -258,14 +303,15 @@ export const snapshotTool = defineBrowserTool({
258
303
  name: "browser_snapshot",
259
304
  label: "Browser Snapshot",
260
305
  description:
261
- "DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy) plus click coordinates for every interactive element as @(x,y). Use this BEFORE deciding whether you need a screenshot — almost always sufficient on its own and far cheaper. Pair with browser_execute_js for surgical reads of specific element values.",
262
- promptSnippet: "Get accessibility-tree snapshot of the current page (default for page inspection)",
306
+ "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
+ promptSnippet: "Get accessibility-tree snapshot with stable element refs (default for page inspection)",
263
308
  promptGuidelines: [
264
309
  "DEFAULT — use this whenever you need to know what's on a page, what's clickable, or how the page is structured.",
265
- "Click coordinates come for free: every interactive element shows '@(x,y)' in the outline. Pass these straight to browser_click. NO screenshot round-trip needed.",
266
- "DO NOT call browser_screenshot just to understand the page. This tool already gives you structure, labels, states, and click targets.",
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.",
267
313
  "Pass includeScreenshot:true ONLY if you also need to verify visual rendering (rare).",
268
- "format:'json' returns the raw slim structure (with `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
314
+ "format:'json' returns the raw slim structure (with `ref` and `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
269
315
  ],
270
316
  parameters: SnapshotArgs,
271
317
 
@@ -293,6 +339,19 @@ export const snapshotTool = defineBrowserTool({
293
339
  // without a screenshot round-trip. Capped budget so a slow page can't
294
340
  // wedge the call.
295
341
  const targets = collectInteractiveTargets(slim);
342
+
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
+ const refMap = new Map<string, number>();
349
+ targets.forEach(({ node, backendId }, i) => {
350
+ const ref = `e${i + 1}`;
351
+ node.ref = ref;
352
+ refMap.set(ref, backendId);
353
+ });
354
+
296
355
  if (targets.length > 0) {
297
356
  const ac = new AbortController();
298
357
  const timer = setTimeout(() => ac.abort(), 1_500);
@@ -320,11 +379,28 @@ export const snapshotTool = defineBrowserTool({
320
379
  cx: Math.round(x + width / 2),
321
380
  cy: Math.round(y + height / 2),
322
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.
386
+ if (ac.signal.aborted || !VALUE_ROLES.has(node.role)) return;
387
+ const live = await liveValue(session, backendId);
388
+ if (live !== undefined) node.value = live;
323
389
  }),
324
390
  );
325
391
  clearTimeout(timer);
326
392
  }
327
393
 
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
+ const refSig = new Map<string, string>();
398
+ for (const { node } of targets) {
399
+ if (node.ref === undefined) continue;
400
+ refSig.set(node.ref, `${node.role}|${node.name ?? ""}|${node.value ?? ""}|${node.state ?? ""}`);
401
+ }
402
+ session.setRefMap(refMap, refSig);
403
+
328
404
  const stripped = stripInternals(slim);
329
405
  const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripped, null, 2);
330
406
  const trunc = await applyTruncation(text, "snapshot");
package/src/prompt.ts CHANGED
@@ -21,9 +21,13 @@ to interact with pages visually, and other tools alongside them.
21
21
 
22
22
  | Tool | Purpose |
23
23
  |------|---------|
24
+ | browser_snapshot | Accessibility-tree snapshot. Assigns every interactive element a stable **ref** (\`[eN]\`) plus \`@(x,y)\`. Refs are the primary handle for interaction tools — they survive re-renders, coordinates don't. |
24
25
  | browser_screenshot | Capture a PNG or JPEG screenshot of the current page (rendered inline in the TUI; the LLM reads the image via the saved file path) |
25
- | browser_click | Click at viewport coordinates |
26
- | browser_type | Type text into the focused element |
26
+ | browser_click | Click an element by **ref** (preferred — re-resolves position, survives re-renders) or viewport x/y. Appends a compact diff of page changes. |
27
+ | browser_type | Type text into the focused element (real keystrokes; requires a focused field) |
28
+ | browser_fill | Fill an input/textarea by **ref** (preferred) or selector — updates React/Vue controlled components, verifies the value, appends a page-changes diff |
29
+ | browser_select_option | Select an option in a native <select> by **ref** (preferred) or selector, choosing by value/label/index (fires change) |
30
+ | browser_focus | Focus an element by **ref** (preferred) or selector (deterministic; use before browser_type) |
27
31
  | browser_press_key | Press a keyboard key (Enter, Tab, Escape, arrows, etc.) |
28
32
  | browser_scroll | Scroll the page at coordinates |
29
33
  | browser_navigate | Navigate to a URL (result details.outcome.kind is "in_place" or "new_tab_created") |
@@ -38,6 +42,7 @@ to interact with pages visually, and other tools alongside them.
38
42
  | browser_http_get | Direct HTTP GET (outside browser, for APIs) |
39
43
  | browser_console | Read JS errors / console output on the current tab (diagnostic — use when an action looks broken; pass sinceSeq from the previous nextCursor to see only new messages) |
40
44
  | browser_wait | Wait N seconds |
45
+ | browser_wait_for | Wait until a selector/text appears (or a selector disappears) — use for SPA content before interacting |
41
46
  | browser_wait_for_load | Wait for document.readyState === 'complete' (returns a typed timeout error if the page doesn't reach readyState=complete in N seconds, default 15) |
42
47
  | browser_handle_dialog | Accept or dismiss a JS dialog |
43
48
  | browser_run_script | Execute a temporary script file with daemon access (write script to disk, then run) |
@@ -83,11 +88,25 @@ browser_http_get("https://api.example.com/data") + browser_click(x, y)
83
88
  browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot()
84
89
  \`\`\`
85
90
 
86
- **Form filling:**
91
+ **Form filling (ref-first — this is the reliable path on React/SPA forms):**
87
92
  \`\`\`
88
- browser_screenshot() → find input coordinates → browser_click(x, y)
89
- → browser_type("text") → browser_press_key("Tab") → browser_screenshot()
93
+ browser_wait_for({ selector: "#email" }) // ensure SPA field is rendered
94
+ browser_snapshot() // each interactive element gets a [eN] ref
95
+ → browser_fill({ ref: "e7", value: "a@b.com" }) // target by ref, not a guessed selector
96
+ → browser_select_option({ ref: "e9", label: "India" }) // native <select> by ref
97
+ → browser_click({ ref: "e12" }) // e.g. Save — re-resolves position even if the form reflowed
98
+ // each mutating call appends a compact "Page changes" diff — read it to confirm
99
+ // the change landed (e.g. a save closed the form, a new field appeared as *[eN]).
90
100
  \`\`\`
101
+ **Why ref over selector/coordinates:** refs are keyed to the element's identity, so
102
+ they survive the re-renders that React/Vue forms trigger on every edit and save —
103
+ coordinates go stale and selectors you guess often don't match. If a call returns
104
+ "ref is stale", the page changed: re-run browser_snapshot for fresh refs.
105
+
106
+ Prefer browser_fill for text inputs/textareas — it fires input/change so controlled
107
+ components update, and returns the field's value for verification. Use browser_click +
108
+ browser_type only for keystroke-sensitive widgets (autocomplete, masked inputs);
109
+ browser_type requires an already-focused field and errors if none is focused.
91
110
 
92
111
  **Data extraction:**
93
112
  \`\`\`
@@ -121,8 +140,8 @@ write("/tmp/extract.js", "...script with daemon access...") → browser_run_scri
121
140
 
122
141
  | Tool | Purpose |
123
142
  |------|---------|
124
- | browser_upload_file | Upload a file to a file input (bypasses file picker) |
125
- | browser_dispatch_key | Dispatch a DOM KeyboardEvent on a specific element (for React/Vue inputs); returns details.matched (number of elements) |
143
+ | browser_upload_file | Upload a file to a file input by **ref** (preferred) or selector (bypasses file picker) |
144
+ | browser_dispatch_key | Dispatch a DOM KeyboardEvent on an element by **ref** (preferred) or selector (for React/Vue inputs); returns details.matched |
126
145
  | browser_download | Configure download directory and disable save-as prompts |
127
146
  | browser_viewport_resize | Resize the viewport for responsive testing |
128
147
  | browser_drag_and_drop | Perform drag-and-drop from one coordinate to another |
package/src/registry.ts CHANGED
@@ -3,7 +3,8 @@ import type { BrowserClient } from "./client";
3
3
  import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
4
4
  import { clickTool } from "./domains/click";
5
5
  import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
6
- import { pageInfoTool, waitTool, waitForLoadTool } from "./domains/page";
6
+ import { fillTool, selectOptionTool, focusTool } from "./domains/form";
7
+ import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
7
8
  import { scrollTool } from "./domains/scroll";
8
9
  import { handleDialogTool } from "./domains/dialog";
9
10
  import { screenshotTool } from "./domains/screenshot";
@@ -27,11 +28,15 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
27
28
  setupTool,
28
29
  { ...clickTool, serialized: true },
29
30
  { ...typeTool, serialized: true },
31
+ { ...fillTool, serialized: true },
32
+ { ...selectOptionTool, serialized: true },
33
+ { ...focusTool, serialized: true },
30
34
  { ...pressKeyTool, serialized: true },
31
35
  { ...dispatchKeyTool, serialized: true },
32
36
  { ...scrollTool, serialized: true },
33
37
  pageInfoTool,
34
38
  waitTool,
39
+ waitForTool,
35
40
  { ...waitForLoadTool, serialized: true },
36
41
  { ...handleDialogTool, serialized: true },
37
42
  screenshotTool,