pi-browser-harness 0.10.2 → 0.12.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 (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -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 +375 -265
  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 +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  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 -37
  28. package/src/domains/console.ts +48 -4
  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 +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
@@ -1,15 +1,13 @@
1
- import { type Result, ok } from "../util/result";
2
- import type { CdpError } from "./errors";
1
+ import { map, type Result, err, ok } from "../util/result";
2
+ import { type CdpError, cdpError } from "./errors";
3
3
  import type { DialogInfo } from "./types";
4
4
  import type { OwnershipRegistry } from "./ownership";
5
- import type { CdpTransport } from "./transport";
5
+ import type { CdpTransport } from "./types";
6
+ import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands";
7
+ import { decodeEvent } from "./events";
6
8
  import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
7
9
  import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
8
10
 
9
- // Per-tab state. One TabSession exists per known targetId.
10
- // Dialog, page-info dirty flag, and CDP buffers are per-tab — switching
11
- // tabs preserves the previous tab's data in its TabSession instead of
12
- // clearing it.
13
11
  type TabSession = {
14
12
  sessionId: string;
15
13
  targetId: string;
@@ -17,43 +15,33 @@ type TabSession = {
17
15
  pageInfoDirty: boolean;
18
16
  networkBuffer: ReturnType<typeof createNetworkBuffer>;
19
17
  consoleBuffer: ReturnType<typeof createConsoleBuffer>;
20
- // Stable element refs (e1, e2, …) → CDP backendNodeId, from the latest
21
- // snapshot of this tab. Replaced wholesale on every snapshot — old refs are
22
- // stale anyway, so this stays bounded (~50–60 KB worst case).
23
18
  refMap: Map<string, number>;
24
- // Per-ref signature ("role|name|value") from the latest snapshot, used as the
25
- // baseline for the post-mutation auto-diff. Replaced alongside refMap.
26
19
  refSig: Map<string, string>;
27
20
  };
28
21
 
22
+ const dialogType = (raw: string): DialogInfo["type"] =>
23
+ raw === "confirm" || raw === "prompt" || raw === "beforeunload" ? raw : "alert";
24
+
29
25
  export type CdpSession = {
30
26
  attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
27
+ attach(targetId: string): Promise<Result<string, CdpError>>;
28
+ windowId(targetId: string): Promise<Result<number, CdpError>>;
31
29
  switchTo(targetId: string): Promise<Result<void, CdpError>>;
32
30
  current(): { readonly sessionId: string; readonly targetId: string } | null;
33
- /** Replace the active tab's ref map and ref signatures (from a fresh snapshot). */
34
31
  setRefMap(refMap: Map<string, number>, refSig: Map<string, string>): void;
35
- /** Resolve a ref (e.g. "e12") to its backendNodeId on the active tab, or undefined if unknown/stale. */
36
32
  resolveRef(ref: string): number | undefined;
37
- /** The active tab's prior ref signatures — baseline for the post-mutation diff. */
38
33
  refSignatures(): ReadonlyMap<string, string>;
39
- call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
40
- callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
41
- callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
34
+ refMappings(): ReadonlyMap<string, number>;
35
+ call<M extends CdpMethod>(method: M, params?: ParamsOf<M>, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
36
+ callOnTarget<M extends CdpMethod>(method: M, params: ParamsOf<M>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
37
+ callBrowser<M extends CdpMethod>(method: M, params?: ParamsOf<M>, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
42
38
  takeDialog(): DialogInfo | null;
43
39
  drainPageInfoInvalidations(): boolean;
44
40
  drainNetworkBuffer(filter: NetworkFilter): DrainResult;
45
41
  drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
46
42
  };
47
43
 
48
- /**
49
- * Supplies the seed tab when attachFirstPage finds no owned tab to attach to.
50
- * Injected by the client so that seeding goes through the target factory —
51
- * which, with a profile pinned, opens the window inside that profile. Without
52
- * it, attachFirstPage's own `Target.createTarget` would land in whichever
53
- * profile currently has focus.
54
- *
55
- * The provider is responsible for its own ownership bookkeeping.
56
- */
44
+ // Injected so seeding goes through the target factory; attachFirstPage's own `Target.createTarget` would land in whichever profile has focus.
57
45
  export type SeedTargetProvider = () => Promise<Result<string, CdpError>>;
58
46
 
59
47
  export const createCdpSession = (
@@ -64,15 +52,11 @@ export const createCdpSession = (
64
52
  let sessionId: string | null = null;
65
53
  let targetId: string | null = null;
66
54
 
67
- // Per-tab tracking: maps targetId → TabSession and sessionId → targetId for
68
- // event routing. Lazy: a TabSession is created on the first visit to a tab.
69
55
  const tabs = new Map<string, TabSession>();
70
56
  const sessionIdToTargetId = new Map<string, string>();
71
57
 
72
58
  let activeConsumer: Promise<void> = Promise.resolve();
73
59
 
74
- // Event → TabSession resolver. Uses sessionId from CDP events to find the
75
- // correct TabSession, falling back to the current targetId when no sessionId.
76
60
  const resolveTab = (evSessionId?: string): TabSession | undefined => {
77
61
  const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId;
78
62
  return tid ? tabs.get(tid) : undefined;
@@ -80,81 +64,93 @@ export const createCdpSession = (
80
64
 
81
65
  const consumeEvents = async (): Promise<void> => {
82
66
  for await (const ev of transport.events()) {
83
- // Filter: skip events from sessions we're not currently tracking.
84
- // Target.targetDestroyed is browser-level (no sessionId) — always process.
85
67
  if (ev.method !== "Target.targetDestroyed") {
86
- if (ev.sessionId && ev.sessionId !== sessionId) continue;
87
- }
88
- if (ev.method === "Page.javascriptDialogOpening") {
89
- const tab = targetId ? tabs.get(targetId) : undefined;
90
- if (!tab) continue;
91
- const params = ev.params as Partial<DialogInfo> | undefined;
92
- tab.dialog = {
93
- type: (params?.type as DialogInfo["type"]) ?? "alert",
94
- message: params?.message ?? "",
95
- ...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
96
- };
97
- continue;
68
+ if (ev.sessionId && !sessionIdToTargetId.has(ev.sessionId)) continue;
98
69
  }
99
- // Page.javascriptDialogClosed is intentionally NOT cleared here —
100
- // the dialog stays in the buffer until takeDialog() is called.
101
- // This prevents fast dismiss flows from dropping a dialog the agent
102
- // was about to read. (Fix for spec §7 predictability bug #2.)
103
- if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
104
- const tab = targetId ? tabs.get(targetId) : undefined;
105
- if (tab) tab.pageInfoDirty = true;
106
- }
107
- if (ev.method === "Target.targetCreated" && ownership) {
108
- // Adopt tabs opened BY a tab we already own (window.open, target=_blank,
109
- // popups). Chrome sets openerId to the opener target; if that opener is
110
- // owned, the child belongs to this session's window and must be
111
- // controllable + cleaned up. Targets the user opens themselves have no
112
- // owned opener, so they are never adopted.
113
- const info = (ev.params as { targetInfo?: { targetId?: string; type?: string; openerId?: string } } | undefined)?.targetInfo;
114
- if (info?.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) {
115
- ownership.add(info.targetId);
70
+ switch (ev.method) {
71
+ case "Page.javascriptDialogOpening": {
72
+ const tab = resolveTab(ev.sessionId);
73
+ if (!tab) break;
74
+ const decoded = decodeEvent(ev.method, ev.params);
75
+ tab.dialog = decoded.success
76
+ ? {
77
+ type: dialogType(decoded.data.type),
78
+ message: decoded.data.message,
79
+ ...(decoded.data.defaultPrompt !== undefined
80
+ ? { defaultPrompt: decoded.data.defaultPrompt }
81
+ : {}),
82
+ }
83
+ : { type: "alert", message: "" };
84
+ break;
116
85
  }
117
- }
118
- if (ev.method === "Target.targetDestroyed" && ownership) {
119
- const params = ev.params as { targetId?: string } | undefined;
120
- if (params?.targetId) {
121
- ownership.remove(params.targetId);
122
- // Prune per-tab state for the destroyed target
123
- const tab = tabs.get(params.targetId);
86
+ // Intentionally NOT cleared on javascriptDialogClosed: a fast dismiss would otherwise drop a dialog the agent was about to read.
87
+ case "Page.frameNavigated":
88
+ case "Page.loadEventFired": {
89
+ const tab = resolveTab(ev.sessionId);
90
+ if (tab) tab.pageInfoDirty = true;
91
+ break;
92
+ }
93
+ case "Target.targetCreated": {
94
+ if (!ownership) break;
95
+ const decoded = decodeEvent(ev.method, ev.params);
96
+ if (!decoded.success) break;
97
+ const info = decoded.data.targetInfo;
98
+ if (info.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) {
99
+ ownership.add(info.targetId);
100
+ }
101
+ break;
102
+ }
103
+ case "Target.targetDestroyed": {
104
+ if (!ownership) break;
105
+ const decoded = decodeEvent(ev.method, ev.params);
106
+ if (!decoded.success || !decoded.data.targetId) break;
107
+ const destroyed = decoded.data.targetId;
108
+ ownership.remove(destroyed);
109
+ const tab = tabs.get(destroyed);
124
110
  if (tab) {
125
111
  sessionIdToTargetId.delete(tab.sessionId);
126
- tabs.delete(params.targetId);
112
+ tabs.delete(destroyed);
127
113
  }
114
+ break;
128
115
  }
129
- }
130
- if (ev.method === "Network.requestWillBeSent") {
131
- const tab = resolveTab(ev.sessionId);
132
- if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
133
- } else if (ev.method === "Network.responseReceived") {
134
- const tab = resolveTab(ev.sessionId);
135
- if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
136
- } else if (ev.method === "Network.loadingFinished") {
137
- const tab = resolveTab(ev.sessionId);
138
- if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
139
- } else if (ev.method === "Network.loadingFailed") {
140
- const tab = resolveTab(ev.sessionId);
141
- if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
142
- } else if (ev.method === "Runtime.consoleAPICalled") {
143
- const tab = resolveTab(ev.sessionId);
144
- if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
145
- } else if (ev.method === "Log.entryAdded") {
146
- const tab = resolveTab(ev.sessionId);
147
- if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
116
+ case "Network.requestWillBeSent": {
117
+ const tab = resolveTab(ev.sessionId);
118
+ if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
119
+ break;
120
+ }
121
+ case "Network.responseReceived": {
122
+ const tab = resolveTab(ev.sessionId);
123
+ if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
124
+ break;
125
+ }
126
+ case "Network.loadingFinished": {
127
+ const tab = resolveTab(ev.sessionId);
128
+ if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
129
+ break;
130
+ }
131
+ case "Network.loadingFailed": {
132
+ const tab = resolveTab(ev.sessionId);
133
+ if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
134
+ break;
135
+ }
136
+ case "Runtime.consoleAPICalled": {
137
+ const tab = resolveTab(ev.sessionId);
138
+ if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
139
+ break;
140
+ }
141
+ case "Log.entryAdded": {
142
+ const tab = resolveTab(ev.sessionId);
143
+ if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
144
+ break;
145
+ }
146
+ default:
147
+ break;
148
148
  }
149
149
  }
150
150
  };
151
151
 
152
152
  const restartConsumer = (): void => {
153
153
  activeConsumer = activeConsumer.then(() => consumeEvents()).catch((e: unknown) => {
154
- // The .then() chain calls consumeEvents() which iterates the transport's
155
- // events() AsyncIterable. The iterable resolves cleanly on close (returns
156
- // {done:true}); any rejection here is an unexpected bug in the event
157
- // handler, not a normal termination. Surface it on stderr so it's not lost.
158
154
  console.warn("[pi-browser-harness] CDP event consumer crashed:", e);
159
155
  });
160
156
  };
@@ -165,37 +161,45 @@ export const createCdpSession = (
165
161
  targetId = null;
166
162
  tabs.clear();
167
163
  sessionIdToTargetId.clear();
168
- // Do NOT clear `dialog` here — same rationale as inside consumeEvents:
169
- // the agent may have a pending takeDialog() call that should still see it.
164
+ // Do NOT clear `dialog` here: a pending takeDialog() must still see it.
170
165
  restartConsumer();
171
166
  });
172
167
 
173
- // TODO(perf): the four enable calls are sequential here for predictability.
174
- // Switching to Promise.all over a single WS pipelines the round-trips and
175
- // saves ~3× on tab-switch latency. Defer until session.ts has tests.
176
168
  const enableDomains = async (sid: string): Promise<void> => {
177
169
  for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) {
178
170
  await transport.request(`${d}.enable`, {}, { sessionId: sid });
179
171
  }
180
172
  };
181
173
 
182
- // CDP response shapes are documented in chromedevtools.github.io but not
183
- // available as TypeScript types. We cast `as` from `unknown` only in this
184
- // file (the CDP boundary). Each cast is paired with the CDP method that
185
- // produced the response. Adding runtime guards for every shape would be
186
- // noise — Chrome's protocol is stable enough that a wrong cast surfaces
187
- // as a clear Error in normal use.
188
- return {
174
+ const req = async <M extends CdpMethod>(
175
+ method: M,
176
+ params: ParamsOf<M> | undefined,
177
+ sid: string | null,
178
+ opts: { timeoutMs?: number } = {},
179
+ ): Promise<Result<ResultOf<M>, CdpError>> => {
180
+ const raw = await transport.request(method, { ...(params ?? {}) }, {
181
+ sessionId: sid,
182
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
183
+ });
184
+ if (!raw.success) return raw;
185
+ return decodeResult(method, raw.data);
186
+ };
187
+
188
+ const attach = async (tid: string): Promise<Result<string, CdpError>> =>
189
+ map(await req("Target.attachToTarget", { targetId: tid, flatten: true }, null), (d) => d.sessionId);
190
+
191
+ const windowId = async (tid: string): Promise<Result<number, CdpError>> =>
192
+ map(await req("Browser.getWindowForTarget", { targetId: tid }, null), (d) => d.windowId);
193
+
194
+ const currentTab = (): TabSession | undefined => (targetId ? tabs.get(targetId) : undefined);
195
+
196
+ const session: CdpSession = {
189
197
  async attachFirstPage() {
190
- // Subscribe to Target.* events so we can react to targetDestroyed.
191
- // Best-effort: failing to enable discovery is not fatal for attach.
192
- await transport.request("Target.setDiscoverTargets", { discover: true }, { sessionId: null });
198
+ await req("Target.setDiscoverTargets", { discover: true }, null);
193
199
 
194
- const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
200
+ const targets = await req("Target.getTargets", {}, null);
195
201
  if (!targets.success) return targets;
196
- const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
197
- const allPages = data.targetInfos.filter((t) => t.type === "page");
198
- // Reconcile the persisted ownership set against live targets — drop dead IDs.
202
+ const allPages = targets.data.targetInfos.filter((t) => t.type === "page");
199
203
  if (ownership) {
200
204
  const live = new Set(allPages.map((p) => p.targetId));
201
205
  const survivors = ownership.list().filter((id) => live.has(id));
@@ -203,44 +207,31 @@ export const createCdpSession = (
203
207
  const hw = ownership.harnessWindow();
204
208
  if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
205
209
 
206
- if (survivors.length === 0) {
207
- // No owned tab survived — the harness window is gone (user closed it,
208
- // or Chrome restarted and reassigned all ids). The persisted windowId
209
- // is now meaningless and could even collide with one of the user's
210
- // windows, so drop it. attachFirstPage will mint a fresh window below.
210
+ const anchorId = survivors[0];
211
+ if (anchorId === undefined) {
211
212
  ownership.setHarnessWindowId(undefined);
212
213
  } else {
213
- // Self-heal ownership from the window itself. We anchor on a tab we
214
- // KNOW we own (a survivor) and re-adopt only its live window-mates —
215
- // popups/children from last session, tabs reopened in our window.
216
- // Ownership is thus DERIVED from the real window, never from the bare
217
- // persisted integer (which Chrome may reassign to a user window on
218
- // restart — anchoring on a survivor makes that misfire impossible).
219
- const anchorId = survivors[0]!;
220
- const anchorWin = await transport.request("Browser.getWindowForTarget", { targetId: anchorId }, { sessionId: null });
214
+ const anchorWin = await windowId(anchorId);
221
215
  if (anchorWin.success) {
222
- const anchorWindowId = (anchorWin.data as { windowId?: number }).windowId;
223
- if (typeof anchorWindowId === "number") {
224
- ownership.setHarnessWindowId(anchorWindowId); // refresh to the live id
225
- const windows = await Promise.all(
226
- allPages.map((p) =>
227
- p.targetId === anchorId
228
- ? Promise.resolve(anchorWin)
229
- : transport.request("Browser.getWindowForTarget", { targetId: p.targetId }, { sessionId: null }),
230
- ),
231
- );
232
- allPages.forEach((p, i) => {
233
- const r = windows[i];
234
- if (r?.success && (r.data as { windowId?: number }).windowId === anchorWindowId) {
235
- ownership.add(p.targetId);
236
- }
237
- });
238
- }
216
+ const anchorWindowId = anchorWin.data;
217
+ ownership.setHarnessWindowId(anchorWindowId);
218
+ const windows = await Promise.all(
219
+ allPages.map((p) =>
220
+ p.targetId === anchorId
221
+ ? Promise.resolve(anchorWin)
222
+ : windowId(p.targetId),
223
+ ),
224
+ );
225
+ allPages.forEach((p, i) => {
226
+ const r = windows[i];
227
+ if (r?.success && r.data === anchorWindowId) {
228
+ ownership.add(p.targetId);
229
+ }
230
+ });
239
231
  }
240
232
  if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId);
241
233
  }
242
234
  }
243
- // Prune per-tab state for pages that no longer exist
244
235
  const liveTargetIds = new Set(allPages.map((p) => p.targetId));
245
236
  for (const tid of tabs.keys()) {
246
237
  if (!liveTargetIds.has(tid)) {
@@ -250,49 +241,39 @@ export const createCdpSession = (
250
241
  }
251
242
  }
252
243
 
253
- // Prefer attaching to a tab this session already owns. Falls back to
254
- // creating a fresh harness-owned tab in a dedicated window — never
255
- // grabs the user's foreground tab.
256
244
  let pickTargetId: string | undefined;
257
245
  if (ownership) {
258
246
  const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
259
247
  pickTargetId = ownedLive[0];
260
248
  }
261
249
  if (!pickTargetId && createSeedTarget) {
262
- // Delegated seeding (see SeedTargetProvider): the provider creates the
263
- // window in the right profile and records ownership itself.
264
250
  const seeded = await createSeedTarget();
265
251
  if (!seeded.success) return seeded;
266
252
  pickTargetId = seeded.data;
267
253
  }
268
254
  if (!pickTargetId) {
269
- const createParams: Record<string, unknown> = { url: "about:blank" };
270
- if (ownership) createParams["newWindow"] = true;
271
- const created = await transport.request("Target.createTarget", createParams, { sessionId: null });
255
+ const created = await req(
256
+ "Target.createTarget",
257
+ { url: "about:blank", ...(ownership ? { newWindow: true } : {}) },
258
+ null,
259
+ );
272
260
  if (!created.success) return created;
273
- const c = created.data as { targetId: string };
274
- pickTargetId = c.targetId;
261
+ pickTargetId = created.data.targetId;
275
262
  if (ownership) {
276
- ownership.setHarnessWindow(c.targetId);
277
- ownership.add(c.targetId);
278
- // Capture the real Chrome windowId — the durable identity of the
279
- // window this session initialized (survives seed-tab closure).
280
- const win = await transport.request("Browser.getWindowForTarget", { targetId: c.targetId }, { sessionId: null });
281
- if (win.success) {
282
- const wid = (win.data as { windowId?: number }).windowId;
283
- if (typeof wid === "number") ownership.setHarnessWindowId(wid);
284
- }
263
+ ownership.setHarnessWindow(created.data.targetId);
264
+ ownership.add(created.data.targetId);
265
+ const win = await windowId(created.data.targetId);
266
+ if (win.success) ownership.setHarnessWindowId(win.data);
285
267
  }
286
268
  }
287
269
 
288
- const attached = await transport.request("Target.attachToTarget", { targetId: pickTargetId, flatten: true }, { sessionId: null });
270
+ const attached = await attach(pickTargetId);
289
271
  if (!attached.success) return attached;
290
- const a = attached.data as { sessionId: string };
291
- sessionId = a.sessionId;
272
+ const attachedSessionId = attached.data;
273
+ sessionId = attachedSessionId;
292
274
  targetId = pickTargetId;
293
- await enableDomains(a.sessionId);
294
275
  tabs.set(pickTargetId, {
295
- sessionId: a.sessionId,
276
+ sessionId: attachedSessionId,
296
277
  targetId: pickTargetId,
297
278
  dialog: null,
298
279
  pageInfoDirty: false,
@@ -301,19 +282,19 @@ export const createCdpSession = (
301
282
  refMap: new Map(),
302
283
  refSig: new Map(),
303
284
  });
304
- sessionIdToTargetId.set(a.sessionId, pickTargetId);
305
- return ok({ targetId: pickTargetId, sessionId: a.sessionId });
285
+ sessionIdToTargetId.set(attachedSessionId, pickTargetId);
286
+ await enableDomains(attachedSessionId);
287
+ return ok({ targetId: pickTargetId, sessionId: attachedSessionId });
306
288
  },
307
289
  async switchTo(tid) {
308
- const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
290
+ const activated = await req("Target.activateTarget", { targetId: tid }, null);
309
291
  if (!activated.success) return activated;
310
- const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
292
+ const attached = await attach(tid);
311
293
  if (!attached.success) return attached;
312
- const a = attached.data as { sessionId: string };
313
- // Reuse existing TabSession or create one on first visit
294
+ const attachedSessionId = attached.data;
314
295
  const existing = tabs.get(tid);
315
296
  const tab: TabSession = existing ?? {
316
- sessionId: a.sessionId,
297
+ sessionId: attachedSessionId,
317
298
  targetId: tid,
318
299
  dialog: null,
319
300
  pageInfoDirty: true,
@@ -323,69 +304,100 @@ export const createCdpSession = (
323
304
  refSig: new Map(),
324
305
  };
325
306
  if (existing) {
326
- // Each Target.attachToTarget produces a new sessionId — update it.
327
307
  sessionIdToTargetId.delete(existing.sessionId);
328
- existing.sessionId = a.sessionId;
329
- sessionIdToTargetId.set(a.sessionId, tid);
308
+ existing.sessionId = attachedSessionId;
330
309
  } else {
331
310
  tabs.set(tid, tab);
332
- await enableDomains(a.sessionId);
333
- sessionIdToTargetId.set(a.sessionId, tid);
334
311
  }
335
- // Update global pointers to point at the new active tab
312
+ sessionIdToTargetId.set(attachedSessionId, tid);
313
+ await enableDomains(attachedSessionId);
336
314
  sessionId = tab.sessionId;
337
315
  targetId = tid;
338
316
  return ok(undefined);
339
317
  },
318
+ attach,
319
+ windowId,
340
320
  current() {
341
321
  return sessionId && targetId ? { sessionId, targetId } : null;
342
322
  },
343
323
  setRefMap(refMap, refSig) {
344
- const tab = targetId ? tabs.get(targetId) : undefined;
324
+ const tab = currentTab();
345
325
  if (!tab) return;
346
326
  tab.refMap = refMap;
347
327
  tab.refSig = refSig;
348
328
  },
349
329
  resolveRef(ref) {
350
- const tab = targetId ? tabs.get(targetId) : undefined;
330
+ const tab = currentTab();
351
331
  return tab?.refMap.get(ref);
352
332
  },
353
333
  refSignatures() {
354
- const tab = targetId ? tabs.get(targetId) : undefined;
334
+ const tab = currentTab();
355
335
  return tab?.refSig ?? new Map();
356
336
  },
357
- call(method, params = {}, opts = {}) {
358
- return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
337
+ refMappings() {
338
+ const tab = currentTab();
339
+ return tab?.refMap ?? new Map();
340
+ },
341
+ call(method, params, opts) {
342
+ return req(method, params, sessionId, opts);
359
343
  },
360
- callOnTarget(method, params, sid, opts = {}) {
361
- return transport.request(method, params, { sessionId: sid, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
344
+ callOnTarget(method, params, sid, opts) {
345
+ return req(method, params, sid, opts);
362
346
  },
363
- callBrowser(method, params = {}, opts = {}) {
364
- return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
347
+ callBrowser(method, params, opts) {
348
+ return req(method, params, null, opts);
365
349
  },
366
350
  takeDialog() {
367
- const tab = targetId ? tabs.get(targetId) : undefined;
351
+ const tab = currentTab();
368
352
  if (!tab) return null;
369
353
  const d = tab.dialog;
370
354
  tab.dialog = null;
371
355
  return d;
372
356
  },
373
357
  drainPageInfoInvalidations() {
374
- const tab = targetId ? tabs.get(targetId) : undefined;
358
+ const tab = currentTab();
375
359
  if (!tab) return false;
376
360
  const dirty = tab.pageInfoDirty;
377
361
  tab.pageInfoDirty = false;
378
362
  return dirty;
379
363
  },
380
364
  drainNetworkBuffer(filter) {
381
- const tab = targetId ? tabs.get(targetId) : undefined;
365
+ const tab = currentTab();
382
366
  if (!tab) return { records: [], total: 0, bufferOverflowed: false };
383
367
  return tab.networkBuffer.drain(filter);
384
368
  },
385
369
  drainConsoleBuffer(filter) {
386
- const tab = targetId ? tabs.get(targetId) : undefined;
370
+ const tab = currentTab();
387
371
  if (!tab) return { records: [], total: 0, bufferOverflowed: false };
388
372
  return tab.consoleBuffer.drain(filter);
389
373
  },
390
374
  };
375
+ return session;
376
+ };
377
+
378
+ export const evaluateJson = async <T>(
379
+ session: CdpSession,
380
+ expression: string,
381
+ check: (v: unknown) => v is T,
382
+ opts?: { sessionId?: string | undefined; timeoutMs?: number | undefined },
383
+ ): Promise<Result<T, CdpError>> => {
384
+ const params = {
385
+ expression,
386
+ returnByValue: true,
387
+ awaitPromise: true,
388
+ };
389
+ const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
390
+ const r = opts?.sessionId !== undefined
391
+ ? await session.callOnTarget("Runtime.evaluate", params, opts.sessionId, callOpts)
392
+ : await session.call("Runtime.evaluate", params, callOpts);
393
+ if (!r.success) return r;
394
+ const { result, exceptionDetails } = r.data;
395
+ if (exceptionDetails !== undefined) {
396
+ return err(cdpError("remote_error", exceptionDetails.text, "Runtime.evaluate"));
397
+ }
398
+ const value = result.value;
399
+ if (!check(value)) {
400
+ return err(cdpError("invalid_response", `unexpected evaluate shape (type: ${result.type}) for: ${expression.slice(0, 60)}`, "Runtime.evaluate"));
401
+ }
402
+ return ok(value);
391
403
  };