pi-browser-harness 0.12.0 → 0.14.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 (102) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +132 -0
  3. package/package.json +15 -7
  4. package/skills/pi-browser-harness/SKILL.md +89 -2
  5. package/src/cdp/commands.ts +205 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +9 -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 +358 -216
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +127 -54
  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 +27 -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 +67 -46
  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/recipe.page.ts +82 -0
  51. package/src/domains/page/serp.page.ts +42 -0
  52. package/src/domains/page.ts +18 -51
  53. package/src/domains/poll.ts +42 -0
  54. package/src/domains/readpage/read-page.ts +48 -53
  55. package/src/domains/ref-resolve.ts +43 -29
  56. package/src/domains/ref-signature.ts +11 -0
  57. package/src/domains/render.ts +12 -23
  58. package/src/domains/screenshot-capture.ts +4 -2
  59. package/src/domains/screenshot.ts +13 -10
  60. package/src/domains/scroll.ts +4 -4
  61. package/src/domains/search/google-serp.ts +78 -8
  62. package/src/domains/search/redirect-resolver.ts +41 -0
  63. package/src/domains/search/web-search.ts +103 -40
  64. package/src/domains/snapshot.ts +81 -78
  65. package/src/domains/tab-resolve.ts +36 -0
  66. package/src/domains/tabs.ts +18 -69
  67. package/src/domains/target.ts +1 -1
  68. package/src/index.ts +101 -33
  69. package/src/profile/bind.ts +45 -14
  70. package/src/profile/browser-process.ts +50 -12
  71. package/src/profile/command.ts +20 -12
  72. package/src/profile/launch.ts +13 -9
  73. package/src/profile/list.ts +9 -13
  74. package/src/profile/store.ts +2 -1
  75. package/src/prompt.ts +34 -8
  76. package/src/recipes/command.ts +96 -0
  77. package/src/recipes/compile.ts +198 -0
  78. package/src/recipes/find.ts +49 -0
  79. package/src/recipes/journal.ts +125 -0
  80. package/src/recipes/recipe.ts +215 -0
  81. package/src/recipes/recordable.ts +359 -0
  82. package/src/recipes/replay.ts +354 -0
  83. package/src/recipes/run-tool.ts +132 -0
  84. package/src/recipes/save-tool.ts +247 -0
  85. package/src/recipes/store.ts +103 -0
  86. package/src/recipes/trust.ts +172 -0
  87. package/src/registry.ts +69 -4
  88. package/src/schemas/parse.ts +2 -2
  89. package/src/setup.ts +28 -19
  90. package/src/state.ts +2 -1
  91. package/src/util/debug.ts +7 -1
  92. package/src/util/guards.ts +3 -0
  93. package/src/util/js-source.ts +28 -0
  94. package/src/util/mutex.ts +1 -1
  95. package/src/util/paths.ts +3 -2
  96. package/src/util/result.ts +8 -0
  97. package/src/util/sharp-shim.ts +18 -11
  98. package/src/util/sleep.ts +18 -0
  99. package/src/util/tool.ts +185 -114
  100. package/src/util/truncate.ts +1 -1
  101. package/tsconfig.json +2 -1
  102. package/src/domains/fill-engine.ts +0 -80
@@ -3,7 +3,9 @@
3
3
  import { randomUUID } from "node:crypto";
4
4
  import type { BrowserClient } from "../client";
5
5
  import { type Result, err, ok } from "../util/result";
6
+ import { safeJs } from "../util/js-template";
6
7
  import { type CdpError, cdpError } from "./errors";
8
+ import { reconcileOwnership } from "./ownership";
7
9
 
8
10
  const SPAWN_DEADLINE_MS = 8_000;
9
11
  const SPAWN_POLL_MS = 100;
@@ -26,30 +28,46 @@ const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyAr
26
28
  return ok(r.data.targetInfos.filter((t) => t.type === "page"));
27
29
  };
28
30
 
29
- const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => {
30
- const current = client.current();
31
- if (current?.targetId === targetId) return ok(current.sessionId);
32
- return client.session().attach(targetId);
31
+ export type BorrowedSession = { readonly sessionId: string; readonly release: () => Promise<void> };
32
+
33
+ // Reuses the tracked, domain-enabled session when the harness holds one for the target; otherwise attaches a raw throwaway session that `release` detaches, so no session outlives its single use.
34
+ export const borrowSession = async (client: BrowserClient, targetId: string): Promise<Result<BorrowedSession, CdpError>> => {
35
+ const tracked = client.session().sessionFor(targetId);
36
+ if (tracked !== undefined) return ok({ sessionId: tracked, release: () => Promise.resolve() });
37
+ const attached = await client.session().attach(targetId);
38
+ if (!attached.success) return attached;
39
+ const sessionId = attached.data;
40
+ return ok({
41
+ sessionId,
42
+ release: async () => {
43
+ await client.session().detach(sessionId);
44
+ },
45
+ });
33
46
  };
34
47
 
35
48
  const spawnTabViaOpener = async (
36
49
  client: BrowserClient,
37
50
  openerTargetId: string,
38
51
  ): Promise<Result<string, CdpError>> => {
39
- const session = await sessionForTarget(client, openerTargetId);
40
- if (!session.success) return session;
52
+ const borrowed = await borrowSession(client, openerTargetId);
53
+ if (!borrowed.success) return borrowed;
41
54
 
42
55
  const token = `pi-${randomUUID()}`;
43
56
  // userGesture: true is what gets this past the popup blocker.
44
- const opened = await client.session().callOnTarget(
45
- "Runtime.evaluate",
46
- {
47
- expression: `!!window.open('about:blank#${token}', '_blank')`,
48
- returnByValue: true,
49
- userGesture: true,
50
- },
51
- session.data,
52
- );
57
+ let opened;
58
+ try {
59
+ opened = await client.session().callOnTarget(
60
+ "Runtime.evaluate",
61
+ {
62
+ expression: safeJs`!!window.open(${`about:blank#${token}`}, '_blank')`,
63
+ returnByValue: true,
64
+ userGesture: true,
65
+ },
66
+ borrowed.data.sessionId,
67
+ );
68
+ } finally {
69
+ await borrowed.data.release();
70
+ }
53
71
  if (!opened.success) return opened;
54
72
  if (opened.data.result.value !== true) {
55
73
  return err(cdpError(
@@ -93,6 +111,7 @@ const ensureHarnessWindowUncoordinated = async (
93
111
  const live = new Set(targets.data.map((t) => t.targetId));
94
112
 
95
113
  const ownership = client.ownership();
114
+ reconcileOwnership(ownership, live);
96
115
  const pinnedCtx = client.profileContextId();
97
116
  // With a pin set but no context resolved yet, a recorded tab's profile cannot be verified, so seed a new window rather than risk adopting one from the wrong profile.
98
117
  const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
package/src/cdp/types.ts CHANGED
@@ -11,7 +11,7 @@ export type CdpEvent = {
11
11
  export type CdpRawMessage = {
12
12
  readonly id?: number;
13
13
  readonly method?: string;
14
- readonly params?: Record<string, unknown>;
14
+ readonly params?: Readonly<Record<string, unknown>>;
15
15
  readonly result?: unknown;
16
16
  readonly error?: { readonly message: string; readonly code?: number };
17
17
  readonly sessionId?: string;
@@ -30,6 +30,15 @@ export type TabInfo = {
30
30
  readonly owned?: boolean;
31
31
  };
32
32
 
33
+ // The identity of an interactive element as seen in one snapshot. Kept on the tab session per ref so the next
34
+ // mutation can report what changed without re-running a full snapshot.
35
+ export type RefSignature = {
36
+ readonly role: string;
37
+ readonly name: string;
38
+ readonly value: string;
39
+ readonly state: string;
40
+ };
41
+
33
42
  export type PageInfo = {
34
43
  readonly url: string;
35
44
  readonly title: string;
@@ -41,6 +50,11 @@ export type PageInfo = {
41
50
  readonly pageHeight: number;
42
51
  };
43
52
 
53
+ // Discriminated on `kind`.
54
+ export type PageInfoResult =
55
+ | ({ readonly kind: "page" } & PageInfo)
56
+ | { readonly kind: "dialog"; readonly dialog: DialogInfo };
57
+
44
58
  export type DaemonStatus = {
45
59
  readonly alive: boolean;
46
60
  readonly sessionId: string | null;
@@ -56,6 +70,8 @@ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
56
70
  if (method !== undefined && typeof method !== "string") return false;
57
71
  const sessionId = v["sessionId"];
58
72
  if (sessionId !== undefined && typeof sessionId !== "string") return false;
73
+ const params = v["params"];
74
+ if (params !== undefined && !isRecord(params)) return false;
59
75
  const errVal = v["error"];
60
76
  if (errVal !== undefined) {
61
77
  if (!isRecord(errVal)) return false;
package/src/client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { DAEMON_RECONNECT_METHOD } from "./daemon/protocol";
1
2
  import { Type } from "typebox";
2
3
  import { Compile } from "typebox/compile";
3
4
  import { parseJson } from "./schemas/parse";
@@ -8,11 +9,26 @@ import { discoverEndpoint } from "./cdp/discovery";
8
9
  import { type CdpError, cdpError } from "./cdp/errors";
9
10
  import type { CdpTransport } from "./cdp/types";
10
11
  import { type CdpSession, createCdpSession } from "./cdp/session";
11
- import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
12
+ import { type OwnershipRegistry, createOwnershipRegistry, reconcileOwnership } from "./cdp/ownership";
12
13
  import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
13
14
  import { seedProfileWindow } from "./profile/bind";
14
15
  import type { ProfilePin } from "./profile/store";
15
- import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
16
+ import type { DaemonStatus, DialogInfo, PageInfo, PageInfoResult, TabInfo } from "./cdp/types";
17
+ import { pageExpression } from "./util/js-source";
18
+
19
+ // REPL mode lets a later call declare the same const or let again, as the console does.
20
+ export const evaluateParams = (
21
+ expression: string,
22
+ ): { readonly expression: string; readonly returnByValue: true; readonly awaitPromise: true; readonly replMode: true } => ({
23
+ expression: pageExpression(expression),
24
+ returnByValue: true,
25
+ awaitPromise: true,
26
+ replMode: true,
27
+ });
28
+
29
+ const CLOSE_ATTEMPTS = 2;
30
+ const CLOSE_CONFIRM_MS = 2_500;
31
+ const CLOSE_POLL_MS = 100;
16
32
 
17
33
  export type BrowserClientOptions = {
18
34
  readonly namespace: string;
@@ -24,6 +40,8 @@ export type BrowserClientOptions = {
24
40
  readonly harnessWindowId?: number;
25
41
  };
26
42
  readonly profilePin?: ProfilePin | null;
43
+ // How long one close may take to show in Chrome's target list before it is sent again; tests shorten it.
44
+ readonly closeConfirmMs?: number;
27
45
  readonly onOwnershipChange?: (snapshot: {
28
46
  readonly ownedTargetIds: ReadonlyArray<string>;
29
47
  readonly harnessWindowTargetId: string | undefined;
@@ -35,12 +53,14 @@ export type BrowserClient = {
35
53
  readonly namespace: string;
36
54
  ensureAlive(): Promise<Result<void, CdpError>>;
37
55
  status(): DaemonStatus;
38
- start(): Promise<Result<void, CdpError>>;
56
+ // `reconnectNow` is for explicit human demand (/browser-setup, /browser-reload-daemon): it asks the
57
+ // daemon to dial Chrome immediately even if it is cooling down after a failed attempt.
58
+ start(opts?: { readonly reconnectNow?: boolean }): Promise<Result<void, CdpError>>;
39
59
  stop(): Promise<void>;
40
60
  detach(): Promise<void>;
41
61
  closeOwnedTabs(): Promise<void>;
42
62
  evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
43
- pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
63
+ pageInfo(): Promise<Result<PageInfoResult, CdpError>>;
44
64
  takeDialog(): DialogInfo | null;
45
65
  listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
46
66
  switchTab(targetId: string): Promise<Result<void, CdpError>>;
@@ -123,7 +143,8 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
123
143
  });
124
144
  const mutationMutex = createMutex();
125
145
  let lastHealth = 0;
126
- let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
146
+ let healthCheck: Promise<Result<void, CdpError>> | null = null;
147
+ const pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
127
148
  let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
128
149
  let userDataDir: string | undefined;
129
150
  let profilePin: ProfilePin | null = opts.profilePin ?? null;
@@ -137,7 +158,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
137
158
  return ok(undefined);
138
159
  };
139
160
 
140
- const start = async (): Promise<Result<void, CdpError>> => {
161
+ const start = async (opts: { readonly reconnectNow?: boolean } = {}): Promise<Result<void, CdpError>> => {
141
162
  if (transport.state() === "open" && session.current()) return ok(undefined);
142
163
  let wsUrl: string;
143
164
  if (remote?.cdpUrl) {
@@ -150,9 +171,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
150
171
  }
151
172
  const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
152
173
  if (!connected.success) return connected;
174
+ if (opts.reconnectNow) {
175
+ // Only the daemon understands this; a direct Chrome transport answers "method not found",
176
+ // which is fine because it has no cooldown to clear.
177
+ const dialed = await transport.request(DAEMON_RECONNECT_METHOD, {}, { timeoutMs: 15_000 });
178
+ if (!dialed.success && dialed.error.kind !== "remote_error") {
179
+ await transport.close();
180
+ return dialed;
181
+ }
182
+ }
153
183
 
154
184
  const newBrowserId: string = wsUrl.split("/").pop() ?? "unknown";
155
- if (!remote || remote.browserId !== newBrowserId) {
185
+ if (remote?.browserId !== newBrowserId) {
156
186
  profileContextId = undefined;
157
187
  }
158
188
  remote = { cdpUrl: wsUrl, browserId: newBrowserId };
@@ -177,43 +207,62 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
177
207
  lastHealth = 0;
178
208
  };
179
209
 
180
- const ensureAlive = async (): Promise<Result<void, CdpError>> => {
181
- if (transport.state() !== "open" || !session.current()) {
182
- await stop();
183
- return start();
210
+ const restart = async (): Promise<Result<void, CdpError>> => {
211
+ await stop();
212
+ return start();
213
+ };
214
+
215
+ const reattach = async (): Promise<Result<void, CdpError>> => {
216
+ const seeded = await seedIfPinned();
217
+ if (!seeded.success) return seeded;
218
+ const reattached = await session.attachFirstPage();
219
+ if (reattached.success) {
220
+ pageCaches.clear();
221
+ return ok(undefined);
184
222
  }
223
+ return restart();
224
+ };
225
+
226
+ // Two probes: the browser one proves the socket works, the page one proves the current session does.
227
+ // Only transport-level failures (closed socket, timeout) mean the connection is dead. A page probe that
228
+ // fails for a page-level reason (navigation in flight, a throwing page) still proves the session routes,
229
+ // so it counts as alive; only a dead session id triggers a re-attach.
230
+ const checkHealth = async (): Promise<Result<void, CdpError>> => {
231
+ if (transport.state() !== "open" || !session.current()) return restart();
185
232
  if (Date.now() - lastHealth < HEALTH_TTL_MS) return ok(undefined);
186
- const probe = await transport.request("Target.getTargets", {}, { sessionId: null, timeoutMs: 2_000 });
187
- if (!probe.success) {
188
- await stop();
189
- return start();
190
- }
233
+ const probe = await session.callBrowser("Target.getTargets", {}, { timeoutMs: 2_000 });
234
+ if (!probe.success) return restart();
191
235
  lastHealth = Date.now();
192
236
  const jsProbe = await session.call("Runtime.evaluate", {
193
237
  expression: "1", returnByValue: true,
194
238
  }, { timeoutMs: 2_000 });
195
- if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
196
- const seeded = await seedIfPinned();
197
- if (!seeded.success) return seeded;
198
- const reattached = await session.attachFirstPage();
199
- if (reattached.success) {
200
- pageCaches.clear();
239
+ if (jsProbe.success) return ok(undefined);
240
+ switch (jsProbe.error.kind) {
241
+ case "session_not_found":
242
+ return reattach();
243
+ case "transport_closed":
244
+ case "timeout":
245
+ return restart();
246
+ case "discovery_failed":
247
+ case "invalid_response":
248
+ case "remote_error":
201
249
  return ok(undefined);
202
- }
203
- await stop();
204
- return start();
205
250
  }
206
- return ok(undefined);
251
+ };
252
+
253
+ // Concurrent callers share one in-flight check, so two tools racing after a disconnect cannot both restart the transport.
254
+ const ensureAlive = (): Promise<Result<void, CdpError>> => {
255
+ if (healthCheck) return healthCheck;
256
+ const running = checkHealth().finally(() => {
257
+ healthCheck = null;
258
+ });
259
+ healthCheck = running;
260
+ return running;
207
261
  };
208
262
 
209
263
  const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
210
- // Wrap only when the trimmed source *starts with* `return` — matching the substring anywhere silently turned expressions mentioning it into `undefined`.
211
- const trimmed = expression.trim();
212
- const isReturnStatement = /^return[\s(]/.test(trimmed);
213
- const wrapped = isReturnStatement ? `(function(){${expression}})()` : expression;
214
- const r = sessionId
215
- ? await session.callOnTarget("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }, sessionId)
216
- : await session.call("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true });
264
+ const params = evaluateParams(expression);
265
+ const r = sessionId ? await session.callOnTarget("Runtime.evaluate", params, sessionId) : await session.call("Runtime.evaluate", params);
217
266
  if (!r.success) return r;
218
267
  if (r.data.exceptionDetails !== undefined) {
219
268
  return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(r.data.exceptionDetails)}`, "Runtime.evaluate"));
@@ -236,10 +285,12 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
236
285
  return ok(info.data);
237
286
  };
238
287
 
239
- const pageInfo = async (): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>> => {
288
+ const pageInfo = async (): Promise<Result<PageInfoResult, CdpError>> => {
240
289
  const d = session.takeDialog();
241
- if (d) return ok({ dialog: d });
242
- return readPageInfo();
290
+ if (d) return ok({ kind: "dialog", dialog: d });
291
+ const info = await readPageInfo();
292
+ if (!info.success) return info;
293
+ return ok({ kind: "page", ...info.data });
243
294
  };
244
295
 
245
296
  const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => {
@@ -255,11 +306,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
255
306
  owned: ownership.has(t.targetId),
256
307
  }));
257
308
  const live = new Set(tabs.map((t) => t.targetId));
258
- const owned = ownership.list();
259
- const survivors = owned.filter((id) => live.has(id));
260
- if (survivors.length !== owned.length) ownership.replaceAll(survivors);
261
- const hw = ownership.harnessWindow();
262
- if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
309
+ reconcileOwnership(ownership, live);
263
310
  for (const tid of pageCaches.keys()) {
264
311
  if (!live.has(tid)) pageCaches.delete(tid);
265
312
  }
@@ -276,9 +323,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
276
323
  };
277
324
 
278
325
  const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
279
- const tabsResult = await listTabs(true);
280
- if (!tabsResult.success) return tabsResult;
281
-
326
+ // ensureHarnessWindow lists live targets and reconciles ownership itself, so no listing is needed here.
282
327
  const window = await ensureHarnessWindow(api);
283
328
  if (!window.success) return window;
284
329
  let tabId: string;
@@ -299,17 +344,46 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
299
344
  return ok(tabId);
300
345
  };
301
346
 
347
+ // True once Chrome no longer lists the target, false if it still does at the deadline, undefined when the list
348
+ // cannot be read (then the close is taken at its word, as before).
349
+ const targetGone = async (targetId: string, timeoutMs: number): Promise<boolean | undefined> => {
350
+ const deadline = Date.now() + timeoutMs;
351
+ for (;;) {
352
+ const r = await session.callBrowser("Target.getTargets", {}, { timeoutMs: 2_000 });
353
+ if (!r.success) return undefined;
354
+ if (!r.data.targetInfos.some((t) => t.targetId === targetId)) return true;
355
+ if (Date.now() >= deadline) return false;
356
+ await new Promise((resolve) => setTimeout(resolve, CLOSE_POLL_MS));
357
+ }
358
+ };
359
+
360
+ // Chrome answers Target.closeTarget with success even when it then keeps the tab: a tab closed while its page is
361
+ // still loading stays open. So the close is checked against Chrome's own target list and sent once more if the
362
+ // tab is still there; the tab leaves the owned list only once it is gone, so a tab left open is not lost track of.
302
363
  const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
303
- const r = await session.callBrowser("Target.closeTarget", { targetId });
304
- if (!r.success) return r;
305
- ownership.remove(targetId);
306
- return ok(undefined);
364
+ for (let attempt = 1; attempt <= CLOSE_ATTEMPTS; attempt++) {
365
+ const r = await session.callBrowser("Target.closeTarget", { targetId });
366
+ if (!r.success) return r;
367
+ const gone = await targetGone(targetId, opts.closeConfirmMs ?? CLOSE_CONFIRM_MS);
368
+ if (gone !== false) {
369
+ ownership.remove(targetId);
370
+ return ok(undefined);
371
+ }
372
+ }
373
+ return err(
374
+ cdpError(
375
+ "timeout",
376
+ `Chrome accepted the close but tab ${targetId} is still open after ${CLOSE_ATTEMPTS} attempts (a page that is still loading can ignore a close); wait for it to load, then close it again`,
377
+ "Target.closeTarget",
378
+ ),
379
+ );
307
380
  };
308
381
 
309
382
  const closeOwnedTabs = async (): Promise<void> => {
310
383
  for (const id of ownership.list()) {
311
- await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
312
- ownership.remove(id);
384
+ const closed = await session.callBrowser("Target.closeTarget", { targetId: id });
385
+ // A remote error means the browser answered (typically "no target with given id"): the tab is gone either way. A transport failure means nothing was closed, so keep the record for the next attempt.
386
+ if (closed.success || closed.error.kind === "remote_error") ownership.remove(id);
313
387
  }
314
388
  ownership.setHarnessWindow(undefined);
315
389
  ownership.setHarnessWindowId(undefined);
@@ -323,9 +397,8 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
323
397
  });
324
398
 
325
399
  const detach = async (): Promise<void> => {
326
- const cur = session.current();
327
- if (!cur) return;
328
- await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
400
+ if (transport.state() !== "open") return;
401
+ await session.detachAll();
329
402
  };
330
403
 
331
404
  const api: BrowserClient = {
@@ -344,7 +417,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
344
417
  setProfilePin: (pin) => {
345
418
  const changed = pin === null
346
419
  ? profilePin !== null
347
- : profilePin === null || profilePin.userDataDir !== pin.userDataDir || profilePin.profileDir !== pin.profileDir;
420
+ : profilePin?.userDataDir !== pin.userDataDir || profilePin.profileDir !== pin.profileDir;
348
421
  profilePin = pin;
349
422
  if (changed) {
350
423
  profileContextId = undefined;