pi-browser-harness 0.1.0 → 0.3.1

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 (43) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +134 -112
  3. package/package.json +4 -1
  4. package/skills/pi-browser-harness/SKILL.md +17 -4
  5. package/src/cdp/discovery.ts +95 -0
  6. package/src/cdp/errors.ts +19 -0
  7. package/src/cdp/session.ts +139 -0
  8. package/src/cdp/transport.ts +221 -0
  9. package/src/cdp/types.ts +72 -0
  10. package/src/client.ts +232 -0
  11. package/src/domains/click.ts +73 -0
  12. package/src/domains/dialog.ts +31 -0
  13. package/src/domains/drag.ts +66 -0
  14. package/src/domains/files.ts +194 -0
  15. package/src/domains/history.ts +76 -0
  16. package/src/domains/js.ts +197 -0
  17. package/src/domains/keyboard.ts +127 -0
  18. package/src/domains/navigate.ts +146 -0
  19. package/src/domains/network.ts +80 -0
  20. package/src/domains/page.ts +88 -0
  21. package/src/domains/screenshot.ts +114 -0
  22. package/src/domains/scroll.ts +53 -0
  23. package/src/domains/tabs.ts +121 -0
  24. package/src/domains/viewport.ts +40 -0
  25. package/src/index.ts +65 -142
  26. package/src/prompt.ts +16 -3
  27. package/src/registry.ts +52 -0
  28. package/src/schemas/common.ts +11 -0
  29. package/src/setup.ts +20 -21
  30. package/src/state.ts +22 -54
  31. package/src/util/js-template.ts +27 -0
  32. package/src/util/keycodes.ts +10 -0
  33. package/src/util/paths.ts +21 -0
  34. package/src/util/result.ts +15 -0
  35. package/src/util/sharp-shim.ts +49 -0
  36. package/src/util/time.ts +29 -0
  37. package/src/util/tool.ts +169 -0
  38. package/src/util/truncate.ts +35 -0
  39. package/tsconfig.json +10 -1
  40. package/src/daemon.ts +0 -767
  41. package/src/protocol.ts +0 -77
  42. package/src/renderers.ts +0 -21
  43. package/src/tools.ts +0 -1358
@@ -0,0 +1,139 @@
1
+ import { type Result, err, ok } from "../util/result";
2
+ import { type CdpError, cdpError } from "./errors";
3
+ import type { DialogInfo } from "./types";
4
+ import { isInternalUrl } from "./types";
5
+ import type { CdpTransport } from "./transport";
6
+
7
+ export type CdpSession = {
8
+ attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
9
+ switchTo(targetId: string): Promise<Result<void, CdpError>>;
10
+ current(): { readonly sessionId: string; readonly targetId: string } | null;
11
+ call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
12
+ callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
13
+ callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
14
+ takeDialog(): DialogInfo | null;
15
+ drainPageInfoInvalidations(): boolean;
16
+ };
17
+
18
+ export const createCdpSession = (transport: CdpTransport): CdpSession => {
19
+ let sessionId: string | null = null;
20
+ let targetId: string | null = null;
21
+ let dialog: DialogInfo | null = null;
22
+ let pageInfoDirty = false;
23
+
24
+ let activeConsumer: Promise<void> = Promise.resolve();
25
+
26
+ const consumeEvents = async (): Promise<void> => {
27
+ for await (const ev of transport.events()) {
28
+ if (ev.method === "Page.javascriptDialogOpening") {
29
+ const params = ev.params as Partial<DialogInfo> | undefined;
30
+ dialog = {
31
+ type: (params?.type as DialogInfo["type"]) ?? "alert",
32
+ message: params?.message ?? "",
33
+ ...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
34
+ };
35
+ continue;
36
+ }
37
+ // Page.javascriptDialogClosed is intentionally NOT cleared here —
38
+ // the dialog stays in the buffer until takeDialog() is called.
39
+ // This prevents fast dismiss flows from dropping a dialog the agent
40
+ // was about to read. (Fix for spec §7 predictability bug #2.)
41
+ if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
42
+ pageInfoDirty = true;
43
+ }
44
+ }
45
+ };
46
+
47
+ const restartConsumer = (): void => {
48
+ activeConsumer = activeConsumer.then(() => consumeEvents()).catch((e: unknown) => {
49
+ // The .then() chain calls consumeEvents() which iterates the transport's
50
+ // events() AsyncIterable. The iterable resolves cleanly on close (returns
51
+ // {done:true}); any rejection here is an unexpected bug in the event
52
+ // handler, not a normal termination. Surface it on stderr so it's not lost.
53
+ console.warn("[pi-browser-harness] CDP event consumer crashed:", e);
54
+ });
55
+ };
56
+
57
+ restartConsumer();
58
+ transport.onClose(() => {
59
+ sessionId = null;
60
+ targetId = null;
61
+ pageInfoDirty = false;
62
+ // Do NOT clear `dialog` here — same rationale as inside consumeEvents:
63
+ // the agent may have a pending takeDialog() call that should still see it.
64
+ restartConsumer();
65
+ });
66
+
67
+ // TODO(perf): the four enable calls are sequential here for predictability.
68
+ // Switching to Promise.all over a single WS pipelines the round-trips and
69
+ // saves ~3× on tab-switch latency. Defer until session.ts has tests.
70
+ const enableDomains = async (sid: string): Promise<void> => {
71
+ for (const d of ["Page", "DOM", "Runtime", "Network"]) {
72
+ await transport.request(`${d}.enable`, {}, { sessionId: sid });
73
+ }
74
+ };
75
+
76
+ // CDP response shapes are documented in chromedevtools.github.io but not
77
+ // available as TypeScript types. We cast `as` from `unknown` only in this
78
+ // file (the CDP boundary). Each cast is paired with the CDP method that
79
+ // produced the response. Adding runtime guards for every shape would be
80
+ // noise — Chrome's protocol is stable enough that a wrong cast surfaces
81
+ // as a clear Error in normal use.
82
+ return {
83
+ async attachFirstPage() {
84
+ const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
85
+ if (!targets.success) return targets;
86
+ const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
87
+ let pages = data.targetInfos.filter((t) => t.type === "page" && !isInternalUrl(t.url));
88
+ if (pages.length === 0) {
89
+ const created = await transport.request("Target.createTarget", { url: "about:blank" }, { sessionId: null });
90
+ if (!created.success) return created;
91
+ const c = created.data as { targetId: string };
92
+ pages = [{ targetId: c.targetId, type: "page", url: "about:blank" }];
93
+ }
94
+ const first = pages[0];
95
+ if (!first) return err(cdpError("invalid_response", "no page targets after creation"));
96
+ const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null });
97
+ if (!attached.success) return attached;
98
+ const a = attached.data as { sessionId: string };
99
+ sessionId = a.sessionId;
100
+ targetId = first.targetId;
101
+ await enableDomains(a.sessionId);
102
+ return ok({ targetId: first.targetId, sessionId: a.sessionId });
103
+ },
104
+ async switchTo(tid) {
105
+ const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
106
+ if (!activated.success) return activated;
107
+ const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
108
+ if (!attached.success) return attached;
109
+ const a = attached.data as { sessionId: string };
110
+ sessionId = a.sessionId;
111
+ targetId = tid;
112
+ pageInfoDirty = true;
113
+ await enableDomains(a.sessionId);
114
+ return ok(undefined);
115
+ },
116
+ current() {
117
+ return sessionId && targetId ? { sessionId, targetId } : null;
118
+ },
119
+ call(method, params = {}, opts = {}) {
120
+ return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
121
+ },
122
+ callOnTarget(method, params, sid, opts = {}) {
123
+ return transport.request(method, params, { sessionId: sid, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
124
+ },
125
+ callBrowser(method, params = {}, opts = {}) {
126
+ return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
127
+ },
128
+ takeDialog() {
129
+ const d = dialog;
130
+ dialog = null;
131
+ return d;
132
+ },
133
+ drainPageInfoInvalidations() {
134
+ const dirty = pageInfoDirty;
135
+ pageInfoDirty = false;
136
+ return dirty;
137
+ },
138
+ };
139
+ };
@@ -0,0 +1,221 @@
1
+ import WebSocket from "ws";
2
+ import { type Result, err, ok } from "../util/result";
3
+ import { type CdpError, cdpError } from "./errors";
4
+ import { isCdpRawMessage } from "./types";
5
+ import type { CdpEvent, CdpRawMessage } from "./types";
6
+
7
+ const DEFAULT_TIMEOUT_MS = 15_000;
8
+
9
+ type Pending = {
10
+ readonly resolve: (v: Result<unknown, CdpError>) => void;
11
+ readonly timer: ReturnType<typeof setTimeout>;
12
+ readonly method: string;
13
+ };
14
+
15
+ export type CdpTransport = {
16
+ connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
17
+ close(): Promise<void>;
18
+ request(
19
+ method: string,
20
+ params: Record<string, unknown>,
21
+ opts?: { sessionId?: string | null; timeoutMs?: number },
22
+ ): Promise<Result<unknown, CdpError>>;
23
+ // Returns the current event queue's iterator. After close()+reconnect, callers
24
+ // must re-call events() — the old iterator will have received done:true.
25
+ events(): AsyncIterable<CdpEvent>;
26
+ state(): "open" | "closed" | "connecting";
27
+ onClose(cb: () => void): () => void;
28
+ };
29
+
30
+ export const createCdpTransport = (): CdpTransport => {
31
+ let ws: WebSocket | null = null;
32
+ let nextId = 1;
33
+ const pending = new Map<number, Pending>();
34
+ const closeListeners = new Set<() => void>();
35
+
36
+ type EventQueue = {
37
+ readonly push: (e: CdpEvent) => void;
38
+ readonly end: () => void;
39
+ readonly iter: AsyncIterable<CdpEvent>;
40
+ };
41
+
42
+ const makeEventQueue = (): EventQueue => {
43
+ const buf: CdpEvent[] = [];
44
+ const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
45
+ let ended = false;
46
+ return {
47
+ push(e) {
48
+ if (ended) return;
49
+ const w = waiters.shift();
50
+ if (w) w({ value: e, done: false });
51
+ else buf.push(e);
52
+ },
53
+ end() {
54
+ ended = true;
55
+ // value: undefined as unknown as CdpEvent is the correct iterator-protocol
56
+ // pattern: when done=true the value field is conventionally undefined but
57
+ // the TS type still requires CdpEvent.
58
+ for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
59
+ },
60
+ iter: {
61
+ [Symbol.asyncIterator]() {
62
+ return {
63
+ next: (): Promise<IteratorResult<CdpEvent>> =>
64
+ new Promise((resolve) => {
65
+ const next = buf.shift();
66
+ if (next) resolve({ value: next, done: false });
67
+ else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
68
+ else waiters.push(resolve);
69
+ }),
70
+ };
71
+ },
72
+ },
73
+ };
74
+ };
75
+
76
+ let queue = makeEventQueue();
77
+
78
+ const handleMessage = (raw: string): void => {
79
+ let parsed: unknown;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ } catch {
83
+ return;
84
+ }
85
+ if (!isCdpRawMessage(parsed)) return;
86
+ const msg: CdpRawMessage = parsed;
87
+ if (msg.id === undefined) {
88
+ if (msg.method) {
89
+ queue.push({
90
+ method: msg.method,
91
+ params: msg.params,
92
+ ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
93
+ });
94
+ }
95
+ return;
96
+ }
97
+ const p = pending.get(msg.id);
98
+ if (!p) return;
99
+ pending.delete(msg.id);
100
+ clearTimeout(p.timer);
101
+ if (msg.error) {
102
+ const kind = msg.error.message.includes("Session with given id not found")
103
+ ? "session_not_found"
104
+ : "remote_error";
105
+ p.resolve(err(cdpError(kind, msg.error.message, p.method)));
106
+ return;
107
+ }
108
+ p.resolve(ok(msg.result));
109
+ };
110
+
111
+ const cleanup = (reason: string): void => {
112
+ for (const [, p] of pending) {
113
+ clearTimeout(p.timer);
114
+ p.resolve(err(cdpError("transport_closed", reason, p.method)));
115
+ }
116
+ pending.clear();
117
+ queue.end();
118
+ queue = makeEventQueue();
119
+ for (const cb of closeListeners) cb();
120
+ };
121
+
122
+ return {
123
+ connect(url, opts = {}): Promise<Result<void, CdpError>> {
124
+ const timeoutMs = opts.timeoutMs ?? 10_000;
125
+ return new Promise((resolve) => {
126
+ let settled = false;
127
+ const settle = (r: Result<void, CdpError>): void => {
128
+ if (settled) return;
129
+ settled = true;
130
+ resolve(r);
131
+ };
132
+ try {
133
+ ws = new WebSocket(url);
134
+ } catch (e) {
135
+ settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
136
+ return;
137
+ }
138
+ const timer = setTimeout(() => {
139
+ ws?.close();
140
+ ws = null;
141
+ settle(err(cdpError("timeout", `CDP WebSocket connection timed out after ${timeoutMs}ms`)));
142
+ }, timeoutMs);
143
+ ws.onopen = () => {
144
+ clearTimeout(timer);
145
+ settle(ok(undefined));
146
+ };
147
+ ws.onmessage = (ev: WebSocket.MessageEvent) => {
148
+ const data = ev.data;
149
+ handleMessage(typeof data === "string" ? data : data.toString());
150
+ };
151
+ ws.onerror = () => {
152
+ clearTimeout(timer);
153
+ settle(err(cdpError("transport_closed", "CDP WebSocket error during connection")));
154
+ };
155
+ ws.onclose = () => {
156
+ clearTimeout(timer);
157
+ ws = null;
158
+ cleanup("WebSocket closed");
159
+ // If onopen already settled the connect promise, this settle is a no-op
160
+ // (guarded by `settled`). cleanup() above still runs unconditionally so
161
+ // pending requests are rejected and the event queue is rotated.
162
+ settle(err(cdpError("transport_closed", "CDP WebSocket closed")));
163
+ };
164
+ });
165
+ },
166
+ close(): Promise<void> {
167
+ if (ws) {
168
+ try { ws.close(1000, "Shutdown"); } catch { /* best effort */ }
169
+ ws = null;
170
+ }
171
+ cleanup("close() called");
172
+ return Promise.resolve();
173
+ },
174
+ request(method, params, opts = {}): Promise<Result<unknown, CdpError>> {
175
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
176
+ const sessionId = opts.sessionId ?? null;
177
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
178
+ return Promise.resolve(err(cdpError("transport_closed", "Browser not connected. Is Chrome running?", method)));
179
+ }
180
+ // Bind to a local const so the non-null is guaranteed even across microtasks
181
+ // between the readyState check and the send() call.
182
+ const sock = ws;
183
+ const id = nextId++;
184
+ const payload: Record<string, unknown> = { id, method, params };
185
+ if (sessionId) payload["sessionId"] = sessionId;
186
+ const json = JSON.stringify(payload);
187
+ return new Promise((resolve) => {
188
+ const timer = setTimeout(() => {
189
+ pending.delete(id);
190
+ resolve(err(cdpError("timeout", `CDP timeout after ${timeoutMs}ms: ${method}`, method)));
191
+ }, timeoutMs);
192
+ pending.set(id, { resolve, timer, method });
193
+ try {
194
+ sock.send(json);
195
+ } catch (e) {
196
+ clearTimeout(timer);
197
+ pending.delete(id);
198
+ resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
199
+ }
200
+ });
201
+ },
202
+ events(): AsyncIterable<CdpEvent> {
203
+ // Single-consumer: each connection's event stream may be iterated by
204
+ // exactly one for-await loop. Calling events() multiple times returns
205
+ // the same iterable; the second consumer will silently steal events
206
+ // from the first. After a reconnect, the previous iterator is ended
207
+ // and callers must re-call events() to receive new events.
208
+ return queue.iter;
209
+ },
210
+ state(): "open" | "closed" | "connecting" {
211
+ if (!ws) return "closed";
212
+ if (ws.readyState === WebSocket.CONNECTING) return "connecting";
213
+ if (ws.readyState === WebSocket.OPEN) return "open";
214
+ return "closed";
215
+ },
216
+ onClose(cb): () => void {
217
+ closeListeners.add(cb);
218
+ return () => closeListeners.delete(cb);
219
+ },
220
+ };
221
+ };
@@ -0,0 +1,72 @@
1
+ export type CdpEvent = {
2
+ readonly method: string;
3
+ readonly params: unknown;
4
+ readonly sessionId?: string;
5
+ };
6
+
7
+ export type CdpRawMessage = {
8
+ readonly id?: number;
9
+ readonly method?: string;
10
+ readonly params?: Record<string, unknown>;
11
+ readonly result?: unknown;
12
+ readonly error?: { readonly message: string; readonly code?: number };
13
+ readonly sessionId?: string;
14
+ };
15
+
16
+ export type DialogInfo = {
17
+ readonly type: "alert" | "confirm" | "prompt" | "beforeunload";
18
+ readonly message: string;
19
+ readonly defaultPrompt?: string;
20
+ };
21
+
22
+ export type TabInfo = {
23
+ readonly targetId: string;
24
+ readonly title: string;
25
+ readonly url: string;
26
+ };
27
+
28
+ export type PageInfo = {
29
+ readonly url: string;
30
+ readonly title: string;
31
+ readonly width: number;
32
+ readonly height: number;
33
+ readonly scrollX: number;
34
+ readonly scrollY: number;
35
+ readonly pageWidth: number;
36
+ readonly pageHeight: number;
37
+ };
38
+
39
+ export type DaemonStatus = {
40
+ readonly alive: boolean;
41
+ readonly sessionId: string | null;
42
+ readonly namespace: string;
43
+ readonly remoteBrowserId?: string;
44
+ };
45
+
46
+ const INTERNAL_PREFIXES: ReadonlyArray<string> = [
47
+ "chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:",
48
+ ];
49
+
50
+ export const isInternalUrl = (url: string): boolean =>
51
+ INTERNAL_PREFIXES.some((p) => url.startsWith(p));
52
+
53
+ const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
54
+ typeof v === "object" && v !== null && !Array.isArray(v);
55
+
56
+ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
57
+ if (!isObject(v)) return false;
58
+ const id = v["id"];
59
+ if (id !== undefined && typeof id !== "number") return false;
60
+ const method = v["method"];
61
+ if (method !== undefined && typeof method !== "string") return false;
62
+ const sessionId = v["sessionId"];
63
+ if (sessionId !== undefined && typeof sessionId !== "string") return false;
64
+ const errVal = v["error"];
65
+ if (errVal !== undefined) {
66
+ if (!isObject(errVal)) return false;
67
+ if (typeof errVal["message"] !== "string") return false;
68
+ const code = errVal["code"];
69
+ if (code !== undefined && typeof code !== "number") return false;
70
+ }
71
+ return true;
72
+ };
package/src/client.ts ADDED
@@ -0,0 +1,232 @@
1
+ import { type Result, err, ok } from "./util/result";
2
+ import { safeJs } from "./util/js-template";
3
+ import { discoverWsUrl } from "./cdp/discovery";
4
+ import { type CdpError, cdpError } from "./cdp/errors";
5
+ import type { CdpTransport } from "./cdp/transport";
6
+ import { createCdpTransport } from "./cdp/transport";
7
+ import { type CdpSession, createCdpSession } from "./cdp/session";
8
+ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
9
+
10
+ export type BrowserClientOptions = {
11
+ readonly namespace: string;
12
+ readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
13
+ };
14
+
15
+ export type BrowserClient = {
16
+ readonly namespace: string;
17
+ ensureAlive(): Promise<Result<void, CdpError>>;
18
+ status(): DaemonStatus;
19
+ start(): Promise<Result<void, CdpError>>;
20
+ stop(): Promise<void>;
21
+ evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
22
+ pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
23
+ takeDialog(): DialogInfo | null;
24
+ listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
25
+ switchTab(targetId: string): Promise<Result<void, CdpError>>;
26
+ newTab(url?: string): Promise<Result<string, CdpError>>;
27
+ current(): { readonly sessionId: string; readonly targetId: string } | null;
28
+ session(): CdpSession;
29
+ transport(): CdpTransport;
30
+ };
31
+
32
+ const HEALTH_TTL_MS = 30_000;
33
+ const PAGE_INFO_TTL_MS = 1_000;
34
+
35
+ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
36
+ if (typeof v !== "object" || v === null) {
37
+ return err(cdpError("invalid_response", "page info payload is not an object"));
38
+ }
39
+ const o = v as Readonly<Record<string, unknown>>;
40
+ const fields: ReadonlyArray<readonly [string, "string" | "number"]> = [
41
+ ["url", "string"], ["title", "string"],
42
+ ["w", "number"], ["h", "number"],
43
+ ["sx", "number"], ["sy", "number"],
44
+ ["pw", "number"], ["ph", "number"],
45
+ ];
46
+ for (const [k, t] of fields) {
47
+ if (typeof o[k] !== t) {
48
+ return err(cdpError("invalid_response", `page info field ${k} has wrong type (expected ${t})`));
49
+ }
50
+ }
51
+ return ok({
52
+ url: o["url"] as string,
53
+ title: o["title"] as string,
54
+ width: o["w"] as number,
55
+ height: o["h"] as number,
56
+ scrollX: o["sx"] as number,
57
+ scrollY: o["sy"] as number,
58
+ pageWidth: o["pw"] as number,
59
+ pageHeight: o["ph"] as number,
60
+ });
61
+ };
62
+
63
+ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
64
+ const transport = createCdpTransport();
65
+ const session = createCdpSession(transport);
66
+ let lastHealth = 0;
67
+ let pageCache: { readonly info: PageInfo; readonly at: number } | null = null;
68
+ let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
69
+
70
+ const start = async (): Promise<Result<void, CdpError>> => {
71
+ if (transport.state() === "open" && session.current()) return ok(undefined);
72
+ const envUrl = process.env["BU_CDP_WS"];
73
+ let wsUrl: string;
74
+ if (remote?.cdpUrl) {
75
+ wsUrl = remote.cdpUrl;
76
+ } else if (envUrl) {
77
+ wsUrl = envUrl;
78
+ } else {
79
+ const discovered = await discoverWsUrl();
80
+ if (!discovered.success) return discovered;
81
+ wsUrl = discovered.data;
82
+ }
83
+ const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
84
+ if (!connected.success) return connected;
85
+ if (!remote) {
86
+ remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" };
87
+ }
88
+ const attached = await session.attachFirstPage();
89
+ if (!attached.success) {
90
+ await transport.close();
91
+ return attached;
92
+ }
93
+ lastHealth = Date.now();
94
+ pageCache = null;
95
+ return ok(undefined);
96
+ };
97
+
98
+ const stop = async (): Promise<void> => {
99
+ await transport.close();
100
+ pageCache = null;
101
+ lastHealth = 0;
102
+ };
103
+
104
+ const ensureAlive = async (): Promise<Result<void, CdpError>> => {
105
+ if (transport.state() !== "open" || !session.current()) {
106
+ await stop();
107
+ return start();
108
+ }
109
+ if (Date.now() - lastHealth < HEALTH_TTL_MS) return ok(undefined);
110
+ const probe = await transport.request("Target.getTargets", {}, { sessionId: null, timeoutMs: 2_000 });
111
+ if (!probe.success) {
112
+ await stop();
113
+ return start();
114
+ }
115
+ lastHealth = Date.now();
116
+ // Verify the page session is still responsive (handles the case where the
117
+ // browser transport is alive but the page target crashed, e.g. localhost died).
118
+ const jsProbe = await session.call("Runtime.evaluate", {
119
+ expression: "1", returnByValue: true,
120
+ }, { timeoutMs: 2_000 });
121
+ if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
122
+ const reattached = await session.attachFirstPage();
123
+ if (reattached.success) {
124
+ pageCache = null;
125
+ return ok(undefined);
126
+ }
127
+ await stop();
128
+ return start();
129
+ }
130
+ return ok(undefined);
131
+ };
132
+
133
+ const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
134
+ // Heuristic IIFE wrap: legacy convenience that lets agents write
135
+ // `return foo` instead of `(() => foo)()`. False-positive on substring
136
+ // `"return "` inside string literals or comments — agents that hit this
137
+ // can pass an explicit IIFE instead.
138
+ const wrapped = expression.includes("return ") && !expression.trim().startsWith("(")
139
+ ? `(function(){${expression}})()`
140
+ : expression;
141
+ const r = sessionId
142
+ ? await session.callOnTarget("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }, sessionId)
143
+ : await session.call("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true });
144
+ if (!r.success) return r;
145
+ const data = r.data as { result?: { value?: unknown }; exceptionDetails?: unknown };
146
+ if (data.exceptionDetails) {
147
+ return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(data.exceptionDetails)}`, "Runtime.evaluate"));
148
+ }
149
+ return ok(data.result?.value);
150
+ };
151
+
152
+ const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => {
153
+ const dirty = session.drainPageInfoInvalidations();
154
+ if (pageCache && !dirty && Date.now() - pageCache.at < PAGE_INFO_TTL_MS) return ok(pageCache.info);
155
+ const expr = safeJs`JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
156
+ const raw = await evaluateJs(expr);
157
+ if (!raw.success) return raw;
158
+ if (typeof raw.data !== "string") return err(cdpError("invalid_response", "page info evaluation did not return a string"));
159
+ let parsedRaw: unknown;
160
+ try {
161
+ parsedRaw = JSON.parse(raw.data);
162
+ } catch (e) {
163
+ return err(cdpError("invalid_response", `page info JSON.parse failed: ${e instanceof Error ? e.message : String(e)}`));
164
+ }
165
+ const info = parsePageInfoPayload(parsedRaw);
166
+ if (!info.success) return info;
167
+ pageCache = { info: info.data, at: Date.now() };
168
+ return ok(info.data);
169
+ };
170
+
171
+ const pageInfo = async (): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>> => {
172
+ const d = session.takeDialog();
173
+ if (d) return ok({ dialog: d });
174
+ return readPageInfo();
175
+ };
176
+
177
+ const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => {
178
+ const r = await session.callBrowser("Target.getTargets");
179
+ if (!r.success) return r;
180
+ const data = r.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; title: string; url: string }> };
181
+ const tabs = data.targetInfos
182
+ .filter((t) => t.type === "page")
183
+ .filter((t) => includeInternal || !t.url.startsWith("chrome://"))
184
+ .map((t): TabInfo => ({ targetId: t.targetId, title: t.title, url: t.url }));
185
+ return ok(tabs);
186
+ };
187
+
188
+ const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
189
+ const r = await session.switchTo(targetId);
190
+ if (!r.success) return r;
191
+ pageCache = null;
192
+ // Best-effort: mark the tab title with a green circle so the user can
193
+ // see which tab the agent attached to. CSP or detached frames may
194
+ // block the eval; we don't surface that as a switchTab failure.
195
+ await session.call("Runtime.evaluate", {
196
+ expression: safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
197
+ });
198
+ return ok(undefined);
199
+ };
200
+
201
+ const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
202
+ const created = await session.callBrowser("Target.createTarget", { url: "about:blank" });
203
+ if (!created.success) return created;
204
+ const c = created.data as { targetId: string };
205
+ const switched = await switchTab(c.targetId);
206
+ if (!switched.success) return switched;
207
+ if (url && url !== "about:blank") {
208
+ const nav = await session.call("Page.navigate", { url });
209
+ if (!nav.success) return nav;
210
+ pageCache = null;
211
+ }
212
+ return ok(c.targetId);
213
+ };
214
+
215
+ const status = (): DaemonStatus => ({
216
+ alive: transport.state() === "open" && session.current() !== null,
217
+ sessionId: session.current()?.sessionId ?? null,
218
+ namespace: opts.namespace,
219
+ ...(remote?.browserId !== undefined ? { remoteBrowserId: remote.browserId } : {}),
220
+ });
221
+
222
+ return {
223
+ namespace: opts.namespace,
224
+ ensureAlive, status, start, stop,
225
+ evaluateJs, pageInfo,
226
+ takeDialog: () => session.takeDialog(),
227
+ listTabs, switchTab, newTab,
228
+ current: () => session.current(),
229
+ session: () => session,
230
+ transport: () => transport,
231
+ };
232
+ };