pi-browser-harness 0.10.2 → 0.12.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 +54 -0
- package/README.md +21 -10
- package/package.json +6 -3
- package/skills/pi-browser-harness/SKILL.md +30 -1
- package/src/cdp/commands.ts +260 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +31 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +375 -265
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +90 -82
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -37
- package/src/domains/console.ts +48 -4
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +75 -30
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +64 -0
- package/src/domains/form.ts +115 -112
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +16 -12
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +32 -51
- package/src/domains/keyboard.ts +38 -34
- package/src/domains/navigate.ts +16 -26
- package/src/domains/network.ts +61 -13
- package/src/domains/page.ts +25 -10
- package/src/domains/readpage/read-page.ts +74 -20
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -90
- package/src/domains/scroll.ts +10 -12
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -21
- package/src/domains/setup.ts +1 -13
- package/src/domains/snapshot.ts +36 -280
- package/src/domains/tabs.ts +15 -16
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -4
- package/src/index.ts +23 -69
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/prompt.ts +22 -122
- package/src/registry.ts +33 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +48 -18
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/paths.ts +11 -1
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,60 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.12.0 — 2026-10-19
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`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)
|
|
18
|
+
- **`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).
|
|
19
|
+
- **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)
|
|
20
|
+
- **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)
|
|
21
|
+
- **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)
|
|
22
|
+
|
|
23
|
+
## 0.11.1 — 2026-08-12
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **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.
|
|
28
|
+
- **`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.
|
|
29
|
+
- **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.
|
|
30
|
+
|
|
31
|
+
## 0.11.0 — 2026-08-02
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **`--browser-debug-clicks` now does something.** The flag was registered and documented but never read; the only working switch was the undocumented `BH_DEBUG_CLICKS` environment variable. Both now drive the same setting.
|
|
36
|
+
- **`BU_CDP_WS` now actually attaches to a remote browser.** The client read the variable and passed the URL to `transport.connect()`, but the daemon transport discards that argument and always dials the daemon socket — and the daemon's own discovery never looked at the variable, so setting it changed nothing. The override moved into `discoverEndpoint`, which both processes go through. It is read when the daemon starts, so a running daemon must be stopped first.
|
|
37
|
+
- **A disconnected client's pending requests no longer fire at a dead socket.** `removeClient` cleared the daemon's id multiplexer but not its callback map, so a gone client's command timeouts stayed armed and later tried to answer it.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **One route from a tool to the browser.** Four coexisted; `src/domains/cdp-call.ts` is now the only one, and two boundary-scanner rules scoped to `src/domains/` keep it that way. A CDP timeout now surfaces as `kind: "timeout"` instead of `cdp_error` at the sites that previously flattened it.
|
|
42
|
+
- **Shared logic moved out of tool files** into `ax-tree.ts`, `element-call.ts`, and `screenshot-capture.ts`, so importing one tool no longer pulls in another tool's module. `cdp/attach.ts` and `cdp/window.ts` became `session.attach()` and `session.windowId()`; `cdp/daemon-transport.ts` became `daemon/transport.ts`; the request-timeout half of `cdp/event-queue.ts` became `cdp/pending-requests.ts`.
|
|
43
|
+
- **The daemon's request bookkeeping is one map instead of two**, and a request that arrives while Chrome is down awaits a single connect signal rather than spinning its own 250 ms poll loop.
|
|
44
|
+
|
|
45
|
+
### Removed
|
|
46
|
+
|
|
47
|
+
- **The second CDP transport.** `createCdpTransport` was a full WebSocket-to-Chrome implementation reachable only through a fallback in `client.ts` that production never took. `cdp/transport.ts` now holds just the `CdpTransport` interface, and a client must be given a transport.
|
|
48
|
+
- **Dead exports and a fake config knob:** `isInternalUrl`, `SPECIAL_KEYS`, `andThen`, `mapErr`, `BrowserState.remoteBrowserId`, `IpcServer.disconnectClient`, `IpcServer.clients()`, and `DAEMON_STALE_SOCKET_CLEANUP` (a hardcoded `true` with a branch around it).
|
|
49
|
+
|
|
50
|
+
- **The `deep-research` skill, the `/deep-research` command, and the `web-search-researcher` subagent.** The harness ships browser tools; orchestrating multi-agent research on top of them belongs to the agent, not to this extension. `browser_web_search` and `browser_read_page` are unaffected — search then read still works, it is just no longer wrapped in a fan-out workflow.
|
|
51
|
+
|
|
52
|
+
## 0.10.3 — 2026-07-26
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **Profile binding after an in-place browser upgrade.** Linux appends `" (deleted)"` to `/proc/<pid>/exe` once the running binary is unlinked — which is exactly what a `google-chrome` package update does while the browser stays open. `detectRunningBrowser` returned that literal string as the executable path, so every profile launch tried to spawn `/opt/google/chrome/chrome (deleted)` and failed with `ENOENT`. Executable paths are now validated against disk, with the marker stripped (and argv[0] used) when the linked binary is gone.
|
|
57
|
+
- **A failed browser launch no longer reports as a sentinel timeout.** `spawn()` signals a missing or non-executable binary asynchronously via an `error` event rather than by throwing, and that event was discarded — so `openProfileWindow` returned success, no window ever opened, and the caller blamed the browser 15 seconds later with `couldn't open a window in "<profile>" automatically`. The launch now waits for `spawn`/`error` and returns the real cause, and removes its handshake page on the failure path.
|
|
58
|
+
|
|
5
59
|
## 0.10.2 — 2026-07-26
|
|
6
60
|
|
|
7
61
|
### Fixed
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Your profile, your logins, your cookies. The agent reads pages as an accessibility tree with stable
|
|
8
8
|
element handles, fills forms that React won't revert, captures network traffic and console errors,
|
|
9
|
-
searches the web, and drops to raw CDP when the tools run out.
|
|
9
|
+
searches the web, and drops to raw CDP when the tools run out. 40 tools over a single CDP connection —
|
|
10
10
|
no MCP hop, no second browser, no extra API keys.
|
|
11
11
|
|
|
12
12
|
---
|
|
@@ -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
|
|
|
@@ -238,11 +244,7 @@ browser_web_search({ query: "chrome devtools protocol overview", limit: 5 })
|
|
|
238
244
|
browser_read_page({ url: "<the best result's url>" })
|
|
239
245
|
```
|
|
240
246
|
|
|
241
|
-
|
|
242
|
-
`/deep-research <question>`. It decomposes the question, fans out one isolated
|
|
243
|
-
`web-search-researcher` subagent per sub-question, loops for coverage under a hard ceiling, and
|
|
244
|
-
writes a Markdown report where every claim carries a source link. The skill also auto-triggers on
|
|
245
|
-
research-shaped requests without the command.
|
|
247
|
+
Both tools open their own isolated tab, so neither disturbs the tab you are working in.
|
|
246
248
|
|
|
247
249
|
### Keyboard modifiers
|
|
248
250
|
|
|
@@ -310,16 +312,17 @@ re-runnable.
|
|
|
310
312
|
| `/browser-setup` | Connect pi to your browser. Run once; idempotent. |
|
|
311
313
|
| `/browser-profile` | Pick which browser profile the agent works in. Remembered across sessions. |
|
|
312
314
|
| `/browser-status` | Connection state, selected profile, and current page. |
|
|
313
|
-
| `/deep-research <question>` | Multi-source web research → cited Markdown report. |
|
|
314
315
|
| `/browser-reload-daemon` | Force a client restart. Rarely needed — the transport reconnects on its own. |
|
|
315
316
|
|
|
316
317
|
| Flag / variable | Effect |
|
|
317
318
|
|---|---|
|
|
318
319
|
| `--browser-namespace <name>` | Override the session namespace (default: auto-generated). |
|
|
319
|
-
| `--browser-debug-clicks` | Save annotated click screenshots for debugging. |
|
|
320
|
-
| `BU_CDP_WS` | Attach to a remote browser by WebSocket URL instead of discovering a local one. |
|
|
320
|
+
| `--browser-debug-clicks` | Save annotated click screenshots for debugging. Equivalent to `BH_DEBUG_CLICKS=1`. |
|
|
321
|
+
| `BU_CDP_WS` | Attach to a remote browser by WebSocket URL instead of discovering a local one. The daemon reads this when it starts, so stop a running daemon first. |
|
|
322
|
+
| `BH_DEBUG_CLICKS` | Same as `--browser-debug-clicks`. |
|
|
321
323
|
| `BU_CDP_PORTS` | Extra ports to probe during CDP discovery, beyond 9222. |
|
|
322
324
|
| `BH_SCRIPT_DIR` | Additional directory `browser_run_script` will accept paths from. |
|
|
325
|
+
| `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. |
|
|
323
326
|
|
|
324
327
|
---
|
|
325
328
|
|
|
@@ -350,6 +353,14 @@ A long-lived daemon owns the CDP connection, so it outlives individual pi sessio
|
|
|
350
353
|
you only see Chrome's "Allow Remote Debugging" prompt once. `browser_run_script` executes in the
|
|
351
354
|
harness process with direct daemon and Node access.
|
|
352
355
|
|
|
356
|
+
### Prompt guidance
|
|
357
|
+
|
|
358
|
+
Tool descriptions and parameter schemas own per-tool usage. Tool guidelines add only decisions
|
|
359
|
+
and caveats not already covered there. The browser prompt carries cross-tool rules:
|
|
360
|
+
tab isolation, concurrency, checking mutations, and stale-ref recovery. These rules are available
|
|
361
|
+
before and after connection. Longer recipes remain in this README and the bundled skill
|
|
362
|
+
rather than being copied into the browser prompt.
|
|
363
|
+
|
|
353
364
|
---
|
|
354
365
|
|
|
355
366
|
## Troubleshooting
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-browser-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -52,12 +52,14 @@
|
|
|
52
52
|
"@mariozechner/pi-coding-agent": "*",
|
|
53
53
|
"@mariozechner/pi-tui": "*",
|
|
54
54
|
"@types/ws": "^8.18.1",
|
|
55
|
-
"tsx": "^4.22.4",
|
|
56
55
|
"typebox": "*",
|
|
57
56
|
"typescript": "^5.0.0"
|
|
58
57
|
},
|
|
59
58
|
"scripts": {
|
|
60
|
-
"typecheck": "tsc --noEmit",
|
|
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\"",
|
|
61
|
+
"boundaries": "tsx scripts/check-boundaries.ts",
|
|
62
|
+
"check": "npm run typecheck && npm run boundaries && npm test",
|
|
61
63
|
"prepublishOnly": "tsc --noEmit"
|
|
62
64
|
},
|
|
63
65
|
"publishConfig": {
|
|
@@ -67,6 +69,7 @@
|
|
|
67
69
|
"sharp": "^0.33.0"
|
|
68
70
|
},
|
|
69
71
|
"dependencies": {
|
|
72
|
+
"tsx": "^4.22.4",
|
|
70
73
|
"ws": "^8.20.0"
|
|
71
74
|
}
|
|
72
75
|
}
|
|
@@ -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
|
│
|
|
@@ -36,7 +39,7 @@ What do you need to know?
|
|
|
36
39
|
|
|
37
40
|
Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot round-trip.
|
|
38
41
|
|
|
39
|
-
`browser_web_search` and `browser_read_page` each run in their own isolated tab and never touch the user's current tab.
|
|
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
|
|
|
41
44
|
## Connection Setup
|
|
42
45
|
|
|
@@ -73,6 +76,14 @@ it by opening tabs elsewhere — a different profile means different accounts.
|
|
|
73
76
|
|
|
74
77
|
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
78
|
|
|
79
|
+
## Downloads land in the user's context
|
|
80
|
+
|
|
81
|
+
`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."*
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
`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.
|
|
86
|
+
|
|
76
87
|
## Diagnosing a "nothing happened" moment
|
|
77
88
|
|
|
78
89
|
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 +108,21 @@ When a workflow repeats 3+ times or needs Node.js APIs, write a script to disk a
|
|
|
97
108
|
**Don't:**
|
|
98
109
|
- Use scripts for one-off actions — call `browser_*` tools directly.
|
|
99
110
|
- Call `browser_*` tools from inside a script — sequence them as separate tool calls outside.
|
|
111
|
+
|
|
112
|
+
## Troubleshooting: "Could not start the browser daemon"
|
|
113
|
+
|
|
114
|
+
`browser_setup` spawns the daemon as `node <tsx cli> <pkg>/src/daemon/index.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
|
+
|
|
116
|
+
Run it in the foreground to see the real error (it prints whether it reached Chrome):
|
|
117
|
+
|
|
118
|
+
cd <pkg> && node "$(node -p "require.resolve('tsx/cli')")" src/daemon/index.ts
|
|
119
|
+
|
|
120
|
+
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
|
+
|
|
122
|
+
Fix it without touching the manifest, which must keep `typebox` out of `dependencies` (pi warns on it and `npm run check` rejects it):
|
|
123
|
+
|
|
124
|
+
cd <pkg> && npm install --no-save typebox --no-audit --no-fund
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
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.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { Type, type Static, type TSchema } from "typebox";
|
|
2
|
+
import { Compile } from "typebox/compile";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import { type CdpError, cdpError } from "./errors";
|
|
5
|
+
|
|
6
|
+
const cmd = <P extends TSchema, R extends TSchema>(params: P, result: R) => ({
|
|
7
|
+
params,
|
|
8
|
+
result,
|
|
9
|
+
validate: Compile(result),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const Empty = Type.Object({});
|
|
13
|
+
|
|
14
|
+
const TargetInfo = Type.Object(
|
|
15
|
+
{
|
|
16
|
+
targetId: Type.String(),
|
|
17
|
+
type: Type.String(),
|
|
18
|
+
title: Type.String(),
|
|
19
|
+
url: Type.String(),
|
|
20
|
+
attached: Type.Optional(Type.Boolean()),
|
|
21
|
+
openerId: Type.Optional(Type.String()),
|
|
22
|
+
browserContextId: Type.Optional(Type.String()),
|
|
23
|
+
},
|
|
24
|
+
{ additionalProperties: true },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const RemoteObject = Type.Object(
|
|
28
|
+
{
|
|
29
|
+
type: Type.String(),
|
|
30
|
+
subtype: Type.Optional(Type.String()),
|
|
31
|
+
className: Type.Optional(Type.String()),
|
|
32
|
+
value: Type.Optional(Type.Unknown()),
|
|
33
|
+
description: Type.Optional(Type.String()),
|
|
34
|
+
objectId: Type.Optional(Type.String()),
|
|
35
|
+
},
|
|
36
|
+
{ additionalProperties: true },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const CallArgument = Type.Object(
|
|
40
|
+
{
|
|
41
|
+
value: Type.Optional(Type.Unknown()),
|
|
42
|
+
objectId: Type.Optional(Type.String()),
|
|
43
|
+
unserializableValue: Type.Optional(Type.String()),
|
|
44
|
+
},
|
|
45
|
+
{ additionalProperties: true },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const ExceptionDetails = Type.Object(
|
|
49
|
+
{ text: Type.String(), exception: Type.Optional(RemoteObject) },
|
|
50
|
+
{ additionalProperties: true },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const AxValue = Type.Object({ value: Type.Optional(Type.Unknown()) }, { additionalProperties: true });
|
|
54
|
+
|
|
55
|
+
const AxNode = Type.Object(
|
|
56
|
+
{
|
|
57
|
+
nodeId: Type.String(),
|
|
58
|
+
parentId: Type.Optional(Type.String()),
|
|
59
|
+
childIds: Type.Optional(Type.Array(Type.String())),
|
|
60
|
+
ignored: Type.Optional(Type.Boolean()),
|
|
61
|
+
role: Type.Optional(AxValue),
|
|
62
|
+
name: Type.Optional(AxValue),
|
|
63
|
+
value: Type.Optional(AxValue),
|
|
64
|
+
description: Type.Optional(AxValue),
|
|
65
|
+
properties: Type.Optional(
|
|
66
|
+
Type.Array(
|
|
67
|
+
Type.Object(
|
|
68
|
+
{ name: Type.Optional(Type.String()), value: Type.Optional(AxValue) },
|
|
69
|
+
{ additionalProperties: true },
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
backendDOMNodeId: Type.Optional(Type.Number()),
|
|
74
|
+
},
|
|
75
|
+
{ additionalProperties: true },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export type AxNodeResult = Static<typeof AxNode>;
|
|
79
|
+
|
|
80
|
+
export const COMMANDS = {
|
|
81
|
+
"Target.attachToTarget": cmd(
|
|
82
|
+
Type.Object({ targetId: Type.String(), flatten: Type.Optional(Type.Boolean()) }),
|
|
83
|
+
Type.Object({ sessionId: Type.String() }),
|
|
84
|
+
),
|
|
85
|
+
"Target.detachFromTarget": cmd(
|
|
86
|
+
Type.Object({ sessionId: Type.Optional(Type.String()) }),
|
|
87
|
+
Empty,
|
|
88
|
+
),
|
|
89
|
+
"Target.createTarget": cmd(
|
|
90
|
+
Type.Object({
|
|
91
|
+
url: Type.String(),
|
|
92
|
+
newWindow: Type.Optional(Type.Boolean()),
|
|
93
|
+
background: Type.Optional(Type.Boolean()),
|
|
94
|
+
windowId: Type.Optional(Type.Number()),
|
|
95
|
+
openerId: Type.Optional(Type.String()),
|
|
96
|
+
}),
|
|
97
|
+
Type.Object({ targetId: Type.String() }),
|
|
98
|
+
),
|
|
99
|
+
"Target.closeTarget": cmd(
|
|
100
|
+
Type.Object({ targetId: Type.String() }),
|
|
101
|
+
Type.Object({ success: Type.Optional(Type.Boolean()) }),
|
|
102
|
+
),
|
|
103
|
+
"Target.activateTarget": cmd(Type.Object({ targetId: Type.String() }), Empty),
|
|
104
|
+
"Target.getTargetInfo": cmd(
|
|
105
|
+
Type.Object({ targetId: Type.Optional(Type.String()) }),
|
|
106
|
+
Type.Object({ targetInfo: TargetInfo }),
|
|
107
|
+
),
|
|
108
|
+
"Target.getTargets": cmd(
|
|
109
|
+
Type.Object({}),
|
|
110
|
+
Type.Object({ targetInfos: Type.Array(TargetInfo) }),
|
|
111
|
+
),
|
|
112
|
+
"Target.setDiscoverTargets": cmd(Type.Object({ discover: Type.Boolean() }), Empty),
|
|
113
|
+
|
|
114
|
+
"Browser.getWindowForTarget": cmd(
|
|
115
|
+
Type.Object({ targetId: Type.Optional(Type.String()) }),
|
|
116
|
+
Type.Object({ windowId: Type.Number(), bounds: Type.Optional(Type.Unknown()) }),
|
|
117
|
+
),
|
|
118
|
+
"Browser.setDownloadBehavior": cmd(
|
|
119
|
+
Type.Object({
|
|
120
|
+
behavior: Type.String(),
|
|
121
|
+
downloadPath: Type.Optional(Type.String()),
|
|
122
|
+
eventsEnabled: Type.Optional(Type.Boolean()),
|
|
123
|
+
}),
|
|
124
|
+
Empty,
|
|
125
|
+
),
|
|
126
|
+
|
|
127
|
+
"Page.enable": cmd(Type.Object({}), Empty),
|
|
128
|
+
"Page.bringToFront": cmd(Type.Object({}), Empty),
|
|
129
|
+
"Page.navigate": cmd(
|
|
130
|
+
Type.Object({ url: Type.String(), referrer: Type.Optional(Type.String()) }),
|
|
131
|
+
Type.Object({
|
|
132
|
+
frameId: Type.String(),
|
|
133
|
+
loaderId: Type.Optional(Type.String()),
|
|
134
|
+
errorText: Type.Optional(Type.String()),
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
"Page.reload": cmd(Type.Object({ ignoreCache: Type.Optional(Type.Boolean()) }), Empty),
|
|
138
|
+
"Page.getNavigationHistory": cmd(
|
|
139
|
+
Type.Object({}),
|
|
140
|
+
Type.Object({
|
|
141
|
+
currentIndex: Type.Number(),
|
|
142
|
+
entries: Type.Array(
|
|
143
|
+
Type.Object({ id: Type.Number(), url: Type.String(), title: Type.String() }),
|
|
144
|
+
),
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
"Page.navigateToHistoryEntry": cmd(Type.Object({ entryId: Type.Number() }), Empty),
|
|
148
|
+
"Page.captureScreenshot": cmd(
|
|
149
|
+
Type.Object({
|
|
150
|
+
format: Type.Optional(Type.String()),
|
|
151
|
+
quality: Type.Optional(Type.Number()),
|
|
152
|
+
clip: Type.Optional(Type.Unknown()),
|
|
153
|
+
captureBeyondViewport: Type.Optional(Type.Boolean()),
|
|
154
|
+
}),
|
|
155
|
+
Type.Object({ data: Type.String() }),
|
|
156
|
+
),
|
|
157
|
+
"Page.printToPDF": cmd(Type.Object({}, { additionalProperties: true }), Type.Object({ data: Type.String() })),
|
|
158
|
+
"Page.handleJavaScriptDialog": cmd(
|
|
159
|
+
Type.Object({ accept: Type.Boolean(), promptText: Type.Optional(Type.String()) }),
|
|
160
|
+
Empty,
|
|
161
|
+
),
|
|
162
|
+
|
|
163
|
+
"Runtime.evaluate": cmd(
|
|
164
|
+
Type.Object(
|
|
165
|
+
{
|
|
166
|
+
expression: Type.String(),
|
|
167
|
+
returnByValue: Type.Optional(Type.Boolean()),
|
|
168
|
+
awaitPromise: Type.Optional(Type.Boolean()),
|
|
169
|
+
userGesture: Type.Optional(Type.Boolean()),
|
|
170
|
+
},
|
|
171
|
+
{ additionalProperties: true },
|
|
172
|
+
),
|
|
173
|
+
Type.Object({ result: RemoteObject, exceptionDetails: Type.Optional(ExceptionDetails) }),
|
|
174
|
+
),
|
|
175
|
+
"Runtime.callFunctionOn": cmd(
|
|
176
|
+
Type.Object(
|
|
177
|
+
{
|
|
178
|
+
functionDeclaration: Type.String(),
|
|
179
|
+
objectId: Type.Optional(Type.String()),
|
|
180
|
+
arguments: Type.Optional(Type.Array(CallArgument)),
|
|
181
|
+
returnByValue: Type.Optional(Type.Boolean()),
|
|
182
|
+
awaitPromise: Type.Optional(Type.Boolean()),
|
|
183
|
+
userGesture: Type.Optional(Type.Boolean()),
|
|
184
|
+
},
|
|
185
|
+
{ additionalProperties: true },
|
|
186
|
+
),
|
|
187
|
+
Type.Object({ result: RemoteObject, exceptionDetails: Type.Optional(ExceptionDetails) }),
|
|
188
|
+
),
|
|
189
|
+
"Runtime.releaseObject": cmd(Type.Object({ objectId: Type.String() }), Empty),
|
|
190
|
+
|
|
191
|
+
"DOM.getDocument": cmd(
|
|
192
|
+
Type.Object({ depth: Type.Optional(Type.Number()), pierce: Type.Optional(Type.Boolean()) }),
|
|
193
|
+
Type.Object({ root: Type.Object({ nodeId: Type.Number() }, { additionalProperties: true }) }),
|
|
194
|
+
),
|
|
195
|
+
"DOM.querySelector": cmd(
|
|
196
|
+
Type.Object({ nodeId: Type.Number(), selector: Type.String() }),
|
|
197
|
+
Type.Object({ nodeId: Type.Number() }),
|
|
198
|
+
),
|
|
199
|
+
"DOM.getBoxModel": cmd(
|
|
200
|
+
Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
|
|
201
|
+
Type.Object({
|
|
202
|
+
model: Type.Object(
|
|
203
|
+
{ content: Type.Array(Type.Number()), width: Type.Number(), height: Type.Number() },
|
|
204
|
+
{ additionalProperties: true },
|
|
205
|
+
),
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
"DOM.resolveNode": cmd(
|
|
209
|
+
Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
|
|
210
|
+
Type.Object({ object: RemoteObject }),
|
|
211
|
+
),
|
|
212
|
+
"DOM.setFileInputFiles": cmd(
|
|
213
|
+
Type.Object({
|
|
214
|
+
files: Type.Array(Type.String()),
|
|
215
|
+
nodeId: Type.Optional(Type.Number()),
|
|
216
|
+
backendNodeId: Type.Optional(Type.Number()),
|
|
217
|
+
objectId: Type.Optional(Type.String()),
|
|
218
|
+
}),
|
|
219
|
+
Empty,
|
|
220
|
+
),
|
|
221
|
+
|
|
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),
|
|
225
|
+
"Input.insertText": cmd(Type.Object({ text: Type.String() }), Empty),
|
|
226
|
+
|
|
227
|
+
"Network.getResponseBody": cmd(
|
|
228
|
+
Type.Object({ requestId: Type.String() }),
|
|
229
|
+
Type.Object({ body: Type.String(), base64Encoded: Type.Optional(Type.Boolean()) }),
|
|
230
|
+
),
|
|
231
|
+
"Network.setExtraHTTPHeaders": cmd(
|
|
232
|
+
Type.Object({ headers: Type.Record(Type.String(), Type.String()) }),
|
|
233
|
+
Empty,
|
|
234
|
+
),
|
|
235
|
+
"Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty),
|
|
236
|
+
"Accessibility.getFullAXTree": cmd(
|
|
237
|
+
Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }),
|
|
238
|
+
Type.Object({ nodes: Type.Array(AxNode) }),
|
|
239
|
+
),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export type CdpMethod = keyof typeof COMMANDS;
|
|
243
|
+
export type ParamsOf<M extends CdpMethod> = Static<(typeof COMMANDS)[M]["params"]>;
|
|
244
|
+
export type ResultOf<M extends CdpMethod> = Static<(typeof COMMANDS)[M]["result"]>;
|
|
245
|
+
|
|
246
|
+
export const decodeResult = <M extends CdpMethod>(
|
|
247
|
+
method: M,
|
|
248
|
+
raw: unknown,
|
|
249
|
+
): Result<ResultOf<M>, CdpError> => {
|
|
250
|
+
const spec = COMMANDS[method];
|
|
251
|
+
if (spec === undefined) {
|
|
252
|
+
return err(cdpError("invalid_response", `unknown CDP method`, method));
|
|
253
|
+
}
|
|
254
|
+
if (!spec.validate.Check(raw)) {
|
|
255
|
+
const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
|
|
256
|
+
return err(cdpError("invalid_response", why, method));
|
|
257
|
+
}
|
|
258
|
+
// Guarded assertion: the check above proves `raw` matches COMMANDS[method].result, which TS cannot correlate from a generic key.
|
|
259
|
+
return ok(raw as ResultOf<M>);
|
|
260
|
+
};
|