pi-browser-harness 0.3.1 → 0.4.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 +43 -0
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +32 -205
- package/src/cdp/network-buffer.ts +213 -0
- package/src/cdp/ownership.ts +72 -0
- package/src/cdp/session.ts +60 -14
- package/src/cdp/types.ts +1 -0
- package/src/client.ts +81 -4
- package/src/domains/click.ts +6 -5
- package/src/domains/js.ts +65 -6
- package/src/domains/navigate.ts +1 -1
- package/src/domains/network.ts +191 -20
- package/src/domains/screenshot.ts +44 -5
- package/src/domains/scroll.ts +16 -9
- package/src/domains/snapshot.ts +421 -0
- package/src/domains/tabs.ts +113 -39
- package/src/index.ts +17 -1
- package/src/prompt.ts +13 -3
- package/src/registry.ts +28 -22
- package/src/setup.ts +23 -5
- package/src/state.ts +6 -1
- package/src/util/mutex.ts +25 -0
- package/src/util/tool.ts +41 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.4.0 — 2026-05-06
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`browser_snapshot`** — new tool. Returns the structured CDP accessibility tree (roles, names, states, hierarchy) for the current page. For every interactive element (button, link, textbox, checkbox, etc.) the outline includes click coordinates as `@(x,y)`, fetched via `DOM.getBoxModel` per node in parallel under a 1.5s aggregate budget. Pass these straight to `browser_click` — no `browser_screenshot` round-trip needed. `format:"json"` returns the slim structure with `box: {x,y,width,height,cx,cy}` per node. Optional `includeScreenshot:true` attaches a JPEG (q=80) when visual confirmation is also wanted.
|
|
10
|
+
- **`browser_network_requests`** — new tool, replacing the deprecated `browser_get_network_log` placeholder. Lists requests captured on the current tab since attach with filters: `urlPattern` (substring; wrap in slashes for regex), `methodFilter`, `statusFilter`, `resourceTypes`, `sinceMs`, `limit` (default 50, cap 500). `includeResponseBodies:true` fetches `Network.getResponseBody` per matched record under a 5s aggregate budget with a 50 KB per-body cap. Buffer is page-scoped (cleared on tab switch) and bounded at 500 records; `bufferOverflowed` flag in the result reports drops since the last drain.
|
|
11
|
+
- **Tab ownership / harness-window isolation.** New `OwnershipRegistry` tracks which page targets this session opened. The harness now creates a dedicated Chrome window on first attach (`newWindow:true`) instead of grabbing the user's foreground tab; subsequent `browser_new_tab` calls open inside that window via `openerId`. `browser_list_tabs` defaults to `scope:"owned"`; pass `scope:"all"` to see the user's other tabs read-only. `browser_switch_tab` and the new **`browser_close_tab`** refuse non-owned tabs with a clear remediation hint. Ownership is persisted across session reloads via `BrowserState`. The session also subscribes `Target.setDiscoverTargets` and reaps `targetDestroyed` events so the registry stays in sync.
|
|
12
|
+
- **`Accessibility` CDP domain enabled** alongside Page/DOM/Runtime/Network on every attach.
|
|
13
|
+
- **`Ctrl+O` (`app.tools.expand`) expand/collapse on tool output.** Three tools now ship custom `renderResult`:
|
|
14
|
+
- `browser_snapshot` — collapsed: 4-line summary (node count, URL, landmarks/buttons/inputs, screenshot status). Expanded: full indented outline + inline screenshot when `includeScreenshot:true`.
|
|
15
|
+
- `browser_network_requests` — collapsed: header + first 5 rows. Expanded: full markdown table + per-request body sections when bodies were requested.
|
|
16
|
+
- `browser_execute_js` — collapsed: size + first 120-char preview. Expanded: pretty-printed JSON if value parses, otherwise raw value, in a code fence.
|
|
17
|
+
All three append a `keyHint("app.tools.expand", ...)` so the binding label adapts to user remaps.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Tool prompts pivoted from screenshot-first to snapshot-first.** `browser_screenshot`'s description now explicitly says "NOT a default exploration tool". `browser_snapshot` is documented as the default for understanding pages; `browser_execute_js` as the default for surgical reads. `browser_click` guidance no longer instructs the agent to screenshot for coordinates — it points at `browser_snapshot`'s `@(x,y)` hints. `browser_open_urls` post-step flipped from screenshot to snapshot.
|
|
22
|
+
- **`SKILL.md` rewritten** (~342 lines → ~53 lines). Frontmatter description carries the tool-hierarchy hint (always in context per pi's progressive-disclosure model). Body keeps only what isn't already in tool prompts: the decision tree, the connection rules (real Chrome, no creds, dialog-first), and the `browser_run_script` daemon bindings (the only tool whose API can't be inferred from its prompt). Pattern reference, parallelization details, troubleshooting, and tool enumeration removed as duplication.
|
|
23
|
+
- **CDP `Network.*` events** are now consumed by an in-process aggregator (`src/cdp/network-buffer.ts`) wired into the existing single-consumer event loop in `src/cdp/session.ts`. Pure module, ring-buffered, non-destructive drain.
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
- **`browser_get_network_log`** — was a deprecated placeholder in v0.3 that returned a "use PerformanceObserver" message because the CDP event stream had no public drain API. Replaced by `browser_network_requests`.
|
|
28
|
+
|
|
29
|
+
### Known follow-ups
|
|
30
|
+
|
|
31
|
+
- Live network streaming (`browser_network_monitor` real-time during a click) — deferred; reuses the same buffer plumbing once the post-hoc form is proven.
|
|
32
|
+
- No tests added in this release. Verification is manual against real Chrome.
|
|
33
|
+
|
|
34
|
+
## 0.3.2 — 2026-05-05
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **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.
|
|
39
|
+
- **New `src/util/mutex.ts`** — lightweight async mutex (~25 LOC) with FIFO queue. `serialized?: boolean` flag added to `BrowserToolDefinition`; `mutationMutex()` exposed on `BrowserClient`.
|
|
40
|
+
- **Prompt and SKILL.md** updated with parallel-execution guidance and safe-parallel-call examples.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **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.
|
|
45
|
+
- **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"`.
|
|
46
|
+
- **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.
|
|
47
|
+
|
|
5
48
|
## 0.3.1 — 2026-05-02
|
|
6
49
|
|
|
7
50
|
### Bug fixes
|
package/package.json
CHANGED
|
@@ -1,226 +1,53 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pi-browser-harness
|
|
3
|
-
description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user's already-running Chrome.
|
|
3
|
+
description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user's already-running Chrome. Default to browser_snapshot for understanding pages and browser_execute_js for surgical reads — browser_screenshot is for visual verification only.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pi-browser-harness
|
|
7
7
|
|
|
8
|
-
Direct browser control
|
|
9
|
-
Browser tools coexist with all standard pi tools (read, bash, edit, write, etc.).
|
|
8
|
+
Direct browser control of the user's running Chrome via CDP.
|
|
10
9
|
|
|
11
|
-
##
|
|
10
|
+
## Tool hierarchy
|
|
12
11
|
|
|
13
|
-
Run `/browser-setup` to install browser-harness and connect to Chrome. Chrome must be running
|
|
14
|
-
with remote debugging enabled. Start Chrome with:
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
--remote-debugging-port=9222
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Check status with `/browser-status`. Restart the daemon with `/browser-reload-daemon`.
|
|
21
|
-
|
|
22
|
-
## Core Workflow
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
browser_screenshot() → browser_click(x, y) → browser_screenshot()
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
1. **Screenshot first** — see what's on screen before any interaction
|
|
29
|
-
2. **Coordinate click** — read the target pixel from the screenshot, click at (x, y)
|
|
30
|
-
3. **Screenshot again** — verify the action worked
|
|
31
|
-
|
|
32
|
-
Compositor-level clicks (`Input.dispatchMouseEvent`) work through iframes, shadow DOM,
|
|
33
|
-
and cross-origin content. No selectors needed.
|
|
34
|
-
|
|
35
|
-
## Available Tools
|
|
36
|
-
|
|
37
|
-
### Navigation
|
|
38
|
-
- `browser_navigate` — navigate to a URL (creates new tab on first call, reuses current tab after)
|
|
39
|
-
- `browser_new_tab` — open a new tab, optionally navigate (use for first navigation)
|
|
40
|
-
- `browser_open_urls` — open multiple URLs in parallel tabs (use after search to open result links)
|
|
41
|
-
- `browser_go_back` / `browser_go_forward` / `browser_reload` — history navigation
|
|
42
|
-
- `browser_page_info` — get URL, title, viewport, scroll position, or dialog info
|
|
43
|
-
- `browser_list_tabs` / `browser_current_tab` / `browser_switch_tab` — tab management
|
|
44
|
-
|
|
45
|
-
### Interaction
|
|
46
|
-
- `browser_click` — click at viewport CSS-pixel coordinates
|
|
47
|
-
- `browser_type` — type text into focused element
|
|
48
|
-
- `browser_press_key` — press a key (Enter, Tab, Escape, arrows, etc.)
|
|
49
|
-
- `browser_scroll` — scroll the page at coordinates
|
|
50
|
-
|
|
51
|
-
### Visual
|
|
52
|
-
- `browser_screenshot` — capture PNG screenshot
|
|
53
|
-
|
|
54
|
-
### Data Extraction
|
|
55
|
-
- `browser_execute_js` — execute JavaScript and return result
|
|
56
|
-
- `browser_http_get` — direct HTTP GET outside browser (10-50x faster for APIs)
|
|
57
|
-
|
|
58
|
-
### Utility
|
|
59
|
-
- `browser_wait` — wait N seconds
|
|
60
|
-
- `browser_wait_for_load` — wait for document.readyState === 'complete'
|
|
61
|
-
- `browser_handle_dialog` — accept or dismiss JS dialogs (alert/confirm/prompt)
|
|
62
|
-
|
|
63
|
-
### Extending
|
|
64
|
-
- `browser_run_script` — execute a temporary script file with daemon access
|
|
65
|
-
|
|
66
|
-
## Pattern Reference
|
|
67
|
-
|
|
68
|
-
### Navigation
|
|
69
|
-
```
|
|
70
|
-
browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot()
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
### Form Filling
|
|
74
|
-
```
|
|
75
|
-
browser_screenshot() → find input coordinates
|
|
76
|
-
→ browser_click(x, y) → browser_type("text")
|
|
77
|
-
→ browser_press_key("Tab") → browser_screenshot()
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
### Data Extraction
|
|
81
|
-
```
|
|
82
|
-
browser_execute_js("document.querySelector('.price').innerText")
|
|
83
|
-
// or for APIs directly:
|
|
84
|
-
browser_http_get("https://api.example.com/data")
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
### Scrolling
|
|
88
|
-
```
|
|
89
|
-
browser_screenshot() → browser_scroll({ deltaY: -500 }) → browser_screenshot()
|
|
90
12
|
```
|
|
13
|
+
What do you need to know?
|
|
91
14
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
browser_new_tab("https://google.com/search?q=query") → search engine results
|
|
95
|
-
browser_open_urls({urls: ["url1", "url2"]}) → open result pages in parallel tabs
|
|
96
|
-
browser_list_tabs() → see all open tabs with targetIds
|
|
97
|
-
browser_switch_tab({targetId: "..."}) → switch to a specific tab
|
|
98
|
-
browser_wait_for_load() → wait for the page to render
|
|
99
|
-
browser_screenshot() → visually inspect the page
|
|
100
|
-
browser_execute_js("document.querySelector('.content').innerText") → extract text
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
## Temporary Scripts
|
|
104
|
-
|
|
105
|
-
When the built-in tools aren't enough, write a temporary script to disk and execute it
|
|
106
|
-
with `browser_run_script`. Scripts run in the harness process with direct access to
|
|
107
|
-
the browser daemon and full Node.js APIs — no dynamic tool registration needed.
|
|
108
|
-
|
|
109
|
-
```
|
|
110
|
-
● agent: needs to scrape 47 paginated search result pages
|
|
15
|
+
├─ Page structure / what's clickable / labels?
|
|
16
|
+
│ → browser_snapshot (DEFAULT — AX tree with @(x,y) per interactive element)
|
|
111
17
|
│
|
|
112
|
-
|
|
18
|
+
├─ A specific element's value / attribute / coords?
|
|
19
|
+
│ → browser_execute_js (e.g. el.innerText, el.getBoundingClientRect())
|
|
113
20
|
│
|
|
114
|
-
|
|
21
|
+
├─ Network behavior on the current page?
|
|
22
|
+
│ → browser_network_requests
|
|
115
23
|
│
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
The script is written to disk, so it's auditable and re-runnable.
|
|
120
|
-
|
|
121
|
-
**When to use scripts:**
|
|
122
|
-
- You're about to do the same 3+ step sequence more than once
|
|
123
|
-
- You need a capability the built-in tools don't provide (e.g., bulk CSV export, structured extraction, pagination)
|
|
124
|
-
- You discover a domain-specific pattern that should be reusable
|
|
125
|
-
- The alternative is a fragile multi-turn loop of screenshots + clicks
|
|
126
|
-
|
|
127
|
-
**What NOT to do:**
|
|
128
|
-
- Don't write scripts for one-off actions — use the built-in browser_* tools directly.
|
|
129
|
-
- Don't try to call browser_* tools from within a script — sequence them as separate tool calls.
|
|
130
|
-
|
|
131
|
-
**Script bindings:**
|
|
132
|
-
- `params` — the arguments passed to browser_run_script
|
|
133
|
-
- `daemon` — browser daemon with these methods:
|
|
134
|
-
- `daemon.evaluateJs(expression)` — run JS in the current page
|
|
135
|
-
- `daemon.pageInfo()` — get current page info or dialog
|
|
136
|
-
- `daemon.listTabs()` — returns `{ success, data: [{ targetId, url, title }] }`
|
|
137
|
-
- `daemon.switchTab(targetId)` — switch to a tab by full targetId
|
|
138
|
-
- `daemon.newTab(url?)` — open and switch to a new tab
|
|
139
|
-
- `daemon.current()` — returns `{ targetId, url?, title? }`
|
|
140
|
-
- `daemon.session(targetId)` — returns a session object for raw CDP:
|
|
141
|
-
- `session.call(method, params?)` — CDP command on the page target
|
|
142
|
-
- `session.callOnTarget(method, params, sessionId)` — CDP on a specific session
|
|
143
|
-
- `session.callBrowser(method, params?)` — CDP on the browser target
|
|
144
|
-
- `session.takeDialog()` — get/dismiss pending dialog
|
|
145
|
-
- `require` — Node.js require() (use 'fs', 'path', 'crypto', etc.)
|
|
146
|
-
- `signal` — AbortSignal for cancellation
|
|
147
|
-
- `onUpdate` — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] })
|
|
148
|
-
- `ctx` — ExtensionContext (ctx.cwd, ctx.signal, etc.)
|
|
149
|
-
- `console`, `fetch`, `JSON`, `Buffer`, `setTimeout`, `clearTimeout`
|
|
150
|
-
|
|
151
|
-
**Example — a script that extracts structured data:**
|
|
152
|
-
```javascript
|
|
153
|
-
// /tmp/extract-products.js
|
|
154
|
-
const info = await daemon.pageInfo();
|
|
155
|
-
if (info && "dialog" in info) throw new Error("Dialog is blocking: " + info.dialog.message);
|
|
156
|
-
const data = await daemon.evaluateJs(`
|
|
157
|
-
JSON.stringify(Array.from(document.querySelectorAll('${params.rowSelector}')).map(el => ({
|
|
158
|
-
title: el.querySelector('${params.titleSelector}')?.textContent?.trim(),
|
|
159
|
-
price: el.querySelector('${params.priceSelector}')?.textContent?.trim(),
|
|
160
|
-
})))
|
|
161
|
-
`);
|
|
162
|
-
return { content: [{ type: 'text', text: data }], details: { raw: JSON.parse(data) } };
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
## What NOT To Do
|
|
166
|
-
|
|
167
|
-
- **Don't launch your own browser** — you're connected to the user's real Chrome
|
|
168
|
-
- **Don't type credentials** — if you hit an auth wall, stop and ask the user
|
|
169
|
-
- **Don't assume page state** — screenshot to confirm
|
|
170
|
-
- **Don't write pixel coordinates into documentation** — describe how to locate targets (selectors, text, aria-label)
|
|
171
|
-
- **Don't use browser_navigate for first navigation** — use browser_new_tab to explicitly preserve the user's active tab (browser_navigate auto-creates a new tab on first call, but explicit is clearer)
|
|
172
|
-
- **Don't ignore dialogs** — if browser_page_info returns a dialog, handle it first
|
|
173
|
-
|
|
174
|
-
## Dialogs
|
|
175
|
-
|
|
176
|
-
JS dialogs (alert, confirm, prompt, beforeunload) freeze the page's JS thread.
|
|
177
|
-
If browser_page_info returns a dialog, handle it with browser_handle_dialog
|
|
178
|
-
before any other browser action.
|
|
179
|
-
|
|
180
|
-
```
|
|
181
|
-
// Check page_info first — if it shows a dialog:
|
|
182
|
-
browser_handle_dialog({ action: "accept" }) // accept/confirm
|
|
183
|
-
browser_handle_dialog({ action: "dismiss" }) // dismiss/cancel
|
|
184
|
-
browser_handle_dialog({ action: "accept", promptText: "hello" }) // prompt with text
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
## SPAs (React, Vue, etc.)
|
|
188
|
-
|
|
189
|
-
`browser_wait_for_load()` only checks `document.readyState === "complete"`.
|
|
190
|
-
Single-page apps often paint content after this. After wait_for_load, use
|
|
191
|
-
`browser_execute_js` to check for specific elements:
|
|
192
|
-
|
|
193
|
-
```
|
|
194
|
-
browser_execute_js("!!document.querySelector('.loaded-content')")
|
|
24
|
+
└─ Visual rendering (layout / colors / chart drew correctly)?
|
|
25
|
+
→ browser_screenshot (LAST RESORT — pixels only)
|
|
195
26
|
```
|
|
196
27
|
|
|
197
|
-
|
|
28
|
+
Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot round-trip.
|
|
198
29
|
|
|
199
|
-
|
|
200
|
-
- 1 = Alt, 2 = Ctrl, 4 = Meta/Cmd, 8 = Shift
|
|
201
|
-
- Combine: Ctrl+Shift = 2|8 = 10, Cmd+Shift = 4|8 = 12
|
|
30
|
+
## Connection
|
|
202
31
|
|
|
203
|
-
|
|
32
|
+
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`.
|
|
204
33
|
|
|
205
|
-
|
|
206
|
-
- **"DevToolsActivePort not found"**: Open chrome://inspect/#remote-debugging, tick checkbox, click Allow
|
|
207
|
-
- **Stale session**: Run `/browser-reload-daemon` to restart the daemon
|
|
208
|
-
- **Dialog blocking**: Run browser_page_info to detect it, then browser_handle_dialog
|
|
209
|
-
- **Status check**: Run `/browser-status` to see daemon health and current page
|
|
210
|
-
- **Screenshot maxDim not working**: Install sharp (`npm install sharp`) for auto-resize support
|
|
211
|
-
- **Google navigation fails**: Google's anti-bot detection may reject CDP navigation; use `browser_http_get` to fetch search results instead
|
|
34
|
+
## Temporary scripts
|
|
212
35
|
|
|
213
|
-
|
|
36
|
+
When a workflow repeats 3+ times or needs Node.js APIs, write a script to disk and run it with `browser_run_script`. Scripts get a `daemon` binding for direct CDP access — much faster than chaining tool calls.
|
|
214
37
|
|
|
215
|
-
|
|
216
|
-
pi agent → pi-browser-harness (TypeScript)
|
|
217
|
-
│ CDP WebSocket
|
|
218
|
-
▼
|
|
219
|
-
Chrome
|
|
38
|
+
**Bindings inside a script:**
|
|
220
39
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
40
|
+
- `params` — args passed to `browser_run_script`
|
|
41
|
+
- `daemon`:
|
|
42
|
+
- `daemon.evaluateJs(expression)` — run JS in the current page
|
|
43
|
+
- `daemon.pageInfo()` — `{ url, title, ... }` or `{ dialog }`
|
|
44
|
+
- `daemon.listTabs()` / `daemon.switchTab(targetId)` / `daemon.newTab(url?)` / `daemon.current()`
|
|
45
|
+
- `daemon.session(targetId)` for raw CDP: `session.call`, `session.callOnTarget`, `session.callBrowser`, `session.takeDialog`
|
|
46
|
+
- `require`, `fetch`, `JSON`, `Buffer`, `console`, `setTimeout`, `clearTimeout`
|
|
47
|
+
- `signal` — AbortSignal
|
|
48
|
+
- `onUpdate({ content: [{ type: 'text', text }] })` — progress callback
|
|
49
|
+
- `ctx` — `ExtensionContext`
|
|
50
|
+
|
|
51
|
+
**Don't:**
|
|
52
|
+
- Use scripts for one-off actions — call `browser_*` tools directly.
|
|
53
|
+
- Call `browser_*` tools from inside a script — sequence them as separate tool calls outside.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory aggregator for CDP Network.* events.
|
|
3
|
+
*
|
|
4
|
+
* The CDP event stream is consumed by a single internal loop in session.ts.
|
|
5
|
+
* That loop hands Network.* event params to this module, which joins them into
|
|
6
|
+
* one record per requestId (request → response → finished/failed). Tools then
|
|
7
|
+
* read records via drain(), filtered to what the caller asked for.
|
|
8
|
+
*
|
|
9
|
+
* Bounded by a fixed capacity (default 500): on overflow the oldest record is
|
|
10
|
+
* evicted FIFO. The overflow flag is reported once per drain so tools can warn
|
|
11
|
+
* the LLM that data may be missing.
|
|
12
|
+
*
|
|
13
|
+
* This module is pure (no CDP dependency) so the joining/filtering logic stays
|
|
14
|
+
* trivially testable in isolation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type NetworkRecord = {
|
|
18
|
+
requestId: string;
|
|
19
|
+
method: string;
|
|
20
|
+
url: string;
|
|
21
|
+
status?: number;
|
|
22
|
+
statusText?: string;
|
|
23
|
+
type: string;
|
|
24
|
+
mimeType?: string;
|
|
25
|
+
/** Wall-clock ms when requestWillBeSent fired, in Date.now() units. */
|
|
26
|
+
requestStartedMs: number;
|
|
27
|
+
durationMs?: number;
|
|
28
|
+
requestBodySize: number;
|
|
29
|
+
responseBodySize?: number;
|
|
30
|
+
failed?: boolean;
|
|
31
|
+
errorText?: string;
|
|
32
|
+
/** Populated by the tool layer via Network.getResponseBody, never by the aggregator. */
|
|
33
|
+
body?: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type NetworkFilter = {
|
|
37
|
+
/** Substring match by default. Wrap in slashes (`/foo/`) for regex. */
|
|
38
|
+
urlPattern?: string;
|
|
39
|
+
methodFilter?: string[];
|
|
40
|
+
statusFilter?: { min?: number; max?: number };
|
|
41
|
+
resourceTypes?: string[];
|
|
42
|
+
/** Only requests whose start is at most N ms ago. */
|
|
43
|
+
sinceMs?: number;
|
|
44
|
+
/** Cap on the returned records. */
|
|
45
|
+
limit?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type DrainResult = {
|
|
49
|
+
readonly records: NetworkRecord[];
|
|
50
|
+
/** Total matches before `limit` was applied. */
|
|
51
|
+
readonly total: number;
|
|
52
|
+
/** True if the ring buffer evicted at least one record since the last drain. */
|
|
53
|
+
readonly bufferOverflowed: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type NetworkBuffer = {
|
|
57
|
+
ingestRequestWillBeSent(p: unknown): void;
|
|
58
|
+
ingestResponseReceived(p: unknown): void;
|
|
59
|
+
ingestLoadingFinished(p: unknown): void;
|
|
60
|
+
ingestLoadingFailed(p: unknown): void;
|
|
61
|
+
drain(filter: NetworkFilter): DrainResult;
|
|
62
|
+
clear(): void;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
|
|
66
|
+
if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
|
|
67
|
+
try {
|
|
68
|
+
const re = new RegExp(pattern.slice(1, -1));
|
|
69
|
+
return (url) => re.test(url);
|
|
70
|
+
} catch {
|
|
71
|
+
// Fall through to substring match if the pattern is not a valid regex.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return (url) => url.includes(pattern);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
|
|
78
|
+
// Insertion-ordered map keyed by requestId. Map preserves insertion order in JS,
|
|
79
|
+
// so iteration gives us oldest-first eviction without a separate index.
|
|
80
|
+
const records = new Map<string, NetworkRecord>();
|
|
81
|
+
let overflowed = false;
|
|
82
|
+
|
|
83
|
+
const evictOldestIfFull = (): void => {
|
|
84
|
+
while (records.size >= capacity) {
|
|
85
|
+
const oldest = records.keys().next();
|
|
86
|
+
if (oldest.done) return;
|
|
87
|
+
records.delete(oldest.value);
|
|
88
|
+
overflowed = true;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
ingestRequestWillBeSent(p) {
|
|
94
|
+
// CDP boundary cast: Network.requestWillBeSent
|
|
95
|
+
const params = p as
|
|
96
|
+
| {
|
|
97
|
+
requestId?: string;
|
|
98
|
+
request?: {
|
|
99
|
+
url?: string;
|
|
100
|
+
method?: string;
|
|
101
|
+
postData?: string;
|
|
102
|
+
headers?: Record<string, string>;
|
|
103
|
+
};
|
|
104
|
+
type?: string;
|
|
105
|
+
timestamp?: number;
|
|
106
|
+
wallTime?: number;
|
|
107
|
+
}
|
|
108
|
+
| undefined;
|
|
109
|
+
const id = params?.requestId;
|
|
110
|
+
const url = params?.request?.url;
|
|
111
|
+
const method = params?.request?.method;
|
|
112
|
+
if (!id || !url || !method) return;
|
|
113
|
+
// Re-fired requestWillBeSent for the same id (e.g. redirect) — refresh in place.
|
|
114
|
+
records.delete(id);
|
|
115
|
+
evictOldestIfFull();
|
|
116
|
+
const postData = params.request?.postData;
|
|
117
|
+
records.set(id, {
|
|
118
|
+
requestId: id,
|
|
119
|
+
method,
|
|
120
|
+
url,
|
|
121
|
+
type: params.type ?? "Other",
|
|
122
|
+
requestStartedMs: Date.now(),
|
|
123
|
+
requestBodySize: postData ? Buffer.byteLength(postData, "utf8") : 0,
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
ingestResponseReceived(p) {
|
|
128
|
+
// CDP boundary cast: Network.responseReceived
|
|
129
|
+
const params = p as
|
|
130
|
+
| {
|
|
131
|
+
requestId?: string;
|
|
132
|
+
response?: {
|
|
133
|
+
status?: number;
|
|
134
|
+
statusText?: string;
|
|
135
|
+
mimeType?: string;
|
|
136
|
+
};
|
|
137
|
+
type?: string;
|
|
138
|
+
}
|
|
139
|
+
| undefined;
|
|
140
|
+
const id = params?.requestId;
|
|
141
|
+
if (!id) return;
|
|
142
|
+
const r = records.get(id);
|
|
143
|
+
if (!r) return;
|
|
144
|
+
if (params?.response?.status !== undefined) r.status = params.response.status;
|
|
145
|
+
if (params?.response?.statusText !== undefined && params.response.statusText !== "") r.statusText = params.response.statusText;
|
|
146
|
+
if (params?.response?.mimeType !== undefined) r.mimeType = params.response.mimeType;
|
|
147
|
+
// type is more accurate on responseReceived (CDP sometimes refines it)
|
|
148
|
+
if (params?.type !== undefined) r.type = params.type;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
ingestLoadingFinished(p) {
|
|
152
|
+
// CDP boundary cast: Network.loadingFinished
|
|
153
|
+
const params = p as
|
|
154
|
+
| { requestId?: string; encodedDataLength?: number }
|
|
155
|
+
| undefined;
|
|
156
|
+
const id = params?.requestId;
|
|
157
|
+
if (!id) return;
|
|
158
|
+
const r = records.get(id);
|
|
159
|
+
if (!r) return;
|
|
160
|
+
if (params?.encodedDataLength !== undefined) r.responseBodySize = params.encodedDataLength;
|
|
161
|
+
r.durationMs = Date.now() - r.requestStartedMs;
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
ingestLoadingFailed(p) {
|
|
165
|
+
// CDP boundary cast: Network.loadingFailed
|
|
166
|
+
const params = p as
|
|
167
|
+
| { requestId?: string; errorText?: string; canceled?: boolean }
|
|
168
|
+
| undefined;
|
|
169
|
+
const id = params?.requestId;
|
|
170
|
+
if (!id) return;
|
|
171
|
+
const r = records.get(id);
|
|
172
|
+
if (!r) return;
|
|
173
|
+
r.failed = true;
|
|
174
|
+
if (params?.errorText !== undefined) r.errorText = params.errorText;
|
|
175
|
+
r.durationMs = Date.now() - r.requestStartedMs;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
drain(filter) {
|
|
179
|
+
const matchUrl = filter.urlPattern !== undefined ? compileUrlMatcher(filter.urlPattern) : undefined;
|
|
180
|
+
const methods = filter.methodFilter ? new Set(filter.methodFilter.map((m) => m.toUpperCase())) : undefined;
|
|
181
|
+
const types = filter.resourceTypes ? new Set(filter.resourceTypes.map((t) => t.toLowerCase())) : undefined;
|
|
182
|
+
const minStatus = filter.statusFilter?.min;
|
|
183
|
+
const maxStatus = filter.statusFilter?.max;
|
|
184
|
+
const cutoff = filter.sinceMs !== undefined ? Date.now() - filter.sinceMs : undefined;
|
|
185
|
+
|
|
186
|
+
const matched: NetworkRecord[] = [];
|
|
187
|
+
for (const r of records.values()) {
|
|
188
|
+
if (matchUrl && !matchUrl(r.url)) continue;
|
|
189
|
+
if (methods && !methods.has(r.method.toUpperCase())) continue;
|
|
190
|
+
if (types && !types.has(r.type.toLowerCase())) continue;
|
|
191
|
+
if (cutoff !== undefined && r.requestStartedMs < cutoff) continue;
|
|
192
|
+
if (minStatus !== undefined && (r.status === undefined || r.status < minStatus)) continue;
|
|
193
|
+
if (maxStatus !== undefined && (r.status === undefined || r.status > maxStatus)) continue;
|
|
194
|
+
// Clone so callers can mutate (e.g. attach body) without polluting the buffer.
|
|
195
|
+
matched.push({ ...r });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const total = matched.length;
|
|
199
|
+
const limit = Math.min(filter.limit ?? 50, 500);
|
|
200
|
+
const limited = matched.slice(-limit); // most recent N
|
|
201
|
+
|
|
202
|
+
const bufferOverflowed = overflowed;
|
|
203
|
+
overflowed = false;
|
|
204
|
+
|
|
205
|
+
return { records: limited, total, bufferOverflowed };
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
clear() {
|
|
209
|
+
records.clear();
|
|
210
|
+
overflowed = false;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks which tab targetIds belong to this harness session.
|
|
3
|
+
*
|
|
4
|
+
* The harness attaches to the user's running Chrome, which is also full of
|
|
5
|
+
* the user's own tabs. Without an ownership boundary the agent could attach
|
|
6
|
+
* to (or close) personal tabs by accident. The registry is the single source
|
|
7
|
+
* of truth for "did this session open that tab?".
|
|
8
|
+
*
|
|
9
|
+
* Mutations notify a single change listener so the orchestrator can persist
|
|
10
|
+
* to disk via the existing BrowserState flow without the registry needing to
|
|
11
|
+
* know about the pi storage API.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type OwnershipRegistry = {
|
|
15
|
+
add(targetId: string): void;
|
|
16
|
+
remove(targetId: string): void;
|
|
17
|
+
has(targetId: string): boolean;
|
|
18
|
+
list(): ReadonlyArray<string>;
|
|
19
|
+
/** Replace the full set — used on restoreState to re-hydrate from disk. */
|
|
20
|
+
replaceAll(ids: ReadonlyArray<string>): void;
|
|
21
|
+
setHarnessWindow(targetId: string | undefined): void;
|
|
22
|
+
harnessWindow(): string | undefined;
|
|
23
|
+
onChange(cb: () => void): void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const createOwnershipRegistry = (
|
|
27
|
+
initial?: { readonly ownedTargetIds?: ReadonlyArray<string>; readonly harnessWindowTargetId?: string },
|
|
28
|
+
): OwnershipRegistry => {
|
|
29
|
+
const owned = new Set<string>(initial?.ownedTargetIds ?? []);
|
|
30
|
+
let harnessWindow: string | undefined = initial?.harnessWindowTargetId;
|
|
31
|
+
let listener: (() => void) | null = null;
|
|
32
|
+
|
|
33
|
+
const notify = (): void => {
|
|
34
|
+
if (listener) listener();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
add(targetId) {
|
|
39
|
+
if (owned.has(targetId)) return;
|
|
40
|
+
owned.add(targetId);
|
|
41
|
+
notify();
|
|
42
|
+
},
|
|
43
|
+
remove(targetId) {
|
|
44
|
+
if (!owned.has(targetId) && harnessWindow !== targetId) return;
|
|
45
|
+
owned.delete(targetId);
|
|
46
|
+
if (harnessWindow === targetId) harnessWindow = undefined;
|
|
47
|
+
notify();
|
|
48
|
+
},
|
|
49
|
+
has(targetId) {
|
|
50
|
+
return owned.has(targetId);
|
|
51
|
+
},
|
|
52
|
+
list() {
|
|
53
|
+
return [...owned];
|
|
54
|
+
},
|
|
55
|
+
replaceAll(ids) {
|
|
56
|
+
owned.clear();
|
|
57
|
+
for (const id of ids) owned.add(id);
|
|
58
|
+
notify();
|
|
59
|
+
},
|
|
60
|
+
setHarnessWindow(targetId) {
|
|
61
|
+
if (harnessWindow === targetId) return;
|
|
62
|
+
harnessWindow = targetId;
|
|
63
|
+
notify();
|
|
64
|
+
},
|
|
65
|
+
harnessWindow() {
|
|
66
|
+
return harnessWindow;
|
|
67
|
+
},
|
|
68
|
+
onChange(cb) {
|
|
69
|
+
listener = cb;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
};
|