pi-browser-harness 0.12.0 → 0.13.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 +36 -0
  2. package/README.md +86 -0
  3. package/package.json +12 -6
  4. package/skills/pi-browser-harness/SKILL.md +57 -2
  5. package/src/cdp/commands.ts +200 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +110 -349
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +21 -22
  25. package/src/daemon/transport.ts +80 -49
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -2
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -64
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +13 -9
  34. package/src/domains/form-batch.ts +13 -10
  35. package/src/domains/form.ts +78 -107
  36. package/src/domains/headers.ts +2 -2
  37. package/src/domains/history.ts +4 -4
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +65 -44
  41. package/src/domains/keyboard.ts +14 -11
  42. package/src/domains/navigate.ts +102 -66
  43. package/src/domains/network.ts +55 -95
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +18 -51
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -53
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -10
  59. package/src/domains/scroll.ts +4 -4
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -33
  62. package/src/domains/snapshot.ts +71 -73
  63. package/src/domains/tab-resolve.ts +36 -0
  64. package/src/domains/tabs.ts +18 -69
  65. package/src/domains/target.ts +1 -1
  66. package/src/index.ts +90 -33
  67. package/src/profile/bind.ts +45 -14
  68. package/src/profile/browser-process.ts +50 -12
  69. package/src/profile/command.ts +20 -12
  70. package/src/profile/launch.ts +13 -9
  71. package/src/profile/list.ts +9 -13
  72. package/src/profile/store.ts +2 -1
  73. package/src/prompt.ts +32 -7
  74. package/src/registry.ts +65 -4
  75. package/src/schemas/parse.ts +2 -2
  76. package/src/setup.ts +28 -19
  77. package/src/state.ts +2 -1
  78. package/src/util/debug.ts +7 -1
  79. package/src/util/guards.ts +3 -0
  80. package/src/util/mutex.ts +1 -1
  81. package/src/util/paths.ts +3 -2
  82. package/src/util/result.ts +8 -0
  83. package/src/util/sharp-shim.ts +18 -11
  84. package/src/util/sleep.ts +18 -0
  85. package/src/util/tool.ts +163 -114
  86. package/src/util/truncate.ts +1 -1
  87. package/tsconfig.json +2 -1
  88. package/src/domains/fill-engine.ts +0 -80
@@ -1,40 +1,64 @@
1
1
  import { map, type Result, err, ok } from "../util/result";
2
2
  import { type CdpError, cdpError } from "./errors";
3
- import type { DialogInfo } from "./types";
4
- import type { OwnershipRegistry } from "./ownership";
3
+ import type { CdpEvent, DialogInfo, RefSignature } from "./types";
4
+ import { type OwnershipRegistry, reconcileOwnership } from "./ownership";
5
5
  import type { CdpTransport } from "./types";
6
6
  import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands";
7
+
8
+ export type CallOpts = { readonly timeoutMs?: number };
9
+
10
+ // True when every key of P is optional (so `{}` is a valid P); false when at least one key is required.
11
+ type HasOnlyOptionalKeys<P> = Partial<P> extends P ? true : false;
12
+
13
+ // The trailing arguments of `call`/`callBrowser`: params may be omitted exactly when the method's params object has
14
+ // no required key, so `call("Page.enable")` compiles and `call("Page.navigate")` does not.
15
+ export type CallArgs<M extends CdpMethod> = HasOnlyOptionalKeys<ParamsOf<M>> extends true
16
+ ? [params?: ParamsOf<M>, opts?: CallOpts]
17
+ : [params: ParamsOf<M>, opts?: CallOpts];
7
18
  import { decodeEvent } from "./events";
8
- import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
9
- import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
19
+ import { createNetworkBuffer, type DrainResult, type NetworkBuffer, type NetworkFilter } from "./network-buffer";
20
+ import { createConsoleBuffer, type ConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
10
21
 
11
22
  type TabSession = {
12
23
  sessionId: string;
13
- targetId: string;
24
+ readonly targetId: string;
14
25
  dialog: DialogInfo | null;
15
26
  pageInfoDirty: boolean;
16
- networkBuffer: ReturnType<typeof createNetworkBuffer>;
17
- consoleBuffer: ReturnType<typeof createConsoleBuffer>;
18
- refMap: Map<string, number>;
19
- refSig: Map<string, string>;
27
+ readonly networkBuffer: NetworkBuffer;
28
+ readonly consoleBuffer: ConsoleBuffer;
29
+ refMap: ReadonlyMap<string, number>;
30
+ refSig: ReadonlyMap<string, RefSignature>;
20
31
  };
21
32
 
33
+ type PageTarget = { readonly targetId: string; readonly type: string };
34
+
35
+ const ENABLED_DOMAINS: ReadonlyArray<string> = ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"];
36
+
22
37
  const dialogType = (raw: string): DialogInfo["type"] =>
23
38
  raw === "confirm" || raw === "prompt" || raw === "beforeunload" ? raw : "alert";
24
39
 
25
40
  export type CdpSession = {
26
41
  attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
42
+ // Raw attach: the returned session is NOT tracked (no domains enabled, no event routing). Pair it with `detach`.
27
43
  attach(targetId: string): Promise<Result<string, CdpError>>;
44
+ detach(sessionId: string): Promise<Result<void, CdpError>>;
45
+ // Detaches every tracked session and forgets them all.
46
+ detachAll(): Promise<void>;
47
+ // The tracked, domain-enabled session for a target, if this instance holds one.
48
+ sessionFor(targetId: string): string | undefined;
28
49
  windowId(targetId: string): Promise<Result<number, CdpError>>;
29
50
  switchTo(targetId: string): Promise<Result<void, CdpError>>;
30
51
  current(): { readonly sessionId: string; readonly targetId: string } | null;
31
- setRefMap(refMap: Map<string, number>, refSig: Map<string, string>): void;
52
+ setRefMap(refMap: ReadonlyMap<string, number>, refSig: ReadonlyMap<string, RefSignature>): void;
32
53
  resolveRef(ref: string): number | undefined;
33
- refSignatures(): ReadonlyMap<string, string>;
54
+ refSignatures(): ReadonlyMap<string, RefSignature>;
34
55
  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>>;
56
+ // Sends on the current page session. Params are required only when the method has a required key (see CallArgs).
57
+ call<M extends CdpMethod>(method: M, ...args: CallArgs<M>): Promise<Result<ResultOf<M>, CdpError>>;
58
+ // Sends on an explicit session id (a borrowed or background tab). Params always come first because the session id follows them.
59
+ callOnTarget<M extends CdpMethod>(method: M, params: ParamsOf<M>, sessionId: string, opts?: CallOpts): Promise<Result<ResultOf<M>, CdpError>>;
60
+ // Sends on the browser-level connection (no session id).
61
+ callBrowser<M extends CdpMethod>(method: M, ...args: CallArgs<M>): Promise<Result<ResultOf<M>, CdpError>>;
38
62
  takeDialog(): DialogInfo | null;
39
63
  drainPageInfoInvalidations(): boolean;
40
64
  drainNetworkBuffer(filter: NetworkFilter): DrainResult;
@@ -57,94 +81,166 @@ export const createCdpSession = (
57
81
 
58
82
  let activeConsumer: Promise<void> = Promise.resolve();
59
83
 
84
+ const clearCurrent = (): void => {
85
+ sessionId = null;
86
+ targetId = null;
87
+ };
88
+ const setCurrent = (tid: string, sid: string): void => {
89
+ sessionId = sid;
90
+ targetId = tid;
91
+ };
92
+
93
+ const freshTab = (tid: string, sid: string, pageInfoDirty: boolean): TabSession => ({
94
+ sessionId: sid,
95
+ targetId: tid,
96
+ dialog: null,
97
+ pageInfoDirty,
98
+ networkBuffer: createNetworkBuffer(),
99
+ consoleBuffer: createConsoleBuffer(),
100
+ refMap: new Map(),
101
+ refSig: new Map(),
102
+ });
103
+
104
+ // Binds a session to a target. An existing tab keeps its buffers, refs and dialog; only its session id changes.
105
+ const bindSession = (tid: string, sid: string, pageInfoDirty: boolean): TabSession => {
106
+ const existing = tabs.get(tid);
107
+ if (existing) {
108
+ sessionIdToTargetId.delete(existing.sessionId);
109
+ existing.sessionId = sid;
110
+ sessionIdToTargetId.set(sid, tid);
111
+ return existing;
112
+ }
113
+ const tab = freshTab(tid, sid, pageInfoDirty);
114
+ tabs.set(tid, tab);
115
+ sessionIdToTargetId.set(sid, tid);
116
+ return tab;
117
+ };
118
+
119
+ // A session is gone (detached by us, by Chrome, or with its target). Only the tab still bound to it is dropped, so a stale notice for a replaced session is harmless.
120
+ const forgetSession = (sid: string): void => {
121
+ const tid = sessionIdToTargetId.get(sid);
122
+ sessionIdToTargetId.delete(sid);
123
+ if (tid === undefined) return;
124
+ const tab = tabs.get(tid);
125
+ if (tab?.sessionId === sid) tabs.delete(tid);
126
+ if (sessionId === sid) clearCurrent();
127
+ };
128
+
129
+ const forgetTarget = (tid: string): void => {
130
+ const tab = tabs.get(tid);
131
+ if (tab) {
132
+ sessionIdToTargetId.delete(tab.sessionId);
133
+ tabs.delete(tid);
134
+ }
135
+ if (targetId === tid) clearCurrent();
136
+ };
137
+
60
138
  const resolveTab = (evSessionId?: string): TabSession | undefined => {
61
139
  const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId;
62
140
  return tid ? tabs.get(tid) : undefined;
63
141
  };
64
142
 
65
- const consumeEvents = async (): Promise<void> => {
66
- for await (const ev of transport.events()) {
67
- if (ev.method !== "Target.targetDestroyed") {
68
- if (ev.sessionId && !sessionIdToTargetId.has(ev.sessionId)) continue;
143
+ const handleEvent = (ev: CdpEvent): void => {
144
+ // Lifecycle notices about a session we no longer track are exactly the ones that must still be handled.
145
+ const lifecycle = ev.method === "Target.targetDestroyed" || ev.method === "Target.detachedFromTarget";
146
+ if (!lifecycle && ev.sessionId && !sessionIdToTargetId.has(ev.sessionId)) return;
147
+ switch (ev.method) {
148
+ case "Page.javascriptDialogOpening": {
149
+ const tab = resolveTab(ev.sessionId);
150
+ if (!tab) break;
151
+ const decoded = decodeEvent(ev.method, ev.params);
152
+ tab.dialog = decoded.success
153
+ ? {
154
+ type: dialogType(decoded.data.type),
155
+ message: decoded.data.message,
156
+ ...(decoded.data.defaultPrompt !== undefined
157
+ ? { defaultPrompt: decoded.data.defaultPrompt }
158
+ : {}),
159
+ }
160
+ : { type: "alert", message: "" };
161
+ break;
69
162
  }
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;
85
- }
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);
110
- if (tab) {
111
- sessionIdToTargetId.delete(tab.sessionId);
112
- tabs.delete(destroyed);
113
- }
114
- break;
115
- }
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;
163
+ // Chrome is the authority on whether a dialog is still blocking the page: handled, navigated away or dismissed by the user, it is gone.
164
+ case "Page.javascriptDialogClosed": {
165
+ const tab = resolveTab(ev.sessionId);
166
+ if (tab) tab.dialog = null;
167
+ break;
168
+ }
169
+ case "Page.frameNavigated":
170
+ case "Page.loadEventFired": {
171
+ const tab = resolveTab(ev.sessionId);
172
+ if (tab) tab.pageInfoDirty = true;
173
+ break;
174
+ }
175
+ case "Target.targetCreated": {
176
+ if (!ownership) break;
177
+ const decoded = decodeEvent(ev.method, ev.params);
178
+ if (!decoded.success) break;
179
+ const info = decoded.data.targetInfo;
180
+ if (info.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) {
181
+ ownership.add(info.targetId);
145
182
  }
146
- default:
147
- break;
183
+ break;
184
+ }
185
+ case "Target.targetDestroyed": {
186
+ const decoded = decodeEvent(ev.method, ev.params);
187
+ if (!decoded.success || !decoded.data.targetId) break;
188
+ const destroyed = decoded.data.targetId;
189
+ ownership?.remove(destroyed);
190
+ forgetTarget(destroyed);
191
+ break;
192
+ }
193
+ case "Target.detachedFromTarget": {
194
+ const decoded = decodeEvent(ev.method, ev.params);
195
+ if (decoded.success) forgetSession(decoded.data.sessionId);
196
+ break;
197
+ }
198
+ case "Inspector.detached": {
199
+ if (ev.sessionId !== undefined) forgetSession(ev.sessionId);
200
+ break;
201
+ }
202
+ case "Network.requestWillBeSent": {
203
+ const tab = resolveTab(ev.sessionId);
204
+ if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
205
+ break;
206
+ }
207
+ case "Network.responseReceived": {
208
+ const tab = resolveTab(ev.sessionId);
209
+ if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
210
+ break;
211
+ }
212
+ case "Network.loadingFinished": {
213
+ const tab = resolveTab(ev.sessionId);
214
+ if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
215
+ break;
216
+ }
217
+ case "Network.loadingFailed": {
218
+ const tab = resolveTab(ev.sessionId);
219
+ if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
220
+ break;
221
+ }
222
+ case "Runtime.consoleAPICalled": {
223
+ const tab = resolveTab(ev.sessionId);
224
+ if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
225
+ break;
226
+ }
227
+ case "Log.entryAdded": {
228
+ const tab = resolveTab(ev.sessionId);
229
+ if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
230
+ break;
231
+ }
232
+ default:
233
+ break;
234
+ }
235
+ };
236
+
237
+ const consumeEvents = async (): Promise<void> => {
238
+ for await (const ev of transport.events()) {
239
+ // One bad event must not end the loop: nothing restarts it until the transport closes.
240
+ try {
241
+ handleEvent(ev);
242
+ } catch (e: unknown) {
243
+ console.warn(`[pi-browser-harness] CDP event handler failed for ${ev.method}:`, e);
148
244
  }
149
245
  }
150
246
  };
@@ -157,25 +253,25 @@ export const createCdpSession = (
157
253
 
158
254
  restartConsumer();
159
255
  transport.onClose(() => {
160
- sessionId = null;
161
- targetId = null;
256
+ // Everything, pending dialogs included, dies with the socket: the sessions those dialogs belonged to no longer exist, so a later takeDialog() correctly sees nothing.
257
+ clearCurrent();
162
258
  tabs.clear();
163
259
  sessionIdToTargetId.clear();
164
- // Do NOT clear `dialog` here: a pending takeDialog() must still see it.
165
260
  restartConsumer();
166
261
  });
167
262
 
168
- const enableDomains = async (sid: string): Promise<void> => {
169
- for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) {
170
- await transport.request(`${d}.enable`, {}, { sessionId: sid });
171
- }
263
+ // Not in the typed command table (only Page.enable is), so the raw transport is used and only success is checked.
264
+ // The enables are independent, so they go out together; the first failure wins.
265
+ const enableDomains = async (sid: string): Promise<Result<void, CdpError>> => {
266
+ const results = await Promise.all(ENABLED_DOMAINS.map((d) => transport.request(`${d}.enable`, {}, { sessionId: sid })));
267
+ return results.find((r) => !r.success) ?? ok(undefined);
172
268
  };
173
269
 
174
270
  const req = async <M extends CdpMethod>(
175
271
  method: M,
176
272
  params: ParamsOf<M> | undefined,
177
273
  sid: string | null,
178
- opts: { timeoutMs?: number } = {},
274
+ opts: CallOpts = {},
179
275
  ): Promise<Result<ResultOf<M>, CdpError>> => {
180
276
  const raw = await transport.request(method, { ...(params ?? {}) }, {
181
277
  sessionId: sid,
@@ -188,134 +284,138 @@ export const createCdpSession = (
188
284
  const attach = async (tid: string): Promise<Result<string, CdpError>> =>
189
285
  map(await req("Target.attachToTarget", { targetId: tid, flatten: true }, null), (d) => d.sessionId);
190
286
 
287
+ // Callers that ignore the Result are best effort: the session may already be gone, which is the outcome they wanted anyway.
288
+ const detachSession = async (sid: string): Promise<Result<void, CdpError>> =>
289
+ map(await req("Target.detachFromTarget", { sessionId: sid }, null), () => undefined);
290
+
191
291
  const windowId = async (tid: string): Promise<Result<number, CdpError>> =>
192
292
  map(await req("Browser.getWindowForTarget", { targetId: tid }, null), (d) => d.windowId);
193
293
 
294
+ // Attaches a new session to `tid` with every domain enabled. A previous session on the same target is detached only once the new one is live, so the tab's buffers and refs carry over without leaking the old session.
295
+ const attachFresh = async (tid: string, pageInfoDirty: boolean): Promise<Result<string, CdpError>> => {
296
+ const previous = tabs.get(tid)?.sessionId;
297
+ const attached = await attach(tid);
298
+ if (!attached.success) return attached;
299
+ const sid = attached.data;
300
+ bindSession(tid, sid, pageInfoDirty);
301
+ const enabled = await enableDomains(sid);
302
+ if (!enabled.success) {
303
+ forgetSession(sid);
304
+ await detachSession(sid);
305
+ return enabled;
306
+ }
307
+ if (previous !== undefined && previous !== sid) await detachSession(previous);
308
+ return ok(sid);
309
+ };
310
+
311
+ const discoverPages = async (): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
312
+ const discovering = await req("Target.setDiscoverTargets", { discover: true }, null);
313
+ if (!discovering.success) return discovering;
314
+ return map(await req("Target.getTargets", {}, null), (r) => r.targetInfos.filter((t) => t.type === "page"));
315
+ };
316
+
317
+ // Drops dead owned targets, then adopts every live page that shares a window with the first surviving owned tab.
318
+ const adoptHarnessWindowTabs = async (
319
+ registry: OwnershipRegistry,
320
+ allPages: ReadonlyArray<PageTarget>,
321
+ live: ReadonlySet<string>,
322
+ ): Promise<void> => {
323
+ const survivors = reconcileOwnership(registry, live);
324
+ const anchorId = survivors[0];
325
+ if (anchorId === undefined) {
326
+ registry.setHarnessWindowId(undefined);
327
+ return;
328
+ }
329
+ const anchorWin = await windowId(anchorId);
330
+ if (anchorWin.success) {
331
+ const anchorWindowId = anchorWin.data;
332
+ registry.setHarnessWindowId(anchorWindowId);
333
+ const windows = await Promise.all(
334
+ allPages.map((p) => (p.targetId === anchorId ? Promise.resolve(anchorWin) : windowId(p.targetId))),
335
+ );
336
+ // One replaceAll, one change notification: adding per page would persist state once per tab.
337
+ const adopted = allPages.filter((_, i) => {
338
+ const r = windows[i];
339
+ return r?.success === true && r.data === anchorWindowId;
340
+ }).map((p) => p.targetId);
341
+ registry.replaceAll([...new Set([...registry.list(), ...adopted])]);
342
+ }
343
+ if (!registry.harnessWindow()) registry.setHarnessWindow(anchorId);
344
+ };
345
+
346
+ const pruneDeadTabs = (live: ReadonlySet<string>): void => {
347
+ for (const tid of [...tabs.keys()]) {
348
+ if (!live.has(tid)) forgetTarget(tid);
349
+ }
350
+ };
351
+
352
+ // First live owned page; otherwise a seeded one; otherwise a fresh about:blank (in its own window when ownership is tracked).
353
+ const pickOrCreateTarget = async (live: ReadonlySet<string>): Promise<Result<string, CdpError>> => {
354
+ const ownedLive = ownership?.list().find((id) => live.has(id));
355
+ if (ownedLive !== undefined) return ok(ownedLive);
356
+ if (createSeedTarget) return createSeedTarget();
357
+ const created = await req(
358
+ "Target.createTarget",
359
+ { url: "about:blank", ...(ownership ? { newWindow: true } : {}) },
360
+ null,
361
+ );
362
+ if (!created.success) return created;
363
+ const tid = created.data.targetId;
364
+ if (ownership) {
365
+ ownership.setHarnessWindow(tid);
366
+ ownership.add(tid);
367
+ const win = await windowId(tid);
368
+ if (win.success) ownership.setHarnessWindowId(win.data);
369
+ }
370
+ return ok(tid);
371
+ };
372
+
194
373
  const currentTab = (): TabSession | undefined => (targetId ? tabs.get(targetId) : undefined);
195
374
 
196
375
  const session: CdpSession = {
197
376
  async attachFirstPage() {
198
- await req("Target.setDiscoverTargets", { discover: true }, null);
199
-
200
- const targets = await req("Target.getTargets", {}, null);
201
- if (!targets.success) return targets;
202
- const allPages = targets.data.targetInfos.filter((t) => t.type === "page");
203
- if (ownership) {
204
- const live = new Set(allPages.map((p) => p.targetId));
205
- const survivors = ownership.list().filter((id) => live.has(id));
206
- if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
207
- const hw = ownership.harnessWindow();
208
- if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
209
-
210
- const anchorId = survivors[0];
211
- if (anchorId === undefined) {
212
- ownership.setHarnessWindowId(undefined);
213
- } else {
214
- const anchorWin = await windowId(anchorId);
215
- if (anchorWin.success) {
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
- });
231
- }
232
- if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId);
233
- }
234
- }
235
- const liveTargetIds = new Set(allPages.map((p) => p.targetId));
236
- for (const tid of tabs.keys()) {
237
- if (!liveTargetIds.has(tid)) {
238
- const tab = tabs.get(tid);
239
- if (tab) sessionIdToTargetId.delete(tab.sessionId);
240
- tabs.delete(tid);
241
- }
242
- }
243
-
244
- let pickTargetId: string | undefined;
245
- if (ownership) {
246
- const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
247
- pickTargetId = ownedLive[0];
248
- }
249
- if (!pickTargetId && createSeedTarget) {
250
- const seeded = await createSeedTarget();
251
- if (!seeded.success) return seeded;
252
- pickTargetId = seeded.data;
253
- }
254
- if (!pickTargetId) {
255
- const created = await req(
256
- "Target.createTarget",
257
- { url: "about:blank", ...(ownership ? { newWindow: true } : {}) },
258
- null,
259
- );
260
- if (!created.success) return created;
261
- pickTargetId = created.data.targetId;
262
- if (ownership) {
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);
267
- }
268
- }
269
-
270
- const attached = await attach(pickTargetId);
377
+ const pages = await discoverPages();
378
+ if (!pages.success) return pages;
379
+ const live: ReadonlySet<string> = new Set(pages.data.map((p) => p.targetId));
380
+ if (ownership) await adoptHarnessWindowTabs(ownership, pages.data, live);
381
+ pruneDeadTabs(live);
382
+ const picked = await pickOrCreateTarget(live);
383
+ if (!picked.success) return picked;
384
+ const tid = picked.data;
385
+ // Always a fresh session: callers reach this on start or after the current session was reported dead.
386
+ const attached = await attachFresh(tid, false);
271
387
  if (!attached.success) return attached;
272
- const attachedSessionId = attached.data;
273
- sessionId = attachedSessionId;
274
- targetId = pickTargetId;
275
- tabs.set(pickTargetId, {
276
- sessionId: attachedSessionId,
277
- targetId: pickTargetId,
278
- dialog: null,
279
- pageInfoDirty: false,
280
- networkBuffer: createNetworkBuffer(),
281
- consoleBuffer: createConsoleBuffer(),
282
- refMap: new Map(),
283
- refSig: new Map(),
284
- });
285
- sessionIdToTargetId.set(attachedSessionId, pickTargetId);
286
- await enableDomains(attachedSessionId);
287
- return ok({ targetId: pickTargetId, sessionId: attachedSessionId });
388
+ setCurrent(tid, attached.data);
389
+ return ok({ targetId: tid, sessionId: attached.data });
288
390
  },
289
391
  async switchTo(tid) {
290
392
  const activated = await req("Target.activateTarget", { targetId: tid }, null);
291
393
  if (!activated.success) return activated;
292
- const attached = await attach(tid);
293
- if (!attached.success) return attached;
294
- const attachedSessionId = attached.data;
295
394
  const existing = tabs.get(tid);
296
- const tab: TabSession = existing ?? {
297
- sessionId: attachedSessionId,
298
- targetId: tid,
299
- dialog: null,
300
- pageInfoDirty: true,
301
- networkBuffer: createNetworkBuffer(),
302
- consoleBuffer: createConsoleBuffer(),
303
- refMap: new Map(),
304
- refSig: new Map(),
305
- };
306
395
  if (existing) {
307
- sessionIdToTargetId.delete(existing.sessionId);
308
- existing.sessionId = attachedSessionId;
309
- } else {
310
- tabs.set(tid, tab);
396
+ setCurrent(tid, existing.sessionId);
397
+ return ok(undefined);
311
398
  }
312
- sessionIdToTargetId.set(attachedSessionId, tid);
313
- await enableDomains(attachedSessionId);
314
- sessionId = tab.sessionId;
315
- targetId = tid;
399
+ const attached = await attachFresh(tid, true);
400
+ if (!attached.success) return attached;
401
+ setCurrent(tid, attached.data);
316
402
  return ok(undefined);
317
403
  },
318
404
  attach,
405
+ async detach(sid) {
406
+ forgetSession(sid);
407
+ return detachSession(sid);
408
+ },
409
+ async detachAll() {
410
+ const sids = [...sessionIdToTargetId.keys()];
411
+ clearCurrent();
412
+ tabs.clear();
413
+ sessionIdToTargetId.clear();
414
+ await Promise.all(sids.map(detachSession));
415
+ },
416
+ sessionFor(tid) {
417
+ return tabs.get(tid)?.sessionId;
418
+ },
319
419
  windowId,
320
420
  current() {
321
421
  return sessionId && targetId ? { sessionId, targetId } : null;
@@ -323,8 +423,9 @@ export const createCdpSession = (
323
423
  setRefMap(refMap, refSig) {
324
424
  const tab = currentTab();
325
425
  if (!tab) return;
326
- tab.refMap = refMap;
327
- tab.refSig = refSig;
426
+ // Copied so later mutation of the caller's maps cannot change what resolveRef sees.
427
+ tab.refMap = new Map(refMap);
428
+ tab.refSig = new Map(refSig);
328
429
  },
329
430
  resolveRef(ref) {
330
431
  const tab = currentTab();
@@ -338,13 +439,15 @@ export const createCdpSession = (
338
439
  const tab = currentTab();
339
440
  return tab?.refMap ?? new Map();
340
441
  },
341
- call(method, params, opts) {
442
+ call(method, ...args) {
443
+ const [params, opts] = args;
342
444
  return req(method, params, sessionId, opts);
343
445
  },
344
446
  callOnTarget(method, params, sid, opts) {
345
447
  return req(method, params, sid, opts);
346
448
  },
347
- callBrowser(method, params, opts) {
449
+ callBrowser(method, ...args) {
450
+ const [params, opts] = args;
348
451
  return req(method, params, null, opts);
349
452
  },
350
453
  takeDialog() {