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,48 +1,15 @@
1
- /**
2
- * The single place harness tabs and windows are created.
3
- *
4
- * Every tab the harness opens must land in the pinned profile, so all creation
5
- * paths (client.newTab, browser_open_urls, isolated tabs for web_search /
6
- * read_page) funnel through here. A missed call site would silently act in
7
- * another profile — with that profile's cookies and logins.
8
- *
9
- * Two mechanisms, chosen by whether a profile is pinned:
10
- *
11
- * unpinned — `Target.createTarget`, exactly as before: `newWindow: true` for
12
- * a fresh dedicated window, `openerId` for siblings inside it.
13
- *
14
- * pinned — the window comes from a command-line launch into the profile
15
- * (see profile/launch.ts), and further tabs come from
16
- * `window.open(..., '_blank')` evaluated in an owned page with
17
- * `userGesture: true`. That is the only CDP-reachable way to place
18
- * a tab in a non-default browser context: createTarget rejects a
19
- * foreign browserContextId outright, and `openerId` does NOT
20
- * inherit the opener's context (verified — it lands in the
21
- * focus-derived default context, in a different window).
22
- * window.open keeps both the context and the window, recursively.
23
- *
24
- * Correlation: window.open returns no target id, so each spawn carries a unique
25
- * `about:blank#pi-<uuid>` token and the new target is found by that token.
26
- * Set-difference polling would be wrong here — isolated tabs run concurrently
27
- * (browser_read_page is registered unserialized).
28
- */
1
+ // `window.open` with userGesture is the only CDP-reachable way into a non-default browser context: createTarget rejects a foreign browserContextId and `openerId` does not inherit the opener's context.
29
2
 
30
3
  import { randomUUID } from "node:crypto";
31
4
  import type { BrowserClient } from "../client";
32
5
  import { type Result, err, ok } from "../util/result";
33
6
  import { type CdpError, cdpError } from "./errors";
34
7
 
35
- /** How long to wait for a window.open-spawned target to appear. */
36
8
  const SPAWN_DEADLINE_MS = 8_000;
37
9
  const SPAWN_POLL_MS = 100;
38
10
 
39
11
  export type HarnessWindow = {
40
- /** Target id of the window's seed tab. */
41
12
  readonly targetId: string;
42
- /**
43
- * True when this call created the window. The seed tab is then a blank tab
44
- * the caller may use directly, instead of opening a second one.
45
- */
46
13
  readonly freshlyCreated: boolean;
47
14
  };
48
15
 
@@ -56,23 +23,15 @@ type PageTarget = {
56
23
  const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
57
24
  const r = await client.session().callBrowser("Target.getTargets");
58
25
  if (!r.success) return r;
59
- const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
60
- return ok(data.targetInfos.filter((t) => t.type === "page"));
26
+ return ok(r.data.targetInfos.filter((t) => t.type === "page"));
61
27
  };
62
28
 
63
- /** A CDP session id for driving `targetId`, reusing the active one when possible. */
64
29
  const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => {
65
30
  const current = client.current();
66
31
  if (current?.targetId === targetId) return ok(current.sessionId);
67
- const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
68
- if (!attached.success) return attached;
69
- return ok((attached.data as { sessionId: string }).sessionId);
32
+ return client.session().attach(targetId);
70
33
  };
71
34
 
72
- /**
73
- * Spawn a tab from `openerTargetId` via window.open, so it inherits the
74
- * opener's profile and window, and return its target id.
75
- */
76
35
  const spawnTabViaOpener = async (
77
36
  client: BrowserClient,
78
37
  openerTargetId: string,
@@ -92,8 +51,7 @@ const spawnTabViaOpener = async (
92
51
  session.data,
93
52
  );
94
53
  if (!opened.success) return opened;
95
- const result = opened.data as { result?: { value?: unknown } };
96
- if (result.result?.value !== true) {
54
+ if (opened.data.result.value !== true) {
97
55
  return err(cdpError(
98
56
  "invalid_response",
99
57
  "the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed",
@@ -113,29 +71,13 @@ const spawnTabViaOpener = async (
113
71
  return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`));
114
72
  };
115
73
 
116
- /**
117
- * In-flight window creation, per client. Isolated tabs run concurrently
118
- * (browser_read_page is registered unserialized), so two of them starting at
119
- * once would otherwise each find "no harness window" and create one — two blank
120
- * windows before, and two launched profile windows now. Callers share the first
121
- * call's result instead.
122
- */
123
74
  const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>();
124
75
 
125
- /**
126
- * The harness window for this session, creating it when absent.
127
- *
128
- * Reuses a live owned tab when one exists — including after the user closed the
129
- * original seed — so reconnects don't scatter windows.
130
- */
131
76
  export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => {
132
77
  const pending = inFlight.get(client);
133
78
  if (pending) {
134
79
  const shared = await pending;
135
- // `freshlyCreated` is a claim on the seed tab, and only the caller that
136
- // started the creation may hold it. Everyone waiting on the same window
137
- // must open a tab of their own beside it, or two callers would drive the
138
- // very same tab.
80
+ // `freshlyCreated` is a claim on the seed tab that only the caller which started the creation may hold, or two callers would drive the same tab.
139
81
  return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared;
140
82
  }
141
83
  const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client));
@@ -152,11 +94,7 @@ const ensureHarnessWindowUncoordinated = async (
152
94
 
153
95
  const ownership = client.ownership();
154
96
  const pinnedCtx = client.profileContextId();
155
- // A tab may only be reused when we can prove which profile it is in. With a
156
- // pin set but no context resolved yet — a fresh connection, or a browser that
157
- // restarted — the recorded tabs cannot be verified, so we seed a new window
158
- // rather than risk adopting one from the wrong profile. (Sessions close their
159
- // owned tabs on shutdown, so this is the normal, no-cost case.)
97
+ // 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.
160
98
  const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
161
99
  const inPinnedProfile = (targetId: string): boolean => {
162
100
  if (!pinnedCtx) return true;
@@ -183,26 +121,18 @@ const ensureHarnessWindowUncoordinated = async (
183
121
  return ok({ targetId: seeded.data, freshlyCreated: true });
184
122
  };
185
123
 
186
- /** Unpinned path: a fresh dedicated window, exactly as the harness always did. */
187
124
  const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => {
188
125
  const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
189
126
  if (!created.success) return created;
190
- const { targetId } = created.data as { targetId: string };
127
+ const { targetId } = created.data;
191
128
  const ownership = client.ownership();
192
129
  ownership.setHarnessWindow(targetId);
193
130
  ownership.add(targetId);
194
- const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId });
195
- if (win.success) {
196
- const windowId = (win.data as { windowId?: number }).windowId;
197
- if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
198
- }
131
+ const win = await client.session().windowId(targetId);
132
+ if (win.success) ownership.setHarnessWindowId(win.data);
199
133
  return ok(targetId);
200
134
  };
201
135
 
202
- /**
203
- * Open a harness-owned tab beside `openerTargetId`, in the same window and —
204
- * when a profile is pinned — the same profile. The tab is registered as owned.
205
- */
206
136
  export const openHarnessTab = async (
207
137
  client: BrowserClient,
208
138
  openerTargetId: string,
@@ -215,7 +145,7 @@ export const openHarnessTab = async (
215
145
  openerId: openerTargetId,
216
146
  });
217
147
  if (!created.success) return created;
218
- return ok((created.data as { targetId: string }).targetId);
148
+ return ok(created.data.targetId);
219
149
  })();
220
150
  if (!spawned.success) return spawned;
221
151
  client.ownership().add(spawned.data);
package/src/cdp/types.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import type { Result } from "../util/result";
2
+ import { isRecord } from "../util/guards";
3
+ import type { CdpError } from "./errors";
4
+
1
5
  export type CdpEvent = {
2
6
  readonly method: string;
3
7
  readonly params: unknown;
@@ -44,18 +48,8 @@ export type DaemonStatus = {
44
48
  readonly remoteBrowserId?: string;
45
49
  };
46
50
 
47
- const INTERNAL_PREFIXES: ReadonlyArray<string> = [
48
- "chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:",
49
- ];
50
-
51
- export const isInternalUrl = (url: string): boolean =>
52
- INTERNAL_PREFIXES.some((p) => url.startsWith(p));
53
-
54
- const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
55
- typeof v === "object" && v !== null && !Array.isArray(v);
56
-
57
51
  export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
58
- if (!isObject(v)) return false;
52
+ if (!isRecord(v)) return false;
59
53
  const id = v["id"];
60
54
  if (id !== undefined && typeof id !== "number") return false;
61
55
  const method = v["method"];
@@ -64,10 +58,25 @@ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
64
58
  if (sessionId !== undefined && typeof sessionId !== "string") return false;
65
59
  const errVal = v["error"];
66
60
  if (errVal !== undefined) {
67
- if (!isObject(errVal)) return false;
61
+ if (!isRecord(errVal)) return false;
68
62
  if (typeof errVal["message"] !== "string") return false;
69
63
  const code = errVal["code"];
70
64
  if (code !== undefined && typeof code !== "number") return false;
71
65
  }
72
66
  return true;
73
67
  };
68
+
69
+ export const DEFAULT_TIMEOUT_MS = 15_000;
70
+
71
+ export type CdpTransport = {
72
+ connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
73
+ close(): Promise<void>;
74
+ request(
75
+ method: string,
76
+ params: Record<string, unknown>,
77
+ opts?: { sessionId?: string | null; timeoutMs?: number },
78
+ ): Promise<Result<unknown, CdpError>>;
79
+ events(): AsyncIterable<CdpEvent>;
80
+ state(): "open" | "closed" | "connecting";
81
+ onClose(cb: () => void): () => void;
82
+ };
package/src/client.ts CHANGED
@@ -1,10 +1,12 @@
1
+ import { Type } from "typebox";
2
+ import { Compile } from "typebox/compile";
3
+ import { parseJson } from "./schemas/parse";
1
4
  import { type Result, err, ok } from "./util/result";
2
5
  import { safeJs } from "./util/js-template";
3
6
  import { type Mutex, createMutex } from "./util/mutex";
4
7
  import { discoverEndpoint } from "./cdp/discovery";
5
8
  import { type CdpError, cdpError } from "./cdp/errors";
6
- import type { CdpTransport } from "./cdp/transport";
7
- import { createCdpTransport } from "./cdp/transport";
9
+ import type { CdpTransport } from "./cdp/types";
8
10
  import { type CdpSession, createCdpSession } from "./cdp/session";
9
11
  import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
10
12
  import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
@@ -14,14 +16,13 @@ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
14
16
 
15
17
  export type BrowserClientOptions = {
16
18
  readonly namespace: string;
17
- readonly transport?: CdpTransport;
19
+ readonly transport: CdpTransport;
18
20
  readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
19
21
  readonly initialOwnership?: {
20
22
  readonly ownedTargetIds?: ReadonlyArray<string>;
21
23
  readonly harnessWindowTargetId?: string;
22
24
  readonly harnessWindowId?: number;
23
25
  };
24
- /** Profile to pin this session to, loaded from the durable pin store. */
25
26
  readonly profilePin?: ProfilePin | null;
26
27
  readonly onOwnershipChange?: (snapshot: {
27
28
  readonly ownedTargetIds: ReadonlyArray<string>;
@@ -36,11 +37,7 @@ export type BrowserClient = {
36
37
  status(): DaemonStatus;
37
38
  start(): Promise<Result<void, CdpError>>;
38
39
  stop(): Promise<void>;
39
- /** Detach from the current page target (removes the "Chrome is being controlled" banner)
40
- * while keeping the transport connection alive. Call on session shutdown. */
41
40
  detach(): Promise<void>;
42
- /** Close every tab this session owns and clear the window binding. Best-effort —
43
- * used on session shutdown so no stale harness tabs survive. */
44
41
  closeOwnedTabs(): Promise<void>;
45
42
  evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
46
43
  pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
@@ -54,57 +51,51 @@ export type BrowserClient = {
54
51
  current(): { readonly sessionId: string; readonly targetId: string } | null;
55
52
  session(): CdpSession;
56
53
  transport(): CdpTransport;
57
- /** User-data-dir of the connected browser, when discovery could determine it.
58
- * Undefined for BU_CDP_WS / bare-port connections, where profile selection
59
- * is unavailable. */
60
54
  userDataDir(): string | undefined;
61
- /** The profile this session is pinned to, or null when unpinned. */
62
55
  profilePin(): ProfilePin | null;
63
56
  setProfilePin(pin: ProfilePin | null): void;
64
- /** browserContextId of the pinned profile, once a window has identified it.
65
- * Session-scoped: Chrome re-mints context ids on every browser run. */
66
57
  profileContextId(): string | undefined;
67
58
  setProfileContextId(contextId: string | undefined): void;
68
- /** Open and adopt a window inside the pinned profile. Returns its seed tab. */
69
59
  seedPinnedProfileWindow(): Promise<Result<string, CdpError>>;
70
- /** Returns the shared async mutex that serialized browser tools must acquire
71
- * before performing mutations. Observation tools should not use this. */
72
60
  mutationMutex(): Mutex;
73
61
  };
74
62
 
75
63
  const HEALTH_TTL_MS = 30_000;
76
64
  const PAGE_INFO_TTL_MS = 1_000;
77
65
 
78
- const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
79
- if (typeof v !== "object" || v === null) {
80
- return err(cdpError("invalid_response", "page info payload is not an object"));
81
- }
82
- const o = v as Readonly<Record<string, unknown>>;
83
- const fields: ReadonlyArray<readonly [string, "string" | "number"]> = [
84
- ["url", "string"], ["title", "string"],
85
- ["w", "number"], ["h", "number"],
86
- ["sx", "number"], ["sy", "number"],
87
- ["pw", "number"], ["ph", "number"],
88
- ];
89
- for (const [k, t] of fields) {
90
- if (typeof o[k] !== t) {
91
- return err(cdpError("invalid_response", `page info field ${k} has wrong type (expected ${t})`));
92
- }
66
+ const PageInfoPayload = Type.Object({
67
+ url: Type.String(),
68
+ title: Type.String(),
69
+ w: Type.Number(),
70
+ h: Type.Number(),
71
+ sx: Type.Number(),
72
+ sy: Type.Number(),
73
+ pw: Type.Number(),
74
+ ph: Type.Number(),
75
+ });
76
+
77
+ const pageInfoValidator = Compile(PageInfoPayload);
78
+
79
+ const parsePageInfoPayload = (raw: string): Result<PageInfo, CdpError> => {
80
+ const parsed = parseJson(raw, pageInfoValidator);
81
+ if (!parsed.success) {
82
+ return err(cdpError("invalid_response", `page info payload is invalid: ${parsed.error}`));
93
83
  }
84
+ const o = parsed.data;
94
85
  return ok({
95
- url: o["url"] as string,
96
- title: o["title"] as string,
97
- width: o["w"] as number,
98
- height: o["h"] as number,
99
- scrollX: o["sx"] as number,
100
- scrollY: o["sy"] as number,
101
- pageWidth: o["pw"] as number,
102
- pageHeight: o["ph"] as number,
86
+ url: o.url,
87
+ title: o.title,
88
+ width: o.w,
89
+ height: o.h,
90
+ scrollX: o.sx,
91
+ scrollY: o.sy,
92
+ pageWidth: o.pw,
93
+ pageHeight: o.ph,
103
94
  });
104
95
  };
105
96
 
106
97
  export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
107
- const transport = opts.transport ?? createCdpTransport();
98
+ const transport = opts.transport;
108
99
  const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
109
100
  if (opts.initialOwnership?.ownedTargetIds !== undefined) {
110
101
  ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
@@ -126,9 +117,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
126
117
  });
127
118
  });
128
119
  }
129
- // The seed provider routes attachFirstPage's fallback through the target
130
- // factory, so a pinned profile is honoured on every path that can create the
131
- // harness window.
132
120
  const session = createCdpSession(transport, ownership, async () => {
133
121
  const window = await ensureHarnessWindow(api);
134
122
  return window.success ? ok(window.data.targetId) : window;
@@ -141,12 +129,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
141
129
  let profilePin: ProfilePin | null = opts.profilePin ?? null;
142
130
  let profileContextId: string | undefined;
143
131
 
144
- /**
145
- * With a profile pinned, the harness window must exist BEFORE anything
146
- * attaches: session.attachFirstPage() would otherwise fall back to a bare
147
- * Target.createTarget, which lands in the focus-derived default profile.
148
- * Seeding first means attachFirstPage always finds an owned tab to attach to.
149
- */
132
+ // With a pin set the window must exist BEFORE anything attaches, or attachFirstPage's bare Target.createTarget lands in the focus-derived default profile.
150
133
  const seedIfPinned = async (): Promise<Result<void, CdpError>> => {
151
134
  if (!profilePin) return ok(undefined);
152
135
  const window = await ensureHarnessWindow(api);
@@ -156,12 +139,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
156
139
 
157
140
  const start = async (): Promise<Result<void, CdpError>> => {
158
141
  if (transport.state() === "open" && session.current()) return ok(undefined);
159
- const envUrl = process.env["BU_CDP_WS"];
160
142
  let wsUrl: string;
161
143
  if (remote?.cdpUrl) {
162
144
  wsUrl = remote.cdpUrl;
163
- } else if (envUrl) {
164
- wsUrl = envUrl;
165
145
  } else {
166
146
  const discovered = await discoverEndpoint();
167
147
  if (!discovered.success) return discovered;
@@ -209,14 +189,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
209
189
  return start();
210
190
  }
211
191
  lastHealth = Date.now();
212
- // Verify the page session is still responsive (handles the case where the
213
- // browser transport is alive but the page target crashed, e.g. localhost died).
214
192
  const jsProbe = await session.call("Runtime.evaluate", {
215
193
  expression: "1", returnByValue: true,
216
194
  }, { timeoutMs: 2_000 });
217
195
  if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
218
- // Re-seed first when pinned: if the user closed every harness tab,
219
- // attachFirstPage would mint a window in the focus-derived profile.
220
196
  const seeded = await seedIfPinned();
221
197
  if (!seeded.success) return seeded;
222
198
  const reattached = await session.attachFirstPage();
@@ -231,12 +207,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
231
207
  };
232
208
 
233
209
  const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
234
- // Heuristic IIFE wrap: legacy convenience that lets agents write
235
- // `return foo` instead of `(() => foo)()`. Only wrap when the trimmed
236
- // source *starts with* a return statement — a bare expression, or one that
237
- // merely mentions `return` inside a string literal or comment, is passed
238
- // through untouched (previously any substring `"return "` triggered a wrap,
239
- // silently turning such expressions into `undefined`).
210
+ // Wrap only when the trimmed source *starts with* `return` — matching the substring anywhere silently turned expressions mentioning it into `undefined`.
240
211
  const trimmed = expression.trim();
241
212
  const isReturnStatement = /^return[\s(]/.test(trimmed);
242
213
  const wrapped = isReturnStatement ? `(function(){${expression}})()` : expression;
@@ -244,11 +215,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
244
215
  ? await session.callOnTarget("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }, sessionId)
245
216
  : await session.call("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true });
246
217
  if (!r.success) return r;
247
- const data = r.data as { result?: { value?: unknown }; exceptionDetails?: unknown };
248
- if (data.exceptionDetails) {
249
- return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(data.exceptionDetails)}`, "Runtime.evaluate"));
218
+ if (r.data.exceptionDetails !== undefined) {
219
+ return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(r.data.exceptionDetails)}`, "Runtime.evaluate"));
250
220
  }
251
- return ok(data.result?.value);
221
+ return ok(r.data.result.value);
252
222
  };
253
223
 
254
224
  const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => {
@@ -260,13 +230,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
260
230
  const raw = await evaluateJs(expr);
261
231
  if (!raw.success) return raw;
262
232
  if (typeof raw.data !== "string") return err(cdpError("invalid_response", "page info evaluation did not return a string"));
263
- let parsedRaw: unknown;
264
- try {
265
- parsedRaw = JSON.parse(raw.data);
266
- } catch (e) {
267
- return err(cdpError("invalid_response", `page info JSON.parse failed: ${e instanceof Error ? e.message : String(e)}`));
268
- }
269
- const info = parsePageInfoPayload(parsedRaw);
233
+ const info = parsePageInfoPayload(raw.data);
270
234
  if (!info.success) return info;
271
235
  if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() });
272
236
  return ok(info.data);
@@ -281,8 +245,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
281
245
  const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => {
282
246
  const r = await session.callBrowser("Target.getTargets");
283
247
  if (!r.success) return r;
284
- const data = r.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; title: string; url: string }> };
285
- const tabs = data.targetInfos
248
+ const tabs = r.data.targetInfos
286
249
  .filter((t) => t.type === "page")
287
250
  .filter((t) => includeInternal || !t.url.startsWith("chrome://"))
288
251
  .map((t): TabInfo => ({
@@ -291,14 +254,12 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
291
254
  url: t.url,
292
255
  owned: ownership.has(t.targetId),
293
256
  }));
294
- // Reconcile any persisted owned ids that no longer exist as live page targets.
295
257
  const live = new Set(tabs.map((t) => t.targetId));
296
258
  const owned = ownership.list();
297
259
  const survivors = owned.filter((id) => live.has(id));
298
260
  if (survivors.length !== owned.length) ownership.replaceAll(survivors);
299
261
  const hw = ownership.harnessWindow();
300
262
  if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
301
- // Prune per-tab page caches for tabs that no longer exist
302
263
  for (const tid of pageCaches.keys()) {
303
264
  if (!live.has(tid)) pageCaches.delete(tid);
304
265
  }
@@ -308,10 +269,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
308
269
  const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
309
270
  const r = await session.switchTo(targetId);
310
271
  if (!r.success) return r;
311
- // pageCache per-tab: no longer cleared — each tab retains its cache
312
- // Best-effort: mark the tab title with a green circle so the user can
313
- // see which tab the agent attached to. CSP or detached frames may
314
- // block the eval; we don't surface that as a switchTab failure.
315
272
  await session.call("Runtime.evaluate", {
316
273
  expression: safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
317
274
  });
@@ -319,17 +276,11 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
319
276
  };
320
277
 
321
278
  const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
322
- // Reconcile ownership against live targets before deciding where to open.
323
279
  const tabsResult = await listTabs(true);
324
280
  if (!tabsResult.success) return tabsResult;
325
281
 
326
- // ensureHarnessWindow reuses the seed tab, falls back to any surviving
327
- // owned tab, and only then creates a window — which, when a profile is
328
- // pinned, means launching one inside that profile.
329
282
  const window = await ensureHarnessWindow(api);
330
283
  if (!window.success) return window;
331
- // A freshly created window IS the new tab; opening another would leave a
332
- // stray blank tab behind.
333
284
  let tabId: string;
334
285
  if (window.data.freshlyCreated) {
335
286
  tabId = window.data.targetId;
@@ -356,9 +307,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
356
307
  };
357
308
 
358
309
  const closeOwnedTabs = async (): Promise<void> => {
359
- // Snapshot first — remove() mutates the list, and targetDestroyed events
360
- // may also prune concurrently. Failures are swallowed: a tab the user
361
- // already closed, or a dead transport, must not block shutdown.
362
310
  for (const id of ownership.list()) {
363
311
  await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
364
312
  ownership.remove(id);
@@ -377,10 +325,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
377
325
  const detach = async (): Promise<void> => {
378
326
  const cur = session.current();
379
327
  if (!cur) return;
380
- // Target.detachFromTarget removes the "Chrome is being controlled" banner
381
- // and releases the page session. The transport (WebSocket/Unix socket)
382
- // stays alive for reuse by the next session.
383
- // Reference: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-detachFromTarget
384
328
  await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
385
329
  };
386
330