pi-browser-harness 0.2.0 → 0.3.2
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 +84 -0
- package/package.json +4 -1
- package/skills/pi-browser-harness/SKILL.md +64 -6
- package/src/cdp/discovery.ts +95 -0
- package/src/cdp/errors.ts +19 -0
- package/src/cdp/session.ts +139 -0
- package/src/cdp/transport.ts +221 -0
- package/src/cdp/types.ts +72 -0
- package/src/client.ts +238 -0
- package/src/domains/click.ts +73 -0
- package/src/domains/dialog.ts +31 -0
- package/src/domains/drag.ts +66 -0
- package/src/domains/files.ts +194 -0
- package/src/domains/history.ts +76 -0
- package/src/domains/js.ts +197 -0
- package/src/domains/keyboard.ts +127 -0
- package/src/domains/navigate.ts +146 -0
- package/src/domains/network.ts +80 -0
- package/src/domains/page.ts +88 -0
- package/src/domains/screenshot.ts +150 -0
- package/src/domains/scroll.ts +60 -0
- package/src/domains/tabs.ts +121 -0
- package/src/domains/viewport.ts +40 -0
- package/src/index.ts +65 -142
- package/src/prompt.ts +27 -4
- package/src/registry.ts +55 -0
- package/src/schemas/common.ts +11 -0
- package/src/setup.ts +43 -26
- package/src/state.ts +22 -54
- package/src/util/js-template.ts +27 -0
- package/src/util/keycodes.ts +10 -0
- package/src/util/mutex.ts +25 -0
- package/src/util/paths.ts +21 -0
- package/src/util/result.ts +15 -0
- package/src/util/sharp-shim.ts +49 -0
- package/src/util/time.ts +29 -0
- package/src/util/tool.ts +193 -0
- package/src/util/truncate.ts +35 -0
- package/tsconfig.json +10 -1
- package/src/daemon.ts +0 -881
- package/src/protocol.ts +0 -77
- package/src/renderers.ts +0 -21
- package/src/tools.ts +0 -1363
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,90 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.3.2 — 2026-05-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Parallel tool execution with automatic mutation serialization.** Observation tools (`browser_screenshot`, `browser_page_info`, `browser_execute_js`, `browser_list_tabs`, `browser_http_get` etc.) can now run in parallel with each other and with mutation tools. Mutation tools (`browser_click`, `browser_type`, `browser_scroll`, `browser_navigate`, `browser_switch_tab`, etc.) are automatically serialized through a shared async mutex so they never race on shared CDP session/page state. LLMs can emit independent operations in the same turn for better latency.
|
|
10
|
+
- **New `src/util/mutex.ts`** — lightweight async mutex (~25 LOC) with FIFO queue. `serialized?: boolean` flag added to `BrowserToolDefinition`; `mutationMutex()` exposed on `BrowserClient`.
|
|
11
|
+
- **Prompt and SKILL.md** updated with parallel-execution guidance and safe-parallel-call examples.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Scroll tool deltaY sign convention corrected.** Previously `deltaY` was documented as positive=up (inverted vs W3C wheel events). Now follows the W3C convention: positive=down, negative=up. Default changed from `-300` to `300` (scroll down). Prompt snippets, tool descriptions, and guidelines all updated.
|
|
16
|
+
- **Scroll tool now calls `Page.bringToFront`** before dispatching mouse events, preventing silent-drop when the target page is not the active browser tab. Mouse events now include explicit `button:"none"`, `buttons:0`, `pointerType:"mouse"`.
|
|
17
|
+
- **Screenshot TUI render no longer crashes the host on long file paths.** The `Image` text-fallback render did not respect terminal width, so a long path could overflow and crash the host TUI. Each rendered line is now truncated with `truncateToWidth` to fit the available width.
|
|
18
|
+
|
|
19
|
+
## 0.3.1 — 2026-05-02
|
|
20
|
+
|
|
21
|
+
### Bug fixes
|
|
22
|
+
|
|
23
|
+
- **`browser_list_tabs`** now shows full 32-character targetIds instead of truncated `BE9DD1DC…` prefixes. The `browser_list_tabs` → `browser_switch_tab` round-trip is repaired.
|
|
24
|
+
- **`browser_switch_tab`** now supports prefix matching: pass a unique hex prefix (≥8 chars) and it resolves to the full targetId automatically. Ambiguous prefixes return a clear error listing all matching tabs.
|
|
25
|
+
- **`browser_download`** auto-creates the download directory with `mkdir -p` if it doesn't exist. Previously it required a pre-existing writable directory.
|
|
26
|
+
- **`ensureAlive()`** now probes the page session with `Runtime.evaluate("1")` after the transport health check. If the page target has crashed (e.g. localhost server died), it reattaches automatically instead of returning a cryptic `session_not_found` error on the next tool call.
|
|
27
|
+
|
|
28
|
+
### Docs & metadata
|
|
29
|
+
|
|
30
|
+
- **`browser_dispatch_key`** prompt guidelines now explicitly note it dispatches a synthetic DOM `KeyboardEvent` and does NOT type text. Point users to `browser_type` / `browser_press_key` for actual text input.
|
|
31
|
+
- **`browser_navigate`** prompt guidelines now warn that Google and strict-anti-bot sites may reject CDP navigation. `browser_http_get` is the recommended workaround.
|
|
32
|
+
- **`browser_get_network_log`** prompt snippet updated with explicit workaround (`browser_execute_js` with `PerformanceObserver`).
|
|
33
|
+
- **`sharp`** added to `optionalDependencies` so `npm install` attempts it (enables `browser_screenshot` `maxDim` auto-resize).
|
|
34
|
+
- **`SKILL.md`** script bindings section updated to document the actual daemon API: `daemon.evaluateJs()`, `daemon.pageInfo()`, `daemon.listTabs()`, `daemon.session().call()`. Example script updated to match.
|
|
35
|
+
- **`SKILL.md`** troubleshooting section now covers `sharp`/`maxDim` and Google anti-bot navigation.
|
|
36
|
+
|
|
37
|
+
## 0.3.0 — 2026-05-02
|
|
38
|
+
|
|
39
|
+
### Internal rewrite
|
|
40
|
+
|
|
41
|
+
- Per-domain module split: every tool now lives in its own `src/domains/<name>.ts` file. The 1140-line `daemon.ts` and 2277-line `tools.ts` are gone; the three largest files are now `src/cdp/transport.ts` (~220 LOC), `src/client.ts` (~220 LOC), and `src/domains/js.ts` (~200 LOC).
|
|
42
|
+
- New transport/session/client split: `BrowserDaemon` class replaced with `createBrowserClient()` factory composing a `CdpTransport` (factory) and `CdpSession` (factory).
|
|
43
|
+
- All tool handlers now return `Result<T, E>`; one `defineBrowserTool` helper converts to pi's `ToolDefinition` and supplies a uniform `details` shape: `{ ok: true, ... }` on success, `{ ok: false, kind, message, ... }` on error.
|
|
44
|
+
- Strict TypeScript flags enabled (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noPropertyAccessFromIndexSignature`, `noUnusedLocals`, `noUnusedParameters`). Zero `any` in the codebase. All boundary `as` casts documented.
|
|
45
|
+
|
|
46
|
+
### Bug fixes (predictability)
|
|
47
|
+
|
|
48
|
+
- `browser_navigate` no longer silently creates a new tab when navigation fails; outcome is reported explicitly via `details.outcome.kind` (`"in_place"` | `"new_tab_created"`).
|
|
49
|
+
- Page-info cache invalidation is now automatic: the session subscribes to CDP `Page.frameNavigated` / `Page.loadEventFired` events. The 5 manual `invalidatePageInfoCache()` calls are gone.
|
|
50
|
+
- Dialog read no longer mutates state on `Page.javascriptDialogClosed` — the dialog persists in the buffer until `takeDialog()` is called, fixing a fast-dismiss race that dropped dialogs.
|
|
51
|
+
- `browser_dispatch_key` now returns `details.matched` (count of elements the synthetic event was dispatched to). Zero matches is an `invalid_state` error instead of a false success.
|
|
52
|
+
- `browser_http_get` timeout now covers the response body read (legacy aborted headers but `await response.text()` could hang indefinitely).
|
|
53
|
+
- `sharp` failures are distinguished from "sharp not installed" — actual errors no longer masked by the install hint.
|
|
54
|
+
- Screenshot paths use `randomUUID()` per-namespace — no more concurrent-write collisions from `Date.now() + global counter`.
|
|
55
|
+
- Reconnect is lazy (via `ensureAlive`) — no background reconnect, no stacked Chrome consent popups.
|
|
56
|
+
- WebSocket events are routed through an `AsyncIterable` bound to each connection — stale events from a previous connection can no longer leak across reconnects.
|
|
57
|
+
- `browser_wait_for_load` returns a typed `timeout` error if the deadline elapses (legacy returned a soft string).
|
|
58
|
+
|
|
59
|
+
### Security fixes
|
|
60
|
+
|
|
61
|
+
- All JS evaluation source is built via `safeJs\`...\`` (always JSON.stringify-safe). The previous `replace(/'/g, "\\'")` selector escaping (broken for backslashes, newlines, unicode quotes, `</script>`) is gone.
|
|
62
|
+
- `browser_run_script` now requires:
|
|
63
|
+
- script path inside `tmpdir()`, `cwd()`, or `BH_SCRIPT_DIR` (other paths rejected with `invalid_state`)
|
|
64
|
+
- a mandatory timeout (default 60s, max 600s, enforced via `Promise.race`)
|
|
65
|
+
- the AbortSignal is honored even if the script ignores its `signal` parameter
|
|
66
|
+
- source size ≤ 1 MB
|
|
67
|
+
- return shape validated structurally (each content item must be `{ type: "text", text: string }`)
|
|
68
|
+
- `browser_download` validates the directory exists and is writable before calling CDP (Chrome was silently downloading to nowhere if the dir was bogus).
|
|
69
|
+
- `browser_upload_file` verifies the file exists and is readable before any CDP call (prevents half-set state on the input).
|
|
70
|
+
- `pdfPath()` / `screenshotPath()` validate the namespace against a strict regex so a hostile namespace cannot escape `tmpdir()`.
|
|
71
|
+
|
|
72
|
+
### Parameter renames (saved scripts must be updated)
|
|
73
|
+
|
|
74
|
+
- `browser_click`: `clicks` → `count`
|
|
75
|
+
- `browser_dispatch_key`: `event` → `eventType`
|
|
76
|
+
|
|
77
|
+
### Removed
|
|
78
|
+
|
|
79
|
+
- The unused `tabHistory`, `screenshotDir`, and `debugClicks` fields on persisted state.
|
|
80
|
+
- The dead `tool_result` hook in `index.ts` for tab-history tracking (`details.targetId` was never set by any tool).
|
|
81
|
+
- `src/protocol.ts`, `src/renderers.ts`, `src/daemon.ts`, `src/tools.ts` (replaced by per-domain files and `client.ts`).
|
|
82
|
+
|
|
83
|
+
### Known follow-ups
|
|
84
|
+
|
|
85
|
+
- `browser_get_network_log` returns a structured deprecation note. The new transport routes events through an `AsyncIterable` consumed by the session manager; a synchronous `recentEvents()` API is deferred. Use `browser_execute_js` with `PerformanceObserver` or `performance.getEntries()` as a workaround.
|
|
86
|
+
- `browser_run_script` script binding is named `daemon` for back-compat, but the underlying object is now a `BrowserClient`. Scripts using `daemon.cdp(method, params)` should switch to `daemon.session().call(method, params)`.
|
|
87
|
+
- No tests added in this rewrite; that's a separate workstream.
|
|
88
|
+
|
|
5
89
|
## [0.2.0] - 2026-05-02
|
|
6
90
|
|
|
7
91
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-browser-harness",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|
|
@@ -62,6 +62,9 @@
|
|
|
62
62
|
"publishConfig": {
|
|
63
63
|
"access": "public"
|
|
64
64
|
},
|
|
65
|
+
"optionalDependencies": {
|
|
66
|
+
"sharp": "^0.33.0"
|
|
67
|
+
},
|
|
65
68
|
"dependencies": {
|
|
66
69
|
"ws": "^8.20.0"
|
|
67
70
|
}
|
|
@@ -32,6 +32,50 @@ browser_screenshot() → browser_click(x, y) → browser_screenshot()
|
|
|
32
32
|
Compositor-level clicks (`Input.dispatchMouseEvent`) work through iframes, shadow DOM,
|
|
33
33
|
and cross-origin content. No selectors needed.
|
|
34
34
|
|
|
35
|
+
## Parallelization
|
|
36
|
+
|
|
37
|
+
**Always parallelize when possible.** The harness automatically serializes mutation tools so they never race on shared state. Emit independent tools in the same turn for better performance.
|
|
38
|
+
|
|
39
|
+
### What can run in parallel
|
|
40
|
+
|
|
41
|
+
- **Observation tools** (`browser_screenshot`, `browser_page_info`, `browser_execute_js`, `browser_list_tabs`, `browser_current_tab`, `browser_http_get`, `browser_print_to_pdf`) can run in parallel with each other and with mutation tools.
|
|
42
|
+
|
|
43
|
+
- **Opening result pages:** Use `browser_open_urls` to open multiple URLs in parallel tabs. After opening, use `browser_list_tabs` to see the new tabs and `browser_switch_tab` to visit each one.
|
|
44
|
+
|
|
45
|
+
- **API calls:** `browser_http_get` calls are independent of browser state — fire multiple GETs in parallel.
|
|
46
|
+
|
|
47
|
+
- **Read + browser tools:** Use `read` and `browser_*` tools in the same turn — reading files or previous results is independent of browser actions.
|
|
48
|
+
|
|
49
|
+
### What must be sequential (harness enforces this)
|
|
50
|
+
|
|
51
|
+
- **Mutation tools** (`click`, `type`, `scroll`, `navigate`, `switch_tab`, `press_key`, `drag_and_drop`, `upload_file`, `go_back`, `go_forward`, `reload`, `handle_dialog`, `viewport_resize`, `wait_for_load`, `download`) are automatically queued through a mutex. Even if you emit them in parallel, they execute serially in FIFO order. You do not need to worry about race conditions.
|
|
52
|
+
|
|
53
|
+
- **Tab switching then acting:** `browser_switch_tab` must complete before acting on the switched-to tab. This is enforced automatically.
|
|
54
|
+
|
|
55
|
+
### Pattern: Parallel research across tabs
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
# Step 1: Open all result pages at once
|
|
59
|
+
browser_open_urls(["url1", "url2", "url3"])
|
|
60
|
+
|
|
61
|
+
# Step 2: Wait for them to load, then extract in parallel
|
|
62
|
+
browser_list_tabs() # get targetIds
|
|
63
|
+
|
|
64
|
+
# Step 3: Extract data from each tab (parallel)
|
|
65
|
+
browser_switch_tab(targetId1) → browser_screenshot() # These are sequential per tab
|
|
66
|
+
browser_switch_tab(targetId2) → browser_execute_js(...) # but you batch switch+act
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Pattern: Parallel observation + mutation
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
# Fire observation tools while a mutation is in progress
|
|
73
|
+
browser_click(x, y) # mutation (queued)
|
|
74
|
+
browser_screenshot() # observation (runs immediately in parallel)
|
|
75
|
+
browser_page_info() # observation (runs immediately in parallel)
|
|
76
|
+
browser_http_get("https://api.example.com") # external (runs immediately in parallel)
|
|
77
|
+
```
|
|
78
|
+
|
|
35
79
|
## Available Tools
|
|
36
80
|
|
|
37
81
|
### Navigation
|
|
@@ -49,7 +93,7 @@ and cross-origin content. No selectors needed.
|
|
|
49
93
|
- `browser_scroll` — scroll the page at coordinates
|
|
50
94
|
|
|
51
95
|
### Visual
|
|
52
|
-
- `browser_screenshot` — capture PNG screenshot
|
|
96
|
+
- `browser_screenshot` — capture PNG or JPEG screenshot (rendered inline in the TUI)
|
|
53
97
|
|
|
54
98
|
### Data Extraction
|
|
55
99
|
- `browser_execute_js` — execute JavaScript and return result
|
|
@@ -86,8 +130,9 @@ browser_http_get("https://api.example.com/data")
|
|
|
86
130
|
|
|
87
131
|
### Scrolling
|
|
88
132
|
```
|
|
89
|
-
browser_screenshot() → browser_scroll({ deltaY:
|
|
133
|
+
browser_screenshot() → browser_scroll({ deltaY: 500 }) → browser_screenshot()
|
|
90
134
|
```
|
|
135
|
+
Note: deltaY follows W3C wheel-event convention: positive=down, negative=up. Default deltaY=300 scrolls down.
|
|
91
136
|
|
|
92
137
|
### Research (search engine + browser_open_urls)
|
|
93
138
|
```
|
|
@@ -130,7 +175,18 @@ The script is written to disk, so it's auditable and re-runnable.
|
|
|
130
175
|
|
|
131
176
|
**Script bindings:**
|
|
132
177
|
- `params` — the arguments passed to browser_run_script
|
|
133
|
-
- `daemon` — browser daemon
|
|
178
|
+
- `daemon` — browser daemon with these methods:
|
|
179
|
+
- `daemon.evaluateJs(expression)` — run JS in the current page
|
|
180
|
+
- `daemon.pageInfo()` — get current page info or dialog
|
|
181
|
+
- `daemon.listTabs()` — returns `{ success, data: [{ targetId, url, title }] }`
|
|
182
|
+
- `daemon.switchTab(targetId)` — switch to a tab by full targetId
|
|
183
|
+
- `daemon.newTab(url?)` — open and switch to a new tab
|
|
184
|
+
- `daemon.current()` — returns `{ targetId, url?, title? }`
|
|
185
|
+
- `daemon.session(targetId)` — returns a session object for raw CDP:
|
|
186
|
+
- `session.call(method, params?)` — CDP command on the page target
|
|
187
|
+
- `session.callOnTarget(method, params, sessionId)` — CDP on a specific session
|
|
188
|
+
- `session.callBrowser(method, params?)` — CDP on the browser target
|
|
189
|
+
- `session.takeDialog()` — get/dismiss pending dialog
|
|
134
190
|
- `require` — Node.js require() (use 'fs', 'path', 'crypto', etc.)
|
|
135
191
|
- `signal` — AbortSignal for cancellation
|
|
136
192
|
- `onUpdate` — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] })
|
|
@@ -140,9 +196,9 @@ The script is written to disk, so it's auditable and re-runnable.
|
|
|
140
196
|
**Example — a script that extracts structured data:**
|
|
141
197
|
```javascript
|
|
142
198
|
// /tmp/extract-products.js
|
|
143
|
-
const info = await daemon.
|
|
144
|
-
if ("dialog" in info) throw new Error("Dialog is blocking: " + info.dialog.message);
|
|
145
|
-
const data = await daemon.
|
|
199
|
+
const info = await daemon.pageInfo();
|
|
200
|
+
if (info && "dialog" in info) throw new Error("Dialog is blocking: " + info.dialog.message);
|
|
201
|
+
const data = await daemon.evaluateJs(`
|
|
146
202
|
JSON.stringify(Array.from(document.querySelectorAll('${params.rowSelector}')).map(el => ({
|
|
147
203
|
title: el.querySelector('${params.titleSelector}')?.textContent?.trim(),
|
|
148
204
|
price: el.querySelector('${params.priceSelector}')?.textContent?.trim(),
|
|
@@ -196,6 +252,8 @@ browser_execute_js("!!document.querySelector('.loaded-content')")
|
|
|
196
252
|
- **Stale session**: Run `/browser-reload-daemon` to restart the daemon
|
|
197
253
|
- **Dialog blocking**: Run browser_page_info to detect it, then browser_handle_dialog
|
|
198
254
|
- **Status check**: Run `/browser-status` to see daemon health and current page
|
|
255
|
+
- **Screenshot maxDim not working**: Install sharp (`npm install sharp`) for auto-resize support
|
|
256
|
+
- **Google navigation fails**: Google's anti-bot detection may reject CDP navigation; use `browser_http_get` to fetch search results instead
|
|
199
257
|
|
|
200
258
|
## Architecture
|
|
201
259
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { connect as netConnect } from "node:net";
|
|
5
|
+
import { type Result, err, ok } from "../util/result";
|
|
6
|
+
import { type CdpError, cdpError } from "./errors";
|
|
7
|
+
|
|
8
|
+
const PORT_PROBE_DEADLINE_MS = 30_000;
|
|
9
|
+
const PORT_PROBE_INTERVAL_MS = 1_000;
|
|
10
|
+
|
|
11
|
+
const profileDirs = (): ReadonlyArray<string> => {
|
|
12
|
+
const home = homedir();
|
|
13
|
+
return [
|
|
14
|
+
join(home, "Library/Application Support/Google/Chrome"),
|
|
15
|
+
join(home, "Library/Application Support/Microsoft Edge"),
|
|
16
|
+
join(home, "Library/Application Support/Microsoft Edge Beta"),
|
|
17
|
+
join(home, "Library/Application Support/Microsoft Edge Dev"),
|
|
18
|
+
join(home, "Library/Application Support/Microsoft Edge Canary"),
|
|
19
|
+
join(home, ".config/google-chrome"),
|
|
20
|
+
join(home, ".config/chromium"),
|
|
21
|
+
join(home, ".config/chromium-browser"),
|
|
22
|
+
join(home, ".config/microsoft-edge"),
|
|
23
|
+
join(home, ".config/microsoft-edge-beta"),
|
|
24
|
+
join(home, ".config/microsoft-edge-dev"),
|
|
25
|
+
join(home, ".var/app/org.chromium.Chromium/config/chromium"),
|
|
26
|
+
join(home, ".var/app/com.google.Chrome/config/google-chrome"),
|
|
27
|
+
join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
|
|
28
|
+
join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
|
|
29
|
+
join(home, "AppData/Local/Google/Chrome/User Data"),
|
|
30
|
+
join(home, "AppData/Local/Chromium/User Data"),
|
|
31
|
+
join(home, "AppData/Local/Microsoft/Edge/User Data"),
|
|
32
|
+
join(home, "AppData/Local/Microsoft/Edge Beta/User Data"),
|
|
33
|
+
join(home, "AppData/Local/Microsoft/Edge Dev/User Data"),
|
|
34
|
+
join(home, "AppData/Local/Microsoft/Edge SxS/User Data"),
|
|
35
|
+
];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const probePort = (port: number): Promise<Result<void, CdpError>> =>
|
|
39
|
+
new Promise((resolve) => {
|
|
40
|
+
const sock = netConnect({ host: "127.0.0.1", port });
|
|
41
|
+
let settled = false;
|
|
42
|
+
const finish = (r: Result<void, CdpError>): void => {
|
|
43
|
+
if (settled) return;
|
|
44
|
+
settled = true;
|
|
45
|
+
sock.setTimeout(0);
|
|
46
|
+
sock.destroy();
|
|
47
|
+
resolve(r);
|
|
48
|
+
};
|
|
49
|
+
sock.setTimeout(1000, () => finish(err(cdpError("discovery_failed", "probe timeout"))));
|
|
50
|
+
sock.once("error", (e) => finish(err(cdpError("discovery_failed", e.message))));
|
|
51
|
+
sock.once("connect", () => finish(ok(undefined)));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
|
|
55
|
+
const end = Date.now() + PORT_PROBE_DEADLINE_MS;
|
|
56
|
+
let lastMessage = "unknown";
|
|
57
|
+
while (Date.now() < end) {
|
|
58
|
+
const probe = await probePort(port);
|
|
59
|
+
if (probe.success) return probe;
|
|
60
|
+
lastMessage = probe.error.message;
|
|
61
|
+
await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS));
|
|
62
|
+
}
|
|
63
|
+
return err(cdpError(
|
|
64
|
+
"discovery_failed",
|
|
65
|
+
`Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastMessage})`,
|
|
66
|
+
));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
70
|
+
const dirs = profileDirs();
|
|
71
|
+
for (const base of dirs) {
|
|
72
|
+
const portFile = join(base, "DevToolsActivePort");
|
|
73
|
+
let raw: string;
|
|
74
|
+
try {
|
|
75
|
+
raw = await readFile(portFile, "utf8");
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// Node fs errors carry .code; see ErrnoException
|
|
78
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
79
|
+
if (code === "ENOENT" || code === undefined) continue;
|
|
80
|
+
return err(cdpError("discovery_failed", `failed to read ${portFile}: ${e instanceof Error ? e.message : String(e)}`));
|
|
81
|
+
}
|
|
82
|
+
const lines = raw.trim().split("\n");
|
|
83
|
+
if (lines.length < 2) continue;
|
|
84
|
+
const port = lines[0]?.trim();
|
|
85
|
+
const path = lines[1]?.trim();
|
|
86
|
+
if (!port || !path) continue;
|
|
87
|
+
const ready = await waitForPort(Number(port));
|
|
88
|
+
if (!ready.success) return ready;
|
|
89
|
+
return ok(`ws://127.0.0.1:${port}${path}`);
|
|
90
|
+
}
|
|
91
|
+
return err(cdpError(
|
|
92
|
+
"discovery_failed",
|
|
93
|
+
`DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.`,
|
|
94
|
+
));
|
|
95
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type CdpErrorKind =
|
|
2
|
+
| "transport_closed"
|
|
3
|
+
| "timeout"
|
|
4
|
+
| "session_not_found"
|
|
5
|
+
| "remote_error"
|
|
6
|
+
| "discovery_failed"
|
|
7
|
+
| "invalid_response";
|
|
8
|
+
|
|
9
|
+
export type CdpError = {
|
|
10
|
+
readonly kind: CdpErrorKind;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly method?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const cdpError = (
|
|
16
|
+
kind: CdpErrorKind,
|
|
17
|
+
message: string,
|
|
18
|
+
method?: string,
|
|
19
|
+
): CdpError => ({ kind, message, ...(method !== undefined ? { method } : {}) });
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { type Result, err, ok } from "../util/result";
|
|
2
|
+
import { type CdpError, cdpError } from "./errors";
|
|
3
|
+
import type { DialogInfo } from "./types";
|
|
4
|
+
import { isInternalUrl } from "./types";
|
|
5
|
+
import type { CdpTransport } from "./transport";
|
|
6
|
+
|
|
7
|
+
export type CdpSession = {
|
|
8
|
+
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
|
|
9
|
+
switchTo(targetId: string): Promise<Result<void, CdpError>>;
|
|
10
|
+
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
11
|
+
call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
12
|
+
callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
13
|
+
callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
14
|
+
takeDialog(): DialogInfo | null;
|
|
15
|
+
drainPageInfoInvalidations(): boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
19
|
+
let sessionId: string | null = null;
|
|
20
|
+
let targetId: string | null = null;
|
|
21
|
+
let dialog: DialogInfo | null = null;
|
|
22
|
+
let pageInfoDirty = false;
|
|
23
|
+
|
|
24
|
+
let activeConsumer: Promise<void> = Promise.resolve();
|
|
25
|
+
|
|
26
|
+
const consumeEvents = async (): Promise<void> => {
|
|
27
|
+
for await (const ev of transport.events()) {
|
|
28
|
+
if (ev.method === "Page.javascriptDialogOpening") {
|
|
29
|
+
const params = ev.params as Partial<DialogInfo> | undefined;
|
|
30
|
+
dialog = {
|
|
31
|
+
type: (params?.type as DialogInfo["type"]) ?? "alert",
|
|
32
|
+
message: params?.message ?? "",
|
|
33
|
+
...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
|
|
34
|
+
};
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Page.javascriptDialogClosed is intentionally NOT cleared here —
|
|
38
|
+
// the dialog stays in the buffer until takeDialog() is called.
|
|
39
|
+
// This prevents fast dismiss flows from dropping a dialog the agent
|
|
40
|
+
// was about to read. (Fix for spec §7 predictability bug #2.)
|
|
41
|
+
if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
|
|
42
|
+
pageInfoDirty = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const restartConsumer = (): void => {
|
|
48
|
+
activeConsumer = activeConsumer.then(() => consumeEvents()).catch((e: unknown) => {
|
|
49
|
+
// The .then() chain calls consumeEvents() which iterates the transport's
|
|
50
|
+
// events() AsyncIterable. The iterable resolves cleanly on close (returns
|
|
51
|
+
// {done:true}); any rejection here is an unexpected bug in the event
|
|
52
|
+
// handler, not a normal termination. Surface it on stderr so it's not lost.
|
|
53
|
+
console.warn("[pi-browser-harness] CDP event consumer crashed:", e);
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
restartConsumer();
|
|
58
|
+
transport.onClose(() => {
|
|
59
|
+
sessionId = null;
|
|
60
|
+
targetId = null;
|
|
61
|
+
pageInfoDirty = false;
|
|
62
|
+
// Do NOT clear `dialog` here — same rationale as inside consumeEvents:
|
|
63
|
+
// the agent may have a pending takeDialog() call that should still see it.
|
|
64
|
+
restartConsumer();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// TODO(perf): the four enable calls are sequential here for predictability.
|
|
68
|
+
// Switching to Promise.all over a single WS pipelines the round-trips and
|
|
69
|
+
// saves ~3× on tab-switch latency. Defer until session.ts has tests.
|
|
70
|
+
const enableDomains = async (sid: string): Promise<void> => {
|
|
71
|
+
for (const d of ["Page", "DOM", "Runtime", "Network"]) {
|
|
72
|
+
await transport.request(`${d}.enable`, {}, { sessionId: sid });
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// CDP response shapes are documented in chromedevtools.github.io but not
|
|
77
|
+
// available as TypeScript types. We cast `as` from `unknown` only in this
|
|
78
|
+
// file (the CDP boundary). Each cast is paired with the CDP method that
|
|
79
|
+
// produced the response. Adding runtime guards for every shape would be
|
|
80
|
+
// noise — Chrome's protocol is stable enough that a wrong cast surfaces
|
|
81
|
+
// as a clear Error in normal use.
|
|
82
|
+
return {
|
|
83
|
+
async attachFirstPage() {
|
|
84
|
+
const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
|
|
85
|
+
if (!targets.success) return targets;
|
|
86
|
+
const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
|
|
87
|
+
let pages = data.targetInfos.filter((t) => t.type === "page" && !isInternalUrl(t.url));
|
|
88
|
+
if (pages.length === 0) {
|
|
89
|
+
const created = await transport.request("Target.createTarget", { url: "about:blank" }, { sessionId: null });
|
|
90
|
+
if (!created.success) return created;
|
|
91
|
+
const c = created.data as { targetId: string };
|
|
92
|
+
pages = [{ targetId: c.targetId, type: "page", url: "about:blank" }];
|
|
93
|
+
}
|
|
94
|
+
const first = pages[0];
|
|
95
|
+
if (!first) return err(cdpError("invalid_response", "no page targets after creation"));
|
|
96
|
+
const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null });
|
|
97
|
+
if (!attached.success) return attached;
|
|
98
|
+
const a = attached.data as { sessionId: string };
|
|
99
|
+
sessionId = a.sessionId;
|
|
100
|
+
targetId = first.targetId;
|
|
101
|
+
await enableDomains(a.sessionId);
|
|
102
|
+
return ok({ targetId: first.targetId, sessionId: a.sessionId });
|
|
103
|
+
},
|
|
104
|
+
async switchTo(tid) {
|
|
105
|
+
const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
|
|
106
|
+
if (!activated.success) return activated;
|
|
107
|
+
const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
|
|
108
|
+
if (!attached.success) return attached;
|
|
109
|
+
const a = attached.data as { sessionId: string };
|
|
110
|
+
sessionId = a.sessionId;
|
|
111
|
+
targetId = tid;
|
|
112
|
+
pageInfoDirty = true;
|
|
113
|
+
await enableDomains(a.sessionId);
|
|
114
|
+
return ok(undefined);
|
|
115
|
+
},
|
|
116
|
+
current() {
|
|
117
|
+
return sessionId && targetId ? { sessionId, targetId } : null;
|
|
118
|
+
},
|
|
119
|
+
call(method, params = {}, opts = {}) {
|
|
120
|
+
return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
121
|
+
},
|
|
122
|
+
callOnTarget(method, params, sid, opts = {}) {
|
|
123
|
+
return transport.request(method, params, { sessionId: sid, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
124
|
+
},
|
|
125
|
+
callBrowser(method, params = {}, opts = {}) {
|
|
126
|
+
return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
127
|
+
},
|
|
128
|
+
takeDialog() {
|
|
129
|
+
const d = dialog;
|
|
130
|
+
dialog = null;
|
|
131
|
+
return d;
|
|
132
|
+
},
|
|
133
|
+
drainPageInfoInvalidations() {
|
|
134
|
+
const dirty = pageInfoDirty;
|
|
135
|
+
pageInfoDirty = false;
|
|
136
|
+
return dirty;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
};
|