pi-browser-harness 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to pi-browser-harness will be documented in this file.
4
4
 
5
+ ## 0.14.0 — 2026-11-01
6
+
7
+ ### Added
8
+
9
+ - **Browser recipes: journal and save.** Every page-changing browser call is journaled per session, and `browser_save_recipe` saves the agent's last browser task as a YAML recipe with `{{params}}`, after the user confirms it.
10
+ - **Browser recipes: replay.** `browser_run_recipe` and `/browser-recipe NAME key=value` replay a saved recipe with no model calls, finding each element by role and name and checking each step's recorded outcome. Downloads and status/alert toasts are now recorded, so a step can expect them.
11
+ - **Browser recipes: finish and repair.** A failed replay hands the agent the failed step and a snapshot; the agent finishes the task, then `browser_save_recipe` with `repair_step` rewrites the recipe from that step, keeping the previous version. An auth wall stops the replay without typing anything.
12
+ - **Browser recipes: project recipes need approval.** A recipe from the project's `.pi/browser-recipes/` asks for confirmation, listing its URLs, uploads and downloads, before its first run and again whenever its file changes.
13
+ - **Browser recipes: read steps return data.** `browser_http_get` and `browser_read_page` of a URL are kept as recipe steps, and a replay returns what they fetched: in `browser_run_recipe`'s result, and as a session message from `/browser-recipe` that starts no model turn. A read made with request headers or a tab id is left out.
14
+ - **Browser recipes: bot checks.** A replay waits up to 30s while Cloudflare's "Just a moment..." check is showing; if it never clears, the replay stops and the agent asks the user to complete it.
15
+
16
+ ### Fixed
17
+
18
+ - **`browser_execute_js` with a top-level `return` returns its value.** Any body with a top-level `return` runs inside an async function that is awaited, and scripts run in REPL mode, so a later call may declare the same `const` or `let` again.
19
+ - **`browser_close_tab` is fast and confirms the close.** Closing the current tab no longer waits 10s, and a tab Chrome keeps open (one closed while still loading) is closed again, or reported as still open, instead of being reported closed.
20
+ - **Every element gets a ref.** `browser_snapshot`'s `maxNodes` caps only what is shown, so a ref the agent read from a long page still resolves.
21
+ - **`browser_page_info` reports the new page right after a navigation**, not the cached previous one.
22
+ - **`browser_web_search` resolves Google `/goto` result links**, and treats every Google TLD as Google's own, not a result.
23
+ - **The agent calls `browser_setup` before it has connected** instead of falling back to `curl`: the prompt now says the `browser_*` tools are available and the browser is just not connected yet.
24
+
5
25
  ## 0.13.0 — 2026-10-20
6
26
 
7
27
  ### Fixed
package/README.md CHANGED
@@ -182,6 +182,8 @@ tabs or navigating the tab to another origin.
182
182
  | Tool | Purpose |
183
183
  |---|---|
184
184
  | `browser_run_script` | Run a temporary script with raw CDP and full Node access. See [Extending](#extending). |
185
+ | `browser_save_recipe` | Save the browser task the agent just finished as a replayable recipe. See [Saved recipes](#saved-recipes). |
186
+ | `browser_run_recipe` | Replay a saved recipe with no model calls, or list recipes when called with no name. |
185
187
  | `browser_setup` | Agent-callable setup. Idempotent — how the agent recovers from `not initialized` on its own. |
186
188
 
187
189
  `browser_snapshot`, `browser_network_requests`, `browser_console`, `browser_execute_js`,
@@ -257,6 +259,49 @@ browser_press_key({ key: "T", modifiers: 10 }) // Ctrl+Shift+T
257
259
 
258
260
  ---
259
261
 
262
+ ## Saved recipes
263
+
264
+ Every page-changing browser call is journaled to `~/.pi/agent/browser-harness/journal/<session>.jsonl`
265
+ (user-only permissions, pruned after 30 days). Password values are never written.
266
+
267
+ After a multi-step task the agent offers to save it. `browser_save_recipe` turns that task into a YAML
268
+ file under `~/.pi/agent/browser-recipes/<site>/<name>.yaml` (or `.pi/browser-recipes/` in the project
269
+ with `project: true`), replacing the values you name as params with `{{param}}`. You confirm the recipe
270
+ before it is written. Saving over a name bumps `version` and keeps the old file under `.history/`.
271
+
272
+ Run one yourself with `/browser-recipe vendor-invoices-export month=2026-10` (no model involved), or ask
273
+ the agent ("run the invoices recipe for October"), which calls `browser_run_recipe`. `/browser-recipe`
274
+ with no name lists recipes; names tab-complete. Each step finds its element by role and name, waits up to
275
+ 5s for it, and checks that the page changed the way the recorded run did. A status or alert message the
276
+ recorded run saw counts when its first words match, so "Exported 43 rows" satisfies "Exported 42 rows".
277
+
278
+ When a step fails, the replay stops and hands the agent the step, what it expected and a fresh snapshot.
279
+ The agent finishes your task from that step first, then calls `browser_save_recipe` with `repair_step`,
280
+ which replaces that step onward with what it just did, bumps `version` and keeps the old file. A failed
281
+ `/browser-recipe` goes to the agent the same way. If the page asks you to sign in, the replay stops and the
282
+ agent asks you to sign in; it never types credentials.
283
+
284
+ A recipe in the project's `.pi/browser-recipes/` comes from whoever wrote the repo, so it needs your
285
+ approval before its first run: you see its file, site, the URLs it opens, the files it uploads and its
286
+ downloads. The approval holds for those exact file bytes (kept in
287
+ `~/.pi/agent/browser-harness/trusted-recipes.json`); if the file changes, you are asked again. Without a UI
288
+ an unapproved project recipe does not run.
289
+
290
+ Reads are kept too: a `browser_http_get`, or a `browser_read_page` of a URL, becomes a read step, and a
291
+ replay returns what each one fetched (`browser_run_recipe` in its result, `/browser-recipe` as a message in
292
+ the session that starts no model turn). A read made with request headers or a tab id is left out, since
293
+ headers can carry credentials and a tab id means nothing next run. A `browser_execute_js` that changed
294
+ nothing is left out as well, so a replay does not return what it read; read data with `browser_http_get`
295
+ or `browser_read_page` when the recipe should hand it back.
296
+
297
+ If the site shows Cloudflare's "Just a moment..." check, a replay waits up to 30s for it to clear; if it
298
+ does not, the replay stops and the agent asks you to complete the check in the browser.
299
+
300
+ A task that typed into a password field, or used a `browser_execute_js` that changed the page or
301
+ `browser_run_script`, cannot be saved.
302
+
303
+ ---
304
+
260
305
  ## Concurrency and isolation
261
306
 
262
307
  **Parallel reads, serialized writes.** Observation tools run concurrently with each other and with
@@ -398,6 +443,7 @@ listed with each.
398
443
  | `/browser-profile` | Pick which browser profile the agent works in. Remembered across sessions. |
399
444
  | `/browser-status` | Connection state, selected profile, and current page. |
400
445
  | `/browser-reload-daemon` | Force a client restart. Rarely needed — the transport reconnects on its own. |
446
+ | `/browser-recipe` | Replay a saved recipe: `/browser-recipe NAME key=value`. No name lists recipes. |
401
447
 
402
448
  | Flag / variable | Effect |
403
449
  |---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "scripts": {
62
62
  "typecheck": "tsc -b tsconfig.page.json && tsc --noEmit && tsc -p tsconfig.test.json",
63
- "test": "node --conditions=import --import tsx --test \"test/{profile,domains,cdp,daemon,util,types}/**/*-test.ts\"",
63
+ "test": "node --conditions=import --import tsx --test \"test/{profile,domains,cdp,daemon,util,types,recipes}/**/*-test.ts\"",
64
64
  "boundaries": "tsx scripts/check-boundaries.ts",
65
65
  "gen:skill": "node --conditions=import --import tsx scripts/gen-skill.ts",
66
66
  "check": "npm run typecheck && npm run boundaries && npm run lint && npm test",
@@ -75,7 +75,9 @@
75
75
  "sharp": "^0.33.0"
76
76
  },
77
77
  "dependencies": {
78
+ "acorn": "^8.19.0",
78
79
  "tsx": "^4.22.4",
79
- "ws": "^8.20.0"
80
+ "ws": "^8.20.0",
81
+ "yaml": "^2.8.3"
80
82
  }
81
83
  }
@@ -78,7 +78,9 @@ Generated from `src/registry.ts` by `npm run gen:skill`; do not edit by hand.
78
78
  | `browser_print_to_pdf` | serialized | Print the current page to PDF |
79
79
  | `browser_read_page` | parallel | Read a page's main content as clean text |
80
80
  | `browser_reload` | serialized | Reload the page |
81
+ | `browser_run_recipe` | parallel | Replay a saved browser recipe, or list recipes |
81
82
  | `browser_run_script` | parallel | Run a temporary script with daemon + Node access (security-bounded) |
83
+ | `browser_save_recipe` | parallel | Save the last browser task as a replayable recipe |
82
84
  | `browser_screenshot` | parallel | Capture a screenshot — visual verification only |
83
85
  | `browser_scroll` | serialized | Scroll the page (deltaY positive=down, negative=up) |
84
86
  | `browser_select_option` | serialized | Select an option in a native <select> by ref (preferred) or selector |
@@ -131,6 +133,36 @@ it by opening tabs elsewhere — a different profile means different accounts.
131
133
 
132
134
  You're attached to the user's real Chrome — never launch your own. If auth is required, stop and ask the user. If `browser_page_info` returns a dialog, handle it first with `browser_handle_dialog`.
133
135
 
136
+ ## Saved recipes
137
+
138
+ `browser_save_recipe` compiles your last browser task into a YAML recipe; `browser_run_recipe` replays one with no model calls. What a recipe keeps:
139
+
140
+ - **Page actions**: navigate, new_tab, click, fill, select_option, type, press_key, scroll, wait_for, close_tab and the other replayable tools. Each step finds its element by role and name and checks the page changed as it did when recorded.
141
+ - **Reads**: `browser_http_get` and `browser_read_page` with a `url`. A replay returns what each one fetched, so a recipe can hand back data, not only a page. A read with `headers` or a `targetId` is left out.
142
+ - **Left out**: a `browser_execute_js` that changed nothing. A replay does not return what it read, so when the user wants data back, fetch it with `browser_http_get` (an API, a JSON endpoint) or `browser_read_page` before saving.
143
+ - **Refused**: typing into a password field, a `browser_execute_js` that changed the page, `browser_run_script`.
144
+
145
+ Params replace values you pass: each value must appear, as written, in a step's args or element name (a URL, typed text, a selected label). Its URL forms count too: `{{topic|url}}`, `{{topic|plus}}`, `{{topic|underscore}}`. A value that only appeared in a left-out call cannot be a param.
146
+
147
+ The file format, for reading a recipe; repair it with `browser_save_recipe` `repair_step`, not by editing it, so the repair is replayed before it is written:
148
+
149
+ ```yaml
150
+ name: npm-downloads
151
+ site: api.npmjs.org
152
+ version: 1
153
+ params:
154
+ package: { type: string, example: pi-browser-harness }
155
+ steps:
156
+ - navigate: { url: "https://www.npmjs.com/package/{{package}}" }
157
+ expect: { url: "https://www.npmjs.com/package/{{package}}" } # origin and path only
158
+ - http_get: { url: "https://api.npmjs.org/downloads/range/last-month/{{package}}" }
159
+ - click: { role: button, name: Export CSV, nth: 0 } # the element: role+name+nth, or { css: "#export" }
160
+ expect: { appeared: [{ role: status, name: Exported 42 rows }] }
161
+ recorded: { at: "2026-11-01T15:14:25.918Z", session: 01a197ab }
162
+ ```
163
+
164
+ At a sign-in page or a bot check (Cloudflare's "Just a moment...", waited out for 30s first) the replay stops: ask the user to sign in or complete the check; never do either yourself.
165
+
134
166
  ## Downloads land in the user's context
135
167
 
136
168
  `browser_download` calls `Browser.setDownloadBehavior` with no `browserContextId`, which Chrome applies to its **default browser context** — the same one the user's own tabs are in. The override redirects the user's manual downloads too, and it lives on the browser connection the daemon keeps open, so it outlives the tab and the pi session. Symptom the user reports: *"I download things in Chrome and nothing appears in Downloads."*
@@ -283,6 +283,7 @@ export const COMMANDS = {
283
283
  returnByValue: Type.Optional(Type.Boolean()),
284
284
  awaitPromise: Type.Optional(Type.Boolean()),
285
285
  userGesture: Type.Optional(Type.Boolean()),
286
+ replMode: Type.Optional(Type.Boolean()),
286
287
  },
287
288
  { additionalProperties: true },
288
289
  ),
@@ -325,6 +326,10 @@ export const COMMANDS = {
325
326
  Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
326
327
  Type.Object({ object: RemoteObject }),
327
328
  ),
329
+ "DOM.describeNode": cmd(
330
+ Type.Object({ objectId: Type.String() }),
331
+ Type.Object({ node: Type.Object({ backendNodeId: Type.Number() }, { additionalProperties: true }) }),
332
+ ),
328
333
  "DOM.setFileInputFiles": cmd(
329
334
  Type.Object({
330
335
  files: Type.Array(Type.String()),
package/src/cdp/events.ts CHANGED
@@ -57,6 +57,7 @@ export const EVENTS = {
57
57
  ),
58
58
  "Page.loadEventFired": ev(Anything),
59
59
  "Page.frameNavigated": ev(Anything),
60
+ "Page.downloadWillBegin": ev(Anything),
60
61
 
61
62
  "Runtime.consoleAPICalled": ev(
62
63
  Type.Object(
@@ -1,4 +1,5 @@
1
1
  import { map, type Result, err, ok } from "../util/result";
2
+ import { isRecord } from "../util/guards";
2
3
  import { type CdpError, cdpError } from "./errors";
3
4
  import type { CdpEvent, DialogInfo, RefSignature } from "./types";
4
5
  import { type OwnershipRegistry, reconcileOwnership } from "./ownership";
@@ -26,12 +27,24 @@ type TabSession = {
26
27
  pageInfoDirty: boolean;
27
28
  readonly networkBuffer: NetworkBuffer;
28
29
  readonly consoleBuffer: ConsoleBuffer;
30
+ downloads: number;
31
+ writes: number;
29
32
  refMap: ReadonlyMap<string, number>;
30
33
  refSig: ReadonlyMap<string, RefSignature>;
31
34
  };
32
35
 
33
36
  type PageTarget = { readonly targetId: string; readonly type: string };
34
37
 
38
+ const NAVIGATES: ReadonlySet<string> = new Set(["Page.navigate", "Page.reload", "Page.navigateToHistoryEntry"]);
39
+
40
+ const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"]);
41
+
42
+ const requestMethod = (params: unknown): string => {
43
+ const request = isRecord(params) ? params["request"] : undefined;
44
+ const method = isRecord(request) ? request["method"] : undefined;
45
+ return typeof method === "string" ? method.toUpperCase() : "";
46
+ };
47
+
35
48
  const ENABLED_DOMAINS: ReadonlyArray<string> = ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"];
36
49
 
37
50
  const dialogType = (raw: string): DialogInfo["type"] =>
@@ -53,6 +66,10 @@ export type CdpSession = {
53
66
  resolveRef(ref: string): number | undefined;
54
67
  refSignatures(): ReadonlyMap<string, RefSignature>;
55
68
  refMappings(): ReadonlyMap<string, number>;
69
+ // Downloads Chrome has started from the current tab; compare before and after an action to see whether it began one.
70
+ downloadCount(): number;
71
+ // Requests the current tab has sent with a method that can change server state (anything but GET, HEAD, OPTIONS).
72
+ writeRequestCount(): number;
56
73
  // Sends on the current page session. Params are required only when the method has a required key (see CallArgs).
57
74
  call<M extends CdpMethod>(method: M, ...args: CallArgs<M>): Promise<Result<ResultOf<M>, CdpError>>;
58
75
  // Sends on an explicit session id (a borrowed or background tab). Params always come first because the session id follows them.
@@ -97,6 +114,8 @@ export const createCdpSession = (
97
114
  pageInfoDirty,
98
115
  networkBuffer: createNetworkBuffer(),
99
116
  consoleBuffer: createConsoleBuffer(),
117
+ downloads: 0,
118
+ writes: 0,
100
119
  refMap: new Map(),
101
120
  refSig: new Map(),
102
121
  });
@@ -172,6 +191,11 @@ export const createCdpSession = (
172
191
  if (tab) tab.pageInfoDirty = true;
173
192
  break;
174
193
  }
194
+ case "Page.downloadWillBegin": {
195
+ const tab = resolveTab(ev.sessionId);
196
+ if (tab) tab.downloads += 1;
197
+ break;
198
+ }
175
199
  case "Target.targetCreated": {
176
200
  if (!ownership) break;
177
201
  const decoded = decodeEvent(ev.method, ev.params);
@@ -201,7 +225,10 @@ export const createCdpSession = (
201
225
  }
202
226
  case "Network.requestWillBeSent": {
203
227
  const tab = resolveTab(ev.sessionId);
204
- if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
228
+ if (tab) {
229
+ tab.networkBuffer.ingestRequestWillBeSent(ev.params);
230
+ if (!SAFE_METHODS.has(requestMethod(ev.params))) tab.writes += 1;
231
+ }
205
232
  break;
206
233
  }
207
234
  case "Network.responseReceived": {
@@ -277,6 +304,12 @@ export const createCdpSession = (
277
304
  sessionId: sid,
278
305
  ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
279
306
  });
307
+ // A navigation command can return before its Page.frameNavigated event arrives, and a page info cached just
308
+ // before it would then still describe the page it left.
309
+ if (NAVIGATES.has(method)) {
310
+ const tab = resolveTab(sid ?? undefined);
311
+ if (tab) tab.pageInfoDirty = true;
312
+ }
280
313
  if (!raw.success) return raw;
281
314
  return decodeResult(method, raw.data);
282
315
  };
@@ -439,6 +472,12 @@ export const createCdpSession = (
439
472
  const tab = currentTab();
440
473
  return tab?.refMap ?? new Map();
441
474
  },
475
+ downloadCount() {
476
+ return currentTab()?.downloads ?? 0;
477
+ },
478
+ writeRequestCount() {
479
+ return currentTab()?.writes ?? 0;
480
+ },
442
481
  call(method, ...args) {
443
482
  const [params, opts] = args;
444
483
  return req(method, params, sessionId, opts);
package/src/client.ts CHANGED
@@ -14,6 +14,21 @@ import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
14
14
  import { seedProfileWindow } from "./profile/bind";
15
15
  import type { ProfilePin } from "./profile/store";
16
16
  import type { DaemonStatus, DialogInfo, PageInfo, PageInfoResult, TabInfo } from "./cdp/types";
17
+ import { pageExpression } from "./util/js-source";
18
+
19
+ // REPL mode lets a later call declare the same const or let again, as the console does.
20
+ export const evaluateParams = (
21
+ expression: string,
22
+ ): { readonly expression: string; readonly returnByValue: true; readonly awaitPromise: true; readonly replMode: true } => ({
23
+ expression: pageExpression(expression),
24
+ returnByValue: true,
25
+ awaitPromise: true,
26
+ replMode: true,
27
+ });
28
+
29
+ const CLOSE_ATTEMPTS = 2;
30
+ const CLOSE_CONFIRM_MS = 2_500;
31
+ const CLOSE_POLL_MS = 100;
17
32
 
18
33
  export type BrowserClientOptions = {
19
34
  readonly namespace: string;
@@ -25,6 +40,8 @@ export type BrowserClientOptions = {
25
40
  readonly harnessWindowId?: number;
26
41
  };
27
42
  readonly profilePin?: ProfilePin | null;
43
+ // How long one close may take to show in Chrome's target list before it is sent again; tests shorten it.
44
+ readonly closeConfirmMs?: number;
28
45
  readonly onOwnershipChange?: (snapshot: {
29
46
  readonly ownedTargetIds: ReadonlyArray<string>;
30
47
  readonly harnessWindowTargetId: string | undefined;
@@ -244,13 +261,8 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
244
261
  };
245
262
 
246
263
  const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
247
- // Wrap only when the trimmed source *starts with* `return` — matching the substring anywhere silently turned expressions mentioning it into `undefined`.
248
- const trimmed = expression.trim();
249
- const isReturnStatement = /^return[\s(]/.test(trimmed);
250
- const wrapped = isReturnStatement ? `(function(){${expression}})()` : expression;
251
- const r = sessionId
252
- ? await session.callOnTarget("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }, sessionId)
253
- : await session.call("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true });
264
+ const params = evaluateParams(expression);
265
+ const r = sessionId ? await session.callOnTarget("Runtime.evaluate", params, sessionId) : await session.call("Runtime.evaluate", params);
254
266
  if (!r.success) return r;
255
267
  if (r.data.exceptionDetails !== undefined) {
256
268
  return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(r.data.exceptionDetails)}`, "Runtime.evaluate"));
@@ -332,11 +344,39 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
332
344
  return ok(tabId);
333
345
  };
334
346
 
347
+ // True once Chrome no longer lists the target, false if it still does at the deadline, undefined when the list
348
+ // cannot be read (then the close is taken at its word, as before).
349
+ const targetGone = async (targetId: string, timeoutMs: number): Promise<boolean | undefined> => {
350
+ const deadline = Date.now() + timeoutMs;
351
+ for (;;) {
352
+ const r = await session.callBrowser("Target.getTargets", {}, { timeoutMs: 2_000 });
353
+ if (!r.success) return undefined;
354
+ if (!r.data.targetInfos.some((t) => t.targetId === targetId)) return true;
355
+ if (Date.now() >= deadline) return false;
356
+ await new Promise((resolve) => setTimeout(resolve, CLOSE_POLL_MS));
357
+ }
358
+ };
359
+
360
+ // Chrome answers Target.closeTarget with success even when it then keeps the tab: a tab closed while its page is
361
+ // still loading stays open. So the close is checked against Chrome's own target list and sent once more if the
362
+ // tab is still there; the tab leaves the owned list only once it is gone, so a tab left open is not lost track of.
335
363
  const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
336
- const r = await session.callBrowser("Target.closeTarget", { targetId });
337
- if (!r.success) return r;
338
- ownership.remove(targetId);
339
- return ok(undefined);
364
+ for (let attempt = 1; attempt <= CLOSE_ATTEMPTS; attempt++) {
365
+ const r = await session.callBrowser("Target.closeTarget", { targetId });
366
+ if (!r.success) return r;
367
+ const gone = await targetGone(targetId, opts.closeConfirmMs ?? CLOSE_CONFIRM_MS);
368
+ if (gone !== false) {
369
+ ownership.remove(targetId);
370
+ return ok(undefined);
371
+ }
372
+ }
373
+ return err(
374
+ cdpError(
375
+ "timeout",
376
+ `Chrome accepted the close but tab ${targetId} is still open after ${CLOSE_ATTEMPTS} attempts (a page that is still loading can ignore a close); wait for it to load, then close it again`,
377
+ "Target.closeTarget",
378
+ ),
379
+ );
340
380
  };
341
381
 
342
382
  const closeOwnedTabs = async (): Promise<void> => {
@@ -145,6 +145,25 @@ export const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
145
145
  return stripped;
146
146
  });
147
147
 
148
+ // The first `max` nodes in document order, counted the way buildTree's budget counts them. A kept node's ancestors
149
+ // come before it, so they are always kept too.
150
+ export const firstNodes = (nodes: ReadonlyArray<SlimNode>, max: number): ReadonlySet<SlimNode> => {
151
+ const kept = new Set<SlimNode>();
152
+ const walk = (ns: ReadonlyArray<SlimNode>): void => {
153
+ for (const n of ns) {
154
+ if (kept.size >= max) return;
155
+ kept.add(n);
156
+ walk(n.children);
157
+ }
158
+ };
159
+ walk(nodes);
160
+ return kept;
161
+ };
162
+
163
+ // A copy of the tree holding only the kept nodes; each node keeps its own fields, refs and boxes included.
164
+ export const keepOnly = (nodes: ReadonlyArray<SlimNode>, kept: ReadonlySet<SlimNode>): SlimNode[] =>
165
+ nodes.filter((n) => kept.has(n)).map((n) => ({ ...n, children: keepOnly(n.children, kept) }));
166
+
148
167
  export const collectInteractiveTargets = (
149
168
  nodes: ReadonlyArray<SlimNode>,
150
169
  ): Array<{ node: SlimNode; backendId: number }> => {
package/src/domains/js.ts CHANGED
@@ -38,7 +38,7 @@ const prettyJson = (text: string): string => {
38
38
  };
39
39
 
40
40
  const ExecuteJsArgs = Type.Object({
41
- expression: Type.String({ description: "JavaScript expression. `return X` is auto-wrapped in an IIFE." }),
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
43
  });
44
44
 
@@ -55,7 +55,7 @@ export const executeJsTool = defineBrowserTool({
55
55
  name: "browser_execute_js",
56
56
  label: "Browser Execute JS",
57
57
  description:
58
- "Surgical reads from the DOM. The cheapest, most precise way to extract a specific element's text, attribute, value, or geometry. Prefer over browser_screenshot for ANY data extraction or coordinate lookup. `return X` is auto-wrapped in an IIFE. Always use safeJs / JSON.stringify when interpolating untrusted strings.",
58
+ "Surgical reads from the DOM. The cheapest, most precise way to extract a specific element's text, attribute, value, or geometry. Prefer over browser_screenshot for ANY data extraction or coordinate lookup. A body with a top-level `return` runs inside an async function; `const`/`let` names can be declared again in later calls. Always use safeJs / JSON.stringify when interpolating untrusted strings.",
59
59
  promptSnippet: "Execute JS in the page — surgical DOM reads (preferred over screenshot)",
60
60
  promptGuidelines: [
61
61
  "DEFAULT for extracting a specific value: `document.querySelector('.price').innerText`, `input.value`, `el.getAttribute('aria-label')`, etc.",
@@ -0,0 +1,82 @@
1
+ // Page-side checks for the recipe journal and replay. See fill.page.ts for the rules.
2
+
3
+ // True for an <input type="password">, the one field whose value the journal must never store.
4
+ export function isPasswordInput(el: Element): boolean {
5
+ return el instanceof HTMLInputElement && el.type === "password";
6
+ }
7
+
8
+ // The same check for whatever has focus, which is where browser_type writes.
9
+ export function focusedIsPassword(): boolean {
10
+ const el = document.activeElement;
11
+ return el instanceof HTMLInputElement && el.type === "password";
12
+ }
13
+
14
+ // The interactive element under a point, so an x/y click can be journaled by role and name.
15
+ export function interactiveAt(x: number, y: number): Element | null {
16
+ const hit = document.elementFromPoint(x, y);
17
+ if (hit === null) return null;
18
+ return hit.closest("a,button,input,select,textarea,summary,[role],[contenteditable='true']");
19
+ }
20
+
21
+ // A visible password field means the page is asking the user to sign in.
22
+ export function hasVisiblePasswordField(): boolean {
23
+ for (const el of Array.from(document.querySelectorAll("input[type=password]"))) {
24
+ const r = el.getBoundingClientRect();
25
+ if (r.width > 0 && r.height > 0) return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ // A bot check standing in for the page: Cloudflare's "Just a moment..." interstitial defines _cf_chl_opt, which
31
+ // a page it lets through does not. Only Cloudflare's is known; a page could fake it, which only makes replay wait
32
+ // and then stop, never run a step it should not.
33
+ export function isBotCheck(): boolean {
34
+ return typeof Reflect.get(window, "_cf_chl_opt") === "object";
35
+ }
36
+
37
+ // Visible status and alert messages (toasts, live regions), which carry no ref, so a recipe can expect one.
38
+ // A live region without one of the message roles is reported as status (polite) or alert (assertive).
39
+ export function liveMessages(): Array<{ role: string; name: string }> {
40
+ const roles = ["status", "alert", "alertdialog"];
41
+ const out: Array<{ role: string; name: string }> = [];
42
+ const found = document.querySelectorAll("[role=status],[role=alert],[role=alertdialog],[aria-live=polite],[aria-live=assertive]");
43
+ for (const el of Array.from(found)) {
44
+ const r = el.getBoundingClientRect();
45
+ if (r.width === 0 || r.height === 0) continue;
46
+ const name = el.textContent.replace(/\s+/g, " ").trim().slice(0, 200);
47
+ if (name === "") continue;
48
+ const explicit = el.getAttribute("role") ?? "";
49
+ const role = roles.includes(explicit) ? explicit : el.getAttribute("aria-live") === "assertive" ? "alert" : "status";
50
+ out.push({ role, name });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // A hash of everything a script could change on the page: its markup, the live values of its form fields (which
56
+ // the markup does not show), its storage and its URL. Two equal hashes around a script mean it only read the page.
57
+ export function pageFingerprint(): string {
58
+ const parts: string[] = [location.href, document.documentElement.outerHTML];
59
+ for (const el of Array.from(document.querySelectorAll("input,textarea,select"))) {
60
+ if (el instanceof HTMLInputElement) parts.push(`${el.value}\u0001${String(el.checked)}`);
61
+ else if (el instanceof HTMLTextAreaElement) parts.push(el.value);
62
+ else if (el instanceof HTMLSelectElement) parts.push(String(el.selectedIndex));
63
+ }
64
+ for (const store of [() => localStorage, () => sessionStorage]) {
65
+ try {
66
+ const s = store();
67
+ for (let i = 0; i < s.length; i++) {
68
+ const key = s.key(i) ?? "";
69
+ parts.push(`${key}\u0001${s.getItem(key) ?? ""}`);
70
+ }
71
+ } catch {
72
+ parts.push("storage unavailable");
73
+ }
74
+ }
75
+ let hash = 0x811c9dc5;
76
+ const text = parts.join("\u0000");
77
+ for (let i = 0; i < text.length; i++) {
78
+ hash ^= text.charCodeAt(i);
79
+ hash = Math.imul(hash, 0x01000193) >>> 0;
80
+ }
81
+ return `${hash.toString(16)}:${text.length}`;
82
+ }
@@ -4,6 +4,7 @@ export type SerpAnchor = {
4
4
  readonly href: string;
5
5
  readonly heading: string;
6
6
  readonly snippet: string;
7
+ readonly cite?: string;
7
8
  };
8
9
 
9
10
  export type SerpExtraction = {
@@ -31,7 +32,9 @@ export function extractSerp(pageTextLimit: number): string {
31
32
  const containerText = container ? container.innerText : "";
32
33
  const heading = h3.innerText || h3.textContent;
33
34
  const snippet = containerText.replace(heading, "").replace(/\s+/g, " ").trim().slice(0, 500);
34
- anchors.push({ href, heading, snippet });
35
+ const citeEl = container ? container.querySelector<HTMLElement>("cite") : null;
36
+ const cite = citeEl ? (citeEl.innerText || citeEl.textContent || "").trim() : "";
37
+ anchors.push({ href, heading, snippet, cite });
35
38
  }
36
39
  const pageText = document.body.innerText.slice(0, pageTextLimit);
37
40
  const extraction: SerpExtraction = { anchors, pageText };
@@ -57,7 +57,7 @@ export const refreshRefs = async (client: BrowserClient): Promise<Result<RefRefr
57
57
 
58
58
  const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {});
59
59
  if (!axRes.success) return axRes;
60
- const slim = buildTree(axRes.data.nodes, { interestingOnly: true, maxNodes: 1000 });
60
+ const slim = buildTree(axRes.data.nodes, { interestingOnly: true, maxNodes: Number.POSITIVE_INFINITY });
61
61
  const targets = collectInteractiveTargets(slim);
62
62
 
63
63
  const priorRefs = session.refMappings();