pi-browser-harness 0.10.2 → 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.
Files changed (88) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -9
  3. package/package.json +5 -2
  4. package/skills/pi-browser-harness/SKILL.md +1 -1
  5. package/src/cdp/commands.ts +256 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +28 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +150 -203
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +18 -68
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -32
  28. package/src/domains/console.ts +48 -2
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +22 -26
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +65 -0
  35. package/src/domains/form.ts +116 -105
  36. package/src/domains/history.ts +13 -9
  37. package/src/domains/isolated-tab.ts +26 -55
  38. package/src/domains/js.ts +29 -42
  39. package/src/domains/keyboard.ts +36 -27
  40. package/src/domains/navigate.ts +16 -23
  41. package/src/domains/network.ts +60 -10
  42. package/src/domains/page.ts +24 -3
  43. package/src/domains/readpage/read-page.ts +74 -19
  44. package/src/domains/readpage/readability.ts +0 -24
  45. package/src/domains/ref-resolve.ts +24 -58
  46. package/src/domains/render.ts +6 -27
  47. package/src/domains/screenshot-capture.ts +80 -0
  48. package/src/domains/screenshot.ts +9 -86
  49. package/src/domains/scroll.ts +10 -11
  50. package/src/domains/search/google-serp.ts +0 -29
  51. package/src/domains/search/web-search.ts +48 -20
  52. package/src/domains/setup.ts +1 -11
  53. package/src/domains/snapshot.ts +35 -276
  54. package/src/domains/tabs.ts +14 -7
  55. package/src/domains/target.ts +35 -0
  56. package/src/domains/viewport.ts +5 -3
  57. package/src/index.ts +8 -58
  58. package/src/profile/bind.ts +8 -57
  59. package/src/profile/browser-process.ts +75 -126
  60. package/src/profile/command.ts +50 -29
  61. package/src/profile/launch.ts +22 -40
  62. package/src/profile/list.ts +14 -54
  63. package/src/profile/paths.ts +2 -65
  64. package/src/profile/store.ts +35 -56
  65. package/src/registry.ts +30 -33
  66. package/src/schemas/parse.ts +23 -0
  67. package/src/setup.ts +0 -25
  68. package/src/state.ts +16 -16
  69. package/src/util/debug.ts +8 -0
  70. package/src/util/guards.ts +30 -0
  71. package/src/util/js-template.ts +1 -16
  72. package/src/util/mutex.ts +0 -5
  73. package/src/util/result.ts +0 -6
  74. package/src/util/sharp-shim.ts +12 -19
  75. package/src/util/tool.ts +17 -42
  76. package/src/util/truncate.ts +6 -0
  77. package/tsconfig.json +1 -16
  78. package/skills/deep-research/SKILL.md +0 -66
  79. package/src/cdp/event-queue.ts +0 -128
  80. package/src/cdp/transport.ts +0 -156
  81. package/src/cdp/window-binding.ts +0 -15
  82. package/src/deep-research.ts +0 -41
  83. package/src/domains/forms.ts +0 -355
  84. package/src/domains/readpage/capture.ts +0 -46
  85. package/src/domains/search/extract.ts +0 -44
  86. package/src/profile/picker.ts +0 -73
  87. package/src/util/keycodes.ts +0 -10
  88. package/src/util/time.ts +0 -29
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
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
+
5
33
  ## 0.10.2 — 2026-07-26
6
34
 
7
35
  ### 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. 38 tools over a single CDP connection —
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
  ---
@@ -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
- For a question that needs several sources synthesized into a **cited report**, use
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.10.2",
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. For a multi-source question that needs a synthesized, cited report, use the **deep-research** skill (or `/deep-research <question>`): it fans out isolated `web-search-researcher` subagents over both tools and writes a source-cited Markdown report.
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
- * 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
- */
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
- // 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>();
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
- // 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;
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
- // 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;
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
- 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 };
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
  };