pi-browser-harness 0.14.0 → 0.15.1

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 CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to pi-browser-harness will be documented in this file.
4
4
 
5
+ ## 0.15.1 — 2026-11-07
6
+
7
+ - Re-release of 0.15.0, whose npm publish failed (the provenance log returned 502, then rejected the retry as a duplicate). No code changes.
8
+
9
+ ## 0.15.0 — 2026-11-07
10
+
11
+ ### Changed
12
+
13
+ - **`browser_fill` and `browser_type` are one tool: `browser_type`.** Pass a `ref` (or `selector`) and it focuses the field, replaces its value (`clear:false` appends; `text: ""` empties it), types with real input events so autocomplete lists open, and reports the value read back. When typing does not produce the value (date or masked inputs) it sets the value directly and says so. With no ref it types into the focused element, appending as before. Saved recipes with `fill` steps still replay.
14
+
15
+ ### Fixed
16
+
17
+ - **`browser_click` no longer clicks into nothing.** A ref is scrolled into view (nested scrolling lists included) and measured at click time; a point still outside the viewport, or an `x`/`y` outside it, is refused with the reason. When something covers the element (an overlay, a sticky header) the result says what was clicked instead. An `x`/`y` click names the element it landed on.
18
+ - **`browser_press_key` runs Cmd/Ctrl shortcuts.** Cmd/Ctrl + A, C, X, V, Z, Y now carry Chrome's editing command, so select-all, copy, paste and undo work. Letter keys send the right `code` (`KeyA`) and key code (65).
19
+ - **`browser_execute_js` waits up to 30s by default, and `timeoutMs` (max 120s) raises it.** It used to stop at 10s, mid-way through scripts that loop with waits. A timeout now says the script may have partly run.
20
+ - **`browser_screenshot` says how its pixels map to click coordinates** when the image is not 1:1 with CSS pixels (2× on Retina, or after `maxDim`).
21
+
22
+ ## 0.14.1 — 2026-11-07
23
+
24
+ ### Fixed
25
+
26
+ - **Browser tools load in every session, not just the first.** pi builds a fresh tool list for each session it starts in a running process (`/new`, resume, fork, and in-process subagents), but the harness remembered which tools it had registered for the whole process and skipped them all. Those sessions had no `browser_*` tools while the prompt still said they were available. The harness now registers its tools with each new session.
27
+
5
28
  ## 0.14.0 — 2026-11-01
6
29
 
7
30
  ### Added
package/README.md CHANGED
@@ -18,7 +18,7 @@ no MCP hop, no second browser, no extra API keys.
18
18
  | **Browser** | Your running Chrome — real profile, live sessions | Launches a clean instance | Launches a clean instance | Launches a clean instance |
19
19
  | **Element targeting** | AX-tree refs (`[eN]`) that re-resolve at action time; coordinate and selector fallbacks | Selectors / AX locators | CSS selectors | LLM-inferred selectors |
20
20
  | **Input dispatch** | Compositor-level CDP input — works through iframes, shadow DOM, cross-origin frames | DOM-level | DOM-level | DOM-level |
21
- | **Framework-safe writes** | `browser_fill` writes via the native setter and fires bubbling events, so controlled inputs keep the value | Native fill | Manual | Native fill |
21
+ | **Framework-safe writes** | `browser_type` types with real input events, reads the value back, and falls back to the native setter when typing doesn't take | Native fill | Manual | Native fill |
22
22
  | **Transport** | Native pi extension, in-process | MCP / JSON-RPC round trip | MCP / JSON-RPC round trip | In-process + its own LLM calls |
23
23
  | **Extra credentials** | None | None | None | Its own model API key |
24
24
  | **Concurrency** | Reads run in parallel; mutations auto-serialize through a shared mutex | Sequential | Sequential | Sequential |
@@ -76,10 +76,12 @@ interactive element carries a stable ref (`[eN]`) plus click coordinates `@(x,y)
76
76
  [e12] button "Create account" @(456,412)
77
77
  ```
78
78
 
79
- Pass the ref — not the coordinates — to `browser_click`, `browser_fill`, `browser_select_option`,
79
+ Pass the ref — not the coordinates — to `browser_click`, `browser_type`, `browser_select_option`,
80
80
  `browser_focus`, `browser_upload_file`, or `browser_dispatch_key`. Refs are keyed to element
81
81
  identity, so they re-resolve position at action time and survive the re-renders that SPA forms
82
- trigger on every keystroke. Coordinates go stale the moment the layout reflows.
82
+ trigger on every keystroke. Coordinates go stale the moment the layout reflows. `browser_click` by ref
83
+ scrolls the element into view first (inside scrolling lists too) and warns when something covers it;
84
+ an `x`/`y` outside the viewport is refused instead of clicking nothing.
83
85
 
84
86
  Three things follow from that:
85
87
 
@@ -127,10 +129,9 @@ Both run in isolated tabs and never disturb your current page.
127
129
  | Tool | Purpose |
128
130
  |---|---|
129
131
  | `browser_click` | Click by `ref` (preferred) or viewport `x`/`y`. Compositor-level, so iframes and shadow DOM work. |
130
- | `browser_fill` | Set an input, textarea, or contenteditable. Fires bubbling `input`/`change`, returns the value written. |
131
132
  | `browser_select_option` | Choose in a native `<select>` by value, label, or index. Returns available options on no match. |
132
133
  | `browser_focus` | Deterministic `.focus()` — no coordinate accuracy needed. |
133
- | `browser_type` | Type into the focused element. For keystroke-sensitive widgets; prefer `browser_fill` otherwise. |
134
+ | `browser_type` | Enter text into a field by `ref`/selector (replaces its value; `clear:false` appends) or into the focused element. Real input events, so autocomplete opens; returns the value read back. |
134
135
  | `browser_press_key` | Press a key with an optional modifier bitfield. |
135
136
  | `browser_dispatch_key` | Synthetic DOM `KeyboardEvent` on one element, for pages that ignore raw CDP keys. |
136
137
  | `browser_scroll` | Scroll by delta. W3C wheel convention: positive `deltaY` = down. |
@@ -202,7 +203,7 @@ Wait, snapshot once, then act by ref. No screenshots anywhere in this loop.
202
203
  ```
203
204
  browser_wait_for({ selector: "#email" })
204
205
  browser_snapshot() // → [e7] textbox, [e9] combobox, [e12] button
205
- browser_fill({ ref: "e7", value: "a@b.com" })
206
+ browser_type({ ref: "e7", text: "a@b.com" })
206
207
  browser_select_option({ ref: "e9", label: "India" })
207
208
  browser_click({ ref: "e12" }) // re-resolves position even after reflow
208
209
  ```
@@ -352,7 +353,7 @@ re-runnable.
352
353
 
353
354
  Another extension can add its own `browser_*` tools to this harness. Contributed tools share the
354
355
  same client, mutation mutex, tab ownership, and lane prompt as the built-ins, so a `serialized` tool
355
- you add queues behind `browser_click` exactly as `browser_fill` does.
356
+ you add queues behind `browser_click` exactly as `browser_type` does.
356
357
 
357
358
  Define the tool with `defineBrowserTool` and hand it to `registerBrowserTools` at your extension's
358
359
  load time, before pi emits `session_start`. The harness registers it with pi on the next
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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",
@@ -61,7 +61,6 @@ Generated from `src/registry.ts` by `npm run gen:skill`; do not edit by hand.
61
61
  | `browser_download` | serialized | Configure download directory |
62
62
  | `browser_drag_and_drop` | serialized | Drag and drop between two coordinates |
63
63
  | `browser_execute_js` | parallel | Execute JS in the page — surgical DOM reads (preferred over screenshot) |
64
- | `browser_fill` | serialized | Fill a form field by ref (preferred) or selector (works with React/Vue controlled inputs) |
65
64
  | `browser_fill_form` | serialized | Fill many form fields at once by ref — React-safe, self-confirming |
66
65
  | `browser_focus` | serialized | Focus an element by ref (preferred) or selector |
67
66
  | `browser_go_back` | serialized | Go back one page |
@@ -89,7 +88,7 @@ Generated from `src/registry.ts` by `npm run gen:skill`; do not edit by hand.
89
88
  | `browser_setup` | parallel | Initialize browser connection |
90
89
  | `browser_snapshot` | parallel | Get accessibility-tree snapshot with stable element refs (default for page inspection) |
91
90
  | `browser_switch_tab` | serialized | Switch tabs |
92
- | `browser_type` | serialized | Type text into the focused element |
91
+ | `browser_type` | serialized | Type text into a field by ref (preferred), selector, or the focused element |
93
92
  | `browser_upload_file` | serialized | Upload a file to a file input by ref (preferred) or selector |
94
93
  | `browser_viewport_resize` | serialized | Resize the viewport (responsive testing) |
95
94
  | `browser_wait` | parallel | Wait N seconds |
@@ -271,6 +271,18 @@ export const COMMANDS = {
271
271
  }),
272
272
  Type.Object({ data: Type.String(), stream: Type.Optional(Type.String()) }),
273
273
  ),
274
+ "Page.getLayoutMetrics": cmd(
275
+ Empty,
276
+ Type.Object(
277
+ {
278
+ cssLayoutViewport: Type.Object(
279
+ { clientWidth: Type.Number(), clientHeight: Type.Number() },
280
+ { additionalProperties: true },
281
+ ),
282
+ },
283
+ { additionalProperties: true },
284
+ ),
285
+ ),
274
286
  "Page.handleJavaScriptDialog": cmd(
275
287
  Type.Object({ accept: Type.Boolean(), promptText: Type.Optional(Type.String()) }),
276
288
  Empty,
@@ -322,6 +334,22 @@ export const COMMANDS = {
322
334
  ),
323
335
  }),
324
336
  ),
337
+ "DOM.scrollIntoViewIfNeeded": cmd(
338
+ Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
339
+ Empty,
340
+ ),
341
+ "DOM.getNodeForLocation": cmd(
342
+ Type.Object({
343
+ x: Type.Integer(),
344
+ y: Type.Integer(),
345
+ includeUserAgentShadowDOM: Type.Optional(Type.Boolean()),
346
+ ignorePointerEventsNone: Type.Optional(Type.Boolean()),
347
+ }),
348
+ Type.Object(
349
+ { backendNodeId: Type.Number(), frameId: Type.Optional(Type.String()), nodeId: Type.Optional(Type.Number()) },
350
+ { additionalProperties: true },
351
+ ),
352
+ ),
325
353
  "DOM.resolveNode": cmd(
326
354
  Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
327
355
  Type.Object({ object: RemoteObject }),
package/src/client.ts CHANGED
@@ -59,7 +59,7 @@ export type BrowserClient = {
59
59
  stop(): Promise<void>;
60
60
  detach(): Promise<void>;
61
61
  closeOwnedTabs(): Promise<void>;
62
- evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
62
+ evaluateJs(expression: string, sessionId?: string, opts?: { readonly timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
63
63
  pageInfo(): Promise<Result<PageInfoResult, CdpError>>;
64
64
  takeDialog(): DialogInfo | null;
65
65
  listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
@@ -260,9 +260,16 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
260
260
  return running;
261
261
  };
262
262
 
263
- const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
263
+ const evaluateJs = async (
264
+ expression: string,
265
+ sessionId?: string,
266
+ opts?: { readonly timeoutMs?: number },
267
+ ): Promise<Result<unknown, CdpError>> => {
264
268
  const params = evaluateParams(expression);
265
- const r = sessionId ? await session.callOnTarget("Runtime.evaluate", params, sessionId) : await session.call("Runtime.evaluate", params);
269
+ const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
270
+ const r = sessionId
271
+ ? await session.callOnTarget("Runtime.evaluate", params, sessionId, callOpts)
272
+ : await session.call("Runtime.evaluate", params, callOpts);
266
273
  if (!r.success) return r;
267
274
  if (r.data.exceptionDetails !== undefined) {
268
275
  return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(r.data.exceptionDetails)}`, "Runtime.evaluate"));
@@ -1,7 +1,7 @@
1
1
  import { isCdpRawMessage } from "../cdp/types";
2
2
  import type { CdpRawMessage } from "../cdp/types";
3
3
  import type { WireRequest, WireResponse, WireEvent } from "./protocol";
4
- import { CDP_COMMAND_TIMEOUT_MS, DAEMON_RECONNECT_METHOD } from "./protocol";
4
+ import { DAEMON_RECONNECT_METHOD, commandTimeoutFor } from "./protocol";
5
5
  import { asString, isRecord } from "../util/guards";
6
6
  import { type ChromeConnectionDependencies, type ConnectionStatus, createChromeConnection } from "./connection";
7
7
 
@@ -246,6 +246,7 @@ export const createCdpBridge = (options: ChromeConnectionDependencies = {}): Cdp
246
246
  if (sessionId !== undefined) router.release(sessionId);
247
247
  }
248
248
 
249
+ const timeoutMs = commandTimeoutFor(req);
249
250
  const daemonId = mux.allocate(
250
251
  {
251
252
  clientId,
@@ -257,8 +258,8 @@ export const createCdpBridge = (options: ChromeConnectionDependencies = {}): Cdp
257
258
  (id) =>
258
259
  setTimeout(() => {
259
260
  mux.take(id);
260
- send(clientId, errorResponse(req.id, `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}`));
261
- }, CDP_COMMAND_TIMEOUT_MS),
261
+ send(clientId, errorResponse(req.id, `Timeout after ${timeoutMs}ms: ${req.method}`));
262
+ }, timeoutMs),
262
263
  );
263
264
 
264
265
  const payload: Record<string, unknown> = {
@@ -6,6 +6,8 @@ export type WireRequest = {
6
6
  readonly method: string;
7
7
  readonly params?: Record<string, unknown>;
8
8
  readonly sessionId?: string;
9
+ // How long the daemon waits for Chrome's answer; see commandTimeoutFor.
10
+ readonly timeoutMs?: number;
9
11
  };
10
12
 
11
13
  export type WireResponse = {
@@ -71,6 +73,7 @@ const isWireMessage = (v: unknown): v is WireMessage => {
71
73
  if (typeof v["id"] !== "number") return false;
72
74
  if (typeof v["method"] !== "string") return false;
73
75
  if (!isOptionalRecord(v["params"])) return false;
76
+ if (v["timeoutMs"] !== undefined && typeof v["timeoutMs"] !== "number") return false;
74
77
  return isOptionalString(v["sessionId"]);
75
78
  }
76
79
  case "response": {
@@ -138,3 +141,11 @@ export const CDP_RECONNECT_COOLDOWN_MAX_MS = 5 * 60 * 1000;
138
141
  export const DAEMON_RECONNECT_METHOD = "Daemon.reconnect";
139
142
 
140
143
  export const CDP_COMMAND_TIMEOUT_MS = 10_000;
144
+
145
+ // The longest a caller may ask the daemon to wait for one command (browser_execute_js's timeoutMs).
146
+ export const CDP_COMMAND_TIMEOUT_MAX_MS = 120_000;
147
+
148
+ // A request may lengthen the daemon's wait (a long-running script) but never shorten it below the default: short
149
+ // client-side probes already give up on their own, and a shorter daemon timer would only race them.
150
+ export const commandTimeoutFor = (req: { readonly timeoutMs?: number }): number =>
151
+ Math.min(Math.max(req.timeoutMs ?? CDP_COMMAND_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS), CDP_COMMAND_TIMEOUT_MAX_MS);
@@ -194,6 +194,7 @@ export const createDaemonTransport = (clientId: string, socketPath: string = DAE
194
194
  method,
195
195
  params,
196
196
  ...(sessionId !== null ? { sessionId } : {}),
197
+ ...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
197
198
  };
198
199
 
199
200
  return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n"));
@@ -55,8 +55,9 @@ export const evalJs = async (
55
55
  client: BrowserClient,
56
56
  expression: string,
57
57
  sessionId?: string,
58
+ opts?: { readonly timeoutMs?: number },
58
59
  ): Promise<Result<unknown, ToolErr>> => {
59
- const r = await client.evaluateJs(expression, sessionId);
60
+ const r = await client.evaluateJs(expression, sessionId, opts);
60
61
  if (!r.success) return err(cdpErrToToolErr(r.error, "Runtime.evaluate"));
61
62
  return r;
62
63
  };
@@ -55,7 +55,7 @@ export const clickTool = defineBrowserTool({
55
55
  name: "browser_click",
56
56
  label: "Browser Click",
57
57
  description:
58
- "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.",
58
+ "Click an element. PREFERRED: pass `ref` (e.g. 'e12') from browser_snapshot — it scrolls the element into view (inside scrolling lists too), measures it at click time, and warns if something covers it. Fallback: pass viewport CSS-pixel `x`/`y` (refused when outside the viewport; the result names the element that was hit). Compositor-level click works through iframes, shadow DOM, and cross-origin content. After clicking, a compact diff of page changes is appended.",
59
59
  promptSnippet: "Click an element by ref (preferred) or pixel coordinates",
60
60
  promptGuidelines: [
61
61
  "If a click doesn't register, run pi with --browser-debug-clicks (or set BH_DEBUG_CLICKS=1) to get annotated screenshots. For React/Vue components ignoring clicks, try browser_dispatch_key.",
@@ -69,7 +69,10 @@ export const clickTool = defineBrowserTool({
69
69
 
70
70
  const resolved = await resolveTarget(client, args);
71
71
  if (!resolved.success) return resolved;
72
- const { x, y, label: target } = resolved.data;
72
+ const { x, y, label, covered, hit } = resolved.data;
73
+ const target = label
74
+ + (hit !== undefined ? ` on ${hit}` : "")
75
+ + (covered !== undefined ? `\n⚠ The point was covered by ${covered}, so the click went to that element, not [${args.ref ?? ""}]. Close the overlay or scroll, then re-snapshot.` : "");
73
76
 
74
77
  const clicked = await dispatchClick(client, x, y, button, count);
75
78
  if (!clicked.success) return clicked;
@@ -2,7 +2,8 @@ import { Type } from "typebox";
2
2
  import { type Result, err, ok } from "../util/result";
3
3
  import { defineBrowserTool, type JsonObject, type ToolErr, type ToolOk } from "../util/tool";
4
4
  import { detailsOf, type ElementOutcome, isPageResult, resolveAndCall, runOnElement } from "./element-call";
5
- import { fillField, type FillOptions } from "./page/fill.page";
5
+ import { fillField, type FillOptions, type PageResult } from "./page/fill.page";
6
+ import type { BrowserClient } from "../client";
6
7
  import { type FocusResult, focusElement, type SelectOption, type SelectResult, type SelectWanted, selectOption, setChecked } from "./page/form.page";
7
8
  import { elementFunctionSource } from "./page/inject";
8
9
  import { interactiveDiff } from "./ref-resolve";
@@ -56,45 +57,53 @@ const FillArgs = Type.Object({
56
57
  const FILL_FN = elementFunctionSource(fillField);
57
58
  const FILL_OPTIONS: FillOptions = { rejectSelect: true, focusFirst: false };
58
59
 
60
+ type FilledField = Extract<PageResult, { readonly ok: true }>;
61
+
62
+ // Writes `value` through the page's native value setter (the fill engine in page/fill.page.ts) and returns what the
63
+ // field holds afterwards. browser_type falls back to it when typing did not produce the value; recipe "fill" steps
64
+ // (recorded by the old browser_fill and by browser_fill_form) replay through it.
65
+ export const nativeFill = async (
66
+ client: BrowserClient,
67
+ t: Target,
68
+ value: string,
69
+ ): Promise<Result<FilledField, ToolErr>> => {
70
+ const r = await runOnElement(client, { ref: t.ref, selector: t.selector, functionDeclaration: FILL_FN, args: [value, FILL_OPTIONS] });
71
+ if (!r.success) return r;
72
+ const narrowed = narrow(r.data, t, "browser_fill", isPageResult);
73
+ if (!narrowed.success) return narrowed;
74
+ const res = narrowed.data;
75
+ if (res.ok) return ok(res);
76
+ const target = targetLabel(t);
77
+ const details = { ...targetDetails(t), ...(res.tag !== undefined ? { tag: res.tag } : {}) };
78
+ if (res.code === "select_rejected") {
79
+ return err({
80
+ kind: "invalid_state",
81
+ message: `Element <select> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
82
+ details,
83
+ });
84
+ }
85
+ return err({ kind: "invalid_state", message: `Could not fill ${target}: ${res.reason}`, details });
86
+ };
87
+
88
+ // Not registered for the agent any more (browser_type replaces it); kept so saved recipes' "fill" steps replay.
59
89
  export const fillTool = defineBrowserTool({
60
90
  name: "browser_fill",
61
91
  label: "Browser Fill",
62
92
  description:
63
- "Fill a form field (input/textarea/contenteditable), or tick a checkbox/radio by passing 'true'/'false'. PREFERRED: pass `ref` (e.g. 'e7') from browser_snapshot — survives re-renders. Fallback: a CSS `selector`. Writes through the native value setter and fires bubbling 'input'/'change' events, so React/Vue/Angular controlled components and rich-text editors update correctly — unlike browser_type. Refuses a disabled field rather than writing a value the page will ignore. Returns the field's value after writing, plus a compact diff of page changes.",
64
- promptSnippet: "Fill a form field by ref (preferred) or selector (works with React/Vue controlled inputs)",
65
- promptGuidelines: [
66
- "Pass the desired value; no browser_click is needed first.",
67
- "For a checkbox or radio pass 'true' or 'false' — it toggles `checked`, not the submit value. browser_set_checked does the same thing with a boolean.",
68
- "Use browser_type instead only for keystroke-sensitive widgets (autocomplete, masked/segmented inputs).",
69
- "Fill sets the value but does not focus or submit. To submit a tag/autocomplete input, follow with browser_dispatch_key({ ref, key: 'Enter' }) — not browser_press_key, which targets the focused element and may miss this field.",
70
- "For a custom (div-based) dropdown, browser_fill won't validate against its option list — open it, run browser_snapshot, then browser_click the option's ref.",
71
- "Confirm the field value browser_fill returns matches what you intended.",
72
- ],
93
+ "Recipe replay only: set a form field's value through the native setter, or tick a checkbox/radio with 'true'/'false'.",
94
+ promptSnippet: "Set a form field's value (recipe replay)",
95
+ promptGuidelines: [],
73
96
  parameters: FillArgs,
74
97
  concurrency: "serialized",
75
98
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
76
- const r = await runOnElement(client, { ref: args.ref, selector: args.selector, functionDeclaration: FILL_FN, args: [args.value, FILL_OPTIONS] });
77
- if (!r.success) return r;
78
- const narrowed = narrow(r.data, args, "browser_fill", isPageResult);
79
- if (!narrowed.success) return narrowed;
80
- const res = narrowed.data;
81
- const target = targetLabel(args);
82
- if (!res.ok) {
83
- const details = { ref: args.ref, selector: args.selector, ...(res.tag !== undefined ? { tag: res.tag } : {}) };
84
- if (res.code === "select_rejected") {
85
- return err({
86
- kind: "invalid_state",
87
- message: `Element <select> is not fillable (not an input, textarea, or contenteditable): ${target}. For <select> use browser_select_option.`,
88
- details,
89
- });
90
- }
91
- return err({ kind: "invalid_state", message: `Could not fill ${target}: ${res.reason}`, details });
92
- }
99
+ const filled = await nativeFill(client, args, args.value);
100
+ if (!filled.success) return filled;
101
+ const res = filled.data;
93
102
  const verified = "checked" in res ? res.checked : res.value;
94
103
  const shown = "checked" in res ? `checked=${res.checked}` : JSON.stringify(res.value);
95
104
  const diff = await interactiveDiff(client);
96
105
  return ok({
97
- text: `Filled ${target} = ${shown}${diff}`,
106
+ text: `Filled ${targetLabel(args)} = ${shown}${diff}`,
98
107
  details: { ...targetDetails(args), value: args.value, verified, tag: res.tag, kind: res.kind },
99
108
  });
100
109
  },
@@ -119,10 +128,10 @@ export const focusTool = defineBrowserTool({
119
128
  name: "browser_focus",
120
129
  label: "Browser Focus",
121
130
  description:
122
- "Focus an element via the DOM .focus() method — deterministic, no coordinate accuracy needed. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`. Use before browser_type when a click might miss the field.",
131
+ "Focus an element via the DOM .focus() method — deterministic, no coordinate accuracy needed. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.",
123
132
  promptSnippet: "Focus an element by ref (preferred) or selector",
124
133
  promptGuidelines: [
125
- "For simply setting a value, prefer browser_fill (it doesn't need a separate focus step).",
134
+ "To enter text, pass the ref straight to browser_type — it focuses the field itself.",
126
135
  ],
127
136
  parameters: FocusArgs,
128
137
  concurrency: "serialized",
package/src/domains/js.ts CHANGED
@@ -40,6 +40,13 @@ const prettyJson = (text: string): string => {
40
40
  const ExecuteJsArgs = Type.Object({
41
41
  expression: Type.String({ description: "A JavaScript expression, or a function body: a body with a top-level `return` (after any statements) runs inside an async function and returns that value." }),
42
42
  targetId: Type.Optional(Type.String({ description: "Optional iframe targetId; default = current page." })),
43
+ timeoutMs: Type.Optional(
44
+ Type.Integer({
45
+ minimum: 1_000,
46
+ maximum: 120_000,
47
+ description: "How long to wait for the result (an awaited promise included). Default 30000, max 120000. Raise it for scripts that loop with waits.",
48
+ }),
49
+ ),
43
50
  });
44
51
 
45
52
  const ExecuteJsDetailsSchema = Type.Object({
@@ -51,6 +58,8 @@ const ExecuteJsDetailsSchema = Type.Object({
51
58
 
52
59
  export type ExecuteJsDetails = DetailsOf<typeof ExecuteJsDetailsSchema>;
53
60
 
61
+ const EXECUTE_JS_DEFAULT_TIMEOUT_MS = 30_000;
62
+
54
63
  export const executeJsTool = defineBrowserTool({
55
64
  name: "browser_execute_js",
56
65
  label: "Browser Execute JS",
@@ -63,13 +72,22 @@ export const executeJsTool = defineBrowserTool({
63
72
  "For 'is this element visible / enabled / checked / focused', a one-liner here beats a screenshot every time.",
64
73
  "For iframes, first run `Object.values(document.querySelectorAll('iframe'))` to find the iframe, then pass its targetId.",
65
74
  "Result must be JSON-serializable (Runtime.evaluate returnByValue=true).",
75
+ "A script that awaits (loops with setTimeout, waits for UI) gets 30s by default; pass timeoutMs (max 120000) for longer ones.",
66
76
  ],
67
77
  parameters: ExecuteJsArgs,
68
78
  details: ExecuteJsDetailsSchema,
69
79
  concurrency: "parallel",
70
80
  async handler(args, { client }): Promise<Result<ToolOk<ExecuteJsDetails>, ToolErr>> {
71
- const r = await evalJs(client, args.expression, args.targetId);
72
- if (!r.success) return r;
81
+ const timeoutMs = args.timeoutMs ?? EXECUTE_JS_DEFAULT_TIMEOUT_MS;
82
+ const r = await evalJs(client, args.expression, args.targetId, { timeoutMs });
83
+ if (!r.success) {
84
+ if (r.error.kind !== "timeout" && !/timeout/i.test(r.error.message)) return r;
85
+ return err({
86
+ kind: "timeout",
87
+ message: `${r.error.message}. The script may have partly run before the wait ran out — check the page's state before retrying. Pass a larger timeoutMs (max 120000) or split the work into several calls.`,
88
+ details: { ...(r.error.details ?? {}), timeoutMs },
89
+ });
90
+ }
73
91
  const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data);
74
92
  // The full value lives in the text (and in fullOutputPath when truncated); details carry only what the
75
93
  // renderer needs so a large result is not persisted twice.
@@ -7,6 +7,11 @@ import { cdpCall, evalJs } from "./cdp-call";
7
7
  import { runOnElement } from "./element-call";
8
8
  import { elementFunctionSource } from "./page/inject";
9
9
  import { dispatchKeyEvent, type KeyEventSpec } from "./page/keyboard.page";
10
+ import { prepareTyping, readTypedValue, type TypePrep, type TypedValue } from "./page/fill.page";
11
+ import { nativeFill } from "./form";
12
+ import { interactiveDiff } from "./ref-resolve";
13
+ import { isRecord } from "../util/guards";
14
+ import type { BrowserClient } from "../client";
10
15
 
11
16
  const DISPATCH_KEY_FN = elementFunctionSource(dispatchKeyEvent);
12
17
 
@@ -19,53 +24,180 @@ const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
19
24
  F7: 118, F8: 119, F9: 120, F10: 121, F11: 122, F12: 123,
20
25
  };
21
26
 
22
- const virtualKeyCode = (key: string): number =>
23
- VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
27
+ // Windows virtual-key codes for letters and digits are the upper-case ASCII code ("a" and "A" are both 65).
28
+ export const virtualKeyCode = (key: string): number => {
29
+ const named = VIRTUAL_KEY_CODES[key];
30
+ if (named !== undefined) return named;
31
+ if (/^[a-z0-9]$/i.test(key)) return key.toUpperCase().charCodeAt(0);
32
+ return key.length === 1 ? key.charCodeAt(0) : 0;
33
+ };
34
+
35
+ // KeyboardEvent.code: the physical key. Pages that check `code` (shortcut handlers) need "KeyA", not "a".
36
+ export const physicalCode = (key: string): string => {
37
+ if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
38
+ if (/^[0-9]$/.test(key)) return `Digit${key}`;
39
+ if (key === " ") return "Space";
40
+ return key;
41
+ };
42
+
43
+ // Chrome only runs an editing shortcut sent over CDP when the event names the command (macOS does not map Cmd+A to
44
+ // select-all for synthetic events), so Ctrl/Cmd + A/C/X/V/Z/Y carry it explicitly.
45
+ export const editingCommands = (key: string, modifiers: number): ReadonlyArray<string> => {
46
+ const ctrlOrMeta = (modifiers & (2 | 4)) !== 0;
47
+ if (!ctrlOrMeta || (modifiers & 1) !== 0) return [];
48
+ const shift = (modifiers & 8) !== 0;
49
+ switch (key.toLowerCase()) {
50
+ case "a": return shift ? [] : ["selectAll"];
51
+ case "c": return shift ? [] : ["copy"];
52
+ case "x": return shift ? [] : ["cut"];
53
+ case "v": return shift ? [] : ["paste"];
54
+ case "z": return shift ? ["redo"] : ["undo"];
55
+ case "y": return shift ? [] : ["redo"];
56
+ default: return [];
57
+ }
58
+ };
24
59
 
25
60
  const TypeArgs = Type.Object({
26
- text: Type.String({ description: "Text to type" }),
61
+ ref: Type.Optional(
62
+ Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7'). PREFERRED. The field is focused for you." }),
63
+ ),
64
+ selector: Type.Optional(Type.String({ description: "CSS selector of the field (fallback when no ref)." })),
65
+ text: Type.String({ description: "Text to enter. With `clear` (the default when a field is named) it replaces the field's value; \"\" empties it." }),
66
+ clear: Type.Optional(
67
+ Type.Boolean({
68
+ description:
69
+ "Replace the field's current value (true) or add to it at the cursor (false). Default: true when ref/selector is given, false when typing into the already-focused element.",
70
+ }),
71
+ ),
27
72
  });
28
73
 
29
- type FocusProbe = { readonly ok: boolean; readonly tag: string | null };
74
+ type TypeTarget = { readonly ref?: string | undefined; readonly selector?: string | undefined };
30
75
 
31
- const isFocusProbe = (v: unknown): v is FocusProbe =>
32
- typeof v === "object" && v !== null
33
- && "ok" in v && typeof v.ok === "boolean"
34
- && "tag" in v && (typeof v.tag === "string" || v.tag === null);
76
+ const PREP_FN = elementFunctionSource(prepareTyping);
77
+ const READ_FN = elementFunctionSource(readTypedValue);
78
+
79
+ const isTypePrep = (v: unknown): v is TypePrep => {
80
+ if (!isRecord(v) || typeof v["ok"] !== "boolean") return false;
81
+ if (v["ok"]) return typeof v["kind"] === "string" && typeof v["value"] === "string" && typeof v["tag"] === "string";
82
+ return typeof v["code"] === "string" && typeof v["reason"] === "string";
83
+ };
84
+
85
+ const isTypedValue = (v: unknown): v is TypedValue =>
86
+ isRecord(v) && typeof v["value"] === "string" && typeof v["focused"] === "boolean";
87
+
88
+ const hasTarget = (t: TypeTarget): boolean => t.ref !== undefined || t.selector !== undefined;
89
+
90
+ const labelOf = (t: TypeTarget): string => (t.ref !== undefined ? `[${t.ref}]` : t.selector ?? "the focused element");
91
+
92
+ // Runs an element function on the named field, or on document.activeElement when none is named.
93
+ const onField = async (
94
+ client: BrowserClient,
95
+ t: TypeTarget,
96
+ fn: string,
97
+ args: ReadonlyArray<unknown>,
98
+ ): Promise<Result<unknown, ToolErr>> => {
99
+ if (hasTarget(t)) {
100
+ const r = await runOnElement(client, { ref: t.ref, selector: t.selector, functionDeclaration: fn, args });
101
+ if (!r.success) return r;
102
+ if (!r.data.found) return err({ kind: "invalid_state", message: `No element matched: ${labelOf(t)}` });
103
+ return ok(r.data.value);
104
+ }
105
+ return evalJs(client, "(" + fn + ").apply(document.activeElement, " + safeJs`${args}` + ")");
106
+ };
107
+
108
+ const prepErr = (t: TypeTarget, prep: Extract<TypePrep, { readonly ok: false }>): ToolErr => {
109
+ const where = labelOf(t);
110
+ const hint =
111
+ prep.code === "select" ? " For a <select> use browser_select_option."
112
+ : prep.code === "checkable" ? " For a checkbox or radio use browser_set_checked."
113
+ : prep.code === "not_element" ? " Pass the field's ref, or click it first so it has focus."
114
+ : "";
115
+ return { kind: "invalid_state", message: `Cannot type into ${where}: ${prep.reason}.${hint}`, details: { code: prep.code, ...(prep.tag !== undefined ? { tag: prep.tag } : {}) } };
116
+ };
117
+
118
+ // Backspace with the field's content selected deletes it, the way a user empties a field.
119
+ const pressBackspace = async (client: BrowserClient): Promise<Result<void, ToolErr>> => {
120
+ const key = { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8 };
121
+ const down = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyDown", ...key });
122
+ if (!down.success) return down;
123
+ const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", ...key });
124
+ if (!up.success) return up;
125
+ return ok(undefined);
126
+ };
35
127
 
36
128
  export const typeTool = defineBrowserTool({
37
129
  name: "browser_type",
38
130
  label: "Browser Type",
39
- description: "Type text into the currently focused element. Use browser_click first to focus an input field.",
40
- promptSnippet: "Type text into the focused element",
131
+ description:
132
+ "Enter text into a field the way a user types: focuses it, replaces its value (or appends with clear:false), fires real input events so autocomplete/suggestion lists open and React/Vue controlled inputs update, then reads the value back. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`; with neither, types into the focused element. If typing does not produce the value (date/masked inputs), it falls back to setting the value directly and says so.",
133
+ promptSnippet: "Type text into a field by ref (preferred), selector, or the focused element",
41
134
  promptGuidelines: [
42
- "For special keys (Enter, Tab, Escape, arrows), use browser_press_key instead.",
135
+ "One tool for all text entry: browser_type({ ref, text }) replaces the field's value; pass clear:false to append; text \"\" empties the field.",
136
+ "Check the value it reports — a page can reformat or reject what was typed.",
137
+ "Suggestion lists open as you type: after browser_type, run browser_snapshot and browser_click the option's ref.",
138
+ "For a <select> use browser_select_option; for a checkbox/radio use browser_set_checked.",
139
+ "For special keys (Enter, Tab, Escape, arrows) use browser_press_key.",
43
140
  ],
44
141
  parameters: TypeArgs,
45
142
  concurrency: "serialized",
46
143
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
47
- const focused = await evalJs(client, safeJs`
48
- (() => {
49
- const el = document.activeElement;
50
- const ok = !!el && el !== document.body &&
51
- (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
52
- return { ok, tag: el ? el.tagName : null };
53
- })()
54
- `);
55
- if (focused.success) {
56
- const f: FocusProbe = isFocusProbe(focused.data) ? focused.data : { ok: false, tag: null };
57
- if (!f.ok) {
144
+ const t: TypeTarget = { ref: args.ref, selector: args.selector };
145
+ const named = hasTarget(t);
146
+ const clear = args.clear ?? named;
147
+ const where = labelOf(t);
148
+
149
+ const prepRaw = await onField(client, t, PREP_FN, [clear]);
150
+ if (!prepRaw.success) return prepRaw;
151
+ if (!isTypePrep(prepRaw.data)) {
152
+ if (named) return err({ kind: "internal", message: "unexpected page result shape from browser_type", details: { target: where } });
153
+ return err({
154
+ kind: "invalid_state",
155
+ message: "No editable element is focused — typed text would be lost. Pass the field's ref, or click it first.",
156
+ });
157
+ }
158
+ const prep = prepRaw.data;
159
+ if (!prep.ok) {
160
+ if (!named && prep.code !== "disabled" && prep.code !== "readonly") {
58
161
  return err({
59
162
  kind: "invalid_state",
60
- message:
61
- "No editable element is focused — typed text would be lost. Click the field with browser_click first, or use browser_fill with a selector.",
62
- details: { activeElement: f.tag },
163
+ message: "No editable element is focused — typed text would be lost. Pass the field's ref, or click it first.",
164
+ details: { activeElement: prep.tag ?? null },
63
165
  });
64
166
  }
167
+ return err(prepErr(t, prep));
65
168
  }
66
- const r = await cdpCall(client, "Input.insertText", { text: args.text });
67
- if (!r.success) return r;
68
- return ok({ text: `Typed: "${args.text}"` });
169
+
170
+ const typed = clear && args.text === "" ? await pressBackspace(client) : await cdpCall(client, "Input.insertText", { text: args.text });
171
+ if (!typed.success) return typed;
172
+
173
+ const readRaw = await onField(client, t, READ_FN, []);
174
+ const after = readRaw.success && isTypedValue(readRaw.data) ? readRaw.data : undefined;
175
+ const expected = clear ? args.text : prep.value + args.text;
176
+ let value = after?.value;
177
+ let note = "";
178
+ if (value !== undefined && value !== expected && clear && named && prep.kind !== "contenteditable") {
179
+ // Typing did not produce the value (a date or masked input, or a handler that swallowed the keys): set it.
180
+ const filled = await nativeFill(client, t, args.text);
181
+ if (filled.success && "value" in filled.data) {
182
+ value = filled.data.value;
183
+ note = " (typing did not take; value set directly)";
184
+ }
185
+ }
186
+ if (value !== undefined && value !== expected) note += ` ⚠ the field holds ${JSON.stringify(value)}, not what was typed — the page reformatted or rejected it`;
187
+ if (after !== undefined && !after.focused && note === "") note = " (the field lost focus after typing)";
188
+
189
+ const diff = await interactiveDiff(client);
190
+ const shown = value !== undefined ? ` → value ${JSON.stringify(value)}` : "";
191
+ return ok({
192
+ text: `Typed "${args.text}" into ${where}${shown}${note}${diff}`,
193
+ details: {
194
+ ...(args.ref !== undefined ? { ref: args.ref } : {}),
195
+ ...(args.selector !== undefined ? { selector: args.selector } : {}),
196
+ text: args.text,
197
+ clear,
198
+ ...(value !== undefined ? { value } : {}),
199
+ },
200
+ });
69
201
  },
70
202
  });
71
203
 
@@ -100,23 +232,26 @@ export const pressKeyTool = defineBrowserTool({
100
232
  const code = virtualKeyCode(k);
101
233
  const modifiers = args.modifiers ?? 0;
102
234
  const isChar = k.length === 1;
235
+ const physical = physicalCode(k);
236
+ // A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable.
237
+ const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
238
+ const commands = editingCommands(k, modifiers);
103
239
  const downParams: ParamsOf<"Input.dispatchKeyEvent"> = {
104
- type: "keyDown",
240
+ type: isChar && hasCommandModifier ? "rawKeyDown" : "keyDown",
105
241
  key: k,
106
- code: k,
242
+ code: physical,
107
243
  modifiers,
108
244
  ...(code ? { windowsVirtualKeyCode: code, nativeVirtualKeyCode: code } : {}),
109
- ...(isChar ? { text: k, unmodifiedText: k } : {}),
245
+ ...(isChar && !hasCommandModifier ? { text: k, unmodifiedText: k } : {}),
246
+ ...(commands.length > 0 ? { commands: [...commands] } : {}),
110
247
  };
111
248
  const down = await cdpCall(client, "Input.dispatchKeyEvent", downParams);
112
249
  if (!down.success) return down;
113
- // A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable.
114
- const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0;
115
250
  if (isChar && !hasCommandModifier) {
116
251
  const charEv = await cdpCall(client, "Input.dispatchKeyEvent", { type: "char", text: k, key: k });
117
252
  if (!charEv.success) return charEv;
118
253
  }
119
- const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, modifiers });
254
+ const up = await cdpCall(client, "Input.dispatchKeyEvent", { type: "keyUp", key: k, code: physical, modifiers });
120
255
  if (!up.success) return up;
121
256
  return ok({ text: `Pressed: ${k}${modifiers ? ` (modifiers=${modifiers})` : ""}` });
122
257
  },
@@ -153,7 +288,8 @@ export const dispatchKeyTool = defineBrowserTool({
153
288
  const eventType = args.eventType ?? "keydown";
154
289
  const target = args.ref ?? args.selector ?? "";
155
290
  // Legacy handlers branch on e.keyCode/e.which rather than e.key, so populate both.
156
- const code = virtualKeyCode(args.key);
291
+ // keypress carries the character code ("a" = 97); keydown/keyup carry the key code ("a" = 65).
292
+ const code = eventType === "keypress" && args.key.length === 1 ? args.key.charCodeAt(0) : virtualKeyCode(args.key);
157
293
  if (args.ref !== undefined) {
158
294
  // Through runOnElement so the resolved RemoteObject is released after the call.
159
295
  const spec: KeyEventSpec = { type: eventType, key: args.key, code };
@@ -0,0 +1,22 @@
1
+ // Page-side code for browser_click; see fill.page.ts for the rules every `*.page.ts` module follows.
2
+
3
+ // What sits under the click point, relative to the element the click was aimed at.
4
+ export type HitInfo = {
5
+ // The point lands on the target, inside it, or on an ancestor that wraps it (a label around an icon).
6
+ readonly onTarget: boolean;
7
+ // A short description of the element actually under the point, e.g. `div.modal-overlay "Close"`.
8
+ readonly hit: string;
9
+ };
10
+
11
+ // `target` is the element the click aims at (Runtime.callFunctionOn's `this`), `hit` the element hit-testing found.
12
+ export function hitInfo(target: Element | null, hit: Element | null): HitInfo {
13
+ if (hit?.nodeType !== 1) return { onTarget: false, hit: "nothing" };
14
+ const onTarget = target !== null && (target === hit || target.contains(hit) || hit.contains(target));
15
+ const tag = hit.tagName.toLowerCase();
16
+ const id = hit.id ? "#" + hit.id : "";
17
+ const cls = typeof hit.className === "string" && hit.className.trim() !== ""
18
+ ? "." + hit.className.trim().split(/\s+/).slice(0, 2).join(".")
19
+ : "";
20
+ const label = (hit.getAttribute("aria-label") ?? hit.textContent).replace(/\s+/g, " ").trim().slice(0, 40);
21
+ return { onTarget, hit: tag + id + cls + (label !== "" ? ' "' + label + '"' : "") };
22
+ }
@@ -10,7 +10,7 @@
10
10
  export type PageOption = { readonly value: string; readonly text: string };
11
11
 
12
12
  export type FillOptions = {
13
- // browser_fill refuses a <select> (its documented error points at browser_select_option); browser_fill_form fills it.
13
+ // The recipe-only browser_fill refuses a <select> (its documented error points at browser_select_option); browser_fill_form fills it.
14
14
  readonly rejectSelect: boolean;
15
15
  readonly focusFirst: boolean;
16
16
  };
@@ -37,6 +37,8 @@ export type FormControl = HTMLElement & {
37
37
  value?: string;
38
38
  checked?: boolean;
39
39
  disabled?: boolean;
40
+ readOnly?: boolean;
41
+ select?: () => void;
40
42
  options?: HTMLOptionsCollection;
41
43
  };
42
44
 
@@ -137,3 +139,70 @@ export function fillField(el: FormControl | null, value: unknown, opts: FillOpti
137
139
 
138
140
  return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")", tag: tagName };
139
141
  }
142
+
143
+ // What browser_type learns about its field before typing: whether it can take text, and its value right now.
144
+ export type TypePrep =
145
+ | { readonly ok: true; readonly kind: "input" | "textarea" | "contenteditable"; readonly value: string; readonly tag: string }
146
+ | {
147
+ readonly ok: false;
148
+ readonly code: "not_element" | "disabled" | "readonly" | "select" | "checkable" | "not_editable";
149
+ readonly reason: string;
150
+ readonly tag?: string;
151
+ };
152
+
153
+ // Focuses `el` and, when `clear` is set, selects everything in it, so the text typed next replaces the old value the
154
+ // way select-all + typing does for a user. Refuses fields that cannot take typed text, naming the tool that can.
155
+ export function prepareTyping(el: FormControl | null, clear: boolean): TypePrep {
156
+ if (el?.nodeType !== 1) return { ok: false, code: "not_element", reason: "no element to type into" };
157
+ const tagName = el.tagName;
158
+ const tag = tagName.toLowerCase();
159
+ const type = (el.type ?? "").toLowerCase();
160
+ if (tag === "select") return { ok: false, code: "select", reason: "element is a <select>", tag: tagName };
161
+ if (tag === "input" && (type === "checkbox" || type === "radio")) {
162
+ return { ok: false, code: "checkable", reason: "element is a " + type, tag: tagName };
163
+ }
164
+ const editableTag = (tag === "input" && type !== "button" && type !== "submit" && type !== "file" && type !== "image" && type !== "reset")
165
+ || tag === "textarea";
166
+ if (!editableTag && !el.isContentEditable) {
167
+ return { ok: false, code: "not_editable", reason: "element is not a text field (tag=" + tag + ")", tag: tagName };
168
+ }
169
+ if (el.disabled) return { ok: false, code: "disabled", reason: "element is disabled", tag: tagName };
170
+ if (editableTag && el.readOnly === true) {
171
+ return { ok: false, code: "readonly", reason: "element is read-only", tag: tagName };
172
+ }
173
+ try {
174
+ el.focus();
175
+ } catch {
176
+ /* focus throws on some detached or shadow nodes; the caller checks where the text landed */
177
+ }
178
+ if (editableTag) {
179
+ if (clear) {
180
+ try {
181
+ el.select?.();
182
+ } catch {
183
+ /* select() is a no-op or throws on a few input types (date, color); the caller verifies and falls back */
184
+ }
185
+ }
186
+ return { ok: true, kind: tag === "textarea" ? "textarea" : "input", value: el.value ?? "", tag: tagName };
187
+ }
188
+ if (clear) {
189
+ const sel = window.getSelection();
190
+ if (sel) {
191
+ sel.removeAllRanges();
192
+ const rng = document.createRange();
193
+ rng.selectNodeContents(el);
194
+ sel.addRange(rng);
195
+ }
196
+ }
197
+ return { ok: true, kind: "contenteditable", value: el.textContent, tag: tagName };
198
+ }
199
+
200
+ // The field's value after typing, and whether it still has focus (a blur handler may have moved it elsewhere).
201
+ export type TypedValue = { readonly value: string; readonly focused: boolean };
202
+
203
+ export function readTypedValue(el: FormControl | null): TypedValue {
204
+ if (el?.nodeType !== 1) return { value: "", focused: false };
205
+ const tag = el.tagName.toLowerCase();
206
+ const value = tag === "input" || tag === "textarea" ? (el.value ?? "") : el.textContent;
207
+ return { value, focused: document.activeElement === el };
208
+ }
@@ -91,7 +91,7 @@ export const waitForTool = defineBrowserTool({
91
91
  "Poll until a condition holds: a selector appears (or disappears with gone:true), or text appears in the page. Returns a typed timeout error if the deadline elapses. Use to wait for SPA content to render before interacting.",
92
92
  promptSnippet: "Wait for an element or text to appear (or an element to disappear)",
93
93
  promptGuidelines: [
94
- "Use before browser_fill / browser_click on dynamic pages so you act after the element renders.",
94
+ "Use before browser_type / browser_click on dynamic pages so you act after the element renders.",
95
95
  ],
96
96
  parameters: WaitForArgs,
97
97
  concurrency: "parallel",
@@ -1,4 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import type { BrowserClient } from "../client";
4
+ import { imageWidth } from "../util/image-size";
5
+ import { evalJs } from "./cdp-call";
2
6
  import { Type } from "typebox";
3
7
  import { Image, type ImageTheme, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
8
  import { type Result, ok } from "../util/result";
@@ -20,6 +24,21 @@ const ScreenshotDetailsSchema = Type.Object({
20
24
 
21
25
  export type ScreenshotDetails = DetailsOf<typeof ScreenshotDetailsSchema>;
22
26
 
27
+ // Screenshots come out in device pixels (2x on a Retina display) and maxDim may shrink them again, so a point read
28
+ // off the image is not a browser_click coordinate. Say how to convert, whenever the two differ.
29
+ const scaleNote = async (client: BrowserClient, path: string, fullPage: boolean): Promise<string> => {
30
+ try {
31
+ const width = imageWidth(await readFile(path));
32
+ const css = await evalJs(client, fullPage ? "document.documentElement.scrollWidth" : "window.innerWidth");
33
+ if (width === undefined || !css.success || typeof css.data !== "number" || css.data <= 0) return "";
34
+ const ratio = width / css.data;
35
+ if (Math.abs(ratio - 1) < 0.05) return "";
36
+ return `\nImage scale: ${ratio.toFixed(2)} image px per CSS px — divide coordinates read off this image by ${ratio.toFixed(2)} before passing them to browser_click (or use refs from browser_snapshot).`;
37
+ } catch {
38
+ return "";
39
+ }
40
+ };
41
+
23
42
  export const screenshotTool = defineBrowserTool({
24
43
  name: "browser_screenshot",
25
44
  label: "Browser Screenshot",
@@ -40,8 +59,9 @@ export const screenshotTool = defineBrowserTool({
40
59
  const resized = await resizeIfNeeded(cap.data.path, args.maxDim);
41
60
  note = resized.note;
42
61
  }
62
+ const scale = await scaleNote(client, cap.data.path, args.fullPage ?? false);
43
63
  return ok({
44
- text: `Screenshot saved: ${cap.data.path}${note}`,
64
+ text: `Screenshot saved: ${cap.data.path}${note}${scale}`,
45
65
  details: { path: cap.data.path, format: cap.data.format, attached: false },
46
66
  });
47
67
  },
@@ -115,7 +115,7 @@ export const snapshotTool = defineBrowserTool({
115
115
  name: "browser_snapshot",
116
116
  label: "Browser Snapshot",
117
117
  description:
118
- "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.",
118
+ "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_type/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.",
119
119
  promptSnippet: "Get accessibility-tree snapshot with stable element refs (default for page inspection)",
120
120
  promptGuidelines: [
121
121
  "Re-run browser_snapshot after a navigation or a major re-render to get fresh refs.",
@@ -1,7 +1,12 @@
1
1
  import type { BrowserClient } from "../client";
2
+ import { isRecord } from "../util/guards";
2
3
  import { type Result, err, ok } from "../util/result";
3
4
  import type { ToolErr } from "../util/tool";
4
- import { resolveRefToBox } from "./ref-resolve";
5
+ import { boxOf } from "./box";
6
+ import { cdpCall } from "./cdp-call";
7
+ import { hitInfo } from "./page/click.page";
8
+ import { elementFunctionSource } from "./page/inject";
9
+ import { resolveRefToBackendId } from "./ref-resolve";
5
10
 
6
11
  export type PointArgs = {
7
12
  readonly ref?: string | undefined;
@@ -13,23 +18,94 @@ export type ResolvedTarget = {
13
18
  readonly x: number;
14
19
  readonly y: number;
15
20
  readonly label: string;
21
+ // Set when hit-testing found something other than the ref's element under the point (an overlay, a sticky header).
22
+ readonly covered?: string;
23
+ // For an x/y click: what is under the point, so the agent can tell whether it hit what it meant to.
24
+ readonly hit?: string;
16
25
  };
17
26
 
27
+ type Viewport = { readonly width: number; readonly height: number };
28
+
29
+ // The layout viewport in CSS pixels, or undefined when Chrome would not say (then no bounds check is made).
30
+ export const viewportOf = async (client: BrowserClient): Promise<Viewport | undefined> => {
31
+ const r = await cdpCall(client, "Page.getLayoutMetrics", {});
32
+ if (!r.success) return undefined;
33
+ const { clientWidth, clientHeight } = r.data.cssLayoutViewport;
34
+ return clientWidth > 0 && clientHeight > 0 ? { width: clientWidth, height: clientHeight } : undefined;
35
+ };
36
+
37
+ const inside = (vp: Viewport, x: number, y: number): boolean => x >= 0 && y >= 0 && x < vp.width && y < vp.height;
38
+
39
+ const outsideErr = (x: number, y: number, vp: Viewport, ref?: string): ToolErr => ({
40
+ kind: "invalid_state",
41
+ message: ref !== undefined
42
+ ? `[${ref}] is at (${x}, ${y}), outside the ${vp.width}x${vp.height} viewport even after scrolling it into view — it may be clipped by a scroll container or positioned off-screen. Scroll its container (browser_scroll) and re-snapshot.`
43
+ : `Point (${x}, ${y}) is outside the ${vp.width}x${vp.height} viewport, so the click would hit nothing. Use the element's ref from browser_snapshot instead — browser_click scrolls it into view.`,
44
+ details: { x, y, viewportWidth: vp.width, viewportHeight: vp.height, ...(ref !== undefined ? { ref } : {}) },
45
+ });
46
+
47
+ const HIT_FN = elementFunctionSource(hitInfo);
48
+
49
+ const isHitInfo = (v: unknown): v is { readonly onTarget: boolean; readonly hit: string } =>
50
+ isRecord(v) && typeof v["onTarget"] === "boolean" && typeof v["hit"] === "string";
51
+
52
+ // What hit-testing finds at the point, relative to the target element (or just what is there, with no target);
53
+ // undefined when it cannot tell.
54
+ const hitTest = async (client: BrowserClient, backendId: number | undefined, x: number, y: number): Promise<{ readonly onTarget: boolean; readonly hit: string } | undefined> => {
55
+ const loc = await cdpCall(client, "DOM.getNodeForLocation", { x: Math.round(x), y: Math.round(y), ignorePointerEventsNone: true });
56
+ if (!loc.success) return undefined;
57
+ const target = await cdpCall(client, "DOM.resolveNode", { backendNodeId: backendId ?? loc.data.backendNodeId });
58
+ const hit = await cdpCall(client, "DOM.resolveNode", { backendNodeId: loc.data.backendNodeId });
59
+ const targetId = target.success ? target.data.object.objectId : undefined;
60
+ const hitId = hit.success ? hit.data.object.objectId : undefined;
61
+ try {
62
+ if (targetId === undefined || hitId === undefined) return undefined;
63
+ const called = await cdpCall(client, "Runtime.callFunctionOn", {
64
+ objectId: targetId,
65
+ functionDeclaration: HIT_FN,
66
+ arguments: [{ objectId: hitId }],
67
+ returnByValue: true,
68
+ });
69
+ if (!called.success || called.data.exceptionDetails !== undefined) return undefined;
70
+ const v: unknown = called.data.result.value;
71
+ return isHitInfo(v) ? v : undefined;
72
+ } finally {
73
+ for (const id of [targetId, hitId]) if (id !== undefined) await cdpCall(client, "Runtime.releaseObject", { objectId: id });
74
+ }
75
+ };
76
+
77
+ // Turns a ref (or x/y) into the point a trusted mouse event should land on. For a ref: scroll the element into view
78
+ // (nested scroll containers included), measure it now, refuse a point still outside the viewport, and hit-test it so
79
+ // a covered element is reported instead of silently clicking whatever is on top.
18
80
  export const resolveTarget = async (
19
81
  client: BrowserClient,
20
82
  args: PointArgs,
21
83
  ): Promise<Result<ResolvedTarget, ToolErr>> => {
22
84
  if (args.ref !== undefined) {
23
- const box = await resolveRefToBox(client, args.ref);
24
- if (!box.success) return box;
85
+ const backendId = resolveRefToBackendId(client, args.ref);
86
+ if (!backendId.success) return backendId;
87
+ // A failed scroll is not fatal: the bounds check below catches an element that is still out of view.
88
+ await cdpCall(client, "DOM.scrollIntoViewIfNeeded", { backendNodeId: backendId.data });
89
+ const box = await boxOf(client, backendId.data);
90
+ if (!box.success) {
91
+ return err({ kind: "invalid_state", message: `Ref ${args.ref} is unknown or stale — re-run browser_snapshot to get fresh refs.`, details: { ref: args.ref } });
92
+ }
93
+ const { cx, cy } = box.data;
94
+ const vp = await viewportOf(client);
95
+ if (vp !== undefined && !inside(vp, cx, cy)) return err(outsideErr(cx, cy, vp, args.ref));
96
+ const hit = await hitTest(client, backendId.data, cx, cy);
25
97
  return ok({
26
- x: box.data.cx,
27
- y: box.data.cy,
28
- label: `[${args.ref}] (${box.data.cx}, ${box.data.cy})`,
98
+ x: cx,
99
+ y: cy,
100
+ label: `[${args.ref}] (${cx}, ${cy})`,
101
+ ...(hit !== undefined && !hit.onTarget ? { covered: hit.hit } : {}),
29
102
  });
30
103
  }
31
104
  if (args.x !== undefined && args.y !== undefined) {
32
- return ok({ x: args.x, y: args.y, label: `(${args.x}, ${args.y})` });
105
+ const vp = await viewportOf(client);
106
+ if (vp !== undefined && !inside(vp, args.x, args.y)) return err(outsideErr(args.x, args.y, vp));
107
+ const hit = await hitTest(client, undefined, args.x, args.y);
108
+ return ok({ x: args.x, y: args.y, label: `(${args.x}, ${args.y})`, ...(hit !== undefined ? { hit: hit.hit } : {}) });
33
109
  }
34
110
  return err({ kind: "invalid_args", message: "Provide either `ref` or both `x` and `y`." });
35
111
  };
@@ -20,7 +20,7 @@ export const REPLAYABLE: ReadonlyMap<string, "ref" | "fields" | "none"> = new Ma
20
20
  ["browser_dispatch_key", "ref"],
21
21
  ["browser_upload_file", "ref"],
22
22
  ["browser_fill_form", "fields"],
23
- ["browser_type", "none"],
23
+ ["browser_type", "ref"],
24
24
  ["browser_press_key", "none"],
25
25
  ["browser_navigate", "none"],
26
26
  ["browser_go_back", "none"],
@@ -303,8 +303,9 @@ export const observeCall: CallObserver = async ({ name, concurrency, args, clien
303
303
  }
304
304
  const fields = mode === "fields" ? await fieldsOf(client, args) : undefined;
305
305
  let password = fields?.some((f) => f.password) ?? false;
306
- if (name === "browser_fill") password = await isPasswordTarget(client, ref, selector);
307
- if (name === "browser_type") {
306
+ if (name === "browser_fill" || (name === "browser_type" && (ref !== undefined || selector !== undefined))) {
307
+ password = await isPasswordTarget(client, ref, selector);
308
+ } else if (name === "browser_type") {
308
309
  const r = await evalJs(client, FOCUSED_IS_PASSWORD_EXPR);
309
310
  password = r.success && r.data === true;
310
311
  }
package/src/registry.ts CHANGED
@@ -4,7 +4,7 @@ import type { BrowserTool } from "./util/tool";
4
4
  import { type Result, err, ok } from "./util/result";
5
5
  import { clickTool } from "./domains/click";
6
6
  import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
7
- import { fillTool, selectOptionTool, focusTool, setCheckedTool } from "./domains/form";
7
+ import { selectOptionTool, focusTool, setCheckedTool } from "./domains/form";
8
8
  import { fillFormTool } from "./domains/form-batch";
9
9
  import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
10
10
  import { scrollTool } from "./domains/scroll";
@@ -31,7 +31,6 @@ export const ALL_TOOLS: ReadonlyArray<BrowserTool> = [
31
31
  setupTool,
32
32
  clickTool,
33
33
  typeTool,
34
- fillTool,
35
34
  fillFormTool,
36
35
  selectOptionTool,
37
36
  setCheckedTool,
@@ -92,8 +91,9 @@ export type ToolRegistry = {
92
91
  // Append a batch atomically: a name clash anywhere in it (with the registry or within the batch)
93
92
  // rejects the whole call and leaves the registry untouched.
94
93
  readonly addAll: (tools: ReadonlyArray<BrowserTool>) => Result<void, RegistryError>;
95
- // Register every tool that has not been registered with pi yet. Safe to call on every
96
- // session_start: tools added after an earlier call land on the next one.
94
+ // Register every tool that has not been registered with this pi instance yet. Safe to call on
95
+ // every session_start: tools added after an earlier call land on the next one, and a fresh pi
96
+ // instance (pi builds one per session but reuses this cached module) gets every tool again.
97
97
  readonly register: (pi: ToolRegistrar, client: BrowserClient) => void;
98
98
  };
99
99
 
@@ -105,7 +105,9 @@ const duplicateName = (name: string): RegistryError => ({
105
105
  export const createToolRegistry = (initial: ReadonlyArray<BrowserTool> = ALL_TOOLS): ToolRegistry => {
106
106
  const tools: BrowserTool[] = [];
107
107
  const names = new Set<string>();
108
- const registered = new Set<string>();
108
+ // Keyed by pi instance: a process-wide set made every session after the first start with no
109
+ // browser tools, because pi hands each session an empty tool list.
110
+ const registeredByPi = new WeakMap<ToolRegistrar, Set<string>>();
109
111
 
110
112
  const addAll = (batch: ReadonlyArray<BrowserTool>): Result<void, RegistryError> => {
111
113
  const seen = new Set<string>();
@@ -129,6 +131,8 @@ export const createToolRegistry = (initial: ReadonlyArray<BrowserTool> = ALL_TOO
129
131
  add,
130
132
  addAll,
131
133
  register: (pi, client) => {
134
+ const registered = registeredByPi.get(pi) ?? new Set<string>();
135
+ registeredByPi.set(pi, registered);
132
136
  for (const tool of tools) {
133
137
  if (registered.has(tool.name)) continue;
134
138
  tool.register(pi, client);
@@ -0,0 +1,16 @@
1
+ // Reads an image's pixel width from its header, without decoding it (sharp is optional). PNG keeps it in IHDR; JPEG
2
+ // in the first start-of-frame marker. Returns undefined for anything else or a truncated file.
3
+ export const imageWidth = (buf: Buffer): number | undefined => {
4
+ if (buf.length >= 24 && buf.readUInt32BE(0) === 0x89504e47) return buf.readUInt32BE(16);
5
+ if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return undefined;
6
+ let i = 2;
7
+ while (i + 9 < buf.length) {
8
+ if (buf[i] !== 0xff) return undefined;
9
+ const marker = buf[i + 1] ?? 0;
10
+ const len = buf.readUInt16BE(i + 2);
11
+ // SOF0..SOF15, minus DHT (C4), JPG (C8) and DAC (CC), carry the frame size: height then width.
12
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return buf.readUInt16BE(i + 7);
13
+ i += 2 + len;
14
+ }
15
+ return undefined;
16
+ };