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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -17,6 +17,7 @@ import { type Result, err, ok } from "../util/result";
17
17
  import { type CdpError, cdpError } from "../cdp/errors";
18
18
  import type { CdpTransport } from "../cdp/transport";
19
19
  import type { CdpEvent } from "../cdp/types";
20
+ import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
20
21
  import {
21
22
  DAEMON_SOCKET_PATH,
22
23
  type WireRequest,
@@ -25,47 +26,6 @@ import {
25
26
  serialize,
26
27
  } from "../daemon/protocol";
27
28
 
28
- // ── Types ──────────────────────────────────────────────────────────────────────
29
-
30
- type EventQueue = {
31
- readonly push: (e: CdpEvent) => void;
32
- readonly end: () => void;
33
- readonly iter: AsyncIterable<CdpEvent>;
34
- };
35
-
36
- // ── Helpers ────────────────────────────────────────────────────────────────────
37
-
38
- const makeEventQueue = (): EventQueue => {
39
- const buf: CdpEvent[] = [];
40
- const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
41
- let ended = false;
42
- return {
43
- push(e) {
44
- if (ended) return;
45
- const w = waiters.shift();
46
- if (w) w({ value: e, done: false });
47
- else buf.push(e);
48
- },
49
- end() {
50
- ended = true;
51
- for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
52
- },
53
- iter: {
54
- [Symbol.asyncIterator]() {
55
- return {
56
- next: (): Promise<IteratorResult<CdpEvent>> =>
57
- new Promise((resolve) => {
58
- const next = buf.shift();
59
- if (next) resolve({ value: next, done: false });
60
- else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
61
- else waiters.push(resolve);
62
- }),
63
- };
64
- },
65
- },
66
- };
67
- };
68
-
69
29
  // ── Implementation ─────────────────────────────────────────────────────────────
70
30
 
71
31
  export const createDaemonTransport = (clientId: string): CdpTransport => {
@@ -73,20 +33,12 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
73
33
  let rl: ReturnType<typeof createInterface> | null = null;
74
34
  let queue = makeEventQueue();
75
35
  const closeListeners = new Set<() => void>();
76
- const pending = new Map<number, {
77
- resolve: (v: Result<unknown, CdpError>) => void;
78
- timer: ReturnType<typeof setTimeout>;
79
- method: string;
80
- }>();
36
+ const pending = new Map<number, Pending>();
81
37
  let registered = false;
82
38
  let nextRequestId = 1;
83
39
 
84
40
  const cleanup = (reason: string): void => {
85
- for (const [, p] of pending) {
86
- clearTimeout(p.timer);
87
- p.resolve(err(cdpError("transport_closed", reason, p.method)));
88
- }
89
- pending.clear();
41
+ rejectAllPending(pending, reason);
90
42
  queue.end();
91
43
  queue = makeEventQueue();
92
44
  registered = false;
@@ -237,22 +189,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
237
189
  ...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
238
190
  };
239
191
 
240
- return new Promise((resolve) => {
241
- const timer = setTimeout(() => {
242
- pending.delete(id);
243
- resolve(err(cdpError("timeout", `Daemon timeout after ${timeoutMs}ms: ${method}`, method)));
244
- }, timeoutMs);
245
-
246
- pending.set(id, { resolve, timer, method });
247
-
248
- try {
249
- socket!.write(serialize(req) + "\n");
250
- } catch (e) {
251
- clearTimeout(timer);
252
- pending.delete(id);
253
- resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
254
- }
255
- });
192
+ return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => socket!.write(serialize(req) + "\n"));
256
193
  };
257
194
 
258
195
  // ── Events ─────────────────────────────────────────────────────────────
@@ -268,10 +205,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
268
205
  return "open";
269
206
  };
270
207
 
271
- const onClose = (cb: () => void): (() => void) => {
272
- closeListeners.add(cb);
273
- return () => closeListeners.delete(cb);
274
- };
208
+ const onClose = makeOnClose(closeListeners);
275
209
 
276
210
  return { connect, close, request, events, state, onClose };
277
211
  };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Shared transport primitives for CdpTransport implementations.
3
+ *
4
+ * Both the WebSocket transport (transport.ts, talking to Chrome directly) and
5
+ * the daemon transport (daemon-transport.ts, talking to the daemon over a Unix
6
+ * socket) need an identical single-consumer async event queue plus the same
7
+ * pending-request bookkeeping. This module is the single source of truth for
8
+ * that shared machinery.
9
+ */
10
+
11
+ import { type Result, err } from "../util/result";
12
+ import { type CdpError, cdpError } from "./errors";
13
+ import type { CdpEvent } from "./types";
14
+
15
+ export type CdpResult = Result<unknown, CdpError>;
16
+
17
+ /**
18
+ * A single-consumer async queue of CDP events.
19
+ *
20
+ * `push` enqueues an event (dropped silently after `end`). `end` terminates the
21
+ * stream — any pending and future iterator `next()` calls resolve `done:true`.
22
+ * `iter` is the AsyncIterable consumed by one for-await loop; a second consumer
23
+ * would silently steal events from the first.
24
+ */
25
+ export type EventQueue = {
26
+ readonly push: (e: CdpEvent) => void;
27
+ readonly end: () => void;
28
+ readonly iter: AsyncIterable<CdpEvent>;
29
+ };
30
+
31
+ export const makeEventQueue = (): EventQueue => {
32
+ const buf: CdpEvent[] = [];
33
+ const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
34
+ let ended = false;
35
+ return {
36
+ push(e) {
37
+ if (ended) return;
38
+ const w = waiters.shift();
39
+ if (w) w({ value: e, done: false });
40
+ else buf.push(e);
41
+ },
42
+ end() {
43
+ ended = true;
44
+ // value: undefined as unknown as CdpEvent is the correct iterator-protocol
45
+ // pattern: when done=true the value field is conventionally undefined but
46
+ // the TS type still requires CdpEvent.
47
+ for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
48
+ },
49
+ iter: {
50
+ [Symbol.asyncIterator]() {
51
+ return {
52
+ next: (): Promise<IteratorResult<CdpEvent>> =>
53
+ new Promise((resolve) => {
54
+ const next = buf.shift();
55
+ if (next) resolve({ value: next, done: false });
56
+ else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
57
+ else waiters.push(resolve);
58
+ }),
59
+ };
60
+ },
61
+ },
62
+ };
63
+ };
64
+
65
+ /** An in-flight CDP request awaiting its response or timeout. */
66
+ export type Pending = {
67
+ readonly resolve: (v: Result<unknown, CdpError>) => void;
68
+ readonly timer: ReturnType<typeof setTimeout>;
69
+ readonly method: string;
70
+ };
71
+
72
+ /**
73
+ * Reject every in-flight request with a transport_closed error and clear the
74
+ * map. Called from each transport's cleanup path when the underlying connection
75
+ * goes away.
76
+ */
77
+ export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => {
78
+ for (const [, p] of pending) {
79
+ clearTimeout(p.timer);
80
+ p.resolve(err(cdpError("transport_closed", reason, p.method)));
81
+ }
82
+ pending.clear();
83
+ };
84
+
85
+ /**
86
+ * Send a CDP request and return a promise that settles when the matching
87
+ * response arrives, the request times out, or the send throws.
88
+ *
89
+ * The transport supplies a `send` callback that puts the already-serialized
90
+ * payload onto its wire (WebSocket frame or Unix-socket line); everything else
91
+ * — registering the pending entry, the timeout timer, and synchronous send
92
+ * failure handling — is identical across transports and lives here.
93
+ *
94
+ * `timeoutLabel` is the transport's prefix for the timeout error message
95
+ * (e.g. "CDP" or "Daemon").
96
+ */
97
+ export const sendWithTimeout = (
98
+ pending: Map<number, Pending>,
99
+ id: number,
100
+ method: string,
101
+ timeoutMs: number,
102
+ timeoutLabel: string,
103
+ send: () => void,
104
+ ): Promise<CdpResult> =>
105
+ new Promise((resolve) => {
106
+ const timer = setTimeout(() => {
107
+ pending.delete(id);
108
+ resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method)));
109
+ }, timeoutMs);
110
+ pending.set(id, { resolve, timer, method });
111
+ try {
112
+ send();
113
+ } catch (e) {
114
+ clearTimeout(timer);
115
+ pending.delete(id);
116
+ resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
117
+ }
118
+ });
119
+
120
+ /**
121
+ * Register a close listener, returning an unsubscribe function. Identical
122
+ * across transports, so it lives here.
123
+ */
124
+ export const makeOnClose = (closeListeners: Set<() => void>) =>
125
+ (cb: () => void): (() => void) => {
126
+ closeListeners.add(cb);
127
+ return () => closeListeners.delete(cb);
128
+ };
@@ -17,12 +17,25 @@ type TabSession = {
17
17
  pageInfoDirty: boolean;
18
18
  networkBuffer: ReturnType<typeof createNetworkBuffer>;
19
19
  consoleBuffer: ReturnType<typeof createConsoleBuffer>;
20
+ // Stable element refs (e1, e2, …) → CDP backendNodeId, from the latest
21
+ // snapshot of this tab. Replaced wholesale on every snapshot — old refs are
22
+ // stale anyway, so this stays bounded (~50–60 KB worst case).
23
+ refMap: Map<string, number>;
24
+ // Per-ref signature ("role|name|value") from the latest snapshot, used as the
25
+ // baseline for the post-mutation auto-diff. Replaced alongside refMap.
26
+ refSig: Map<string, string>;
20
27
  };
21
28
 
22
29
  export type CdpSession = {
23
30
  attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
24
31
  switchTo(targetId: string): Promise<Result<void, CdpError>>;
25
32
  current(): { readonly sessionId: string; readonly targetId: string } | null;
33
+ /** Replace the active tab's ref map and ref signatures (from a fresh snapshot). */
34
+ setRefMap(refMap: Map<string, number>, refSig: Map<string, string>): void;
35
+ /** Resolve a ref (e.g. "e12") to its backendNodeId on the active tab, or undefined if unknown/stale. */
36
+ resolveRef(ref: string): number | undefined;
37
+ /** The active tab's prior ref signatures — baseline for the post-mutation diff. */
38
+ refSignatures(): ReadonlyMap<string, string>;
26
39
  call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
27
40
  callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
28
41
  callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
@@ -211,6 +224,8 @@ export const createCdpSession = (
211
224
  pageInfoDirty: false,
212
225
  networkBuffer: createNetworkBuffer(),
213
226
  consoleBuffer: createConsoleBuffer(),
227
+ refMap: new Map(),
228
+ refSig: new Map(),
214
229
  });
215
230
  sessionIdToTargetId.set(a.sessionId, pickTargetId);
216
231
  return ok({ targetId: pickTargetId, sessionId: a.sessionId });
@@ -230,6 +245,8 @@ export const createCdpSession = (
230
245
  pageInfoDirty: true,
231
246
  networkBuffer: createNetworkBuffer(),
232
247
  consoleBuffer: createConsoleBuffer(),
248
+ refMap: new Map(),
249
+ refSig: new Map(),
233
250
  };
234
251
  if (existing) {
235
252
  // Each Target.attachToTarget produces a new sessionId — update it.
@@ -249,6 +266,20 @@ export const createCdpSession = (
249
266
  current() {
250
267
  return sessionId && targetId ? { sessionId, targetId } : null;
251
268
  },
269
+ setRefMap(refMap, refSig) {
270
+ const tab = targetId ? tabs.get(targetId) : undefined;
271
+ if (!tab) return;
272
+ tab.refMap = refMap;
273
+ tab.refSig = refSig;
274
+ },
275
+ resolveRef(ref) {
276
+ const tab = targetId ? tabs.get(targetId) : undefined;
277
+ return tab?.refMap.get(ref);
278
+ },
279
+ refSignatures() {
280
+ const tab = targetId ? tabs.get(targetId) : undefined;
281
+ return tab?.refSig ?? new Map();
282
+ },
252
283
  call(method, params = {}, opts = {}) {
253
284
  return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
254
285
  },
@@ -3,15 +3,10 @@ import { type Result, err, ok } from "../util/result";
3
3
  import { type CdpError, cdpError } from "./errors";
4
4
  import { isCdpRawMessage } from "./types";
5
5
  import type { CdpEvent, CdpRawMessage } from "./types";
6
+ import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
6
7
 
7
8
  const DEFAULT_TIMEOUT_MS = 15_000;
8
9
 
9
- type Pending = {
10
- readonly resolve: (v: Result<unknown, CdpError>) => void;
11
- readonly timer: ReturnType<typeof setTimeout>;
12
- readonly method: string;
13
- };
14
-
15
10
  export type CdpTransport = {
16
11
  connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
17
12
  close(): Promise<void>;
@@ -33,46 +28,6 @@ export const createCdpTransport = (): CdpTransport => {
33
28
  const pending = new Map<number, Pending>();
34
29
  const closeListeners = new Set<() => void>();
35
30
 
36
- type EventQueue = {
37
- readonly push: (e: CdpEvent) => void;
38
- readonly end: () => void;
39
- readonly iter: AsyncIterable<CdpEvent>;
40
- };
41
-
42
- const makeEventQueue = (): EventQueue => {
43
- const buf: CdpEvent[] = [];
44
- const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
45
- let ended = false;
46
- return {
47
- push(e) {
48
- if (ended) return;
49
- const w = waiters.shift();
50
- if (w) w({ value: e, done: false });
51
- else buf.push(e);
52
- },
53
- end() {
54
- ended = true;
55
- // value: undefined as unknown as CdpEvent is the correct iterator-protocol
56
- // pattern: when done=true the value field is conventionally undefined but
57
- // the TS type still requires CdpEvent.
58
- for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
59
- },
60
- iter: {
61
- [Symbol.asyncIterator]() {
62
- return {
63
- next: (): Promise<IteratorResult<CdpEvent>> =>
64
- new Promise((resolve) => {
65
- const next = buf.shift();
66
- if (next) resolve({ value: next, done: false });
67
- else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
68
- else waiters.push(resolve);
69
- }),
70
- };
71
- },
72
- },
73
- };
74
- };
75
-
76
31
  let queue = makeEventQueue();
77
32
 
78
33
  const handleMessage = (raw: string): void => {
@@ -109,11 +64,7 @@ export const createCdpTransport = (): CdpTransport => {
109
64
  };
110
65
 
111
66
  const cleanup = (reason: string): void => {
112
- for (const [, p] of pending) {
113
- clearTimeout(p.timer);
114
- p.resolve(err(cdpError("transport_closed", reason, p.method)));
115
- }
116
- pending.clear();
67
+ rejectAllPending(pending, reason);
117
68
  queue.end();
118
69
  queue = makeEventQueue();
119
70
  for (const cb of closeListeners) cb();
@@ -184,20 +135,7 @@ export const createCdpTransport = (): CdpTransport => {
184
135
  const payload: Record<string, unknown> = { id, method, params };
185
136
  if (sessionId) payload["sessionId"] = sessionId;
186
137
  const json = JSON.stringify(payload);
187
- return new Promise((resolve) => {
188
- const timer = setTimeout(() => {
189
- pending.delete(id);
190
- resolve(err(cdpError("timeout", `CDP timeout after ${timeoutMs}ms: ${method}`, method)));
191
- }, timeoutMs);
192
- pending.set(id, { resolve, timer, method });
193
- try {
194
- sock.send(json);
195
- } catch (e) {
196
- clearTimeout(timer);
197
- pending.delete(id);
198
- resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
199
- }
200
- });
138
+ return sendWithTimeout(pending, id, method, timeoutMs, "CDP", () => sock.send(json));
201
139
  },
202
140
  events(): AsyncIterable<CdpEvent> {
203
141
  // Single-consumer: each connection's event stream may be iterated by
@@ -213,9 +151,6 @@ export const createCdpTransport = (): CdpTransport => {
213
151
  if (ws.readyState === WebSocket.OPEN) return "open";
214
152
  return "closed";
215
153
  },
216
- onClose(cb): () => void {
217
- closeListeners.add(cb);
218
- return () => closeListeners.delete(cb);
219
- },
154
+ onClose: makeOnClose(closeListeners),
220
155
  };
221
156
  };
@@ -1,13 +1,21 @@
1
- import { Type, type Static } from "typebox";
1
+ import { Type } from "typebox";
2
2
  import { Text } from "@mariozechner/pi-tui";
3
3
  import type { BrowserClient } from "../client";
4
4
  import { Coords, MouseButton } from "../schemas/common";
5
5
  import { type Result, err, ok } from "../util/result";
6
6
  import { defineBrowserTool, type ToolErr } from "../util/tool";
7
7
  import { captureWithCrosshair } from "./screenshot";
8
+ import { interactiveDiff, resolveRefToBox } from "./ref-resolve";
8
9
 
9
10
  const ClickArgs = Type.Object({
10
- ...Coords.properties,
11
+ ref: Type.Optional(
12
+ Type.String({
13
+ description:
14
+ "Stable element ref from browser_snapshot (e.g. 'e12'). PREFERRED over x/y — survives re-renders. When set, x/y are ignored.",
15
+ }),
16
+ ),
17
+ x: Type.Optional(Coords.properties.x),
18
+ y: Type.Optional(Coords.properties.y),
11
19
  button: Type.Optional(MouseButton),
12
20
  count: Type.Optional(
13
21
  Type.Integer({
@@ -18,20 +26,27 @@ const ClickArgs = Type.Object({
18
26
  }),
19
27
  ),
20
28
  });
21
- type ClickArgsT = Static<typeof ClickArgs>;
22
29
 
23
30
  const dispatchClick = async (
24
31
  client: BrowserClient,
25
- args: ClickArgsT,
32
+ x: number,
33
+ y: number,
34
+ button: "left" | "right" | "middle",
35
+ count: number,
26
36
  ): Promise<Result<void, ToolErr>> => {
27
- const button = args.button ?? "left";
28
- const count = args.count ?? 1;
37
+ // Move the pointer to the target first. Some focus/hover handlers (and React
38
+ // synthetic events) only fire when a mousemove precedes the press, so without
39
+ // it a click can land without focusing the element.
40
+ const moved = await client.session().call("Input.dispatchMouseEvent", {
41
+ type: "mouseMoved", x, y,
42
+ });
43
+ if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message });
29
44
  const pressed = await client.session().call("Input.dispatchMouseEvent", {
30
- type: "mousePressed", x: args.x, y: args.y, button, clickCount: count,
45
+ type: "mousePressed", x, y, button, clickCount: count,
31
46
  });
32
47
  if (!pressed.success) return err({ kind: "cdp_error", message: pressed.error.message });
33
48
  const released = await client.session().call("Input.dispatchMouseEvent", {
34
- type: "mouseReleased", x: args.x, y: args.y, button, clickCount: count,
49
+ type: "mouseReleased", x, y, button, clickCount: count,
35
50
  });
36
51
  if (!released.success) return err({ kind: "cdp_error", message: released.error.message });
37
52
  return ok(undefined);
@@ -41,34 +56,55 @@ export const clickTool = defineBrowserTool({
41
56
  name: "browser_click",
42
57
  label: "Browser Click",
43
58
  description:
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
- promptSnippet: "Click at pixel coordinates (x, y) on the page",
59
+ "Click an element. PREFERRED: pass `ref` (e.g. 'e12') from browser_snapshot — it re-resolves the element's position at click time, so it works even after the page re-renders and moves things. Fallback: pass viewport CSS-pixel `x`/`y`. Compositor-level click works through iframes, shadow DOM, and cross-origin content. After clicking, a compact diff of page changes is appended.",
60
+ promptSnippet: "Click an element by ref (preferred) or pixel coordinates",
46
61
  promptGuidelines: [
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.",
51
- "Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content — no selector needed.",
52
- "If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots showing exact click positions (debug only).",
53
- "For React/Vue components that don't respond to clicks, try browser_dispatch_key to send DOM-level events.",
62
+ "PREFER `ref` from browser_snapshot (the outline shows '[eN]' for every interactive element) — it survives re-renders, unlike coordinates which go stale after a save/edit reflows the page.",
63
+ "Fallback: pass (x, y) from the snapshot's '@(x,y)' hint when there's no ref.",
64
+ "A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
65
+ "After clicking, read the appended page-changes diff to confirm the action landed before moving on (no separate snapshot needed for a quick check).",
66
+ "Coordinates are viewport CSS pixels (not device pixels). Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content.",
67
+ "If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots (debug only). For React/Vue components ignoring clicks, try browser_dispatch_key.",
54
68
  ],
55
69
  parameters: ClickArgs,
56
- renderCall: (a) => new Text(`🖱️ Click at (${a.x}, ${a.y})`, 0, 0),
70
+ renderCall: (a) => new Text(`🖱️ Click ${a.ref ? `[${a.ref}]` : `(${a.x}, ${a.y})`}`, 0, 0),
57
71
  async handler(args, { client }) {
58
- const clicked = await dispatchClick(client, args);
72
+ const button = args.button ?? "left";
73
+ const count = args.count ?? 1;
74
+
75
+ // Resolve the click point: ref re-resolves to the element's CURRENT box
76
+ // (so re-renders don't matter); otherwise use the literal x/y.
77
+ let x: number;
78
+ let y: number;
79
+ if (args.ref !== undefined) {
80
+ const box = await resolveRefToBox(client, args.ref);
81
+ if (!box.success) return box;
82
+ x = box.data.cx;
83
+ y = box.data.cy;
84
+ } else if (args.x !== undefined && args.y !== undefined) {
85
+ x = args.x;
86
+ y = args.y;
87
+ } else {
88
+ return err({ kind: "invalid_state", message: "Provide either `ref` or both `x` and `y`." });
89
+ }
90
+
91
+ const clicked = await dispatchClick(client, x, y, button, count);
59
92
  if (!clicked.success) return clicked;
93
+
94
+ const target = args.ref !== undefined ? `[${args.ref}] (${x}, ${y})` : `(${x}, ${y})`;
95
+ const diff = await interactiveDiff(client);
60
96
  if (process.env["BH_DEBUG_CLICKS"]) {
61
- const debug = await captureWithCrosshair(client, { x: args.x, y: args.y });
97
+ const debug = await captureWithCrosshair(client, { x, y });
62
98
  if (debug.success) {
63
99
  return ok({
64
- text: `Clicked at (${args.x}, ${args.y})\n[DEBUG] Overlay screenshot: ${debug.data.path}`,
65
- details: { debugScreenshotPath: debug.data.path, x: args.x, y: args.y },
100
+ text: `Clicked at ${target}\n[DEBUG] Overlay screenshot: ${debug.data.path}${diff}`,
101
+ details: { debugScreenshotPath: debug.data.path, x, y, ref: args.ref },
66
102
  });
67
103
  }
68
104
  }
69
105
  return ok({
70
- text: `Clicked at (${args.x}, ${args.y})`,
71
- details: { x: args.x, y: args.y },
106
+ text: `Clicked at ${target}${diff}`,
107
+ details: { x, y, ref: args.ref },
72
108
  });
73
109
  },
74
110
  });
@@ -6,12 +6,28 @@ import { type Result, err, ok } from "../util/result";
6
6
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
7
  import { safeJs } from "../util/js-template";
8
8
  import { pdfPath } from "../util/paths";
9
+ import { resolveRefToBackendId } from "./ref-resolve";
9
10
 
10
11
  const UploadArgs = Type.Object({
11
- selector: Type.String({ description: "CSS selector of the file <input>" }),
12
+ ref: Type.Optional(
13
+ Type.String({ description: "Stable ref of the file <input> from browser_snapshot. PREFERRED over selector." }),
14
+ ),
15
+ selector: Type.Optional(Type.String({ description: "CSS selector of the file <input> (fallback when no ref)" })),
12
16
  filePath: Type.String({ description: "Absolute path to the file to upload" }),
13
17
  });
14
18
 
19
+ // Ref path: set files directly on the resolved backendNodeId via CDP — simplest
20
+ // and most reliable, no selector needed.
21
+ const tryRefUpload = async (
22
+ client: BrowserClient,
23
+ backendId: number,
24
+ filePath: string,
25
+ ): Promise<Result<void, ToolErr>> => {
26
+ const set = await client.session().call("DOM.setFileInputFiles", { files: [filePath], backendNodeId: backendId });
27
+ if (!set.success) return err({ kind: "cdp_error", message: set.error.message });
28
+ return ok(undefined);
29
+ };
30
+
15
31
  const verifyReadable = async (filePath: string): Promise<Result<void, ToolErr>> => {
16
32
  try {
17
33
  await access(filePath, constants.R_OK);
@@ -89,16 +105,29 @@ const jsFallbackUpload = async (
89
105
  export const uploadFileTool = defineBrowserTool({
90
106
  name: "browser_upload_file",
91
107
  label: "Browser Upload File",
92
- description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages.",
93
- promptSnippet: "Upload a file to a file input",
108
+ description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.",
109
+ promptSnippet: "Upload a file to a file input by ref (preferred) or selector",
94
110
  promptGuidelines: [
111
+ "PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders. (File inputs are often hidden; snapshot still surfaces them.)",
95
112
  "File path must be absolute and readable.",
96
- "Selector must match a file input.",
97
113
  ],
98
114
  parameters: UploadArgs,
99
115
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
100
116
  const readable = await verifyReadable(args.filePath);
101
117
  if (!readable.success) return readable;
118
+
119
+ // Ref path: resolve to backendNodeId and set files directly via CDP.
120
+ if (args.ref !== undefined) {
121
+ const backendId = resolveRefToBackendId(client, args.ref);
122
+ if (!backendId.success) return backendId;
123
+ const up = await tryRefUpload(client, backendId.data, args.filePath);
124
+ if (!up.success) return up;
125
+ return ok({ text: `Uploaded ${args.filePath} via CDP (ref ${args.ref})`, details: { mode: "cdp", ref: args.ref, filePath: args.filePath } });
126
+ }
127
+
128
+ if (args.selector === undefined) {
129
+ return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
130
+ }
102
131
  const cdp = await tryCdpUpload(client, args.selector, args.filePath);
103
132
  if (cdp.success) {
104
133
  return ok({