pi-browser-harness 0.4.0 → 0.5.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 +16 -0
- package/README.md +150 -56
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +7 -0
- package/src/cdp/console-buffer.ts +263 -0
- package/src/cdp/session.ts +124 -23
- package/src/client.ts +13 -8
- package/src/domains/console.ts +173 -0
- package/src/prompt.ts +3 -0
- package/src/registry.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.5.0 — 2026-05-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`browser_console`** — new tool. Reads JS errors and console messages from the active tab via two CDP sources merged into one buffer: `Runtime.consoleAPICalled` (page `console.*` calls and uncaught exceptions) and `Log.entryAdded` (browser-level entries — CSP violations, mixed content, deprecations, network errors). Filters: `levels` (log/info/warn/error/debug), `textPattern` (substring; wrap in slashes for regex), `sinceMs`, `limit` (default 50, cap 500). Each record carries a monotonic `seq`; the response includes `nextCursor` so callers can pass `sinceSeq` to see only what's new since the previous drain — the cursor pattern that makes "what did this action cause?" answerable in one call. Buffer is page-scoped (cleared on tab switch) and bounded at 500 records; `bufferOverflowed` flag reports drops since the last drain. Stack traces (top 3 frames) are preserved for error/warn records. Per-arg cap of 2 KB prevents a single `console.log(hugeBlob)` from blowing the buffer.
|
|
10
|
+
- **`Log` CDP domain enabled** alongside Page/DOM/Runtime/Network/Accessibility on every attach. (`Runtime` was already enabled, so `Runtime.consoleAPICalled` was reachable; this adds the missing domain for browser-level entries.)
|
|
11
|
+
- **`browser_console` ships with a custom `renderResult`** following the established pattern — collapsed: header counts (`3 errors · 2 warnings · 47 logs`) + last 5 rows. Expanded: full list with stack traces inline as code fences. Appends `keyHint("app.tools.expand", ...)` so the binding label adapts to user remaps.
|
|
12
|
+
|
|
13
|
+
### Internal
|
|
14
|
+
|
|
15
|
+
- New pure module `src/cdp/console-buffer.ts` mirrors the `network-buffer.ts` pattern (insertion-ordered Map, FIFO eviction at capacity 500, overflow flag reset per drain). Wired into the existing single-consumer event loop in `src/cdp/session.ts`.
|
|
16
|
+
|
|
17
|
+
### Known follow-ups
|
|
18
|
+
|
|
19
|
+
- No tests added in this release. Verification is manual against real Chrome.
|
|
20
|
+
|
|
5
21
|
## 0.4.0 — 2026-05-06
|
|
6
22
|
|
|
7
23
|
### Added
|
package/README.md
CHANGED
|
@@ -2,7 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
Full browser control for pi agents
|
|
5
|
+
Full browser control for pi agents in **your real Chrome** — your sessions, your cookies, your tabs. Drives navigation, structured page reads, network capture, clicks, typing, screenshots, and arbitrary scripts via CDP.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why pi-browser-harness?
|
|
10
|
+
|
|
11
|
+
| Capability | pi-browser-harness | Playwright MCP | Stagehand | Puppeteer MCP |
|
|
12
|
+
|---|:---:|:---:|:---:|:---:|
|
|
13
|
+
| **Drives your real Chrome** (logged-in sessions preserved) | ✅ | ❌ launches its own browser | ❌ | ❌ |
|
|
14
|
+
| **Coordinate clicks** that work through iframes, shadow DOM, cross-origin | ✅ | ❌ selector-based | ❌ selector-based | ❌ selector-based |
|
|
15
|
+
| **Inline TUI screenshot rendering** (Kitty/iTerm2/Ghostty/WezTerm) | ✅ | ❌ | ❌ | ❌ |
|
|
16
|
+
| **Accessibility-tree snapshot with click coords `@(x,y)` per element** | ✅ | ✅ tree only, no coords | partial | ❌ |
|
|
17
|
+
| **Network request capture** with filters + body capture | ✅ | ✅ post-hoc list | ❌ | ❌ |
|
|
18
|
+
| **Parallel tool execution** with automatic mutation serialization | ✅ | ❌ | ❌ | ❌ |
|
|
19
|
+
| **Temporary scripts** with daemon + full Node.js | ✅ | ❌ | ❌ | ❌ |
|
|
20
|
+
| **Direct HTTP GET** outside the browser (10–50× faster for APIs) | ✅ | ❌ | ❌ | ❌ |
|
|
21
|
+
| **Tab ownership isolation** — never touches the user's other tabs | ✅ | N/A | N/A | N/A |
|
|
22
|
+
| **Pi-native** — no MCP/JSON-RPC overhead, no extra LLM API keys | ✅ | ❌ MCP roundtrip | ❌ external LLM | ❌ MCP roundtrip |
|
|
23
|
+
| **TypeScript strict mode**, zero `any`, all CDP casts documented | ✅ | unknown | unknown | unknown |
|
|
24
|
+
| Ctrl+O expand/collapse on tool output | ✅ | ❌ | ❌ | ❌ |
|
|
25
|
+
| Compositor-level dispatch (works on every site, no flakey waits) | ✅ | ❌ | ❌ | ❌ |
|
|
26
|
+
|
|
27
|
+
If you live in pi and you want an agent driving the same Chrome you're already signed into, this is the only one that fits.
|
|
6
28
|
|
|
7
29
|
---
|
|
8
30
|
|
|
@@ -29,84 +51,144 @@ pi install npm:pi-browser-harness
|
|
|
29
51
|
|
|
30
52
|
---
|
|
31
53
|
|
|
54
|
+
## Tool hierarchy — read this first
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
What do you need to know?
|
|
58
|
+
|
|
59
|
+
├─ Page structure / what's clickable / labels?
|
|
60
|
+
│ → browser_snapshot (DEFAULT — AX tree with @(x,y) per interactive element)
|
|
61
|
+
│
|
|
62
|
+
├─ A specific element's value / attribute / coords?
|
|
63
|
+
│ → browser_execute_js (e.g. el.innerText, el.getBoundingClientRect())
|
|
64
|
+
│
|
|
65
|
+
├─ Network behavior on the current page?
|
|
66
|
+
│ → browser_network_requests
|
|
67
|
+
│
|
|
68
|
+
└─ Visual rendering (layout / colors / chart drew correctly)?
|
|
69
|
+
→ browser_screenshot (LAST RESORT — pixels only)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. **No screenshot round-trip needed** to find click targets — the snapshot already has them.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
32
76
|
## Tools
|
|
33
77
|
|
|
78
|
+
### Page inspection (use these by default)
|
|
79
|
+
|
|
34
80
|
| Tool | Purpose |
|
|
35
81
|
|------|---------|
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
|
|
42
|
-
|
|
82
|
+
| `browser_snapshot` | **Default for page inspection.** Returns the CDP accessibility tree with click coords `@(x,y)` for every interactive element. Optional `includeScreenshot:true`. |
|
|
83
|
+
| `browser_execute_js` | Surgical DOM reads — element text, attributes, `getBoundingClientRect()`. Cheapest, most precise. |
|
|
84
|
+
| `browser_network_requests` | List recent network requests on the current tab. Filter by url/method/status/type/recency; optional response-body capture. |
|
|
85
|
+
| `browser_page_info` | URL, title, viewport, scroll position, or pending dialog. |
|
|
86
|
+
| `browser_http_get` | Direct HTTP GET outside the browser — 10-50× faster for APIs. |
|
|
87
|
+
|
|
88
|
+
### Visual (last resort)
|
|
89
|
+
|
|
90
|
+
| Tool | Purpose |
|
|
91
|
+
|------|---------|
|
|
92
|
+
| `browser_screenshot` | Capture PNG/JPEG. Use only when you need to verify visual rendering. |
|
|
93
|
+
|
|
94
|
+
### Navigation
|
|
95
|
+
|
|
96
|
+
| Tool | Purpose |
|
|
97
|
+
|------|---------|
|
|
98
|
+
| `browser_navigate` / `browser_new_tab` | Navigate or open a tab |
|
|
43
99
|
| `browser_open_urls` | Open multiple URLs in parallel tabs |
|
|
44
100
|
| `browser_go_back` / `browser_go_forward` / `browser_reload` | History navigation |
|
|
45
|
-
| `
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
101
|
+
| `browser_list_tabs` / `browser_current_tab` / `browser_switch_tab` / `browser_close_tab` | Tab management (only tabs this session opened) |
|
|
102
|
+
|
|
103
|
+
### Interaction
|
|
104
|
+
|
|
105
|
+
| Tool | Purpose |
|
|
106
|
+
|------|---------|
|
|
107
|
+
| `browser_click` | Click at viewport coordinates (use `@(x,y)` from `browser_snapshot`) |
|
|
108
|
+
| `browser_type` | Type text into the focused element |
|
|
109
|
+
| `browser_press_key` | Press a key with optional modifiers |
|
|
110
|
+
| `browser_scroll` | Scroll the page by delta pixels |
|
|
111
|
+
|
|
112
|
+
### Utility
|
|
113
|
+
|
|
114
|
+
| Tool | Purpose |
|
|
115
|
+
|------|---------|
|
|
49
116
|
| `browser_wait` / `browser_wait_for_load` | Sleep, or wait for `readyState === 'complete'` |
|
|
50
117
|
| `browser_handle_dialog` | Accept or dismiss `alert` / `confirm` / `prompt` |
|
|
51
118
|
| `browser_run_script` | Execute a temporary script with daemon and Node.js access |
|
|
52
119
|
|
|
53
|
-
|
|
120
|
+
Three tools (`browser_snapshot`, `browser_network_requests`, `browser_execute_js`) ship custom TUI rendering with **Ctrl+O** (`app.tools.expand`) to toggle between compact and full output.
|
|
54
121
|
|
|
55
|
-
|
|
122
|
+
---
|
|
56
123
|
|
|
57
|
-
|
|
124
|
+
## Core patterns
|
|
58
125
|
|
|
59
|
-
###
|
|
126
|
+
### Page inspection
|
|
60
127
|
|
|
61
128
|
```
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
browser_screenshot()
|
|
129
|
+
browser_snapshot()
|
|
130
|
+
# → AX outline with @(x,y) per button/link/input
|
|
65
131
|
```
|
|
66
132
|
|
|
67
|
-
### Form filling
|
|
133
|
+
### Form filling (no screenshots)
|
|
68
134
|
|
|
69
135
|
```
|
|
70
|
-
|
|
71
|
-
browser_click({ x
|
|
72
|
-
browser_type({ text: "query" })
|
|
73
|
-
browser_press_key({ key: "Enter" })
|
|
136
|
+
browser_snapshot() # find input @(x,y) and labels
|
|
137
|
+
browser_click({ x, y }) # click using snapshot's @(x,y)
|
|
138
|
+
browser_type({ text: "query" })
|
|
139
|
+
browser_press_key({ key: "Enter" })
|
|
74
140
|
browser_wait_for_load()
|
|
75
|
-
|
|
141
|
+
browser_snapshot() # verify next state
|
|
76
142
|
```
|
|
77
143
|
|
|
78
144
|
### Data extraction
|
|
79
145
|
|
|
80
|
-
```
|
|
146
|
+
```js
|
|
147
|
+
// One value
|
|
81
148
|
browser_execute_js({ expression: "document.querySelector('.price').innerText" })
|
|
82
149
|
|
|
150
|
+
// Direct API call outside the browser
|
|
83
151
|
browser_http_get({ url: "https://api.github.com/repos/amankumarsingh77/pi-browser-harness" })
|
|
84
152
|
|
|
85
153
|
// Structured arrays
|
|
86
|
-
browser_execute_js({ expression: `
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
154
|
+
browser_execute_js({ expression: `JSON.stringify(
|
|
155
|
+
Array.from(document.querySelectorAll('.result')).map(el => ({
|
|
156
|
+
title: el.querySelector('h3')?.textContent,
|
|
157
|
+
link: el.querySelector('a')?.href,
|
|
158
|
+
}))
|
|
159
|
+
)` })
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Network debugging
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
browser_navigate({ url: "https://app.example.com/feed" })
|
|
166
|
+
browser_wait_for_load()
|
|
167
|
+
browser_network_requests({
|
|
168
|
+
urlPattern: "/api/",
|
|
169
|
+
statusFilter: { min: 400 },
|
|
170
|
+
includeResponseBodies: true
|
|
171
|
+
})
|
|
90
172
|
```
|
|
91
173
|
|
|
92
174
|
### Research workflow
|
|
93
175
|
|
|
94
176
|
```
|
|
95
177
|
browser_navigate({ url: "https://google.com/search?q=..." })
|
|
96
|
-
// read search results
|
|
97
178
|
browser_open_urls({ urls: ["url1", "url2", "url3"] })
|
|
98
179
|
browser_list_tabs()
|
|
99
180
|
browser_switch_tab({ targetId: "..." })
|
|
100
|
-
|
|
181
|
+
browser_wait_for_load()
|
|
182
|
+
browser_snapshot()
|
|
101
183
|
browser_execute_js({ expression: "document.querySelector('.content').innerText" })
|
|
102
184
|
```
|
|
103
185
|
|
|
104
|
-
###
|
|
186
|
+
### Visual verification (only when pixels matter)
|
|
105
187
|
|
|
106
188
|
```
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
browser_screenshot()
|
|
189
|
+
browser_click({ x, y }) # got coords from browser_snapshot
|
|
190
|
+
browser_snapshot() # confirm the form transitioned
|
|
191
|
+
browser_screenshot() # ONLY if you need to verify a chart/modal/CSS rendered correctly
|
|
110
192
|
```
|
|
111
193
|
|
|
112
194
|
### Keyboard modifiers
|
|
@@ -118,55 +200,65 @@ browser_screenshot()
|
|
|
118
200
|
| Meta / Cmd | 4 |
|
|
119
201
|
| Shift | 8 |
|
|
120
202
|
|
|
121
|
-
Combine with bitwise OR:
|
|
122
203
|
```
|
|
123
204
|
browser_press_key({ key: "c", modifiers: 2 }) // Ctrl+C
|
|
124
205
|
browser_press_key({ key: "v", modifiers: 4 }) // Cmd+V
|
|
125
206
|
browser_press_key({ key: "T", modifiers: 10 }) // Ctrl+Shift+T
|
|
126
207
|
```
|
|
127
208
|
|
|
128
|
-
###
|
|
209
|
+
### Dialogs
|
|
129
210
|
|
|
130
|
-
`
|
|
211
|
+
JS dialogs freeze the page. Check `browser_page_info` first — if it reports a dialog, handle it before anything else:
|
|
131
212
|
|
|
132
213
|
```
|
|
133
|
-
|
|
214
|
+
browser_handle_dialog({ action: "accept" }) // confirm
|
|
215
|
+
browser_handle_dialog({ action: "dismiss" }) // cancel
|
|
216
|
+
browser_handle_dialog({ action: "accept", promptText: "hello" }) // prompt
|
|
134
217
|
```
|
|
135
218
|
|
|
136
|
-
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Parallel execution
|
|
137
222
|
|
|
138
|
-
|
|
223
|
+
Observation tools run in parallel by default. Mutation tools (`click`, `type`, `scroll`, `navigate`, `switch_tab`, …) are automatically serialized through a shared mutex — emit them in the same turn and the harness FIFO-queues them.
|
|
139
224
|
|
|
140
225
|
```
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
226
|
+
# All three run concurrently
|
|
227
|
+
browser_snapshot()
|
|
228
|
+
browser_network_requests({ sinceMs: 5000 })
|
|
229
|
+
browser_http_get({ url: "..." })
|
|
144
230
|
```
|
|
145
231
|
|
|
146
232
|
---
|
|
147
233
|
|
|
148
|
-
##
|
|
234
|
+
## Tab ownership
|
|
149
235
|
|
|
150
|
-
|
|
236
|
+
The harness never touches tabs you didn't open through it. On first attach it spawns a dedicated Chrome window; subsequent `browser_new_tab` calls open inside that window. `browser_list_tabs` defaults to `scope:"owned"` (pass `scope:"all"` to see read-only listings of your other tabs); `browser_switch_tab` and `browser_close_tab` refuse non-owned tabs.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Temporary scripts
|
|
241
|
+
|
|
242
|
+
When the built-in tools aren't enough, write a script to disk and run it. Scripts get a `daemon` binding for direct CDP access — much faster than chaining tool calls.
|
|
151
243
|
|
|
152
244
|
```js
|
|
153
|
-
// 1. Write the script
|
|
154
245
|
write("/tmp/scrape-pages.js", `
|
|
155
246
|
const results = [];
|
|
156
247
|
for (const url of params.urls) {
|
|
157
|
-
await daemon.
|
|
248
|
+
await daemon.session().call("Page.navigate", { url });
|
|
158
249
|
await new Promise(r => setTimeout(r, 2000));
|
|
159
|
-
const title = await daemon.
|
|
250
|
+
const title = await daemon.evaluateJs("document.title");
|
|
160
251
|
results.push({ url, title });
|
|
161
252
|
}
|
|
162
253
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
163
254
|
`)
|
|
164
255
|
|
|
165
|
-
// 2. Execute it
|
|
166
256
|
browser_run_script({ path: "/tmp/scrape-pages.js", params: { urls: [...] } })
|
|
167
257
|
```
|
|
168
258
|
|
|
169
|
-
**
|
|
259
|
+
**Bindings:** `params`, `daemon`, `require`, `signal`, `onUpdate`, `ctx`, `console`, `fetch`, `JSON`, `Buffer`, `setTimeout`, `clearTimeout`.
|
|
260
|
+
|
|
261
|
+
`daemon` exposes: `evaluateJs`, `pageInfo`, `listTabs`, `switchTab`, `newTab`, `current`, and `session(targetId?)` for raw CDP via `session.call` / `session.callOnTarget` / `session.callBrowser` / `session.takeDialog`.
|
|
170
262
|
|
|
171
263
|
Scripts are written to disk — auditable and re-runnable.
|
|
172
264
|
|
|
@@ -186,8 +278,9 @@ Scripts are written to disk — auditable and re-runnable.
|
|
|
186
278
|
|
|
187
279
|
- Don't launch your own browser — you're connected to the user's real Chrome
|
|
188
280
|
- Don't type credentials — if you hit an auth wall, stop and ask
|
|
189
|
-
- Don't
|
|
190
|
-
- Don't
|
|
281
|
+
- Don't screenshot to understand the page — `browser_snapshot` is the default
|
|
282
|
+
- Don't screenshot to find click coordinates — `browser_snapshot`'s `@(x,y)` is exact
|
|
283
|
+
- Don't screenshot to read a value — `browser_execute_js` is one round-trip
|
|
191
284
|
- Don't ignore dialogs — check `browser_page_info` first
|
|
192
285
|
|
|
193
286
|
---
|
|
@@ -211,9 +304,10 @@ Temporary scripts run inside the harness process with full daemon and Node.js ac
|
|
|
211
304
|
|---------|-----|
|
|
212
305
|
| `DevToolsActivePort not found` | Open `chrome://inspect/#remote-debugging`, tick the checkbox, click Allow |
|
|
213
306
|
| Connection fails after Chrome restart | Run `/browser-reload-daemon` |
|
|
214
|
-
| Page seems loaded but content is missing | SPA —
|
|
215
|
-
|
|
|
307
|
+
| Page seems loaded but content is missing | SPA — call `browser_snapshot` again, or `browser_execute_js` for a specific element |
|
|
308
|
+
| JS dialog is blocking actions | `browser_page_info` will report it — use `browser_handle_dialog` |
|
|
216
309
|
| Daemon not starting | Run `/browser-setup` to re-run guided setup |
|
|
310
|
+
| Snapshot didn't return `@(x,y)` for a target | Element wasn't recognized as interactive. Fall back to `browser_execute_js` with `getBoundingClientRect()` |
|
|
217
311
|
|
|
218
312
|
---
|
|
219
313
|
|
package/package.json
CHANGED
|
@@ -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
|
+
├─ JS errors / why did nothing happen after an action?
|
|
25
|
+
│ → browser_console (DIAGNOSTIC — only when something looks broken)
|
|
26
|
+
│
|
|
24
27
|
└─ Visual rendering (layout / colors / chart drew correctly)?
|
|
25
28
|
→ browser_screenshot (LAST RESORT — pixels only)
|
|
26
29
|
```
|
|
@@ -31,6 +34,10 @@ Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot
|
|
|
31
34
|
|
|
32
35
|
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`.
|
|
33
36
|
|
|
37
|
+
## Diagnosing a "nothing happened" moment
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
34
41
|
## Temporary scripts
|
|
35
42
|
|
|
36
43
|
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.
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory aggregator for CDP Runtime.consoleAPICalled and Log.entryAdded events.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors network-buffer.ts: the single CDP event loop in session.ts hands raw
|
|
5
|
+
* params to ingestConsoleApi / ingestLogEntry; tools later read records via drain().
|
|
6
|
+
*
|
|
7
|
+
* Bounded by capacity (default 500). On overflow the oldest record is evicted FIFO,
|
|
8
|
+
* and the overflow flag is reported once per drain so tools can warn the LLM that
|
|
9
|
+
* data may be missing.
|
|
10
|
+
*
|
|
11
|
+
* Each record carries a monotonic `seq`. Tools expose the last drained seq as
|
|
12
|
+
* `nextCursor`; callers pass it back as `sinceSeq` to read only what's new since
|
|
13
|
+
* the previous drain. This is the cursor pattern that makes "what did this action
|
|
14
|
+
* cause?" answerable in one tool call.
|
|
15
|
+
*
|
|
16
|
+
* Pure module (no CDP dependency) — joining/filtering logic stays trivially testable.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
|
|
20
|
+
|
|
21
|
+
export type ConsoleRecord = {
|
|
22
|
+
seq: number;
|
|
23
|
+
/** Wall-clock ms when the event was ingested, in Date.now() units. */
|
|
24
|
+
timestampMs: number;
|
|
25
|
+
level: ConsoleLevel;
|
|
26
|
+
/** Joined args (console-api) or the entry text (log-entry). Per-arg truncated. */
|
|
27
|
+
text: string;
|
|
28
|
+
source: "console-api" | "log-entry";
|
|
29
|
+
url?: string;
|
|
30
|
+
lineNumber?: number;
|
|
31
|
+
/** Top frames only, joined with newlines. Populated for error/warn when CDP provides one. */
|
|
32
|
+
stackTrace?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ConsoleFilter = {
|
|
36
|
+
levels?: ConsoleLevel[];
|
|
37
|
+
/** Substring match by default. Wrap in slashes (`/foo/`) for regex. */
|
|
38
|
+
textPattern?: string;
|
|
39
|
+
/** Only records with seq strictly greater than this. */
|
|
40
|
+
sinceSeq?: number;
|
|
41
|
+
/** Only records with timestamp at most N ms ago. */
|
|
42
|
+
sinceMs?: number;
|
|
43
|
+
/** Cap on returned records (default 50, max 500). */
|
|
44
|
+
limit?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ConsoleDrainResult = {
|
|
48
|
+
readonly records: ConsoleRecord[];
|
|
49
|
+
/** Total matches before `limit` was applied. */
|
|
50
|
+
readonly total: number;
|
|
51
|
+
/** True if the ring buffer evicted at least one record since the last drain. */
|
|
52
|
+
readonly bufferOverflowed: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ConsoleBuffer = {
|
|
56
|
+
ingestConsoleApi(p: unknown): void;
|
|
57
|
+
ingestLogEntry(p: unknown): void;
|
|
58
|
+
drain(filter: ConsoleFilter): ConsoleDrainResult;
|
|
59
|
+
clear(): void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Per-arg cap to stop a single console.log(hugeBlob) from blowing the buffer. */
|
|
63
|
+
const PER_ARG_CAP = 2000;
|
|
64
|
+
/** Max stack frames preserved per record. */
|
|
65
|
+
const MAX_STACK_FRAMES = 3;
|
|
66
|
+
|
|
67
|
+
const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
68
|
+
if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
|
|
69
|
+
try {
|
|
70
|
+
const re = new RegExp(pattern.slice(1, -1));
|
|
71
|
+
return (text) => re.test(text);
|
|
72
|
+
} catch {
|
|
73
|
+
// Fall through to substring match if the pattern is not a valid regex.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return (text) => text.includes(pattern);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const truncateArg = (s: string): string =>
|
|
80
|
+
s.length > PER_ARG_CAP ? s.slice(0, PER_ARG_CAP) + "…" : s;
|
|
81
|
+
|
|
82
|
+
/** Best-effort stringification of one Runtime.RemoteObject. */
|
|
83
|
+
const stringifyRemoteObject = (
|
|
84
|
+
arg: { type?: string; subtype?: string; value?: unknown; description?: string; unserializableValue?: string },
|
|
85
|
+
): string => {
|
|
86
|
+
if (arg.unserializableValue !== undefined) return arg.unserializableValue;
|
|
87
|
+
if (arg.value !== undefined) {
|
|
88
|
+
if (typeof arg.value === "string") return arg.value;
|
|
89
|
+
try {
|
|
90
|
+
return JSON.stringify(arg.value);
|
|
91
|
+
} catch {
|
|
92
|
+
return String(arg.value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (arg.description !== undefined) return arg.description;
|
|
96
|
+
return arg.type ?? "";
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Map CDP console-API method names to our normalized levels. */
|
|
100
|
+
const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
101
|
+
switch (type) {
|
|
102
|
+
case "error":
|
|
103
|
+
case "assert":
|
|
104
|
+
return "error";
|
|
105
|
+
case "warning":
|
|
106
|
+
return "warn";
|
|
107
|
+
case "debug":
|
|
108
|
+
case "trace":
|
|
109
|
+
return "debug";
|
|
110
|
+
case "info":
|
|
111
|
+
return "info";
|
|
112
|
+
default:
|
|
113
|
+
return "log";
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Map Log.LogEntry.level values ("verbose","info","warning","error") to our levels. */
|
|
118
|
+
const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => {
|
|
119
|
+
switch (level) {
|
|
120
|
+
case "error":
|
|
121
|
+
return "error";
|
|
122
|
+
case "warning":
|
|
123
|
+
return "warn";
|
|
124
|
+
case "verbose":
|
|
125
|
+
return "debug";
|
|
126
|
+
default:
|
|
127
|
+
return "info";
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const formatStackTrace = (
|
|
132
|
+
st: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> } | undefined,
|
|
133
|
+
): string | undefined => {
|
|
134
|
+
const frames = st?.callFrames;
|
|
135
|
+
if (!frames || frames.length === 0) return undefined;
|
|
136
|
+
const top = frames.slice(0, MAX_STACK_FRAMES);
|
|
137
|
+
const rendered = top.map((f) => {
|
|
138
|
+
const fn = f.functionName !== undefined && f.functionName !== "" ? f.functionName : "<anonymous>";
|
|
139
|
+
const loc = f.url !== undefined && f.url !== "" ? `${f.url}:${(f.lineNumber ?? 0) + 1}` : "<unknown>";
|
|
140
|
+
return ` at ${fn} (${loc})`;
|
|
141
|
+
});
|
|
142
|
+
return rendered.join("\n");
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
|
|
146
|
+
// Insertion-ordered map keyed by seq. Map preserves insertion order in JS,
|
|
147
|
+
// so iteration gives us oldest-first eviction without a separate index.
|
|
148
|
+
const records = new Map<number, ConsoleRecord>();
|
|
149
|
+
let nextSeq = 1;
|
|
150
|
+
let overflowed = false;
|
|
151
|
+
|
|
152
|
+
const evictOldestIfFull = (): void => {
|
|
153
|
+
while (records.size >= capacity) {
|
|
154
|
+
const oldest = records.keys().next();
|
|
155
|
+
if (oldest.done) return;
|
|
156
|
+
records.delete(oldest.value);
|
|
157
|
+
overflowed = true;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const insert = (rec: ConsoleRecord): void => {
|
|
162
|
+
evictOldestIfFull();
|
|
163
|
+
records.set(rec.seq, rec);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
ingestConsoleApi(p) {
|
|
168
|
+
// CDP boundary cast: Runtime.consoleAPICalled
|
|
169
|
+
const params = p as
|
|
170
|
+
| {
|
|
171
|
+
type?: string;
|
|
172
|
+
args?: ReadonlyArray<{
|
|
173
|
+
type?: string;
|
|
174
|
+
subtype?: string;
|
|
175
|
+
value?: unknown;
|
|
176
|
+
description?: string;
|
|
177
|
+
unserializableValue?: string;
|
|
178
|
+
}>;
|
|
179
|
+
stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> };
|
|
180
|
+
}
|
|
181
|
+
| undefined;
|
|
182
|
+
if (!params) return;
|
|
183
|
+
const level = mapConsoleApiLevel(params.type);
|
|
184
|
+
const argTexts = (params.args ?? []).map((a) => truncateArg(stringifyRemoteObject(a)));
|
|
185
|
+
const text = argTexts.join(" ");
|
|
186
|
+
const stack = level === "error" || level === "warn" ? formatStackTrace(params.stackTrace) : undefined;
|
|
187
|
+
const top = params.stackTrace?.callFrames?.[0];
|
|
188
|
+
const rec: ConsoleRecord = {
|
|
189
|
+
seq: nextSeq++,
|
|
190
|
+
timestampMs: Date.now(),
|
|
191
|
+
level,
|
|
192
|
+
text,
|
|
193
|
+
source: "console-api",
|
|
194
|
+
...(top?.url !== undefined && top.url !== "" ? { url: top.url } : {}),
|
|
195
|
+
...(top?.lineNumber !== undefined ? { lineNumber: top.lineNumber + 1 } : {}),
|
|
196
|
+
...(stack !== undefined ? { stackTrace: stack } : {}),
|
|
197
|
+
};
|
|
198
|
+
insert(rec);
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
ingestLogEntry(p) {
|
|
202
|
+
// CDP boundary cast: Log.entryAdded
|
|
203
|
+
const params = p as
|
|
204
|
+
| {
|
|
205
|
+
entry?: {
|
|
206
|
+
level?: string;
|
|
207
|
+
text?: string;
|
|
208
|
+
url?: string;
|
|
209
|
+
lineNumber?: number;
|
|
210
|
+
stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> };
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
| undefined;
|
|
214
|
+
const entry = params?.entry;
|
|
215
|
+
if (!entry) return;
|
|
216
|
+
const level = mapLogEntryLevel(entry.level);
|
|
217
|
+
const text = truncateArg(entry.text ?? "");
|
|
218
|
+
const stack = level === "error" || level === "warn" ? formatStackTrace(entry.stackTrace) : undefined;
|
|
219
|
+
const rec: ConsoleRecord = {
|
|
220
|
+
seq: nextSeq++,
|
|
221
|
+
timestampMs: Date.now(),
|
|
222
|
+
level,
|
|
223
|
+
text,
|
|
224
|
+
source: "log-entry",
|
|
225
|
+
...(entry.url !== undefined && entry.url !== "" ? { url: entry.url } : {}),
|
|
226
|
+
...(entry.lineNumber !== undefined ? { lineNumber: entry.lineNumber + 1 } : {}),
|
|
227
|
+
...(stack !== undefined ? { stackTrace: stack } : {}),
|
|
228
|
+
};
|
|
229
|
+
insert(rec);
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
drain(filter) {
|
|
233
|
+
const matchText = filter.textPattern !== undefined ? compileTextMatcher(filter.textPattern) : undefined;
|
|
234
|
+
const levels = filter.levels && filter.levels.length > 0 ? new Set(filter.levels) : undefined;
|
|
235
|
+
const cutoff = filter.sinceMs !== undefined ? Date.now() - filter.sinceMs : undefined;
|
|
236
|
+
const sinceSeq = filter.sinceSeq;
|
|
237
|
+
|
|
238
|
+
const matched: ConsoleRecord[] = [];
|
|
239
|
+
for (const r of records.values()) {
|
|
240
|
+
if (sinceSeq !== undefined && r.seq <= sinceSeq) continue;
|
|
241
|
+
if (levels && !levels.has(r.level)) continue;
|
|
242
|
+
if (cutoff !== undefined && r.timestampMs < cutoff) continue;
|
|
243
|
+
if (matchText && !matchText(r.text)) continue;
|
|
244
|
+
matched.push({ ...r });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const total = matched.length;
|
|
248
|
+
const limit = Math.min(filter.limit ?? 50, 500);
|
|
249
|
+
const limited = matched.slice(-limit);
|
|
250
|
+
|
|
251
|
+
const bufferOverflowed = overflowed;
|
|
252
|
+
overflowed = false;
|
|
253
|
+
|
|
254
|
+
return { records: limited, total, bufferOverflowed };
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
clear() {
|
|
258
|
+
records.clear();
|
|
259
|
+
nextSeq = 1;
|
|
260
|
+
overflowed = false;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
};
|
package/src/cdp/session.ts
CHANGED
|
@@ -4,6 +4,20 @@ import type { DialogInfo } from "./types";
|
|
|
4
4
|
import type { OwnershipRegistry } from "./ownership";
|
|
5
5
|
import type { CdpTransport } from "./transport";
|
|
6
6
|
import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
|
|
7
|
+
import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
|
|
8
|
+
|
|
9
|
+
// Per-tab state. One TabSession exists per known targetId.
|
|
10
|
+
// Dialog, page-info dirty flag, and CDP buffers are per-tab — switching
|
|
11
|
+
// tabs preserves the previous tab's data in its TabSession instead of
|
|
12
|
+
// clearing it.
|
|
13
|
+
type TabSession = {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
targetId: string;
|
|
16
|
+
dialog: DialogInfo | null;
|
|
17
|
+
pageInfoDirty: boolean;
|
|
18
|
+
networkBuffer: ReturnType<typeof createNetworkBuffer>;
|
|
19
|
+
consoleBuffer: ReturnType<typeof createConsoleBuffer>;
|
|
20
|
+
};
|
|
7
21
|
|
|
8
22
|
export type CdpSession = {
|
|
9
23
|
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
|
|
@@ -15,6 +29,7 @@ export type CdpSession = {
|
|
|
15
29
|
takeDialog(): DialogInfo | null;
|
|
16
30
|
drainPageInfoInvalidations(): boolean;
|
|
17
31
|
drainNetworkBuffer(filter: NetworkFilter): DrainResult;
|
|
32
|
+
drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
|
|
18
33
|
};
|
|
19
34
|
|
|
20
35
|
export const createCdpSession = (
|
|
@@ -23,17 +38,33 @@ export const createCdpSession = (
|
|
|
23
38
|
): CdpSession => {
|
|
24
39
|
let sessionId: string | null = null;
|
|
25
40
|
let targetId: string | null = null;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
|
|
42
|
+
// Per-tab tracking: maps targetId → TabSession and sessionId → targetId for
|
|
43
|
+
// event routing. Lazy: a TabSession is created on the first visit to a tab.
|
|
44
|
+
const tabs = new Map<string, TabSession>();
|
|
45
|
+
const sessionIdToTargetId = new Map<string, string>();
|
|
29
46
|
|
|
30
47
|
let activeConsumer: Promise<void> = Promise.resolve();
|
|
31
48
|
|
|
49
|
+
// Event → TabSession resolver. Uses sessionId from CDP events to find the
|
|
50
|
+
// correct TabSession, falling back to the current targetId when no sessionId.
|
|
51
|
+
const resolveTab = (evSessionId?: string): TabSession | undefined => {
|
|
52
|
+
const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId;
|
|
53
|
+
return tid ? tabs.get(tid) : undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
32
56
|
const consumeEvents = async (): Promise<void> => {
|
|
33
57
|
for await (const ev of transport.events()) {
|
|
58
|
+
// Filter: skip events from sessions we're not currently tracking.
|
|
59
|
+
// Target.targetDestroyed is browser-level (no sessionId) — always process.
|
|
60
|
+
if (ev.method !== "Target.targetDestroyed") {
|
|
61
|
+
if (ev.sessionId && ev.sessionId !== sessionId) continue;
|
|
62
|
+
}
|
|
34
63
|
if (ev.method === "Page.javascriptDialogOpening") {
|
|
64
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
65
|
+
if (!tab) continue;
|
|
35
66
|
const params = ev.params as Partial<DialogInfo> | undefined;
|
|
36
|
-
dialog = {
|
|
67
|
+
tab.dialog = {
|
|
37
68
|
type: (params?.type as DialogInfo["type"]) ?? "alert",
|
|
38
69
|
message: params?.message ?? "",
|
|
39
70
|
...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
|
|
@@ -45,16 +76,40 @@ export const createCdpSession = (
|
|
|
45
76
|
// This prevents fast dismiss flows from dropping a dialog the agent
|
|
46
77
|
// was about to read. (Fix for spec §7 predictability bug #2.)
|
|
47
78
|
if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
|
|
48
|
-
|
|
79
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
80
|
+
if (tab) tab.pageInfoDirty = true;
|
|
49
81
|
}
|
|
50
82
|
if (ev.method === "Target.targetDestroyed" && ownership) {
|
|
51
83
|
const params = ev.params as { targetId?: string } | undefined;
|
|
52
|
-
if (params?.targetId)
|
|
84
|
+
if (params?.targetId) {
|
|
85
|
+
ownership.remove(params.targetId);
|
|
86
|
+
// Prune per-tab state for the destroyed target
|
|
87
|
+
const tab = tabs.get(params.targetId);
|
|
88
|
+
if (tab) {
|
|
89
|
+
sessionIdToTargetId.delete(tab.sessionId);
|
|
90
|
+
tabs.delete(params.targetId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (ev.method === "Network.requestWillBeSent") {
|
|
95
|
+
const tab = resolveTab(ev.sessionId);
|
|
96
|
+
if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
|
|
97
|
+
} else if (ev.method === "Network.responseReceived") {
|
|
98
|
+
const tab = resolveTab(ev.sessionId);
|
|
99
|
+
if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
|
|
100
|
+
} else if (ev.method === "Network.loadingFinished") {
|
|
101
|
+
const tab = resolveTab(ev.sessionId);
|
|
102
|
+
if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
|
|
103
|
+
} else if (ev.method === "Network.loadingFailed") {
|
|
104
|
+
const tab = resolveTab(ev.sessionId);
|
|
105
|
+
if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
|
|
106
|
+
} else if (ev.method === "Runtime.consoleAPICalled") {
|
|
107
|
+
const tab = resolveTab(ev.sessionId);
|
|
108
|
+
if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
|
|
109
|
+
} else if (ev.method === "Log.entryAdded") {
|
|
110
|
+
const tab = resolveTab(ev.sessionId);
|
|
111
|
+
if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
|
|
53
112
|
}
|
|
54
|
-
if (ev.method === "Network.requestWillBeSent") networkBuffer.ingestRequestWillBeSent(ev.params);
|
|
55
|
-
else if (ev.method === "Network.responseReceived") networkBuffer.ingestResponseReceived(ev.params);
|
|
56
|
-
else if (ev.method === "Network.loadingFinished") networkBuffer.ingestLoadingFinished(ev.params);
|
|
57
|
-
else if (ev.method === "Network.loadingFailed") networkBuffer.ingestLoadingFailed(ev.params);
|
|
58
113
|
}
|
|
59
114
|
};
|
|
60
115
|
|
|
@@ -72,7 +127,8 @@ export const createCdpSession = (
|
|
|
72
127
|
transport.onClose(() => {
|
|
73
128
|
sessionId = null;
|
|
74
129
|
targetId = null;
|
|
75
|
-
|
|
130
|
+
tabs.clear();
|
|
131
|
+
sessionIdToTargetId.clear();
|
|
76
132
|
// Do NOT clear `dialog` here — same rationale as inside consumeEvents:
|
|
77
133
|
// the agent may have a pending takeDialog() call that should still see it.
|
|
78
134
|
restartConsumer();
|
|
@@ -82,7 +138,7 @@ export const createCdpSession = (
|
|
|
82
138
|
// Switching to Promise.all over a single WS pipelines the round-trips and
|
|
83
139
|
// saves ~3× on tab-switch latency. Defer until session.ts has tests.
|
|
84
140
|
const enableDomains = async (sid: string): Promise<void> => {
|
|
85
|
-
for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility"]) {
|
|
141
|
+
for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) {
|
|
86
142
|
await transport.request(`${d}.enable`, {}, { sessionId: sid });
|
|
87
143
|
}
|
|
88
144
|
};
|
|
@@ -111,6 +167,15 @@ export const createCdpSession = (
|
|
|
111
167
|
const hw = ownership.harnessWindow();
|
|
112
168
|
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
113
169
|
}
|
|
170
|
+
// Prune per-tab state for pages that no longer exist
|
|
171
|
+
const liveTargetIds = new Set(allPages.map((p) => p.targetId));
|
|
172
|
+
for (const tid of tabs.keys()) {
|
|
173
|
+
if (!liveTargetIds.has(tid)) {
|
|
174
|
+
const tab = tabs.get(tid);
|
|
175
|
+
if (tab) sessionIdToTargetId.delete(tab.sessionId);
|
|
176
|
+
tabs.delete(tid);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
114
179
|
|
|
115
180
|
// Prefer attaching to a tab this session already owns. Falls back to
|
|
116
181
|
// creating a fresh harness-owned tab in a dedicated window — never
|
|
@@ -139,6 +204,15 @@ export const createCdpSession = (
|
|
|
139
204
|
sessionId = a.sessionId;
|
|
140
205
|
targetId = pickTargetId;
|
|
141
206
|
await enableDomains(a.sessionId);
|
|
207
|
+
tabs.set(pickTargetId, {
|
|
208
|
+
sessionId: a.sessionId,
|
|
209
|
+
targetId: pickTargetId,
|
|
210
|
+
dialog: null,
|
|
211
|
+
pageInfoDirty: false,
|
|
212
|
+
networkBuffer: createNetworkBuffer(),
|
|
213
|
+
consoleBuffer: createConsoleBuffer(),
|
|
214
|
+
});
|
|
215
|
+
sessionIdToTargetId.set(a.sessionId, pickTargetId);
|
|
142
216
|
return ok({ targetId: pickTargetId, sessionId: a.sessionId });
|
|
143
217
|
},
|
|
144
218
|
async switchTo(tid) {
|
|
@@ -147,13 +221,29 @@ export const createCdpSession = (
|
|
|
147
221
|
const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
|
|
148
222
|
if (!attached.success) return attached;
|
|
149
223
|
const a = attached.data as { sessionId: string };
|
|
150
|
-
|
|
224
|
+
// Reuse existing TabSession or create one on first visit
|
|
225
|
+
const existing = tabs.get(tid);
|
|
226
|
+
const tab: TabSession = existing ?? {
|
|
227
|
+
sessionId: a.sessionId,
|
|
228
|
+
targetId: tid,
|
|
229
|
+
dialog: null,
|
|
230
|
+
pageInfoDirty: true,
|
|
231
|
+
networkBuffer: createNetworkBuffer(),
|
|
232
|
+
consoleBuffer: createConsoleBuffer(),
|
|
233
|
+
};
|
|
234
|
+
if (existing) {
|
|
235
|
+
// Each Target.attachToTarget produces a new sessionId — update it.
|
|
236
|
+
sessionIdToTargetId.delete(existing.sessionId);
|
|
237
|
+
existing.sessionId = a.sessionId;
|
|
238
|
+
sessionIdToTargetId.set(a.sessionId, tid);
|
|
239
|
+
} else {
|
|
240
|
+
tabs.set(tid, tab);
|
|
241
|
+
await enableDomains(a.sessionId);
|
|
242
|
+
sessionIdToTargetId.set(a.sessionId, tid);
|
|
243
|
+
}
|
|
244
|
+
// Update global pointers to point at the new active tab
|
|
245
|
+
sessionId = tab.sessionId;
|
|
151
246
|
targetId = tid;
|
|
152
|
-
pageInfoDirty = true;
|
|
153
|
-
// Buffer is page-scoped: on switch, the network history of the previous
|
|
154
|
-
// tab is no longer relevant to what the agent is now looking at.
|
|
155
|
-
networkBuffer.clear();
|
|
156
|
-
await enableDomains(a.sessionId);
|
|
157
247
|
return ok(undefined);
|
|
158
248
|
},
|
|
159
249
|
current() {
|
|
@@ -169,17 +259,28 @@ export const createCdpSession = (
|
|
|
169
259
|
return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
170
260
|
},
|
|
171
261
|
takeDialog() {
|
|
172
|
-
const
|
|
173
|
-
|
|
262
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
263
|
+
if (!tab) return null;
|
|
264
|
+
const d = tab.dialog;
|
|
265
|
+
tab.dialog = null;
|
|
174
266
|
return d;
|
|
175
267
|
},
|
|
176
268
|
drainPageInfoInvalidations() {
|
|
177
|
-
const
|
|
178
|
-
|
|
269
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
270
|
+
if (!tab) return false;
|
|
271
|
+
const dirty = tab.pageInfoDirty;
|
|
272
|
+
tab.pageInfoDirty = false;
|
|
179
273
|
return dirty;
|
|
180
274
|
},
|
|
181
275
|
drainNetworkBuffer(filter) {
|
|
182
|
-
|
|
276
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
277
|
+
if (!tab) return { records: [], total: 0, bufferOverflowed: false };
|
|
278
|
+
return tab.networkBuffer.drain(filter);
|
|
279
|
+
},
|
|
280
|
+
drainConsoleBuffer(filter) {
|
|
281
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
282
|
+
if (!tab) return { records: [], total: 0, bufferOverflowed: false };
|
|
283
|
+
return tab.consoleBuffer.drain(filter);
|
|
183
284
|
},
|
|
184
285
|
};
|
|
185
286
|
};
|
package/src/client.ts
CHANGED
|
@@ -98,7 +98,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
98
98
|
const session = createCdpSession(transport, ownership);
|
|
99
99
|
const mutationMutex = createMutex();
|
|
100
100
|
let lastHealth = 0;
|
|
101
|
-
let
|
|
101
|
+
let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
|
|
102
102
|
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
|
|
103
103
|
|
|
104
104
|
const start = async (): Promise<Result<void, CdpError>> => {
|
|
@@ -125,13 +125,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
125
125
|
return attached;
|
|
126
126
|
}
|
|
127
127
|
lastHealth = Date.now();
|
|
128
|
-
|
|
128
|
+
pageCaches.clear();
|
|
129
129
|
return ok(undefined);
|
|
130
130
|
};
|
|
131
131
|
|
|
132
132
|
const stop = async (): Promise<void> => {
|
|
133
133
|
await transport.close();
|
|
134
|
-
|
|
134
|
+
pageCaches.clear();
|
|
135
135
|
lastHealth = 0;
|
|
136
136
|
};
|
|
137
137
|
|
|
@@ -155,7 +155,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
155
155
|
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
|
|
156
156
|
const reattached = await session.attachFirstPage();
|
|
157
157
|
if (reattached.success) {
|
|
158
|
-
|
|
158
|
+
pageCaches.clear();
|
|
159
159
|
return ok(undefined);
|
|
160
160
|
}
|
|
161
161
|
await stop();
|
|
@@ -185,7 +185,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
185
185
|
|
|
186
186
|
const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => {
|
|
187
187
|
const dirty = session.drainPageInfoInvalidations();
|
|
188
|
-
|
|
188
|
+
const currentTid = session.current()?.targetId;
|
|
189
|
+
const cached = currentTid ? pageCaches.get(currentTid) : undefined;
|
|
190
|
+
if (cached && !dirty && Date.now() - cached.at < PAGE_INFO_TTL_MS) return ok(cached.info);
|
|
189
191
|
const expr = safeJs`JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
|
|
190
192
|
const raw = await evaluateJs(expr);
|
|
191
193
|
if (!raw.success) return raw;
|
|
@@ -198,7 +200,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
198
200
|
}
|
|
199
201
|
const info = parsePageInfoPayload(parsedRaw);
|
|
200
202
|
if (!info.success) return info;
|
|
201
|
-
|
|
203
|
+
if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() });
|
|
202
204
|
return ok(info.data);
|
|
203
205
|
};
|
|
204
206
|
|
|
@@ -228,13 +230,17 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
228
230
|
if (survivors.length !== owned.length) ownership.replaceAll(survivors);
|
|
229
231
|
const hw = ownership.harnessWindow();
|
|
230
232
|
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
233
|
+
// Prune per-tab page caches for tabs that no longer exist
|
|
234
|
+
for (const tid of pageCaches.keys()) {
|
|
235
|
+
if (!live.has(tid)) pageCaches.delete(tid);
|
|
236
|
+
}
|
|
231
237
|
return ok(tabs);
|
|
232
238
|
};
|
|
233
239
|
|
|
234
240
|
const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
|
|
235
241
|
const r = await session.switchTo(targetId);
|
|
236
242
|
if (!r.success) return r;
|
|
237
|
-
pageCache
|
|
243
|
+
// pageCache per-tab: no longer cleared — each tab retains its cache
|
|
238
244
|
// Best-effort: mark the tab title with a green circle so the user can
|
|
239
245
|
// see which tab the agent attached to. CSP or detached frames may
|
|
240
246
|
// block the eval; we don't surface that as a switchTab failure.
|
|
@@ -274,7 +280,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
274
280
|
if (url && url !== "about:blank") {
|
|
275
281
|
const nav = await session.call("Page.navigate", { url });
|
|
276
282
|
if (!nav.success) return nav;
|
|
277
|
-
pageCache = null;
|
|
278
283
|
}
|
|
279
284
|
return ok(c.targetId);
|
|
280
285
|
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { Markdown, Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import { type Result, ok } from "../util/result";
|
|
5
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
6
|
+
import { applyTruncation } from "../util/truncate";
|
|
7
|
+
import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer";
|
|
8
|
+
|
|
9
|
+
const ConsoleArgs = Type.Object({
|
|
10
|
+
levels: Type.Optional(
|
|
11
|
+
Type.Array(
|
|
12
|
+
Type.Union(
|
|
13
|
+
[
|
|
14
|
+
Type.Literal("log"),
|
|
15
|
+
Type.Literal("info"),
|
|
16
|
+
Type.Literal("warn"),
|
|
17
|
+
Type.Literal("error"),
|
|
18
|
+
Type.Literal("debug"),
|
|
19
|
+
],
|
|
20
|
+
),
|
|
21
|
+
{ description: 'Filter to these levels only, e.g. ["error","warn"]. Default: all levels.' },
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
textPattern: Type.Optional(
|
|
25
|
+
Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /TypeError/) for regex." }),
|
|
26
|
+
),
|
|
27
|
+
sinceSeq: Type.Optional(
|
|
28
|
+
Type.Integer({ minimum: 0, description: "Only records with seq strictly greater than this. Use the previous call's nextCursor to see only what's new." }),
|
|
29
|
+
),
|
|
30
|
+
sinceMs: Type.Optional(
|
|
31
|
+
Type.Integer({ minimum: 0, description: "Only records from the last N ms." }),
|
|
32
|
+
),
|
|
33
|
+
limit: Type.Optional(
|
|
34
|
+
Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const levelTag = (lvl: ConsoleLevel): string => {
|
|
39
|
+
switch (lvl) {
|
|
40
|
+
case "error": return "[error]";
|
|
41
|
+
case "warn": return "[warn] ";
|
|
42
|
+
case "info": return "[info] ";
|
|
43
|
+
case "debug": return "[debug]";
|
|
44
|
+
default: return "[log] ";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const formatLocation = (r: ConsoleRecord): string => {
|
|
49
|
+
if (r.url === undefined || r.url === "") return "";
|
|
50
|
+
const short = r.url.length > 60 ? "…" + r.url.slice(-59) : r.url;
|
|
51
|
+
return r.lineNumber !== undefined ? `${short}:${r.lineNumber}` : short;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const renderConsoleMarkdown = (
|
|
55
|
+
records: ReadonlyArray<ConsoleRecord>,
|
|
56
|
+
opts: { total: number; bufferOverflowed: boolean; expanded: boolean; nextCursor: number | undefined },
|
|
57
|
+
): string => {
|
|
58
|
+
if (records.length === 0) {
|
|
59
|
+
if (opts.bufferOverflowed) return "_No matching console records. (Buffer overflowed since last drain — some events were dropped.)_";
|
|
60
|
+
return "_No matching console records captured yet._";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const counts = records.reduce<Record<ConsoleLevel, number>>(
|
|
64
|
+
(acc, r) => { acc[r.level]++; return acc; },
|
|
65
|
+
{ log: 0, info: 0, warn: 0, error: 0, debug: 0 },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const summaryParts: string[] = [];
|
|
69
|
+
if (counts.error > 0) summaryParts.push(`${counts.error} error${counts.error === 1 ? "" : "s"}`);
|
|
70
|
+
if (counts.warn > 0) summaryParts.push(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`);
|
|
71
|
+
if (counts.info > 0) summaryParts.push(`${counts.info} info`);
|
|
72
|
+
if (counts.log > 0) summaryParts.push(`${counts.log} log${counts.log === 1 ? "" : "s"}`);
|
|
73
|
+
if (counts.debug > 0) summaryParts.push(`${counts.debug} debug`);
|
|
74
|
+
|
|
75
|
+
const headerLines: string[] = [`**${summaryParts.join(" · ")}**`];
|
|
76
|
+
if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older entries dropped");
|
|
77
|
+
if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
|
|
78
|
+
|
|
79
|
+
const renderRow = (r: ConsoleRecord, compact: boolean): string => {
|
|
80
|
+
const tag = levelTag(r.level);
|
|
81
|
+
const loc = formatLocation(r);
|
|
82
|
+
const maxText = compact ? 80 : 200;
|
|
83
|
+
const text = r.text.length > maxText ? r.text.slice(0, maxText - 1) + "…" : r.text;
|
|
84
|
+
const locSuffix = loc !== "" ? ` _at ${loc}_` : "";
|
|
85
|
+
const main = `\`${tag}\` ${text}${locSuffix}`;
|
|
86
|
+
if (!compact && r.stackTrace !== undefined) return `${main}\n\`\`\`\n${r.stackTrace}\n\`\`\``;
|
|
87
|
+
return main;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const visible = !opts.expanded && records.length > 5 ? records.slice(-5) : records;
|
|
91
|
+
const rows = visible.map((r) => renderRow(r, !opts.expanded)).join(opts.expanded ? "\n\n" : "\n");
|
|
92
|
+
|
|
93
|
+
const more = !opts.expanded && records.length > 5 ? `\n … ${records.length - 5} earlier (expand to see all)` : "";
|
|
94
|
+
const cursor = opts.nextCursor !== undefined ? `\n\n_nextCursor=${opts.nextCursor}_` : "";
|
|
95
|
+
|
|
96
|
+
return `${headerLines.join("\n")}\n\n${rows}${more}${cursor}`;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type ConsoleDetails = {
|
|
100
|
+
total: number;
|
|
101
|
+
returned: number;
|
|
102
|
+
bufferOverflowed: boolean;
|
|
103
|
+
truncated: boolean;
|
|
104
|
+
fullOutputPath?: string;
|
|
105
|
+
records: ReadonlyArray<ConsoleRecord>;
|
|
106
|
+
nextCursor?: number;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const consoleTool = defineBrowserTool({
|
|
110
|
+
name: "browser_console",
|
|
111
|
+
label: "Browser Console",
|
|
112
|
+
description:
|
|
113
|
+
"Read JS errors and console messages from the active tab. Diagnostic — use when an action looks broken or silent.",
|
|
114
|
+
promptSnippet: "Read JS errors / console output on the current tab",
|
|
115
|
+
promptGuidelines: [
|
|
116
|
+
"Use after a click, submit, or navigate produced no visible change — errors often explain it.",
|
|
117
|
+
"Pass `sinceSeq` from the previous call's `nextCursor` to see only new messages after an action.",
|
|
118
|
+
"Buffer is reset on tab switch (capacity 500 records). bufferOverflowed:true means older entries were dropped.",
|
|
119
|
+
"Don't use as a default observation tool — prefer browser_snapshot for page structure.",
|
|
120
|
+
],
|
|
121
|
+
parameters: ConsoleArgs,
|
|
122
|
+
|
|
123
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
124
|
+
const session = client.session();
|
|
125
|
+
const drained = session.drainConsoleBuffer({
|
|
126
|
+
...(args.levels !== undefined ? { levels: args.levels } : {}),
|
|
127
|
+
...(args.textPattern !== undefined ? { textPattern: args.textPattern } : {}),
|
|
128
|
+
...(args.sinceSeq !== undefined ? { sinceSeq: args.sinceSeq } : {}),
|
|
129
|
+
...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
|
|
130
|
+
limit: args.limit ?? 50,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const last = drained.records.at(-1);
|
|
134
|
+
const nextCursor = last?.seq;
|
|
135
|
+
|
|
136
|
+
const text = renderConsoleMarkdown(drained.records, {
|
|
137
|
+
total: drained.total,
|
|
138
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
139
|
+
expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans
|
|
140
|
+
nextCursor,
|
|
141
|
+
});
|
|
142
|
+
const trunc = await applyTruncation(text, "console");
|
|
143
|
+
|
|
144
|
+
const details: ConsoleDetails = {
|
|
145
|
+
total: drained.total,
|
|
146
|
+
returned: drained.records.length,
|
|
147
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
148
|
+
truncated: trunc.wasTruncated,
|
|
149
|
+
records: drained.records,
|
|
150
|
+
...(nextCursor !== undefined ? { nextCursor } : {}),
|
|
151
|
+
};
|
|
152
|
+
if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
|
|
153
|
+
|
|
154
|
+
return ok({ text: trunc.text, details });
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
renderResult(result, expanded, theme) {
|
|
158
|
+
const details = result.details as ConsoleDetails | undefined;
|
|
159
|
+
if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0);
|
|
160
|
+
|
|
161
|
+
const md = renderConsoleMarkdown(details.records, {
|
|
162
|
+
total: details.total,
|
|
163
|
+
bufferOverflowed: details.bufferOverflowed,
|
|
164
|
+
expanded,
|
|
165
|
+
nextCursor: details.nextCursor,
|
|
166
|
+
});
|
|
167
|
+
const tail = expanded
|
|
168
|
+
? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
|
|
169
|
+
: `\n\n${keyHint("app.tools.expand", "to expand")}`;
|
|
170
|
+
|
|
171
|
+
return new Markdown(md + tail, 0, 0, getMarkdownTheme());
|
|
172
|
+
},
|
|
173
|
+
});
|
package/src/prompt.ts
CHANGED
|
@@ -36,6 +36,7 @@ to interact with pages visually, and other tools alongside them.
|
|
|
36
36
|
| browser_switch_tab | Switch to a different tab by targetId |
|
|
37
37
|
| browser_execute_js | Execute JavaScript and return the result |
|
|
38
38
|
| browser_http_get | Direct HTTP GET (outside browser, for APIs) |
|
|
39
|
+
| browser_console | Read JS errors / console output on the current tab (diagnostic — use when an action looks broken; pass sinceSeq from the previous nextCursor to see only new messages) |
|
|
39
40
|
| browser_wait | Wait N seconds |
|
|
40
41
|
| browser_wait_for_load | Wait for document.readyState === 'complete' (returns a typed timeout error if the page doesn't reach readyState=complete in N seconds, default 15) |
|
|
41
42
|
| browser_handle_dialog | Accept or dismiss a JS dialog |
|
|
@@ -73,6 +74,8 @@ browser_screenshot() + browser_page_info() + browser_execute_js("document.title"
|
|
|
73
74
|
browser_http_get("https://api.example.com/data") + browser_click(x, y)
|
|
74
75
|
\`\`\`
|
|
75
76
|
|
|
77
|
+
**Multi-agent note**: When multiple subagents use the browser, tab switching by one agent changes the active tab for all agents. Per-tab data (console buffers, network traces, dialogs, page info cache) is isolated — switching tabs does not destroy another agent's collected data. Call \`browser_current_tab\` before mutation tools to confirm you're on the expected tab. If \`browser_page_info\` returns a dialog, handle it with \`browser_handle_dialog\` promptly — dialogs block page interaction and are not queued across agents.
|
|
78
|
+
|
|
76
79
|
### Common Patterns
|
|
77
80
|
|
|
78
81
|
**Navigation:**
|
package/src/registry.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files";
|
|
|
14
14
|
import { viewportResizeTool } from "./domains/viewport";
|
|
15
15
|
import { dragAndDropTool } from "./domains/drag";
|
|
16
16
|
import { httpGetTool, networkRequestsTool } from "./domains/network";
|
|
17
|
+
import { consoleTool } from "./domains/console";
|
|
17
18
|
import { snapshotTool } from "./domains/snapshot";
|
|
18
19
|
import { executeJsTool, runScriptTool } from "./domains/js";
|
|
19
20
|
|
|
@@ -48,6 +49,7 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
|
|
|
48
49
|
{ ...dragAndDropTool, serialized: true },
|
|
49
50
|
httpGetTool,
|
|
50
51
|
networkRequestsTool,
|
|
52
|
+
consoleTool,
|
|
51
53
|
snapshotTool,
|
|
52
54
|
executeJsTool,
|
|
53
55
|
runScriptTool,
|