pi-browser-harness 0.11.0 → 0.13.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +102 -1
  3. package/package.json +13 -7
  4. package/skills/pi-browser-harness/SKILL.md +84 -0
  5. package/src/cdp/commands.ts +204 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +72 -37
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +115 -191
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +84 -27
  25. package/src/daemon/transport.ts +88 -52
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -7
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -66
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +64 -11
  34. package/src/domains/form-batch.ts +13 -11
  35. package/src/domains/form.ts +79 -116
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +7 -7
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +68 -53
  41. package/src/domains/keyboard.ts +16 -18
  42. package/src/domains/navigate.ts +102 -69
  43. package/src/domains/network.ts +56 -98
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +19 -58
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -54
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -14
  59. package/src/domains/scroll.ts +4 -5
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -34
  62. package/src/domains/setup.ts +0 -2
  63. package/src/domains/snapshot.ts +72 -77
  64. package/src/domains/tab-resolve.ts +36 -0
  65. package/src/domains/tabs.ts +19 -78
  66. package/src/domains/target.ts +1 -1
  67. package/src/domains/viewport.ts +0 -1
  68. package/src/index.ts +100 -39
  69. package/src/profile/bind.ts +45 -14
  70. package/src/profile/browser-process.ts +50 -12
  71. package/src/profile/command.ts +20 -12
  72. package/src/profile/launch.ts +13 -9
  73. package/src/profile/list.ts +9 -13
  74. package/src/profile/store.ts +2 -1
  75. package/src/prompt.ts +42 -117
  76. package/src/registry.ts +68 -4
  77. package/src/schemas/parse.ts +2 -2
  78. package/src/setup.ts +28 -19
  79. package/src/state.ts +33 -2
  80. package/src/util/debug.ts +7 -1
  81. package/src/util/guards.ts +3 -0
  82. package/src/util/mutex.ts +1 -1
  83. package/src/util/paths.ts +12 -1
  84. package/src/util/result.ts +8 -0
  85. package/src/util/sharp-shim.ts +18 -11
  86. package/src/util/sleep.ts +18 -0
  87. package/src/util/tool.ts +163 -114
  88. package/src/util/truncate.ts +1 -1
  89. package/tsconfig.json +2 -1
  90. package/src/domains/fill-engine.ts +0 -80
package/CHANGELOG.md CHANGED
@@ -2,6 +2,68 @@
2
2
 
3
3
  All notable changes to pi-browser-harness will be documented in this file.
4
4
 
5
+ ## 0.13.0 — 2026-10-20
6
+
7
+ ### Fixed
8
+
9
+ - **A killed daemon no longer crashes pi with `read ECONNRESET`.** The daemon's liveness probe, the client transport and the IPC server read the wire protocol through `node:readline`, which re-emits the socket's errors on its own interface with no listener. They now share a small line reader in `src/daemon/lines.ts` that only emits lines.
10
+ - **Waking from sleep no longer stacks up "Allow remote debugging" prompts.** Each dial to Chrome is one prompt, and the daemon abandoned a dial after 10s and started another on the next tool call, so an agent mid-task could open a dozen prompts before anyone was back at the keyboard. A dial now stays open for 5 minutes while callers get a "waiting for the Allow prompt" answer after 10s, every caller joins the dial in flight, and an outright failure starts a doubling cooldown (30s to 5min) during which no automatic dial happens. A dropped connection still re-dials once at the next demand. `/browser-setup` and `/browser-reload-daemon` send `Daemon.reconnect`, which bypasses the cooldown.
11
+ - **Tool failures now reach the model as errors.** `defineBrowserTool` set `isError` on the returned result, but pi only flags a result as an error when `execute` rejects, so every failed browser tool read as a success. Failed handlers now throw with the rendered error text; replayed errors still render in the UI.
12
+ - **The daemon transport no longer exits mid-handshake.** The connect timer was cleared on TCP connect, before the daemon's `registered` reply, and the socket was already unref'd, so a print-mode run could exit before registering. The timer now stays armed until the handshake settles; a daemon that accepts but never registers times out instead of hanging.
13
+ - **Disconnected clients are cleaned up in the daemon.** The idle-timer registration overwrote the bridge's disconnect handler, so router ownership and armed timers leaked per client. The IPC server now keeps a handler list; `deregister` is handled.
14
+ - **`--browser-namespace` works.** The flag was read before it was registered, so it was always undefined.
15
+ - **CDP sessions are reused or detached.** Switching tabs re-attached every time and left the old session alive; destroying the current target left `current()` pointing at it for up to 30s. Sessions are now tracked per target, detached on re-attach, and cleared on `Target.targetDestroyed` / `Inspector.detached`.
16
+ - **Connect timeouts no longer kick every client off the daemon**, overlapping `connect()` calls share one handshake, the detach release reads the session from `params`, and the duplicate-clientId reply is flushed before the socket closes.
17
+ - **Time budgets in `browser_network_requests` and `browser_snapshot` actually bound the work** through a concurrency-limited runner that honours the abort signal.
18
+ - **`browser_handle_dialog` clears the recorded dialog**, so the next `browser_page_info` no longer reports an already-handled dialog.
19
+ - **`browser_press_key` sends virtual key codes for function and modifier keys.**
20
+ - **`browser_open_urls` applies the tab-title marker after the page loads** instead of to the document being replaced.
21
+ - **`browser_print_to_pdf`, screenshots and uploads return `io_error`** on unwritable or unreadable paths instead of throwing.
22
+ - **Element refs are released** (`Runtime.releaseObject`) on every ref path; `browser_read_page` and `browser_open_urls` detach the sessions they attach.
23
+ - sharp "missing" is classified correctly under ESM; `isPathWithin` accepts siblings starting with `..`; `BH_DEBUG_CLICKS=0` disables debug; Linux `chromium-browser` process names are matched after `ps` truncation; discovery probes stale `DevToolsActivePort` candidates concurrently under one deadline.
24
+
25
+ ### Changed
26
+
27
+ - **Bad arguments return `invalid_args`** instead of `invalid_state`.
28
+ - **`browser_switch_tab` resolves targetId prefixes against owned tabs only**, matching `browser_close_tab`.
29
+ - **`browser_execute_js` details hold a bounded preview**; the full value lives in the text.
30
+ - **CDP calls require params when the method needs them**, and `Input.*`, `Emulation.setDeviceMetricsOverride`, `Page.printToPDF` and `Page.captureScreenshot` have real schemas.
31
+ - **Tool `details` are typed** via a TypeBox schema on the definition and validated on replay.
32
+ - **Page-side JS is TypeScript** under `src/domains/page/*.page.ts`, type-checked against the DOM lib and injected via `fn.toString()`. `src/domains/fill-engine.ts` is removed.
33
+ - The browser prompt's lane lists and the skill file's tool reference are generated from the registry (`npm run gen:skill`).
34
+
35
+ ### Added
36
+
37
+ - **Other pi extensions can contribute browser tools** through `registerBrowserTools` (see README, Extending).
38
+ - Type-aware ESLint (`npm run lint`) and `npm run check` running typecheck, boundaries, lint and tests. The boundary scanner gained a `raw-js-template` rule.
39
+ - Injection seams on the daemon entry, extension factory, setup and profile binding; tests for every tool in `src/domains` and the previously untested util, profile and daemon modules (365 tests to 1017).
40
+
41
+ ## 0.12.0 — 2026-10-19
42
+
43
+ ### Changed
44
+
45
+ - **Browser guidance no longer repeats tool declarations.** The browser prompt now contains only cross-tool coordination, available before and after connection, and tool guidelines omit usage and argument details already present in descriptions and schemas. Tool names, schemas, handlers, and safety warnings are unchanged.
46
+
47
+ ### Added
48
+
49
+ - **Per-tab extra HTTP request headers.** `browser_set_headers` applies headers to subsequent document, subresource, fetch, and XHR requests on the current owned tab through CDP, while `browser_clear_headers` removes them. Header values are not echoed in tool output, and guidance requires clearing them before switching tabs or navigating to another origin.
50
+
51
+ ### Fixed
52
+
53
+ - **`pi -p` exits after using browser tools.** The client's socket to the daemon is kept open across sessions by design, and in non-interactive mode it was the last handle keeping Node alive, so a print-mode run that had connected to the browser printed its answer and then hung until killed. The socket no longer holds the event loop; in-flight requests keep their own timers, so nothing awaited is affected. (#39)
54
+ - **`browser_download` no longer redirects the user's own downloads for good.** `Browser.setDownloadBehavior` was sent without a `browserContextId`, which Chrome applies to its default context — the one the user's own tabs are in — and the override lives on the browser connection, which the daemon keeps open across pi sessions. Every later download in that Chrome, the user's own included, went to the tool's temp directory, and nothing undid it when the session ended. The tool now takes `restore: true` to reset the behavior, and a clean session shutdown restores the default whenever this session's override is still in force (a killed session cannot, so call `restore: true` as soon as you are done).
55
+ - **Browser state is no longer appended to the session on every callback.** Every ownership change, every `session_tree` event (even one that merely restored the entry already on the branch) and every shutdown appended a `browser-harness-state` entry, so the append-only session file and Pi Web's `+N custom` chain grew without bound. A snapshot is now written only when it differs from the last one persisted or restored; tab order does not count as a difference. (#26)
56
+ - **The daemon starts from a pi-managed install without `typebox`.** pi installs packages with dev and peer dependencies omitted and maps host-provided packages such as `typebox` only inside the extension host, but the daemon is a separate Node process that resolved `typebox` through `src/cdp/discovery.ts` and died with `Cannot find module 'typebox'` before binding its socket, so `/browser-setup` reported `Could not start the browser daemon`. The daemon's `/json/version` parsing no longer uses `typebox`, and a test walks the daemon's import graph to keep every reachable package a runtime dependency. (#34)
57
+ - **The daemon starts from a Bun-compiled pi binary.** Inside `pi.exe` (and any other `bun build --compile` host), `createRequire().resolve("tsx/cli")` is rooted at the binary's embedded virtual filesystem and cannot see the extension's real `node_modules`, so `browser_setup` failed instantly with `Could not start the browser daemon`. The resolver result is now checked on disk and, when it fails, the tsx CLI is found by walking up the `node_modules` chain the way Node does. Under Bun the daemon is launched with `node` from `PATH` instead of `process.execPath` (which is the pi binary itself); `PI_BROWSER_NODE` overrides the executable. (#27)
58
+
59
+ ## 0.11.1 — 2026-08-12
60
+
61
+ ### Fixed
62
+
63
+ - **Chrome remote-debugging consent is no longer retriggered indefinitely in the background.** A cancelled, failed, or disconnected CDP connection now remains disconnected until the next explicit browser request, which makes one on-demand connection attempt instead of running an unbounded retry loop that can repeatedly interrupt normal browsing.
64
+ - **`browser_run_script` accepts paths on Windows again.** The allowed-directory check compared against a hardcoded `/` separator, so every backslash path — including files inside `os.tmpdir()`, which the rejection message itself listed as allowed — was refused. Containment is now decided by `path.relative`, which carries each platform's own separator and case rules.
65
+ - **The daemon starts from a production install.** `tsx` is needed to run the daemon but was declared only as a dev dependency, so `browser_setup` reported `Could not start the browser daemon` under `npm omit=dev`. It is now a runtime dependency, and the daemon is launched via the CLI entry that Node's resolver finds — a fixed `node_modules/.bin` path does not exist in a hoisted install, which is how pi lays extensions out.
66
+
5
67
  ## 0.11.0 — 2026-08-02
6
68
 
7
69
  ### Fixed
package/README.md CHANGED
@@ -161,13 +161,19 @@ Both run in isolated tabs and never disturb your current page.
161
161
  |---|---|
162
162
  | `browser_network_requests` | Requests captured on the current tab since attach. Filter by URL, method, status, resource type, or recency; optional response bodies. |
163
163
  | `browser_http_get` | Plain GET outside the browser. Much faster than navigate-then-read for JSON APIs — but no JS rendering. |
164
+ | `browser_set_headers` | Set extra HTTP request headers on the current owned tab. Values are not echoed in output. |
165
+ | `browser_clear_headers` | Clear extra HTTP request headers from the current owned tab. |
166
+
167
+ Extra headers persist for subsequent document, subresource, fetch, and XHR requests until cleared or
168
+ the tab closes. Create or select an owned tab before setting them, and clear them before switching
169
+ tabs or navigating the tab to another origin.
164
170
 
165
171
  ### Files and viewport
166
172
 
167
173
  | Tool | Purpose |
168
174
  |---|---|
169
175
  | `browser_upload_file` | Set files on a file input, with a DataTransfer fallback for stubborn pages. |
170
- | `browser_download` | Set the download directory and suppress the save-as prompt. |
176
+ | `browser_download` | Set the download directory and suppress the save-as prompt, or restore the default. Applies to the user's own downloads until restored. |
171
177
  | `browser_print_to_pdf` | Print the page to PDF via `Page.printToPDF`. |
172
178
  | `browser_viewport_resize` | Override viewport size and device pixel ratio for responsive checks. |
173
179
 
@@ -297,6 +303,91 @@ Scripts must live under the OS temp dir, the cwd, or `BH_SCRIPT_DIR`, and every
297
303
  mandatory timeout (60s default, 600s max). They land on disk first, so they're reviewable and
298
304
  re-runnable.
299
305
 
306
+ ### Contributing tools from another pi extension
307
+
308
+ Another extension can add its own `browser_*` tools to this harness. Contributed tools share the
309
+ same client, mutation mutex, tab ownership, and lane prompt as the built-ins, so a `serialized` tool
310
+ you add queues behind `browser_click` exactly as `browser_fill` does.
311
+
312
+ Define the tool with `defineBrowserTool` and hand it to `registerBrowserTools` at your extension's
313
+ load time, before pi emits `session_start`. The harness registers it with pi on the next
314
+ `session_start` alongside the built-ins.
315
+
316
+ ```ts
317
+ // my-extension/src/index.ts
318
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
319
+ import { Type } from "typebox";
320
+ import { defineBrowserTool, registerBrowserTools, ok } from "pi-browser-harness/src/index.ts";
321
+ import { evalJs } from "pi-browser-harness/src/domains/cdp-call.ts";
322
+
323
+ const pageHeightTool = defineBrowserTool({
324
+ name: "browser_page_height",
325
+ label: "Browser Page Height",
326
+ description: "Report the document's full scroll height in CSS pixels.",
327
+ promptSnippet: "Measure the page's scroll height",
328
+ promptGuidelines: [],
329
+ parameters: Type.Object({}),
330
+ concurrency: "parallel",
331
+ handler: async (_args, { client }) => {
332
+ const height = await evalJs(client, "document.documentElement.scrollHeight");
333
+ if (!height.success) return height;
334
+ return ok({ text: `Scroll height: ${String(height.data)}px` });
335
+ },
336
+ });
337
+
338
+ export default function myExtension(_pi: ExtensionAPI): void {
339
+ const added = registerBrowserTools([pageHeightTool]);
340
+ if (!added.success) console.warn(added.error.message);
341
+ }
342
+ ```
343
+
344
+ `registerBrowserTools` returns a `Result`. The whole batch is rejected, and nothing is added, when any
345
+ name is already taken by a built-in, an earlier contribution, or another tool in the same batch. Pick
346
+ `concurrency: "serialized"` for anything that changes page or tab state; `"parallel"` for reads.
347
+ `browserTools()` lists what the harness will register, built-ins plus contributions so far.
348
+
349
+ A tool that wants a custom result view declares a `details` schema next to `parameters`. The handler
350
+ then returns `details` of that shape, and `renderResult` receives them typed. pi stores details with
351
+ the session and replays them later; the harness validates replayed details against the schema first,
352
+ so a renderer never sees a stale shape from an older session (it falls back to the stored text instead).
353
+ Details must be plain JSON: no `Map`s, and omit a key rather than setting it to `undefined`.
354
+
355
+ ```ts
356
+ import { Text } from "@mariozechner/pi-tui";
357
+
358
+ const PageHeightDetails = Type.Object({ height: Type.Number(), unit: Type.Literal("px") });
359
+
360
+ const pageHeightTool = defineBrowserTool({
361
+ // ...name, label, description, promptSnippet, promptGuidelines, parameters, concurrency as above
362
+ details: PageHeightDetails,
363
+ handler: async (_args, { client }) => {
364
+ const height = await evalJs(client, "document.documentElement.scrollHeight");
365
+ if (!height.success) return height;
366
+ const px = typeof height.data === "number" ? height.data : 0;
367
+ return ok({ text: `Scroll height: ${px}px`, details: { height: px, unit: "px" } });
368
+ },
369
+ renderResult: (result, _expanded, theme) =>
370
+ new Text(theme.fg("muted", `${result.details.height}${result.details.unit}`), 0, 0),
371
+ });
372
+ ```
373
+
374
+ Tools without a `details` schema need nothing extra: they may still attach any JSON object for logs,
375
+ and their `renderResult` (if any) sees it untyped.
376
+
377
+ ### Known limits
378
+
379
+ Two extension points are documented but not implemented; the escape hatches that exist today are
380
+ listed with each.
381
+
382
+ - **No per-tab extension slot.** `TabSession` in `src/cdp/session.ts` carries only the harness's own
383
+ per-tab state (session id, dialog, page-info cache). A contributed tool cannot stash its own state
384
+ on a tab. Keep per-tab state in your extension keyed by `targetId` from `browser_current_tab` or
385
+ `browser_list_tabs`, or compute it on demand with `browser_run_script` through `daemon.session(targetId)`.
386
+ - **Hardcoded browser list.** `src/profile/paths.ts` knows the user-data directories of a fixed set
387
+ of Chromium browsers, so `/browser-profile` cannot discover one that is not on that list. Point the
388
+ harness at any Chromium profile directory with `CHROME_USER_DATA_DIR`, or skip discovery entirely
389
+ by attaching to a running browser's DevTools endpoint with `BU_CDP_WS`.
390
+
300
391
  ---
301
392
 
302
393
  ## Commands, flags, and environment
@@ -316,6 +407,7 @@ re-runnable.
316
407
  | `BH_DEBUG_CLICKS` | Same as `--browser-debug-clicks`. |
317
408
  | `BU_CDP_PORTS` | Extra ports to probe during CDP discovery, beyond 9222. |
318
409
  | `BH_SCRIPT_DIR` | Additional directory `browser_run_script` will accept paths from. |
410
+ | `PI_BROWSER_NODE` | Node executable used to run the daemon. Defaults to the one running pi, or `node` from `PATH` when pi is a Bun-compiled binary. |
319
411
 
320
412
  ---
321
413
 
@@ -346,6 +438,14 @@ A long-lived daemon owns the CDP connection, so it outlives individual pi sessio
346
438
  you only see Chrome's "Allow Remote Debugging" prompt once. `browser_run_script` executes in the
347
439
  harness process with direct daemon and Node access.
348
440
 
441
+ ### Prompt guidance
442
+
443
+ Tool descriptions and parameter schemas own per-tool usage. Tool guidelines add only decisions
444
+ and caveats not already covered there. The browser prompt carries cross-tool rules:
445
+ tab isolation, concurrency, checking mutations, and stale-ref recovery. These rules are available
446
+ before and after connection. Longer recipes remain in this README and the bundled skill
447
+ rather than being copied into the browser prompt.
448
+
349
449
  ---
350
450
 
351
451
  ## Troubleshooting
@@ -378,6 +478,7 @@ path-restricted and timeout-bounded, but that is a blast-radius limit, not a san
378
478
  ## Contributing
379
479
 
380
480
  See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, conventions, and the PR process.
481
+ `npm run check` runs the typecheck, the boundary scan, the type-aware ESLint pass and the test suite in that order; it is the one command a PR must pass.
381
482
  Release history lives in [CHANGELOG.md](CHANGELOG.md).
382
483
 
383
484
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.11.0",
3
+ "version": "0.13.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",
@@ -49,19 +49,24 @@
49
49
  "typebox": "*"
50
50
  },
51
51
  "devDependencies": {
52
+ "@eslint/js": "10.0.1",
52
53
  "@mariozechner/pi-coding-agent": "*",
53
54
  "@mariozechner/pi-tui": "*",
54
55
  "@types/ws": "^8.18.1",
55
- "tsx": "^4.22.4",
56
+ "eslint": "10.11.0",
56
57
  "typebox": "*",
57
- "typescript": "^5.0.0"
58
+ "typescript": "^5.0.0",
59
+ "typescript-eslint": "8.71.0"
58
60
  },
59
61
  "scripts": {
60
- "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
61
- "test": "node --conditions=import --import tsx --test \"test/{profile,domains,cdp,daemon,util}/**/*-test.ts\"",
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\"",
62
64
  "boundaries": "tsx scripts/check-boundaries.ts",
63
- "check": "npm run typecheck && npm run boundaries && npm test",
64
- "prepublishOnly": "tsc --noEmit"
65
+ "gen:skill": "node --conditions=import --import tsx scripts/gen-skill.ts",
66
+ "check": "npm run typecheck && npm run boundaries && npm run lint && npm test",
67
+ "prepublishOnly": "npm run typecheck",
68
+ "lint": "eslint .",
69
+ "lint:fix": "eslint . --fix"
65
70
  },
66
71
  "publishConfig": {
67
72
  "access": "public"
@@ -70,6 +75,7 @@
70
75
  "sharp": "^0.33.0"
71
76
  },
72
77
  "dependencies": {
78
+ "tsx": "^4.22.4",
73
79
  "ws": "^8.20.0"
74
80
  }
75
81
  }
@@ -21,6 +21,9 @@ What do you need to know?
21
21
  ├─ Network behavior on the current page?
22
22
  │ → browser_network_requests
23
23
  │
24
+ ├─ A page requires custom request headers?
25
+ │ → create/select owned tab → browser_set_headers → navigate/interact → browser_clear_headers
26
+ │
24
27
  ├─ Find pages on the web about a topic?
25
28
  │ → browser_web_search (ranked SERP — links only; follow up with browser_read_page)
26
29
  │
@@ -38,6 +41,61 @@ Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot
38
41
 
39
42
  `browser_web_search` and `browser_read_page` each run in their own isolated tab and never touch the user's current tab. Pair them for a research question: search for candidate URLs, then read the promising ones.
40
43
 
44
+ ## Tool reference
45
+
46
+ Every tool, its concurrency lane, and a one-line summary. `serialized` tools queue through one
47
+ shared mutation lane; `parallel` tools may run alongside them. The decision tree above says which
48
+ to reach for first; the tool descriptions and parameter schemas carry per-tool detail.
49
+
50
+ <!-- tools:start -->
51
+ Generated from `src/registry.ts` by `npm run gen:skill`; do not edit by hand.
52
+
53
+ | Tool | Lane | Summary |
54
+ |---|---|---|
55
+ | `browser_clear_headers` | serialized | Clear extra request headers from the current owned tab |
56
+ | `browser_click` | serialized | Click an element by ref (preferred) or pixel coordinates |
57
+ | `browser_close_tab` | serialized | Close an owned tab |
58
+ | `browser_console` | parallel | Read JS errors / console output on the current tab |
59
+ | `browser_current_tab` | parallel | Get current tab info |
60
+ | `browser_dispatch_key` | serialized | Dispatch a DOM KeyboardEvent on an element by ref (preferred) or selector |
61
+ | `browser_download` | serialized | Configure download directory |
62
+ | `browser_drag_and_drop` | serialized | Drag and drop between two coordinates |
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
+ | `browser_fill_form` | serialized | Fill many form fields at once by ref — React-safe, self-confirming |
66
+ | `browser_focus` | serialized | Focus an element by ref (preferred) or selector |
67
+ | `browser_go_back` | serialized | Go back one page |
68
+ | `browser_go_forward` | serialized | Go forward one page |
69
+ | `browser_handle_dialog` | serialized | Accept or dismiss a JS dialog |
70
+ | `browser_http_get` | parallel | HTTP GET (outside browser; for APIs/static pages) |
71
+ | `browser_list_tabs` | parallel | List browser tabs |
72
+ | `browser_navigate` | serialized | Navigate to a URL |
73
+ | `browser_network_requests` | parallel | Inspect recent network requests on the current page |
74
+ | `browser_new_tab` | serialized | Open a new tab |
75
+ | `browser_open_urls` | serialized | Open multiple URLs in new tabs (parallel) |
76
+ | `browser_page_info` | parallel | Get current page URL, title, viewport, and scroll position |
77
+ | `browser_press_key` | serialized | Press a key (Enter, Tab, Escape, arrows, or any character) |
78
+ | `browser_print_to_pdf` | serialized | Print the current page to PDF |
79
+ | `browser_read_page` | parallel | Read a page's main content as clean text |
80
+ | `browser_reload` | serialized | Reload the page |
81
+ | `browser_run_script` | parallel | Run a temporary script with daemon + Node access (security-bounded) |
82
+ | `browser_screenshot` | parallel | Capture a screenshot — visual verification only |
83
+ | `browser_scroll` | serialized | Scroll the page (deltaY positive=down, negative=up) |
84
+ | `browser_select_option` | serialized | Select an option in a native <select> by ref (preferred) or selector |
85
+ | `browser_set_checked` | serialized | Set a checkbox/radio checked state by ref |
86
+ | `browser_set_headers` | serialized | Set extra request headers on the current owned tab |
87
+ | `browser_setup` | parallel | Initialize browser connection |
88
+ | `browser_snapshot` | parallel | Get accessibility-tree snapshot with stable element refs (default for page inspection) |
89
+ | `browser_switch_tab` | serialized | Switch tabs |
90
+ | `browser_type` | serialized | Type text into the focused element |
91
+ | `browser_upload_file` | serialized | Upload a file to a file input by ref (preferred) or selector |
92
+ | `browser_viewport_resize` | serialized | Resize the viewport (responsive testing) |
93
+ | `browser_wait` | parallel | Wait N seconds |
94
+ | `browser_wait_for` | parallel | Wait for an element or text to appear (or an element to disappear) |
95
+ | `browser_wait_for_load` | serialized | Wait for the page to finish loading |
96
+ | `browser_web_search` | serialized | Search the web for a query |
97
+ <!-- tools:end -->
98
+
41
99
  ## Connection Setup
42
100
 
43
101
  Browser control is **on-demand** — the daemon does NOT start automatically.
@@ -73,6 +131,14 @@ it by opening tabs elsewhere — a different profile means different accounts.
73
131
 
74
132
  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`.
75
133
 
134
+ ## Downloads land in the user's context
135
+
136
+ `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."*
137
+
138
+ A clean session shutdown restores the default; a killed session does not. Call `browser_download({ restore: true })` as soon as you are done with a download path, and if a user reports the symptom, call it first.
139
+
140
+ `browser_run_script` is the same hazard: a script that sets download behavior and then throws partway leaves Chrome mutated. Reset it in a `finally`, not after the last line.
141
+
76
142
  ## Diagnosing a "nothing happened" moment
77
143
 
78
144
  When an action runs but the page didn't change, capture `browser_console`'s `nextCursor` *before* the action, take the action, then call `browser_console({ sinceSeq: <cursor> })` after — this isolates what your action caused from what was already there. Pair with `browser_network_requests({ sinceMs: 5000 })` to see if an API call fired and failed. The console buffer is page-scoped: it clears on tab switch, capacity 500.
@@ -97,3 +163,21 @@ When a workflow repeats 3+ times or needs Node.js APIs, write a script to disk a
97
163
  **Don't:**
98
164
  - Use scripts for one-off actions — call `browser_*` tools directly.
99
165
  - Call `browser_*` tools from inside a script — sequence them as separate tool calls outside.
166
+
167
+ ## Troubleshooting: "Could not start the browser daemon"
168
+
169
+ `browser_setup` spawns the daemon as `node <tsx cli> <pkg>/src/daemon/main.ts` with `stdio: "ignore"` (tsx is resolved from the package directory upward, so in pi's hoisted install it sits beside the package rather than inside it). A crash before the socket binds is completely silent and all you see is `Could not start the browser daemon. Check /tmp/pi-browser-daemon.sock.`
170
+
171
+ Run it in the foreground to see the real error (it prints whether it reached Chrome):
172
+
173
+ cd <pkg> && node "$(node -p "require.resolve('tsx/cli')")" src/daemon/main.ts
174
+
175
+ Known cause (package 0.11.1): `Error: Cannot find module 'typebox'` at `src/cdp/discovery.ts`. The package declares `typebox` in `peerDependencies` and `devDependencies`, and pi installs packages with dev and peer dependencies omitted, so the tree has no physical copy. It still resolves inside the pi extension host, which maps `typebox` to pi's bundled copy, but not in the spawned daemon process, which resolves modules from the package directory upward. The daemon dies, no socket appears, and every browser tool reports `not_connected`.
176
+
177
+ Fix it without touching the manifest, which must keep `typebox` out of `dependencies` (pi warns on it and `npm run check` rejects it):
178
+
179
+ cd <pkg> && npm install --no-save typebox --no-audit --no-fund
180
+
181
+ or restore the full tree with `npm ci`, which installs the `devDependencies` copy. Then confirm with `node -e "console.log(require.resolve('typebox'))"` inside the package directory, and start the daemon with `NODE_PATH` unset to prove no environment workaround is in play. A fresh clone or update reinstalls the tree, so the missing copy can come back; the manifest itself is correct upstream.
182
+
183
+ Do not work around it with `npm install typebox --save` (that writes `typebox` into `dependencies`, tripping pi's extension warning and the manifest check), a global `NODE_PATH`, or a hand-made symlink: the spawn inherits the extension host environment, so a shell-level export may not reach it, and the underlying problem is an incomplete install, not an undeclared dependency.
@@ -1,3 +1,17 @@
1
+ // The typed CDP command table — the extension point for everything the harness sends to Chrome.
2
+ //
3
+ // To add a command:
4
+ // 1. Look the method up in the Chrome DevTools Protocol viewer (https://chromedevtools.github.io/devtools-protocol/).
5
+ // 2. Add a `"Domain.method": cmd(ParamsSchema, ResultSchema)` entry below, in its domain's block.
6
+ // - Mark optional protocol fields with `Type.Optional(...)` — never with `Type.Union([..., Type.Undefined()])`:
7
+ // the project compiles with exactOptionalPropertyTypes, so callers must omit a field rather than pass undefined.
8
+ // - Use `Type.Union([Type.Literal(...), ...])` for enumerated strings so a typo fails to compile.
9
+ // - Result schemas are `additionalProperties: true` where Chrome may add fields; param schemas are closed.
10
+ // 3. `ParamsOf<"Domain.method">` / `ResultOf<"Domain.method">` now exist, and `session.call("Domain.method", ...)`
11
+ // requires params exactly when the schema has a required key (see `CallArgs` in session.ts).
12
+ // 4. Results are validated at runtime by `decodeResult`; a payload that does not match is an `invalid_response` error.
13
+ // Events live in ./events.ts and follow the same pattern.
14
+
1
15
  import { Type, type Static, type TSchema } from "typebox";
2
16
  import { Compile } from "typebox/compile";
3
17
  import { type Result, err, ok } from "../util/result";
@@ -77,6 +91,82 @@ const AxNode = Type.Object(
77
91
 
78
92
  export type AxNodeResult = Static<typeof AxNode>;
79
93
 
94
+ // Page.Viewport: a clip rectangle in CSS pixels.
95
+ const Viewport = Type.Object({
96
+ x: Type.Number(),
97
+ y: Type.Number(),
98
+ width: Type.Number(),
99
+ height: Type.Number(),
100
+ scale: Type.Number(),
101
+ });
102
+
103
+ // Browser.setDownloadBehavior — "allowAndName" saves under the download's GUID; "default" restores Chrome's own policy.
104
+ const DownloadBehavior = Type.Union([
105
+ Type.Literal("deny"),
106
+ Type.Literal("allow"),
107
+ Type.Literal("allowAndName"),
108
+ Type.Literal("default"),
109
+ ]);
110
+
111
+ const ScreenshotFormat = Type.Union([Type.Literal("jpeg"), Type.Literal("png"), Type.Literal("webp")]);
112
+
113
+ // Input.MouseButton
114
+ const MouseButton = Type.Union([
115
+ Type.Literal("none"),
116
+ Type.Literal("left"),
117
+ Type.Literal("middle"),
118
+ Type.Literal("right"),
119
+ Type.Literal("back"),
120
+ Type.Literal("forward"),
121
+ ]);
122
+
123
+ const MouseEventType = Type.Union([
124
+ Type.Literal("mousePressed"),
125
+ Type.Literal("mouseReleased"),
126
+ Type.Literal("mouseMoved"),
127
+ Type.Literal("mouseWheel"),
128
+ ]);
129
+
130
+ const KeyEventType = Type.Union([
131
+ Type.Literal("keyDown"),
132
+ Type.Literal("keyUp"),
133
+ Type.Literal("rawKeyDown"),
134
+ Type.Literal("char"),
135
+ ]);
136
+
137
+ const DragEventType = Type.Union([
138
+ Type.Literal("dragEnter"),
139
+ Type.Literal("dragOver"),
140
+ Type.Literal("drop"),
141
+ Type.Literal("dragCancel"),
142
+ ]);
143
+
144
+ // Input.DragDataItem / Input.DragData. `data` is the payload as a string (binary payloads are base64).
145
+ const DragDataItem = Type.Object({
146
+ mimeType: Type.String(),
147
+ data: Type.String(),
148
+ title: Type.Optional(Type.String()),
149
+ baseURL: Type.Optional(Type.String()),
150
+ });
151
+
152
+ const DragData = Type.Object({
153
+ items: Type.Array(DragDataItem),
154
+ files: Type.Optional(Type.Array(Type.String())),
155
+ // Bit field of allowed operations: 1=Copy, 2=Link, 16=Move.
156
+ dragOperationsMask: Type.Integer(),
157
+ });
158
+
159
+ // Emulation.ScreenOrientation
160
+ const ScreenOrientation = Type.Object({
161
+ type: Type.Union([
162
+ Type.Literal("portraitPrimary"),
163
+ Type.Literal("portraitSecondary"),
164
+ Type.Literal("landscapePrimary"),
165
+ Type.Literal("landscapeSecondary"),
166
+ ]),
167
+ angle: Type.Integer(),
168
+ });
169
+
80
170
  export const COMMANDS = {
81
171
  "Target.attachToTarget": cmd(
82
172
  Type.Object({ targetId: Type.String(), flatten: Type.Optional(Type.Boolean()) }),
@@ -117,7 +207,8 @@ export const COMMANDS = {
117
207
  ),
118
208
  "Browser.setDownloadBehavior": cmd(
119
209
  Type.Object({
120
- behavior: Type.String(),
210
+ behavior: DownloadBehavior,
211
+ browserContextId: Type.Optional(Type.String()),
121
212
  downloadPath: Type.Optional(Type.String()),
122
213
  eventsEnabled: Type.Optional(Type.Boolean()),
123
214
  }),
@@ -147,14 +238,39 @@ export const COMMANDS = {
147
238
  "Page.navigateToHistoryEntry": cmd(Type.Object({ entryId: Type.Number() }), Empty),
148
239
  "Page.captureScreenshot": cmd(
149
240
  Type.Object({
150
- format: Type.Optional(Type.String()),
151
- quality: Type.Optional(Type.Number()),
152
- clip: Type.Optional(Type.Unknown()),
241
+ format: Type.Optional(ScreenshotFormat),
242
+ // 0–100, jpeg and webp only.
243
+ quality: Type.Optional(Type.Integer()),
244
+ clip: Type.Optional(Viewport),
245
+ fromSurface: Type.Optional(Type.Boolean()),
153
246
  captureBeyondViewport: Type.Optional(Type.Boolean()),
247
+ optimizeForSpeed: Type.Optional(Type.Boolean()),
154
248
  }),
155
249
  Type.Object({ data: Type.String() }),
156
250
  ),
157
- "Page.printToPDF": cmd(Type.Object({}, { additionalProperties: true }), Type.Object({ data: Type.String() })),
251
+ "Page.printToPDF": cmd(
252
+ Type.Object({
253
+ landscape: Type.Optional(Type.Boolean()),
254
+ displayHeaderFooter: Type.Optional(Type.Boolean()),
255
+ printBackground: Type.Optional(Type.Boolean()),
256
+ scale: Type.Optional(Type.Number()),
257
+ // Paper size and margins are in inches.
258
+ paperWidth: Type.Optional(Type.Number()),
259
+ paperHeight: Type.Optional(Type.Number()),
260
+ marginTop: Type.Optional(Type.Number()),
261
+ marginBottom: Type.Optional(Type.Number()),
262
+ marginLeft: Type.Optional(Type.Number()),
263
+ marginRight: Type.Optional(Type.Number()),
264
+ pageRanges: Type.Optional(Type.String()),
265
+ headerTemplate: Type.Optional(Type.String()),
266
+ footerTemplate: Type.Optional(Type.String()),
267
+ preferCSSPageSize: Type.Optional(Type.Boolean()),
268
+ transferMode: Type.Optional(Type.Union([Type.Literal("ReturnAsBase64"), Type.Literal("ReturnAsStream")])),
269
+ generateTaggedPDF: Type.Optional(Type.Boolean()),
270
+ generateDocumentOutline: Type.Optional(Type.Boolean()),
271
+ }),
272
+ Type.Object({ data: Type.String(), stream: Type.Optional(Type.String()) }),
273
+ ),
158
274
  "Page.handleJavaScriptDialog": cmd(
159
275
  Type.Object({ accept: Type.Boolean(), promptText: Type.Optional(Type.String()) }),
160
276
  Empty,
@@ -219,18 +335,91 @@ export const COMMANDS = {
219
335
  Empty,
220
336
  ),
221
337
 
222
- "Input.dispatchMouseEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
223
- "Input.dispatchKeyEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
224
- "Input.dispatchDragEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
338
+ "Input.dispatchMouseEvent": cmd(
339
+ Type.Object({
340
+ type: MouseEventType,
341
+ x: Type.Number(),
342
+ y: Type.Number(),
343
+ // Bit field: 1=Alt, 2=Ctrl, 4=Meta/Command, 8=Shift.
344
+ modifiers: Type.Optional(Type.Integer()),
345
+ timestamp: Type.Optional(Type.Number()),
346
+ button: Type.Optional(MouseButton),
347
+ // Bit field of pressed buttons: 1=Left, 2=Right, 4=Middle, 8=Back, 16=Forward.
348
+ buttons: Type.Optional(Type.Integer()),
349
+ clickCount: Type.Optional(Type.Integer()),
350
+ force: Type.Optional(Type.Number()),
351
+ tangentialPressure: Type.Optional(Type.Number()),
352
+ tiltX: Type.Optional(Type.Number()),
353
+ tiltY: Type.Optional(Type.Number()),
354
+ twist: Type.Optional(Type.Integer()),
355
+ deltaX: Type.Optional(Type.Number()),
356
+ deltaY: Type.Optional(Type.Number()),
357
+ pointerType: Type.Optional(Type.Union([Type.Literal("mouse"), Type.Literal("pen")])),
358
+ }),
359
+ Empty,
360
+ ),
361
+ "Input.dispatchKeyEvent": cmd(
362
+ Type.Object({
363
+ type: KeyEventType,
364
+ modifiers: Type.Optional(Type.Integer()),
365
+ timestamp: Type.Optional(Type.Number()),
366
+ text: Type.Optional(Type.String()),
367
+ unmodifiedText: Type.Optional(Type.String()),
368
+ keyIdentifier: Type.Optional(Type.String()),
369
+ code: Type.Optional(Type.String()),
370
+ key: Type.Optional(Type.String()),
371
+ windowsVirtualKeyCode: Type.Optional(Type.Integer()),
372
+ nativeVirtualKeyCode: Type.Optional(Type.Integer()),
373
+ autoRepeat: Type.Optional(Type.Boolean()),
374
+ isKeypad: Type.Optional(Type.Boolean()),
375
+ isSystemKey: Type.Optional(Type.Boolean()),
376
+ // 0=Standard, 1=Left, 2=Right, 3=Numpad.
377
+ location: Type.Optional(Type.Integer()),
378
+ commands: Type.Optional(Type.Array(Type.String())),
379
+ }),
380
+ Empty,
381
+ ),
382
+ "Input.dispatchDragEvent": cmd(
383
+ Type.Object({
384
+ type: DragEventType,
385
+ x: Type.Number(),
386
+ y: Type.Number(),
387
+ data: DragData,
388
+ modifiers: Type.Optional(Type.Integer()),
389
+ }),
390
+ Empty,
391
+ ),
225
392
  "Input.insertText": cmd(Type.Object({ text: Type.String() }), Empty),
226
393
 
227
394
  "Network.getResponseBody": cmd(
228
395
  Type.Object({ requestId: Type.String() }),
229
396
  Type.Object({ body: Type.String(), base64Encoded: Type.Optional(Type.Boolean()) }),
230
397
  ),
231
- "Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty),
398
+ "Network.setExtraHTTPHeaders": cmd(
399
+ Type.Object({ headers: Type.Record(Type.String(), Type.String()) }),
400
+ Empty,
401
+ ),
402
+ "Emulation.setDeviceMetricsOverride": cmd(
403
+ Type.Object({
404
+ // 0 disables the override for that dimension.
405
+ width: Type.Integer(),
406
+ height: Type.Integer(),
407
+ // 0 disables the override.
408
+ deviceScaleFactor: Type.Number(),
409
+ mobile: Type.Boolean(),
410
+ scale: Type.Optional(Type.Number()),
411
+ screenWidth: Type.Optional(Type.Integer()),
412
+ screenHeight: Type.Optional(Type.Integer()),
413
+ positionX: Type.Optional(Type.Integer()),
414
+ positionY: Type.Optional(Type.Integer()),
415
+ dontSetVisibleSize: Type.Optional(Type.Boolean()),
416
+ screenOrientation: Type.Optional(ScreenOrientation),
417
+ viewport: Type.Optional(Viewport),
418
+ }),
419
+ Empty,
420
+ ),
232
421
  "Accessibility.getFullAXTree": cmd(
233
- Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }),
422
+ Type.Object({ depth: Type.Optional(Type.Number()), frameId: Type.Optional(Type.String()) }),
234
423
  Type.Object({ nodes: Type.Array(AxNode) }),
235
424
  ),
236
425
  };
@@ -243,14 +432,15 @@ export const decodeResult = <M extends CdpMethod>(
243
432
  method: M,
244
433
  raw: unknown,
245
434
  ): Result<ResultOf<M>, CdpError> => {
435
+ // `method` is a key of COMMANDS, so the lookup cannot miss.
246
436
  const spec = COMMANDS[method];
247
- if (spec === undefined) {
248
- return err(cdpError("invalid_response", `unknown CDP method`, method));
249
- }
250
437
  if (!spec.validate.Check(raw)) {
251
438
  const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
252
439
  return err(cdpError("invalid_response", why, method));
253
440
  }
254
- // Guarded assertion: the check above proves `raw` matches COMMANDS[method].result, which TS cannot correlate from a generic key.
441
+ // Guarded assertion: the check above proves `raw` matches COMMANDS[method].result. TS resolves
442
+ // `spec.validate` through a generic key to the union of every validator, so the predicate
443
+ // narrows `raw` to the union of every result type rather than to ResultOf<M>.
444
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- sanctioned decoder, see scripts/boundary-allowlist.ts
255
445
  return ok(raw as ResultOf<M>);
256
446
  };