pi-browser-harness 0.12.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.
- package/CHANGELOG.md +36 -0
- package/README.md +86 -0
- package/package.json +12 -6
- package/skills/pi-browser-harness/SKILL.md +57 -2
- package/src/cdp/commands.ts +200 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +57 -25
- package/src/cdp/events.ts +8 -3
- package/src/cdp/network-buffer.ts +38 -32
- package/src/cdp/ownership.ts +22 -4
- package/src/cdp/record-store.ts +2 -2
- package/src/cdp/session.ts +320 -217
- package/src/cdp/target-factory.ts +34 -15
- package/src/cdp/types.ts +17 -1
- package/src/client.ts +76 -43
- package/src/daemon/bridge.ts +110 -349
- package/src/daemon/connection.ts +323 -0
- package/src/daemon/index.ts +77 -48
- package/src/daemon/lines.ts +40 -0
- package/src/daemon/main.ts +12 -0
- package/src/daemon/protocol.ts +55 -25
- package/src/daemon/server.ts +100 -63
- package/src/daemon/socket.ts +10 -0
- package/src/daemon/spawn.ts +21 -22
- package/src/daemon/transport.ts +80 -49
- package/src/domains/ax-tree.ts +8 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -2
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -64
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +13 -9
- package/src/domains/form-batch.ts +13 -10
- package/src/domains/form.ts +78 -107
- package/src/domains/headers.ts +2 -2
- package/src/domains/history.ts +4 -4
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +65 -44
- package/src/domains/keyboard.ts +14 -11
- package/src/domains/navigate.ts +102 -66
- package/src/domains/network.ts +55 -95
- package/src/domains/page/fill.page.ts +139 -0
- package/src/domains/page/form.page.ts +61 -0
- package/src/domains/page/inject.ts +37 -0
- package/src/domains/page/keyboard.page.ts +16 -0
- package/src/domains/page/live-value.page.ts +10 -0
- package/src/domains/page/read-page.page.ts +51 -0
- package/src/domains/page/serp.page.ts +39 -0
- package/src/domains/page.ts +18 -51
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -53
- package/src/domains/ref-resolve.ts +43 -29
- package/src/domains/ref-signature.ts +11 -0
- package/src/domains/render.ts +12 -23
- package/src/domains/screenshot-capture.ts +4 -2
- package/src/domains/screenshot.ts +13 -10
- package/src/domains/scroll.ts +4 -4
- package/src/domains/search/google-serp.ts +1 -1
- package/src/domains/search/web-search.ts +38 -33
- package/src/domains/snapshot.ts +71 -73
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +18 -69
- package/src/domains/target.ts +1 -1
- package/src/index.ts +90 -33
- package/src/profile/bind.ts +45 -14
- package/src/profile/browser-process.ts +50 -12
- package/src/profile/command.ts +20 -12
- package/src/profile/launch.ts +13 -9
- package/src/profile/list.ts +9 -13
- package/src/profile/store.ts +2 -1
- package/src/prompt.ts +32 -7
- package/src/registry.ts +65 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +2 -1
- package/src/util/debug.ts +7 -1
- package/src/util/guards.ts +3 -0
- package/src/util/mutex.ts +1 -1
- package/src/util/paths.ts +3 -2
- package/src/util/result.ts +8 -0
- package/src/util/sharp-shim.ts +18 -11
- package/src/util/sleep.ts +18 -0
- package/src/util/tool.ts +163 -114
- package/src/util/truncate.ts +1 -1
- package/tsconfig.json +2 -1
- package/src/domains/fill-engine.ts +0 -80
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
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
|
+
|
|
5
41
|
## 0.12.0 — 2026-10-19
|
|
6
42
|
|
|
7
43
|
### Changed
|
package/README.md
CHANGED
|
@@ -303,6 +303,91 @@ Scripts must live under the OS temp dir, the cwd, or `BH_SCRIPT_DIR`, and every
|
|
|
303
303
|
mandatory timeout (60s default, 600s max). They land on disk first, so they're reviewable and
|
|
304
304
|
re-runnable.
|
|
305
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
|
+
|
|
306
391
|
---
|
|
307
392
|
|
|
308
393
|
## Commands, flags, and environment
|
|
@@ -393,6 +478,7 @@ path-restricted and timeout-bounded, but that is a blast-radius limit, not a san
|
|
|
393
478
|
## Contributing
|
|
394
479
|
|
|
395
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.
|
|
396
482
|
Release history lives in [CHANGELOG.md](CHANGELOG.md).
|
|
397
483
|
|
|
398
484
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-browser-harness",
|
|
3
|
-
"version": "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,18 +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",
|
|
56
|
+
"eslint": "10.11.0",
|
|
55
57
|
"typebox": "*",
|
|
56
|
-
"typescript": "^5.0.0"
|
|
58
|
+
"typescript": "^5.0.0",
|
|
59
|
+
"typescript-eslint": "8.71.0"
|
|
57
60
|
},
|
|
58
61
|
"scripts": {
|
|
59
|
-
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
|
|
60
|
-
"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\"",
|
|
61
64
|
"boundaries": "tsx scripts/check-boundaries.ts",
|
|
62
|
-
"
|
|
63
|
-
"
|
|
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"
|
|
64
70
|
},
|
|
65
71
|
"publishConfig": {
|
|
66
72
|
"access": "public"
|
|
@@ -41,6 +41,61 @@ Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot
|
|
|
41
41
|
|
|
42
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.
|
|
43
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
|
+
|
|
44
99
|
## Connection Setup
|
|
45
100
|
|
|
46
101
|
Browser control is **on-demand** — the daemon does NOT start automatically.
|
|
@@ -111,11 +166,11 @@ When a workflow repeats 3+ times or needs Node.js APIs, write a script to disk a
|
|
|
111
166
|
|
|
112
167
|
## Troubleshooting: "Could not start the browser daemon"
|
|
113
168
|
|
|
114
|
-
`browser_setup` spawns the daemon as `node <tsx cli> <pkg>/src/daemon/
|
|
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.`
|
|
115
170
|
|
|
116
171
|
Run it in the foreground to see the real error (it prints whether it reached Chrome):
|
|
117
172
|
|
|
118
|
-
cd <pkg> && node "$(node -p "require.resolve('tsx/cli')")" src/daemon/
|
|
173
|
+
cd <pkg> && node "$(node -p "require.resolve('tsx/cli')")" src/daemon/main.ts
|
|
119
174
|
|
|
120
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`.
|
|
121
176
|
|
package/src/cdp/commands.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
151
|
-
|
|
152
|
-
|
|
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(
|
|
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,9 +335,60 @@ export const COMMANDS = {
|
|
|
219
335
|
Empty,
|
|
220
336
|
),
|
|
221
337
|
|
|
222
|
-
"Input.dispatchMouseEvent": cmd(
|
|
223
|
-
|
|
224
|
-
|
|
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(
|
|
@@ -232,9 +399,27 @@ export const COMMANDS = {
|
|
|
232
399
|
Type.Object({ headers: Type.Record(Type.String(), Type.String()) }),
|
|
233
400
|
Empty,
|
|
234
401
|
),
|
|
235
|
-
"Emulation.setDeviceMetricsOverride": cmd(
|
|
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
|
+
),
|
|
236
421
|
"Accessibility.getFullAXTree": cmd(
|
|
237
|
-
Type.Object({ depth: Type.Optional(Type.Number())
|
|
422
|
+
Type.Object({ depth: Type.Optional(Type.Number()), frameId: Type.Optional(Type.String()) }),
|
|
238
423
|
Type.Object({ nodes: Type.Array(AxNode) }),
|
|
239
424
|
),
|
|
240
425
|
};
|
|
@@ -247,14 +432,15 @@ export const decodeResult = <M extends CdpMethod>(
|
|
|
247
432
|
method: M,
|
|
248
433
|
raw: unknown,
|
|
249
434
|
): Result<ResultOf<M>, CdpError> => {
|
|
435
|
+
// `method` is a key of COMMANDS, so the lookup cannot miss.
|
|
250
436
|
const spec = COMMANDS[method];
|
|
251
|
-
if (spec === undefined) {
|
|
252
|
-
return err(cdpError("invalid_response", `unknown CDP method`, method));
|
|
253
|
-
}
|
|
254
437
|
if (!spec.validate.Check(raw)) {
|
|
255
438
|
const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
|
|
256
439
|
return err(cdpError("invalid_response", why, method));
|
|
257
440
|
}
|
|
258
|
-
// Guarded assertion: the check above proves `raw` matches COMMANDS[method].result
|
|
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
|
|
259
445
|
return ok(raw as ResultOf<M>);
|
|
260
446
|
};
|
|
@@ -1,32 +1,39 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
1
2
|
import { decodeEvent } from "./events";
|
|
2
|
-
import { createRecordStore } from "./record-store";
|
|
3
|
+
import { createRecordStore, type DrainPage } from "./record-store";
|
|
3
4
|
|
|
4
5
|
export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
// The one declaration of a console record: the buffer produces it and browser_console validates replayed
|
|
8
|
+
// details against it, so the two cannot drift.
|
|
9
|
+
export const ConsoleRecordSchema = Type.Object({
|
|
10
|
+
seq: Type.Number(),
|
|
11
|
+
timestampMs: Type.Number(),
|
|
12
|
+
level: Type.Union([
|
|
13
|
+
Type.Literal("log"),
|
|
14
|
+
Type.Literal("info"),
|
|
15
|
+
Type.Literal("warn"),
|
|
16
|
+
Type.Literal("error"),
|
|
17
|
+
Type.Literal("debug"),
|
|
18
|
+
]),
|
|
19
|
+
text: Type.String(),
|
|
20
|
+
source: Type.Union([Type.Literal("console-api"), Type.Literal("log-entry")]),
|
|
21
|
+
url: Type.Optional(Type.String()),
|
|
22
|
+
lineNumber: Type.Optional(Type.Number()),
|
|
23
|
+
stackTrace: Type.Optional(Type.String()),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type ConsoleRecord = Readonly<Static<typeof ConsoleRecordSchema>>;
|
|
16
27
|
|
|
17
28
|
export type ConsoleFilter = {
|
|
18
|
-
levels?: ConsoleLevel
|
|
19
|
-
textPattern?: string;
|
|
20
|
-
sinceSeq?: number;
|
|
21
|
-
sinceMs?: number;
|
|
22
|
-
limit?: number;
|
|
29
|
+
readonly levels?: ReadonlyArray<ConsoleLevel>;
|
|
30
|
+
readonly textPattern?: string;
|
|
31
|
+
readonly sinceSeq?: number;
|
|
32
|
+
readonly sinceMs?: number;
|
|
33
|
+
readonly limit?: number;
|
|
23
34
|
};
|
|
24
35
|
|
|
25
|
-
export type ConsoleDrainResult =
|
|
26
|
-
readonly records: ConsoleRecord[];
|
|
27
|
-
readonly total: number;
|
|
28
|
-
readonly bufferOverflowed: boolean;
|
|
29
|
-
};
|
|
36
|
+
export type ConsoleDrainResult = DrainPage<ConsoleRecord>;
|
|
30
37
|
|
|
31
38
|
export type ConsoleBuffer = {
|
|
32
39
|
ingestConsoleApi(p: unknown): void;
|
|
@@ -43,7 +50,9 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
|
43
50
|
try {
|
|
44
51
|
const re = new RegExp(pattern.slice(1, -1));
|
|
45
52
|
return (text) => re.test(text);
|
|
46
|
-
} catch {
|
|
53
|
+
} catch {
|
|
54
|
+
// Not a valid regex: fall through and match the pattern as a literal substring.
|
|
55
|
+
}
|
|
47
56
|
}
|
|
48
57
|
return (text) => text.includes(pattern);
|
|
49
58
|
};
|
|
@@ -60,7 +69,8 @@ const stringifyRemoteObject = (
|
|
|
60
69
|
try {
|
|
61
70
|
return JSON.stringify(arg.value);
|
|
62
71
|
} catch {
|
|
63
|
-
|
|
72
|
+
// Circular or BigInt payloads cannot be JSON-encoded; fall back to the CDP description.
|
|
73
|
+
return arg.description ?? arg.type ?? "";
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
if (arg.description !== undefined) return arg.description;
|
|
@@ -69,6 +79,8 @@ const stringifyRemoteObject = (
|
|
|
69
79
|
|
|
70
80
|
const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
71
81
|
switch (type) {
|
|
82
|
+
case undefined:
|
|
83
|
+
return "log";
|
|
72
84
|
case "error":
|
|
73
85
|
case "assert":
|
|
74
86
|
return "error";
|
|
@@ -86,6 +98,8 @@ const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
|
86
98
|
|
|
87
99
|
const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => {
|
|
88
100
|
switch (level) {
|
|
101
|
+
case undefined:
|
|
102
|
+
return "info";
|
|
89
103
|
case "error":
|
|
90
104
|
return "error";
|
|
91
105
|
case "warning":
|