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 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
  ![pi-browser-harness](https://raw.githubusercontent.com/amankumarsingh77/pi-browser-harness/main/assets/hero.png)
4
4
 
5
- Full browser control for pi agents. Navigate, click, type, screenshot, and extract data from your real Chrome — all driven through natural language via CDP.
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
- | `browser_screenshot` | Capture a PNG screenshot of the current page |
37
- | `browser_click` | Click at viewport coordinates |
38
- | `browser_type` | Type text into the focused element |
39
- | `browser_press_key` | Press a key (Enter, Tab, Escape, arrows, etc.) with optional modifiers |
40
- | `browser_scroll` | Scroll the page by delta pixels |
41
- | `browser_navigate` | Navigate to a URL |
42
- | `browser_new_tab` | Open a new tab, optionally navigate |
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
- | `browser_page_info` | Get URL, title, viewport, scroll position, or dialog state |
46
- | `browser_list_tabs` / `browser_current_tab` / `browser_switch_tab` | Tab discovery and switching |
47
- | `browser_execute_js` | Run JavaScript in the page and return the result |
48
- | `browser_http_get` | Direct HTTP GET outside the browser — much faster for APIs |
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
- ## Core Patterns
122
+ ---
56
123
 
57
- The golden rule: **screenshot → act → screenshot → verify.** Never assume — always look.
124
+ ## Core patterns
58
125
 
59
- ### Navigation
126
+ ### Page inspection
60
127
 
61
128
  ```
62
- browser_new_tab({ url: "https://example.com" })
63
- browser_wait_for_load()
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
- browser_screenshot() # see the form
71
- browser_click({ x: 400, y: 200 }) # click the search input
72
- browser_type({ text: "query" }) # type
73
- browser_press_key({ key: "Enter" }) # submit
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
- browser_screenshot() # verify
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: `Array.from(document.querySelectorAll('.result')).map(el => ({
87
- title: el.querySelector('h3')?.textContent,
88
- link: el.querySelector('a')?.href,
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
- browser_screenshot()
181
+ browser_wait_for_load()
182
+ browser_snapshot()
101
183
  browser_execute_js({ expression: "document.querySelector('.content').innerText" })
102
184
  ```
103
185
 
104
- ### Scrolling
186
+ ### Visual verification (only when pixels matter)
105
187
 
106
188
  ```
107
- browser_screenshot()
108
- browser_scroll({ deltaY: -500 })
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
- ### SPAs (React, Vue, etc.)
209
+ ### Dialogs
129
210
 
130
- `browser_wait_for_load` only checks `readyState`. Poll for elements after:
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
- browser_execute_js({ expression: "!!document.querySelector('.loaded-content')" })
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
- ### Dialogs
219
+ ---
220
+
221
+ ## Parallel execution
137
222
 
138
- JS dialogs (`alert` / `confirm` / `prompt`) freeze the page. Check `browser_page_info` first — if it reports a dialog, handle it before anything else:
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
- browser_handle_dialog({ action: "accept" }) // confirm
142
- browser_handle_dialog({ action: "dismiss" }) // cancel
143
- browser_handle_dialog({ action: "accept", promptText: "hello" }) // prompt
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
- ## Temporary Scripts
234
+ ## Tab ownership
149
235
 
150
- When built-in tools aren't enough, write a script to disk and run it:
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.cdp("Page.navigate", { url });
248
+ await daemon.session().call("Page.navigate", { url });
158
249
  await new Promise(r => setTimeout(r, 2000));
159
- const title = await daemon.evaluateJS("document.title");
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
- **Script bindings:** `params`, `daemon`, `require`, `signal`, `onUpdate`, `ctx`, `console`, `fetch`, `JSON`, `Buffer`, `setTimeout`, `clearTimeout`.
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 assume a page action worked — screenshot to verify
190
- - Don't write pixel coordinates into documentation — describe selectors and text instead
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 — poll with `browser_execute_js` for the element you need |
215
- | A JS dialog is blocking actions | `browser_page_info` will report it — use `browser_handle_dialog` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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
+ };
@@ -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
- let dialog: DialogInfo | null = null;
27
- let pageInfoDirty = false;
28
- const networkBuffer = createNetworkBuffer();
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
- pageInfoDirty = true;
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) ownership.remove(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
- pageInfoDirty = false;
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
- sessionId = a.sessionId;
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 d = dialog;
173
- dialog = null;
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 dirty = pageInfoDirty;
178
- pageInfoDirty = false;
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
- return networkBuffer.drain(filter);
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 pageCache: { readonly info: PageInfo; readonly at: number } | null = null;
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
- pageCache = null;
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
- pageCache = null;
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
- pageCache = null;
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
- if (pageCache && !dirty && Date.now() - pageCache.at < PAGE_INFO_TTL_MS) return ok(pageCache.info);
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
- pageCache = { info: info.data, at: Date.now() };
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 = null;
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,