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
@@ -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,11 @@ 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";
16
17
 
17
18
  export type BrowserClientOptions = {
18
19
  readonly namespace: string;
@@ -35,12 +36,14 @@ export type BrowserClient = {
35
36
  readonly namespace: string;
36
37
  ensureAlive(): Promise<Result<void, CdpError>>;
37
38
  status(): DaemonStatus;
38
- start(): Promise<Result<void, CdpError>>;
39
+ // `reconnectNow` is for explicit human demand (/browser-setup, /browser-reload-daemon): it asks the
40
+ // daemon to dial Chrome immediately even if it is cooling down after a failed attempt.
41
+ start(opts?: { readonly reconnectNow?: boolean }): Promise<Result<void, CdpError>>;
39
42
  stop(): Promise<void>;
40
43
  detach(): Promise<void>;
41
44
  closeOwnedTabs(): Promise<void>;
42
45
  evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
43
- pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
46
+ pageInfo(): Promise<Result<PageInfoResult, CdpError>>;
44
47
  takeDialog(): DialogInfo | null;
45
48
  listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
46
49
  switchTab(targetId: string): Promise<Result<void, CdpError>>;
@@ -123,7 +126,8 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
123
126
  });
124
127
  const mutationMutex = createMutex();
125
128
  let lastHealth = 0;
126
- let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
129
+ let healthCheck: Promise<Result<void, CdpError>> | null = null;
130
+ const pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
127
131
  let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
128
132
  let userDataDir: string | undefined;
129
133
  let profilePin: ProfilePin | null = opts.profilePin ?? null;
@@ -137,7 +141,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
137
141
  return ok(undefined);
138
142
  };
139
143
 
140
- const start = async (): Promise<Result<void, CdpError>> => {
144
+ const start = async (opts: { readonly reconnectNow?: boolean } = {}): Promise<Result<void, CdpError>> => {
141
145
  if (transport.state() === "open" && session.current()) return ok(undefined);
142
146
  let wsUrl: string;
143
147
  if (remote?.cdpUrl) {
@@ -150,9 +154,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
150
154
  }
151
155
  const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
152
156
  if (!connected.success) return connected;
157
+ if (opts.reconnectNow) {
158
+ // Only the daemon understands this; a direct Chrome transport answers "method not found",
159
+ // which is fine because it has no cooldown to clear.
160
+ const dialed = await transport.request(DAEMON_RECONNECT_METHOD, {}, { timeoutMs: 15_000 });
161
+ if (!dialed.success && dialed.error.kind !== "remote_error") {
162
+ await transport.close();
163
+ return dialed;
164
+ }
165
+ }
153
166
 
154
167
  const newBrowserId: string = wsUrl.split("/").pop() ?? "unknown";
155
- if (!remote || remote.browserId !== newBrowserId) {
168
+ if (remote?.browserId !== newBrowserId) {
156
169
  profileContextId = undefined;
157
170
  }
158
171
  remote = { cdpUrl: wsUrl, browserId: newBrowserId };
@@ -177,33 +190,57 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
177
190
  lastHealth = 0;
178
191
  };
179
192
 
180
- const ensureAlive = async (): Promise<Result<void, CdpError>> => {
181
- if (transport.state() !== "open" || !session.current()) {
182
- await stop();
183
- return start();
193
+ const restart = async (): Promise<Result<void, CdpError>> => {
194
+ await stop();
195
+ return start();
196
+ };
197
+
198
+ const reattach = async (): Promise<Result<void, CdpError>> => {
199
+ const seeded = await seedIfPinned();
200
+ if (!seeded.success) return seeded;
201
+ const reattached = await session.attachFirstPage();
202
+ if (reattached.success) {
203
+ pageCaches.clear();
204
+ return ok(undefined);
184
205
  }
206
+ return restart();
207
+ };
208
+
209
+ // Two probes: the browser one proves the socket works, the page one proves the current session does.
210
+ // Only transport-level failures (closed socket, timeout) mean the connection is dead. A page probe that
211
+ // fails for a page-level reason (navigation in flight, a throwing page) still proves the session routes,
212
+ // so it counts as alive; only a dead session id triggers a re-attach.
213
+ const checkHealth = async (): Promise<Result<void, CdpError>> => {
214
+ if (transport.state() !== "open" || !session.current()) return restart();
185
215
  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
- }
216
+ const probe = await session.callBrowser("Target.getTargets", {}, { timeoutMs: 2_000 });
217
+ if (!probe.success) return restart();
191
218
  lastHealth = Date.now();
192
219
  const jsProbe = await session.call("Runtime.evaluate", {
193
220
  expression: "1", returnByValue: true,
194
221
  }, { 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();
222
+ if (jsProbe.success) return ok(undefined);
223
+ switch (jsProbe.error.kind) {
224
+ case "session_not_found":
225
+ return reattach();
226
+ case "transport_closed":
227
+ case "timeout":
228
+ return restart();
229
+ case "discovery_failed":
230
+ case "invalid_response":
231
+ case "remote_error":
201
232
  return ok(undefined);
202
- }
203
- await stop();
204
- return start();
205
233
  }
206
- return ok(undefined);
234
+ };
235
+
236
+ // Concurrent callers share one in-flight check, so two tools racing after a disconnect cannot both restart the transport.
237
+ const ensureAlive = (): Promise<Result<void, CdpError>> => {
238
+ if (healthCheck) return healthCheck;
239
+ const running = checkHealth().finally(() => {
240
+ healthCheck = null;
241
+ });
242
+ healthCheck = running;
243
+ return running;
207
244
  };
208
245
 
209
246
  const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
@@ -236,10 +273,12 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
236
273
  return ok(info.data);
237
274
  };
238
275
 
239
- const pageInfo = async (): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>> => {
276
+ const pageInfo = async (): Promise<Result<PageInfoResult, CdpError>> => {
240
277
  const d = session.takeDialog();
241
- if (d) return ok({ dialog: d });
242
- return readPageInfo();
278
+ if (d) return ok({ kind: "dialog", dialog: d });
279
+ const info = await readPageInfo();
280
+ if (!info.success) return info;
281
+ return ok({ kind: "page", ...info.data });
243
282
  };
244
283
 
245
284
  const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => {
@@ -255,11 +294,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
255
294
  owned: ownership.has(t.targetId),
256
295
  }));
257
296
  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);
297
+ reconcileOwnership(ownership, live);
263
298
  for (const tid of pageCaches.keys()) {
264
299
  if (!live.has(tid)) pageCaches.delete(tid);
265
300
  }
@@ -276,9 +311,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
276
311
  };
277
312
 
278
313
  const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
279
- const tabsResult = await listTabs(true);
280
- if (!tabsResult.success) return tabsResult;
281
-
314
+ // ensureHarnessWindow lists live targets and reconciles ownership itself, so no listing is needed here.
282
315
  const window = await ensureHarnessWindow(api);
283
316
  if (!window.success) return window;
284
317
  let tabId: string;
@@ -308,8 +341,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
308
341
 
309
342
  const closeOwnedTabs = async (): Promise<void> => {
310
343
  for (const id of ownership.list()) {
311
- await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
312
- ownership.remove(id);
344
+ const closed = await session.callBrowser("Target.closeTarget", { targetId: id });
345
+ // 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.
346
+ if (closed.success || closed.error.kind === "remote_error") ownership.remove(id);
313
347
  }
314
348
  ownership.setHarnessWindow(undefined);
315
349
  ownership.setHarnessWindowId(undefined);
@@ -323,9 +357,8 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
323
357
  });
324
358
 
325
359
  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 });
360
+ if (transport.state() !== "open") return;
361
+ await session.detachAll();
329
362
  };
330
363
 
331
364
  const api: BrowserClient = {
@@ -344,7 +377,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
344
377
  setProfilePin: (pin) => {
345
378
  const changed = pin === null
346
379
  ? profilePin !== null
347
- : profilePin === null || profilePin.userDataDir !== pin.userDataDir || profilePin.profileDir !== pin.profileDir;
380
+ : profilePin?.userDataDir !== pin.userDataDir || profilePin.profileDir !== pin.profileDir;
348
381
  profilePin = pin;
349
382
  if (changed) {
350
383
  profileContextId = undefined;