pi-browser-harness 0.10.1 → 0.11.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 +34 -0
- package/README.md +8 -12
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +40 -96
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.11.0 — 2026-08-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`--browser-debug-clicks` now does something.** The flag was registered and documented but never read; the only working switch was the undocumented `BH_DEBUG_CLICKS` environment variable. Both now drive the same setting.
|
|
10
|
+
- **`BU_CDP_WS` now actually attaches to a remote browser.** The client read the variable and passed the URL to `transport.connect()`, but the daemon transport discards that argument and always dials the daemon socket — and the daemon's own discovery never looked at the variable, so setting it changed nothing. The override moved into `discoverEndpoint`, which both processes go through. It is read when the daemon starts, so a running daemon must be stopped first.
|
|
11
|
+
- **A disconnected client's pending requests no longer fire at a dead socket.** `removeClient` cleared the daemon's id multiplexer but not its callback map, so a gone client's command timeouts stayed armed and later tried to answer it.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **One route from a tool to the browser.** Four coexisted; `src/domains/cdp-call.ts` is now the only one, and two boundary-scanner rules scoped to `src/domains/` keep it that way. A CDP timeout now surfaces as `kind: "timeout"` instead of `cdp_error` at the sites that previously flattened it.
|
|
16
|
+
- **Shared logic moved out of tool files** into `ax-tree.ts`, `element-call.ts`, and `screenshot-capture.ts`, so importing one tool no longer pulls in another tool's module. `cdp/attach.ts` and `cdp/window.ts` became `session.attach()` and `session.windowId()`; `cdp/daemon-transport.ts` became `daemon/transport.ts`; the request-timeout half of `cdp/event-queue.ts` became `cdp/pending-requests.ts`.
|
|
17
|
+
- **The daemon's request bookkeeping is one map instead of two**, and a request that arrives while Chrome is down awaits a single connect signal rather than spinning its own 250 ms poll loop.
|
|
18
|
+
|
|
19
|
+
### Removed
|
|
20
|
+
|
|
21
|
+
- **The second CDP transport.** `createCdpTransport` was a full WebSocket-to-Chrome implementation reachable only through a fallback in `client.ts` that production never took. `cdp/transport.ts` now holds just the `CdpTransport` interface, and a client must be given a transport.
|
|
22
|
+
- **Dead exports and a fake config knob:** `isInternalUrl`, `SPECIAL_KEYS`, `andThen`, `mapErr`, `BrowserState.remoteBrowserId`, `IpcServer.disconnectClient`, `IpcServer.clients()`, and `DAEMON_STALE_SOCKET_CLEANUP` (a hardcoded `true` with a branch around it).
|
|
23
|
+
|
|
24
|
+
- **The `deep-research` skill, the `/deep-research` command, and the `web-search-researcher` subagent.** The harness ships browser tools; orchestrating multi-agent research on top of them belongs to the agent, not to this extension. `browser_web_search` and `browser_read_page` are unaffected — search then read still works, it is just no longer wrapped in a fan-out workflow.
|
|
25
|
+
|
|
26
|
+
## 0.10.3 — 2026-07-26
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Profile binding after an in-place browser upgrade.** Linux appends `" (deleted)"` to `/proc/<pid>/exe` once the running binary is unlinked — which is exactly what a `google-chrome` package update does while the browser stays open. `detectRunningBrowser` returned that literal string as the executable path, so every profile launch tried to spawn `/opt/google/chrome/chrome (deleted)` and failed with `ENOENT`. Executable paths are now validated against disk, with the marker stripped (and argv[0] used) when the linked binary is gone.
|
|
31
|
+
- **A failed browser launch no longer reports as a sentinel timeout.** `spawn()` signals a missing or non-executable binary asynchronously via an `error` event rather than by throwing, and that event was discarded — so `openProfileWindow` returned success, no window ever opened, and the caller blamed the browser 15 seconds later with `couldn't open a window in "<profile>" automatically`. The launch now waits for `spawn`/`error` and returns the real cause, and removes its handshake page on the failure path.
|
|
32
|
+
|
|
33
|
+
## 0.10.2 — 2026-07-26
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **Page info no longer fails on a missing `documentElement`.** The page-info probe read `document.documentElement.scrollWidth`/`scrollHeight` directly, which throws when `documentElement` is absent — leaving every caller with an evaluation error instead of a page snapshot. The probe now falls back to zeroed scroll dimensions and returns a usable `PageInfo`.
|
|
38
|
+
|
|
5
39
|
## 0.10.1 — 2026-07-26
|
|
6
40
|
|
|
7
41
|
### Fixed
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Your profile, your logins, your cookies. The agent reads pages as an accessibility tree with stable
|
|
8
8
|
element handles, fills forms that React won't revert, captures network traffic and console errors,
|
|
9
|
-
searches the web, and drops to raw CDP when the tools run out.
|
|
9
|
+
searches the web, and drops to raw CDP when the tools run out. 40 tools over a single CDP connection —
|
|
10
10
|
no MCP hop, no second browser, no extra API keys.
|
|
11
11
|
|
|
12
12
|
---
|
|
@@ -50,9 +50,9 @@ The first time you connect, you'll be asked which browser profile the agent shou
|
|
|
50
50
|
|
|
51
51
|
```
|
|
52
52
|
Select the browser profile the agent should use
|
|
53
|
-
|
|
54
|
-
✓
|
|
55
|
-
|
|
53
|
+
Work (work@example.com)
|
|
54
|
+
✓ Personal (personal@example.com)
|
|
55
|
+
Testing (test@example.com)
|
|
56
56
|
— Clear selection (use whichever window is focused) —
|
|
57
57
|
```
|
|
58
58
|
|
|
@@ -238,11 +238,7 @@ browser_web_search({ query: "chrome devtools protocol overview", limit: 5 })
|
|
|
238
238
|
browser_read_page({ url: "<the best result's url>" })
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
`/deep-research <question>`. It decomposes the question, fans out one isolated
|
|
243
|
-
`web-search-researcher` subagent per sub-question, loops for coverage under a hard ceiling, and
|
|
244
|
-
writes a Markdown report where every claim carries a source link. The skill also auto-triggers on
|
|
245
|
-
research-shaped requests without the command.
|
|
241
|
+
Both tools open their own isolated tab, so neither disturbs the tab you are working in.
|
|
246
242
|
|
|
247
243
|
### Keyboard modifiers
|
|
248
244
|
|
|
@@ -310,14 +306,14 @@ re-runnable.
|
|
|
310
306
|
| `/browser-setup` | Connect pi to your browser. Run once; idempotent. |
|
|
311
307
|
| `/browser-profile` | Pick which browser profile the agent works in. Remembered across sessions. |
|
|
312
308
|
| `/browser-status` | Connection state, selected profile, and current page. |
|
|
313
|
-
| `/deep-research <question>` | Multi-source web research → cited Markdown report. |
|
|
314
309
|
| `/browser-reload-daemon` | Force a client restart. Rarely needed — the transport reconnects on its own. |
|
|
315
310
|
|
|
316
311
|
| Flag / variable | Effect |
|
|
317
312
|
|---|---|
|
|
318
313
|
| `--browser-namespace <name>` | Override the session namespace (default: auto-generated). |
|
|
319
|
-
| `--browser-debug-clicks` | Save annotated click screenshots for debugging. |
|
|
320
|
-
| `BU_CDP_WS` | Attach to a remote browser by WebSocket URL instead of discovering a local one. |
|
|
314
|
+
| `--browser-debug-clicks` | Save annotated click screenshots for debugging. Equivalent to `BH_DEBUG_CLICKS=1`. |
|
|
315
|
+
| `BU_CDP_WS` | Attach to a remote browser by WebSocket URL instead of discovering a local one. The daemon reads this when it starts, so stop a running daemon first. |
|
|
316
|
+
| `BH_DEBUG_CLICKS` | Same as `--browser-debug-clicks`. |
|
|
321
317
|
| `BU_CDP_PORTS` | Extra ports to probe during CDP discovery, beyond 9222. |
|
|
322
318
|
| `BH_SCRIPT_DIR` | Additional directory `browser_run_script` will accept paths from. |
|
|
323
319
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-browser-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -57,7 +57,10 @@
|
|
|
57
57
|
"typescript": "^5.0.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
|
-
"typecheck": "tsc --noEmit",
|
|
60
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
|
|
61
|
+
"test": "node --conditions=import --import tsx --test \"test/{profile,domains,cdp,daemon,util}/**/*-test.ts\"",
|
|
62
|
+
"boundaries": "tsx scripts/check-boundaries.ts",
|
|
63
|
+
"check": "npm run typecheck && npm run boundaries && npm test",
|
|
61
64
|
"prepublishOnly": "tsc --noEmit"
|
|
62
65
|
},
|
|
63
66
|
"publishConfig": {
|
|
@@ -36,7 +36,7 @@ What do you need to know?
|
|
|
36
36
|
|
|
37
37
|
Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot round-trip.
|
|
38
38
|
|
|
39
|
-
`browser_web_search` and `browser_read_page` each run in their own isolated tab and never touch the user's current tab.
|
|
39
|
+
`browser_web_search` and `browser_read_page` each run in their own isolated tab and never touch the user's current tab. Pair them for a research question: search for candidate URLs, then read the promising ones.
|
|
40
40
|
|
|
41
41
|
## Connection Setup
|
|
42
42
|
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Type, type Static, type TSchema } from "typebox";
|
|
2
|
+
import { Compile } from "typebox/compile";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import { type CdpError, cdpError } from "./errors";
|
|
5
|
+
|
|
6
|
+
const cmd = <P extends TSchema, R extends TSchema>(params: P, result: R) => ({
|
|
7
|
+
params,
|
|
8
|
+
result,
|
|
9
|
+
validate: Compile(result),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const Empty = Type.Object({});
|
|
13
|
+
|
|
14
|
+
const TargetInfo = Type.Object(
|
|
15
|
+
{
|
|
16
|
+
targetId: Type.String(),
|
|
17
|
+
type: Type.String(),
|
|
18
|
+
title: Type.String(),
|
|
19
|
+
url: Type.String(),
|
|
20
|
+
attached: Type.Optional(Type.Boolean()),
|
|
21
|
+
openerId: Type.Optional(Type.String()),
|
|
22
|
+
browserContextId: Type.Optional(Type.String()),
|
|
23
|
+
},
|
|
24
|
+
{ additionalProperties: true },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const RemoteObject = Type.Object(
|
|
28
|
+
{
|
|
29
|
+
type: Type.String(),
|
|
30
|
+
subtype: Type.Optional(Type.String()),
|
|
31
|
+
className: Type.Optional(Type.String()),
|
|
32
|
+
value: Type.Optional(Type.Unknown()),
|
|
33
|
+
description: Type.Optional(Type.String()),
|
|
34
|
+
objectId: Type.Optional(Type.String()),
|
|
35
|
+
},
|
|
36
|
+
{ additionalProperties: true },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const CallArgument = Type.Object(
|
|
40
|
+
{
|
|
41
|
+
value: Type.Optional(Type.Unknown()),
|
|
42
|
+
objectId: Type.Optional(Type.String()),
|
|
43
|
+
unserializableValue: Type.Optional(Type.String()),
|
|
44
|
+
},
|
|
45
|
+
{ additionalProperties: true },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const ExceptionDetails = Type.Object(
|
|
49
|
+
{ text: Type.String(), exception: Type.Optional(RemoteObject) },
|
|
50
|
+
{ additionalProperties: true },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const AxValue = Type.Object({ value: Type.Optional(Type.Unknown()) }, { additionalProperties: true });
|
|
54
|
+
|
|
55
|
+
const AxNode = Type.Object(
|
|
56
|
+
{
|
|
57
|
+
nodeId: Type.String(),
|
|
58
|
+
parentId: Type.Optional(Type.String()),
|
|
59
|
+
childIds: Type.Optional(Type.Array(Type.String())),
|
|
60
|
+
ignored: Type.Optional(Type.Boolean()),
|
|
61
|
+
role: Type.Optional(AxValue),
|
|
62
|
+
name: Type.Optional(AxValue),
|
|
63
|
+
value: Type.Optional(AxValue),
|
|
64
|
+
description: Type.Optional(AxValue),
|
|
65
|
+
properties: Type.Optional(
|
|
66
|
+
Type.Array(
|
|
67
|
+
Type.Object(
|
|
68
|
+
{ name: Type.Optional(Type.String()), value: Type.Optional(AxValue) },
|
|
69
|
+
{ additionalProperties: true },
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
backendDOMNodeId: Type.Optional(Type.Number()),
|
|
74
|
+
},
|
|
75
|
+
{ additionalProperties: true },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export type AxNodeResult = Static<typeof AxNode>;
|
|
79
|
+
|
|
80
|
+
export const COMMANDS = {
|
|
81
|
+
"Target.attachToTarget": cmd(
|
|
82
|
+
Type.Object({ targetId: Type.String(), flatten: Type.Optional(Type.Boolean()) }),
|
|
83
|
+
Type.Object({ sessionId: Type.String() }),
|
|
84
|
+
),
|
|
85
|
+
"Target.detachFromTarget": cmd(
|
|
86
|
+
Type.Object({ sessionId: Type.Optional(Type.String()) }),
|
|
87
|
+
Empty,
|
|
88
|
+
),
|
|
89
|
+
"Target.createTarget": cmd(
|
|
90
|
+
Type.Object({
|
|
91
|
+
url: Type.String(),
|
|
92
|
+
newWindow: Type.Optional(Type.Boolean()),
|
|
93
|
+
background: Type.Optional(Type.Boolean()),
|
|
94
|
+
windowId: Type.Optional(Type.Number()),
|
|
95
|
+
openerId: Type.Optional(Type.String()),
|
|
96
|
+
}),
|
|
97
|
+
Type.Object({ targetId: Type.String() }),
|
|
98
|
+
),
|
|
99
|
+
"Target.closeTarget": cmd(
|
|
100
|
+
Type.Object({ targetId: Type.String() }),
|
|
101
|
+
Type.Object({ success: Type.Optional(Type.Boolean()) }),
|
|
102
|
+
),
|
|
103
|
+
"Target.activateTarget": cmd(Type.Object({ targetId: Type.String() }), Empty),
|
|
104
|
+
"Target.getTargetInfo": cmd(
|
|
105
|
+
Type.Object({ targetId: Type.Optional(Type.String()) }),
|
|
106
|
+
Type.Object({ targetInfo: TargetInfo }),
|
|
107
|
+
),
|
|
108
|
+
"Target.getTargets": cmd(
|
|
109
|
+
Type.Object({}),
|
|
110
|
+
Type.Object({ targetInfos: Type.Array(TargetInfo) }),
|
|
111
|
+
),
|
|
112
|
+
"Target.setDiscoverTargets": cmd(Type.Object({ discover: Type.Boolean() }), Empty),
|
|
113
|
+
|
|
114
|
+
"Browser.getWindowForTarget": cmd(
|
|
115
|
+
Type.Object({ targetId: Type.Optional(Type.String()) }),
|
|
116
|
+
Type.Object({ windowId: Type.Number(), bounds: Type.Optional(Type.Unknown()) }),
|
|
117
|
+
),
|
|
118
|
+
"Browser.setDownloadBehavior": cmd(
|
|
119
|
+
Type.Object({
|
|
120
|
+
behavior: Type.String(),
|
|
121
|
+
downloadPath: Type.Optional(Type.String()),
|
|
122
|
+
eventsEnabled: Type.Optional(Type.Boolean()),
|
|
123
|
+
}),
|
|
124
|
+
Empty,
|
|
125
|
+
),
|
|
126
|
+
|
|
127
|
+
"Page.enable": cmd(Type.Object({}), Empty),
|
|
128
|
+
"Page.bringToFront": cmd(Type.Object({}), Empty),
|
|
129
|
+
"Page.navigate": cmd(
|
|
130
|
+
Type.Object({ url: Type.String(), referrer: Type.Optional(Type.String()) }),
|
|
131
|
+
Type.Object({
|
|
132
|
+
frameId: Type.String(),
|
|
133
|
+
loaderId: Type.Optional(Type.String()),
|
|
134
|
+
errorText: Type.Optional(Type.String()),
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
"Page.reload": cmd(Type.Object({ ignoreCache: Type.Optional(Type.Boolean()) }), Empty),
|
|
138
|
+
"Page.getNavigationHistory": cmd(
|
|
139
|
+
Type.Object({}),
|
|
140
|
+
Type.Object({
|
|
141
|
+
currentIndex: Type.Number(),
|
|
142
|
+
entries: Type.Array(
|
|
143
|
+
Type.Object({ id: Type.Number(), url: Type.String(), title: Type.String() }),
|
|
144
|
+
),
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
"Page.navigateToHistoryEntry": cmd(Type.Object({ entryId: Type.Number() }), Empty),
|
|
148
|
+
"Page.captureScreenshot": cmd(
|
|
149
|
+
Type.Object({
|
|
150
|
+
format: Type.Optional(Type.String()),
|
|
151
|
+
quality: Type.Optional(Type.Number()),
|
|
152
|
+
clip: Type.Optional(Type.Unknown()),
|
|
153
|
+
captureBeyondViewport: Type.Optional(Type.Boolean()),
|
|
154
|
+
}),
|
|
155
|
+
Type.Object({ data: Type.String() }),
|
|
156
|
+
),
|
|
157
|
+
"Page.printToPDF": cmd(Type.Object({}, { additionalProperties: true }), Type.Object({ data: Type.String() })),
|
|
158
|
+
"Page.handleJavaScriptDialog": cmd(
|
|
159
|
+
Type.Object({ accept: Type.Boolean(), promptText: Type.Optional(Type.String()) }),
|
|
160
|
+
Empty,
|
|
161
|
+
),
|
|
162
|
+
|
|
163
|
+
"Runtime.evaluate": cmd(
|
|
164
|
+
Type.Object(
|
|
165
|
+
{
|
|
166
|
+
expression: Type.String(),
|
|
167
|
+
returnByValue: Type.Optional(Type.Boolean()),
|
|
168
|
+
awaitPromise: Type.Optional(Type.Boolean()),
|
|
169
|
+
userGesture: Type.Optional(Type.Boolean()),
|
|
170
|
+
},
|
|
171
|
+
{ additionalProperties: true },
|
|
172
|
+
),
|
|
173
|
+
Type.Object({ result: RemoteObject, exceptionDetails: Type.Optional(ExceptionDetails) }),
|
|
174
|
+
),
|
|
175
|
+
"Runtime.callFunctionOn": cmd(
|
|
176
|
+
Type.Object(
|
|
177
|
+
{
|
|
178
|
+
functionDeclaration: Type.String(),
|
|
179
|
+
objectId: Type.Optional(Type.String()),
|
|
180
|
+
arguments: Type.Optional(Type.Array(CallArgument)),
|
|
181
|
+
returnByValue: Type.Optional(Type.Boolean()),
|
|
182
|
+
awaitPromise: Type.Optional(Type.Boolean()),
|
|
183
|
+
userGesture: Type.Optional(Type.Boolean()),
|
|
184
|
+
},
|
|
185
|
+
{ additionalProperties: true },
|
|
186
|
+
),
|
|
187
|
+
Type.Object({ result: RemoteObject, exceptionDetails: Type.Optional(ExceptionDetails) }),
|
|
188
|
+
),
|
|
189
|
+
"Runtime.releaseObject": cmd(Type.Object({ objectId: Type.String() }), Empty),
|
|
190
|
+
|
|
191
|
+
"DOM.getDocument": cmd(
|
|
192
|
+
Type.Object({ depth: Type.Optional(Type.Number()), pierce: Type.Optional(Type.Boolean()) }),
|
|
193
|
+
Type.Object({ root: Type.Object({ nodeId: Type.Number() }, { additionalProperties: true }) }),
|
|
194
|
+
),
|
|
195
|
+
"DOM.querySelector": cmd(
|
|
196
|
+
Type.Object({ nodeId: Type.Number(), selector: Type.String() }),
|
|
197
|
+
Type.Object({ nodeId: Type.Number() }),
|
|
198
|
+
),
|
|
199
|
+
"DOM.getBoxModel": cmd(
|
|
200
|
+
Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
|
|
201
|
+
Type.Object({
|
|
202
|
+
model: Type.Object(
|
|
203
|
+
{ content: Type.Array(Type.Number()), width: Type.Number(), height: Type.Number() },
|
|
204
|
+
{ additionalProperties: true },
|
|
205
|
+
),
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
"DOM.resolveNode": cmd(
|
|
209
|
+
Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
|
|
210
|
+
Type.Object({ object: RemoteObject }),
|
|
211
|
+
),
|
|
212
|
+
"DOM.setFileInputFiles": cmd(
|
|
213
|
+
Type.Object({
|
|
214
|
+
files: Type.Array(Type.String()),
|
|
215
|
+
nodeId: Type.Optional(Type.Number()),
|
|
216
|
+
backendNodeId: Type.Optional(Type.Number()),
|
|
217
|
+
objectId: Type.Optional(Type.String()),
|
|
218
|
+
}),
|
|
219
|
+
Empty,
|
|
220
|
+
),
|
|
221
|
+
|
|
222
|
+
"Input.dispatchMouseEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
|
|
223
|
+
"Input.dispatchKeyEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
|
|
224
|
+
"Input.dispatchDragEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
|
|
225
|
+
"Input.insertText": cmd(Type.Object({ text: Type.String() }), Empty),
|
|
226
|
+
|
|
227
|
+
"Network.getResponseBody": cmd(
|
|
228
|
+
Type.Object({ requestId: Type.String() }),
|
|
229
|
+
Type.Object({ body: Type.String(), base64Encoded: Type.Optional(Type.Boolean()) }),
|
|
230
|
+
),
|
|
231
|
+
"Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty),
|
|
232
|
+
"Accessibility.getFullAXTree": cmd(
|
|
233
|
+
Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }),
|
|
234
|
+
Type.Object({ nodes: Type.Array(AxNode) }),
|
|
235
|
+
),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export type CdpMethod = keyof typeof COMMANDS;
|
|
239
|
+
export type ParamsOf<M extends CdpMethod> = Static<(typeof COMMANDS)[M]["params"]>;
|
|
240
|
+
export type ResultOf<M extends CdpMethod> = Static<(typeof COMMANDS)[M]["result"]>;
|
|
241
|
+
|
|
242
|
+
export const decodeResult = <M extends CdpMethod>(
|
|
243
|
+
method: M,
|
|
244
|
+
raw: unknown,
|
|
245
|
+
): Result<ResultOf<M>, CdpError> => {
|
|
246
|
+
const spec = COMMANDS[method];
|
|
247
|
+
if (spec === undefined) {
|
|
248
|
+
return err(cdpError("invalid_response", `unknown CDP method`, method));
|
|
249
|
+
}
|
|
250
|
+
if (!spec.validate.Check(raw)) {
|
|
251
|
+
const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
|
|
252
|
+
return err(cdpError("invalid_response", why, method));
|
|
253
|
+
}
|
|
254
|
+
// Guarded assertion: the check above proves `raw` matches COMMANDS[method].result, which TS cannot correlate from a generic key.
|
|
255
|
+
return ok(raw as ResultOf<M>);
|
|
256
|
+
};
|
|
@@ -1,54 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
*/
|
|
1
|
+
import { decodeEvent } from "./events";
|
|
2
|
+
import { createRecordStore } from "./record-store";
|
|
18
3
|
|
|
19
4
|
export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
|
|
20
5
|
|
|
21
6
|
export type ConsoleRecord = {
|
|
22
7
|
seq: number;
|
|
23
|
-
/** Wall-clock ms when the event was ingested, in Date.now() units. */
|
|
24
8
|
timestampMs: number;
|
|
25
9
|
level: ConsoleLevel;
|
|
26
|
-
/** Joined args (console-api) or the entry text (log-entry). Per-arg truncated. */
|
|
27
10
|
text: string;
|
|
28
11
|
source: "console-api" | "log-entry";
|
|
29
12
|
url?: string;
|
|
30
13
|
lineNumber?: number;
|
|
31
|
-
/** Top frames only, joined with newlines. Populated for error/warn when CDP provides one. */
|
|
32
14
|
stackTrace?: string;
|
|
33
15
|
};
|
|
34
16
|
|
|
35
17
|
export type ConsoleFilter = {
|
|
36
18
|
levels?: ConsoleLevel[];
|
|
37
|
-
/** Substring match by default. Wrap in slashes (`/foo/`) for regex. */
|
|
38
19
|
textPattern?: string;
|
|
39
|
-
/** Only records with seq strictly greater than this. */
|
|
40
20
|
sinceSeq?: number;
|
|
41
|
-
/** Only records with timestamp at most N ms ago. */
|
|
42
21
|
sinceMs?: number;
|
|
43
|
-
/** Cap on returned records (default 50, max 500). */
|
|
44
22
|
limit?: number;
|
|
45
23
|
};
|
|
46
24
|
|
|
47
25
|
export type ConsoleDrainResult = {
|
|
48
26
|
readonly records: ConsoleRecord[];
|
|
49
|
-
/** Total matches before `limit` was applied. */
|
|
50
27
|
readonly total: number;
|
|
51
|
-
/** True if the ring buffer evicted at least one record since the last drain. */
|
|
52
28
|
readonly bufferOverflowed: boolean;
|
|
53
29
|
};
|
|
54
30
|
|
|
@@ -59,9 +35,7 @@ export type ConsoleBuffer = {
|
|
|
59
35
|
clear(): void;
|
|
60
36
|
};
|
|
61
37
|
|
|
62
|
-
/** Per-arg cap to stop a single console.log(hugeBlob) from blowing the buffer. */
|
|
63
38
|
const PER_ARG_CAP = 2000;
|
|
64
|
-
/** Max stack frames preserved per record. */
|
|
65
39
|
const MAX_STACK_FRAMES = 3;
|
|
66
40
|
|
|
67
41
|
const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
@@ -69,9 +43,7 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
|
69
43
|
try {
|
|
70
44
|
const re = new RegExp(pattern.slice(1, -1));
|
|
71
45
|
return (text) => re.test(text);
|
|
72
|
-
} catch {
|
|
73
|
-
// Fall through to substring match if the pattern is not a valid regex.
|
|
74
|
-
}
|
|
46
|
+
} catch {}
|
|
75
47
|
}
|
|
76
48
|
return (text) => text.includes(pattern);
|
|
77
49
|
};
|
|
@@ -79,7 +51,6 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
|
79
51
|
const truncateArg = (s: string): string =>
|
|
80
52
|
s.length > PER_ARG_CAP ? s.slice(0, PER_ARG_CAP) + "…" : s;
|
|
81
53
|
|
|
82
|
-
/** Best-effort stringification of one Runtime.RemoteObject. */
|
|
83
54
|
const stringifyRemoteObject = (
|
|
84
55
|
arg: { type?: string; subtype?: string; value?: unknown; description?: string; unserializableValue?: string },
|
|
85
56
|
): string => {
|
|
@@ -96,7 +67,6 @@ const stringifyRemoteObject = (
|
|
|
96
67
|
return arg.type ?? "";
|
|
97
68
|
};
|
|
98
69
|
|
|
99
|
-
/** Map CDP console-API method names to our normalized levels. */
|
|
100
70
|
const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
101
71
|
switch (type) {
|
|
102
72
|
case "error":
|
|
@@ -114,7 +84,6 @@ const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
|
114
84
|
}
|
|
115
85
|
};
|
|
116
86
|
|
|
117
|
-
/** Map Log.LogEntry.level values ("verbose","info","warning","error") to our levels. */
|
|
118
87
|
const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => {
|
|
119
88
|
switch (level) {
|
|
120
89
|
case "error":
|
|
@@ -143,43 +112,18 @@ const formatStackTrace = (
|
|
|
143
112
|
};
|
|
144
113
|
|
|
145
114
|
export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
|
|
146
|
-
|
|
147
|
-
// so iteration gives us oldest-first eviction without a separate index.
|
|
148
|
-
const records = new Map<number, ConsoleRecord>();
|
|
115
|
+
const records = createRecordStore<number, ConsoleRecord>(capacity);
|
|
149
116
|
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
117
|
|
|
161
118
|
const insert = (rec: ConsoleRecord): void => {
|
|
162
|
-
evictOldestIfFull();
|
|
163
119
|
records.set(rec.seq, rec);
|
|
164
120
|
};
|
|
165
121
|
|
|
166
122
|
return {
|
|
167
123
|
ingestConsoleApi(p) {
|
|
168
|
-
|
|
169
|
-
|
|
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;
|
|
124
|
+
const decoded = decodeEvent("Runtime.consoleAPICalled", p);
|
|
125
|
+
if (!decoded.success) return;
|
|
126
|
+
const params = decoded.data;
|
|
183
127
|
const level = mapConsoleApiLevel(params.type);
|
|
184
128
|
const argTexts = (params.args ?? []).map((a) => truncateArg(stringifyRemoteObject(a)));
|
|
185
129
|
const text = argTexts.join(" ");
|
|
@@ -199,19 +143,8 @@ export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
|
|
|
199
143
|
},
|
|
200
144
|
|
|
201
145
|
ingestLogEntry(p) {
|
|
202
|
-
|
|
203
|
-
const
|
|
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;
|
|
146
|
+
const decoded = decodeEvent("Log.entryAdded", p);
|
|
147
|
+
const entry = decoded.success ? decoded.data.entry : undefined;
|
|
215
148
|
if (!entry) return;
|
|
216
149
|
const level = mapLogEntryLevel(entry.level);
|
|
217
150
|
const text = truncateArg(entry.text ?? "");
|
|
@@ -244,20 +177,12 @@ export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
|
|
|
244
177
|
matched.push({ ...r });
|
|
245
178
|
}
|
|
246
179
|
|
|
247
|
-
|
|
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 };
|
|
180
|
+
return records.page(matched, filter.limit);
|
|
255
181
|
},
|
|
256
182
|
|
|
257
183
|
clear() {
|
|
258
184
|
records.clear();
|
|
259
185
|
nextSeq = 1;
|
|
260
|
-
overflowed = false;
|
|
261
186
|
},
|
|
262
187
|
};
|
|
263
188
|
};
|