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
@@ -2,20 +2,24 @@ import { readFile, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { connect as netConnect } from "node:net";
4
4
  import { request as httpRequest } from "node:http";
5
+ import { Type } from "typebox";
6
+ import { Compile } from "typebox/compile";
7
+ import { parseJson } from "../schemas/parse";
5
8
  import { type Result, err, ok } from "../util/result";
6
9
  import { userDataDirCandidates } from "../profile/paths";
7
10
  import { type CdpError, cdpError } from "./errors";
11
+ import { errnoCode } from "../util/guards";
8
12
 
9
13
  const PORT_PROBE_DEADLINE_MS = 30_000;
10
14
  const PORT_PROBE_INTERVAL_MS = 1_000;
11
15
 
12
- /**
13
- * A live CDP endpoint plus, when discovery went through a DevToolsActivePort
14
- * file, the user-data-dir that file lives in. That directory is the browser's
15
- * profile registry (`Local State`) — Chromium writes DevToolsActivePort to
16
- * chrome::DIR_USER_DATA — so it is what profile selection enumerates.
17
- * Undefined when the endpoint came from BU_CDP_WS or a bare port probe.
18
- */
16
+ const VersionResponse = Type.Object(
17
+ { webSocketDebuggerUrl: Type.Optional(Type.String()) },
18
+ { additionalProperties: true },
19
+ );
20
+
21
+ const versionValidator = Compile(VersionResponse);
22
+
19
23
  export type CdpEndpoint = {
20
24
  readonly wsUrl: string;
21
25
  readonly userDataDir?: string;
@@ -52,17 +56,9 @@ const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
52
56
  ));
53
57
  };
54
58
 
55
- // Fast, single-shot TCP check (no retry loop). Used to skip stale
56
- // DevToolsActivePort files left behind by a browser that has since quit —
57
- // unlike waitForPort, this returns immediately instead of blocking ~30s.
58
59
  const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
59
60
 
60
- // Ask the live browser for its canonical webSocketDebuggerUrl via
61
- // http://127.0.0.1:<port>/json/version. This is authoritative: it avoids
62
- // trusting a path from a stale DevToolsActivePort file that may belong to a
63
- // different browser which has since exited (e.g. Chrome's stale file pointing
64
- // at 9222 while Brave actually owns the port now). Returns null if the endpoint
65
- // is unreachable, non-200, or doesn't expose a WS URL.
61
+ // /json/version is authoritative: a stale DevToolsActivePort file may name a browser that has since exited while another now owns the port.
66
62
  const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> =>
67
63
  new Promise<string | null>((resolve) => {
68
64
  const req = httpRequest(
@@ -76,13 +72,8 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
76
72
  const chunks: Buffer[] = [];
77
73
  res.on("data", (c: Buffer) => chunks.push(c));
78
74
  res.on("end", () => {
79
- try {
80
- const json = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
81
- const ws = json["webSocketDebuggerUrl"];
82
- resolve(typeof ws === "string" ? ws : null);
83
- } catch {
84
- resolve(null);
85
- }
75
+ const parsed = parseJson(Buffer.concat(chunks).toString("utf8"), versionValidator);
76
+ resolve(parsed.success ? parsed.data.webSocketDebuggerUrl ?? null : null);
86
77
  });
87
78
  },
88
79
  );
@@ -91,10 +82,6 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
91
82
  req.end();
92
83
  });
93
84
 
94
- // Well-known CDP ports to probe when no DevToolsActivePort file is readable
95
- // (sandboxed harness, restricted filesystem permissions, or an unusual profile
96
- // location not on the profileDirs list). The default Chromium remote-debugging
97
- // port is 9222; users can extend via BU_CDP_PORTS="9222,9333".
98
85
  const fallbackPorts = (): ReadonlyArray<number> => {
99
86
  const ports = new Set<number>([9222]);
100
87
  const fromEnv = process.env["BU_CDP_PORTS"];
@@ -111,21 +98,21 @@ type Candidate = {
111
98
  readonly port: number;
112
99
  readonly path: string;
113
100
  readonly mtimeMs: number;
114
- /** The user-data-dir whose DevToolsActivePort produced this candidate. */
115
101
  readonly userDataDir: string;
116
102
  };
117
103
 
118
- /**
119
- * Locate a live CDP endpoint, reporting the user-data-dir it belongs to when
120
- * that is knowable. `discoverWsUrl` is the URL-only view of the same walk.
121
- */
104
+ // Read here rather than in the client: the daemon owns the only socket to Chrome, so an override the daemon cannot see has no effect.
105
+ export const endpointFromEnv = (env: NodeJS.ProcessEnv = process.env): CdpEndpoint | undefined => {
106
+ const wsUrl = env["BU_CDP_WS"]?.trim();
107
+ return wsUrl ? { wsUrl } : undefined;
108
+ };
109
+
122
110
  export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
111
+ const override = endpointFromEnv();
112
+ if (override) return ok(override);
113
+
123
114
  const dirs = userDataDirCandidates();
124
115
 
125
- // Phase 1: collect every readable DevToolsActivePort candidate up front,
126
- // rather than committing to the first one found. A single browser writes one
127
- // such file, but stale files from quit browsers linger, so we must
128
- // disambiguate below instead of trusting discovery order.
129
116
  const candidates: Candidate[] = [];
130
117
  const readErrors: string[] = [];
131
118
  for (const base of dirs) {
@@ -136,15 +123,10 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
136
123
  raw = await readFile(portFile, "utf8");
137
124
  try {
138
125
  mtimeMs = (await stat(portFile)).mtimeMs;
139
- } catch {
140
- // mtime is only a tiebreaker; a stat failure just leaves it at 0.
141
- }
126
+ } catch {}
142
127
  } catch (e) {
143
- // Node fs errors carry .code; see ErrnoException. ENOENT is normal — the
144
- // dir is on our list but the user hasn't installed that browser. EPERM/
145
- // EACCES is common under sandboxes; remember it and fall back to network
146
- // probing rather than failing the whole discovery.
147
- const code = (e as NodeJS.ErrnoException).code;
128
+ // EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery.
129
+ const code = errnoCode(e);
148
130
  if (code === "ENOENT" || code === undefined) continue;
149
131
  if (code === "EPERM" || code === "EACCES") {
150
132
  readErrors.push(`${portFile}: ${code}`);
@@ -157,25 +139,16 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
157
139
  const port = lines[0]?.trim();
158
140
  const path = lines[1]?.trim();
159
141
  if (!port || !path) continue;
160
- // Guard against a truncated/corrupt port file (e.g. a non-numeric first
161
- // line): an out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT
162
- // synchronously, which would escape discoverWsUrl instead of surfacing as
163
- // a Result error. Skip such entries.
142
+ // An out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT synchronously, escaping discoverWsUrl instead of surfacing as a Result error.
164
143
  const portNum = Number(port);
165
144
  if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
166
145
  candidates.push({ port: portNum, path, mtimeMs, userDataDir: base });
167
146
  }
168
147
 
169
- // Phase 2: no readable candidate — probe well-known ports directly. Covers
170
- // sandboxed harnesses that can't read profile dirs and non-default install
171
- // locations. Requires the browser to have been launched with
172
- // --remote-debugging-port so /json/version is served.
173
148
  if (candidates.length === 0) {
174
149
  for (const port of fallbackPorts()) {
175
150
  if (!(await isPortLive(port))) continue;
176
151
  const live = await queryLiveWsUrl(port);
177
- // No DevToolsActivePort file was involved, so the user-data-dir behind
178
- // this endpoint is unknown — profile selection stays unavailable.
179
152
  if (live) return ok({ wsUrl: live });
180
153
  }
181
154
  const permHint = readErrors.length > 0
@@ -187,9 +160,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
187
160
  ));
188
161
  }
189
162
 
190
- // Phase 3: disambiguate candidates sharing a port (one browser running, and
191
- // another's file left behind on the default 9222): keep the most-recently-
192
- // written file — that's the currently-running browser.
193
163
  const byPort = new Map<number, Candidate>();
194
164
  for (const c of candidates) {
195
165
  const prev = byPort.get(c.port);
@@ -197,16 +167,11 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
197
167
  }
198
168
  const unique = Array.from(byPort.values());
199
169
 
200
- // Prefer candidates whose port is actually accepting connections; this skips
201
- // stale files whose browser has quit (avoiding a ~30s waitForPort block).
202
170
  const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port)));
203
171
  const liveCandidates = unique.filter((_, i) => liveChecks[i]);
204
172
  const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
205
173
 
206
- // Phase 4: for each candidate, prefer the browser's own canonical WS URL from
207
- // /json/version. Some browsers disable that HTTP endpoint when remote
208
- // debugging is toggled only via chrome://inspect (rather than a launch flag),
209
- // so fall back to the WS path written in DevToolsActivePort.
174
+ // Some browsers disable the /json/version endpoint when remote debugging is toggled via chrome://inspect rather than a launch flag, hence the DevToolsActivePort fallback.
210
175
  let lastErr: Result<CdpEndpoint, CdpError> | null = null;
211
176
  for (const c of ordered) {
212
177
  const ready = await waitForPort(c.port);
@@ -218,10 +183,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
218
183
  return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
219
184
  }
220
185
 
221
- // Every discovered candidate was stale/unreachable. Before giving up, try the
222
- // well-known fallback ports — a user with a leftover DevToolsActivePort file
223
- // may still have a live browser reachable via 9222 / BU_CDP_PORTS. (Skip ports
224
- // already covered by a candidate above so we don't re-probe them.)
225
186
  for (const port of fallbackPorts()) {
226
187
  if (byPort.has(port) || !(await isPortLive(port))) continue;
227
188
  const liveUrl = await queryLiveWsUrl(port);
@@ -231,10 +192,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
231
192
  return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
232
193
  };
233
194
 
234
- /**
235
- * URL-only view of {@link discoverEndpoint}, kept as the stable entry point for
236
- * callers that don't care which user-data-dir answered.
237
- */
238
195
  export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
239
196
  const endpoint = await discoverEndpoint();
240
197
  return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint;
package/src/cdp/errors.ts CHANGED
@@ -12,6 +12,10 @@ export type CdpError = {
12
12
  readonly method?: string;
13
13
  };
14
14
 
15
+ // Chrome reports a dead session as a generic protocol error, so the message text is the only signal that a reattach — not a retry — is what will fix it.
16
+ export const classifyRemoteError = (message: string): CdpErrorKind =>
17
+ message.includes("Session with given id not found") ? "session_not_found" : "remote_error";
18
+
15
19
  export const cdpError = (
16
20
  kind: CdpErrorKind,
17
21
  message: string,
@@ -0,0 +1,170 @@
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 ev = <P extends TSchema>(params: P) => ({ params, validate: Compile(params) });
7
+
8
+ const open = { additionalProperties: true } as const;
9
+
10
+ const Anything = Type.Object({}, open);
11
+
12
+ const StackTrace = Type.Object(
13
+ {
14
+ callFrames: Type.Optional(
15
+ Type.Array(
16
+ Type.Object(
17
+ {
18
+ url: Type.Optional(Type.String()),
19
+ lineNumber: Type.Optional(Type.Number()),
20
+ functionName: Type.Optional(Type.String()),
21
+ },
22
+ open,
23
+ ),
24
+ ),
25
+ ),
26
+ },
27
+ open,
28
+ );
29
+
30
+ const ConsoleArg = Type.Object(
31
+ {
32
+ type: Type.Optional(Type.String()),
33
+ value: Type.Optional(Type.Unknown()),
34
+ description: Type.Optional(Type.String()),
35
+ unserializableValue: Type.Optional(Type.String()),
36
+ },
37
+ open,
38
+ );
39
+
40
+ export const EVENTS = {
41
+ "Inspector.detached": ev(Anything),
42
+
43
+ "Page.javascriptDialogOpening": ev(
44
+ Type.Object(
45
+ {
46
+ type: Type.String(),
47
+ message: Type.String(),
48
+ defaultPrompt: Type.Optional(Type.String()),
49
+ },
50
+ open,
51
+ ),
52
+ ),
53
+ "Page.loadEventFired": ev(Anything),
54
+ "Page.frameNavigated": ev(Anything),
55
+
56
+ "Runtime.consoleAPICalled": ev(
57
+ Type.Object(
58
+ {
59
+ type: Type.Optional(Type.String()),
60
+ args: Type.Optional(Type.Array(ConsoleArg)),
61
+ stackTrace: Type.Optional(StackTrace),
62
+ },
63
+ open,
64
+ ),
65
+ ),
66
+
67
+ "Log.entryAdded": ev(
68
+ Type.Object(
69
+ {
70
+ entry: Type.Object(
71
+ {
72
+ level: Type.Optional(Type.String()),
73
+ text: Type.Optional(Type.String()),
74
+ url: Type.Optional(Type.String()),
75
+ lineNumber: Type.Optional(Type.Number()),
76
+ stackTrace: Type.Optional(StackTrace),
77
+ },
78
+ open,
79
+ ),
80
+ },
81
+ open,
82
+ ),
83
+ ),
84
+
85
+ "Network.requestWillBeSent": ev(
86
+ Type.Object(
87
+ {
88
+ requestId: Type.String(),
89
+ request: Type.Object(
90
+ {
91
+ url: Type.String(),
92
+ method: Type.String(),
93
+ postData: Type.Optional(Type.String()),
94
+ },
95
+ open,
96
+ ),
97
+ type: Type.Optional(Type.String()),
98
+ },
99
+ open,
100
+ ),
101
+ ),
102
+ "Network.responseReceived": ev(
103
+ Type.Object(
104
+ {
105
+ requestId: Type.String(),
106
+ response: Type.Optional(
107
+ Type.Object(
108
+ {
109
+ status: Type.Optional(Type.Number()),
110
+ statusText: Type.Optional(Type.String()),
111
+ mimeType: Type.Optional(Type.String()),
112
+ },
113
+ open,
114
+ ),
115
+ ),
116
+ type: Type.Optional(Type.String()),
117
+ },
118
+ open,
119
+ ),
120
+ ),
121
+ "Network.loadingFinished": ev(
122
+ Type.Object(
123
+ { requestId: Type.String(), encodedDataLength: Type.Optional(Type.Number()) },
124
+ open,
125
+ ),
126
+ ),
127
+ "Network.loadingFailed": ev(
128
+ Type.Object(
129
+ { requestId: Type.String(), errorText: Type.Optional(Type.String()) },
130
+ open,
131
+ ),
132
+ ),
133
+
134
+ "Target.targetCreated": ev(
135
+ Type.Object(
136
+ {
137
+ targetInfo: Type.Object(
138
+ {
139
+ targetId: Type.String(),
140
+ type: Type.String(),
141
+ openerId: Type.Optional(Type.String()),
142
+ },
143
+ open,
144
+ ),
145
+ },
146
+ open,
147
+ ),
148
+ ),
149
+ "Target.targetDestroyed": ev(Type.Object({ targetId: Type.String() }, open)),
150
+ };
151
+
152
+ export type CdpEventName = keyof typeof EVENTS;
153
+ export type EventParamsOf<E extends CdpEventName> = Static<(typeof EVENTS)[E]["params"]>;
154
+
155
+ export const isKnownEvent = (name: string): name is CdpEventName => Object.hasOwn(EVENTS, name);
156
+
157
+ export const decodeEvent = <E extends CdpEventName>(
158
+ name: E,
159
+ raw: unknown,
160
+ ): Result<EventParamsOf<E>, CdpError> => {
161
+ const spec = EVENTS[name];
162
+ if (spec === undefined) {
163
+ return err(cdpError("invalid_response", "unknown CDP event", name));
164
+ }
165
+ if (!spec.validate.Check(raw)) {
166
+ const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
167
+ return err(cdpError("invalid_response", why, name));
168
+ }
169
+ return ok(raw as EventParamsOf<E>);
170
+ };
@@ -1,18 +1,5 @@
1
- /**
2
- * In-memory aggregator for CDP Network.* events.
3
- *
4
- * The CDP event stream is consumed by a single internal loop in session.ts.
5
- * That loop hands Network.* event params to this module, which joins them into
6
- * one record per requestId (request → response → finished/failed). Tools then
7
- * read records via drain(), filtered to what the caller asked for.
8
- *
9
- * Bounded by a fixed capacity (default 500): on overflow the oldest record is
10
- * evicted FIFO. The overflow flag is reported once per drain so tools can warn
11
- * the LLM that data may be missing.
12
- *
13
- * This module is pure (no CDP dependency) so the joining/filtering logic stays
14
- * trivially testable in isolation.
15
- */
1
+ import { decodeEvent, type EventParamsOf } from "./events";
2
+ import { createRecordStore } from "./record-store";
16
3
 
17
4
  export type NetworkRecord = {
18
5
  requestId: string;
@@ -22,34 +9,27 @@ export type NetworkRecord = {
22
9
  statusText?: string;
23
10
  type: string;
24
11
  mimeType?: string;
25
- /** Wall-clock ms when requestWillBeSent fired, in Date.now() units. */
26
12
  requestStartedMs: number;
27
13
  durationMs?: number;
28
14
  requestBodySize: number;
29
15
  responseBodySize?: number;
30
16
  failed?: boolean;
31
17
  errorText?: string;
32
- /** Populated by the tool layer via Network.getResponseBody, never by the aggregator. */
33
18
  body?: string | null;
34
19
  };
35
20
 
36
21
  export type NetworkFilter = {
37
- /** Substring match by default. Wrap in slashes (`/foo/`) for regex. */
38
22
  urlPattern?: string;
39
23
  methodFilter?: string[];
40
24
  statusFilter?: { min?: number; max?: number };
41
25
  resourceTypes?: string[];
42
- /** Only requests whose start is at most N ms ago. */
43
26
  sinceMs?: number;
44
- /** Cap on the returned records. */
45
27
  limit?: number;
46
28
  };
47
29
 
48
30
  export type DrainResult = {
49
31
  readonly records: NetworkRecord[];
50
- /** Total matches before `limit` was applied. */
51
32
  readonly total: number;
52
- /** True if the ring buffer evicted at least one record since the last drain. */
53
33
  readonly bufferOverflowed: boolean;
54
34
  };
55
35
 
@@ -67,53 +47,41 @@ const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
67
47
  try {
68
48
  const re = new RegExp(pattern.slice(1, -1));
69
49
  return (url) => re.test(url);
70
- } catch {
71
- // Fall through to substring match if the pattern is not a valid regex.
72
- }
50
+ } catch {}
73
51
  }
74
52
  return (url) => url.includes(pattern);
75
53
  };
76
54
 
55
+ type UpdateEvent = "Network.responseReceived" | "Network.loadingFinished" | "Network.loadingFailed";
56
+
77
57
  export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
78
- // Insertion-ordered map keyed by requestId. Map preserves insertion order in JS,
79
- // so iteration gives us oldest-first eviction without a separate index.
80
- const records = new Map<string, NetworkRecord>();
81
- let overflowed = false;
82
-
83
- const evictOldestIfFull = (): void => {
84
- while (records.size >= capacity) {
85
- const oldest = records.keys().next();
86
- if (oldest.done) return;
87
- records.delete(oldest.value);
88
- overflowed = true;
89
- }
58
+ const records = createRecordStore<string, NetworkRecord>(capacity);
59
+
60
+ // All three follow-up events say the same thing first: decode, find the request they belong to, give up quietly if it is gone.
61
+ const update = <E extends UpdateEvent>(
62
+ method: E,
63
+ p: unknown,
64
+ apply: (r: NetworkRecord, params: EventParamsOf<E>) => void,
65
+ ): void => {
66
+ const decoded = decodeEvent(method, p);
67
+ if (!decoded.success) return;
68
+ const id = decoded.data.requestId;
69
+ if (!id) return;
70
+ const r = records.get(id);
71
+ if (!r) return;
72
+ apply(r, decoded.data);
90
73
  };
91
74
 
92
75
  return {
93
76
  ingestRequestWillBeSent(p) {
94
- // CDP boundary cast: Network.requestWillBeSent
95
- const params = p as
96
- | {
97
- requestId?: string;
98
- request?: {
99
- url?: string;
100
- method?: string;
101
- postData?: string;
102
- headers?: Record<string, string>;
103
- };
104
- type?: string;
105
- timestamp?: number;
106
- wallTime?: number;
107
- }
108
- | undefined;
77
+ const decoded = decodeEvent("Network.requestWillBeSent", p);
78
+ const params = decoded.success ? decoded.data : undefined;
109
79
  const id = params?.requestId;
110
- const url = params?.request?.url;
111
- const method = params?.request?.method;
80
+ const url = params?.request.url;
81
+ const method = params?.request.method;
112
82
  if (!id || !url || !method) return;
113
- // Re-fired requestWillBeSent for the same id (e.g. redirect) — refresh in place.
114
83
  records.delete(id);
115
- evictOldestIfFull();
116
- const postData = params.request?.postData;
84
+ const postData = params.request.postData;
117
85
  records.set(id, {
118
86
  requestId: id,
119
87
  method,
@@ -125,54 +93,28 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
125
93
  },
126
94
 
127
95
  ingestResponseReceived(p) {
128
- // CDP boundary cast: Network.responseReceived
129
- const params = p as
130
- | {
131
- requestId?: string;
132
- response?: {
133
- status?: number;
134
- statusText?: string;
135
- mimeType?: string;
136
- };
137
- type?: string;
138
- }
139
- | undefined;
140
- const id = params?.requestId;
141
- if (!id) return;
142
- const r = records.get(id);
143
- if (!r) return;
144
- if (params?.response?.status !== undefined) r.status = params.response.status;
145
- if (params?.response?.statusText !== undefined && params.response.statusText !== "") r.statusText = params.response.statusText;
146
- if (params?.response?.mimeType !== undefined) r.mimeType = params.response.mimeType;
147
- // type is more accurate on responseReceived (CDP sometimes refines it)
148
- if (params?.type !== undefined) r.type = params.type;
96
+ update("Network.responseReceived", p, (r, params) => {
97
+ if (params.response?.status !== undefined) r.status = params.response.status;
98
+ if (params.response?.statusText !== undefined && params.response.statusText !== "") r.statusText = params.response.statusText;
99
+ if (params.response?.mimeType !== undefined) r.mimeType = params.response.mimeType;
100
+ // type is more accurate on responseReceived (CDP sometimes refines it)
101
+ if (params.type !== undefined) r.type = params.type;
102
+ });
149
103
  },
150
104
 
151
105
  ingestLoadingFinished(p) {
152
- // CDP boundary cast: Network.loadingFinished
153
- const params = p as
154
- | { requestId?: string; encodedDataLength?: number }
155
- | undefined;
156
- const id = params?.requestId;
157
- if (!id) return;
158
- const r = records.get(id);
159
- if (!r) return;
160
- if (params?.encodedDataLength !== undefined) r.responseBodySize = params.encodedDataLength;
161
- r.durationMs = Date.now() - r.requestStartedMs;
106
+ update("Network.loadingFinished", p, (r, params) => {
107
+ if (params.encodedDataLength !== undefined) r.responseBodySize = params.encodedDataLength;
108
+ r.durationMs = Date.now() - r.requestStartedMs;
109
+ });
162
110
  },
163
111
 
164
112
  ingestLoadingFailed(p) {
165
- // CDP boundary cast: Network.loadingFailed
166
- const params = p as
167
- | { requestId?: string; errorText?: string; canceled?: boolean }
168
- | undefined;
169
- const id = params?.requestId;
170
- if (!id) return;
171
- const r = records.get(id);
172
- if (!r) return;
173
- r.failed = true;
174
- if (params?.errorText !== undefined) r.errorText = params.errorText;
175
- r.durationMs = Date.now() - r.requestStartedMs;
113
+ update("Network.loadingFailed", p, (r, params) => {
114
+ r.failed = true;
115
+ if (params.errorText !== undefined) r.errorText = params.errorText;
116
+ r.durationMs = Date.now() - r.requestStartedMs;
117
+ });
176
118
  },
177
119
 
178
120
  drain(filter) {
@@ -191,23 +133,14 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
191
133
  if (cutoff !== undefined && r.requestStartedMs < cutoff) continue;
192
134
  if (minStatus !== undefined && (r.status === undefined || r.status < minStatus)) continue;
193
135
  if (maxStatus !== undefined && (r.status === undefined || r.status > maxStatus)) continue;
194
- // Clone so callers can mutate (e.g. attach body) without polluting the buffer.
195
136
  matched.push({ ...r });
196
137
  }
197
138
 
198
- const total = matched.length;
199
- const limit = Math.min(filter.limit ?? 50, 500);
200
- const limited = matched.slice(-limit); // most recent N
201
-
202
- const bufferOverflowed = overflowed;
203
- overflowed = false;
204
-
205
- return { records: limited, total, bufferOverflowed };
139
+ return records.page(matched, filter.limit);
206
140
  },
207
141
 
208
142
  clear() {
209
143
  records.clear();
210
- overflowed = false;
211
144
  },
212
145
  };
213
146
  };
@@ -1,28 +1,11 @@
1
- /**
2
- * Tracks which tab targetIds belong to this harness session.
3
- *
4
- * The harness attaches to the user's running Chrome, which is also full of
5
- * the user's own tabs. Without an ownership boundary the agent could attach
6
- * to (or close) personal tabs by accident. The registry is the single source
7
- * of truth for "did this session open that tab?".
8
- *
9
- * Mutations notify a single change listener so the orchestrator can persist
10
- * to disk via the existing BrowserState flow without the registry needing to
11
- * know about the pi storage API.
12
- */
13
-
14
1
  export type OwnershipRegistry = {
15
2
  add(targetId: string): void;
16
3
  remove(targetId: string): void;
17
4
  has(targetId: string): boolean;
18
5
  list(): ReadonlyArray<string>;
19
- /** Replace the full set — used on restoreState to re-hydrate from disk. */
20
6
  replaceAll(ids: ReadonlyArray<string>): void;
21
7
  setHarnessWindow(targetId: string | undefined): void;
22
8
  harnessWindow(): string | undefined;
23
- /** The real Chrome windowId of the dedicated harness window — the durable
24
- * identity of "the window this session initialized", surviving seed-tab
25
- * closure. Undefined until the window is created (or after teardown). */
26
9
  setHarnessWindowId(windowId: number | undefined): void;
27
10
  harnessWindowId(): number | undefined;
28
11
  onChange(cb: () => void): void;