pi-browser-harness 0.1.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.
package/src/daemon.ts ADDED
@@ -0,0 +1,767 @@
1
+ /**
2
+ * BrowserDaemon — manages a direct CDP WebSocket connection to Chrome.
3
+ *
4
+ * Architecture:
5
+ * Node.js → CDP WebSocket (ws://localhost:9222/...) → Chrome
6
+ *
7
+ * No Python proxy, no Unix socket. Direct CDP over a single persistent
8
+ * WebSocket with message-ID multiplexing, auto-reconnect, and event buffering.
9
+ */
10
+
11
+ import { readFile, writeFile } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { connect as netConnect } from "node:net";
15
+ import WebSocket from "ws";
16
+ import {
17
+ type CDPEvent,
18
+ type DaemonStatus,
19
+ type DialogInfo,
20
+ type PageInfo,
21
+ type PageInfoResult,
22
+ type RemoteConfig,
23
+ type TabInfo,
24
+ isInternalUrl,
25
+ } from "./protocol";
26
+
27
+ // ── Constants ────────────────────────────────────────────────────────────────
28
+
29
+ const CDP_CONNECT_TIMEOUT_MS = 10_000;
30
+ const CDP_REQUEST_TIMEOUT_MS = 15_000;
31
+ const CDP_RECONNECT_BASE_MS = 500;
32
+ const CDP_MAX_RECONNECT_ATTEMPTS = 5;
33
+ const PORT_PROBE_DEADLINE_MS = 30_000;
34
+ const PORT_PROBE_INTERVAL_MS = 1_000;
35
+
36
+ /**
37
+ * Discover Chrome's CDP WebSocket URL by reading the per-profile
38
+ * `DevToolsActivePort` file written when remote debugging is enabled
39
+ * via chrome://inspect/#remote-debugging. Mirrors browser-harness/daemon.py.
40
+ */
41
+ async function discoverWsUrl(): Promise<string> {
42
+ const dirs = chromeProfileDirs();
43
+ const tried: string[] = [];
44
+ for (const base of dirs) {
45
+ const portFile = join(base, "DevToolsActivePort");
46
+ let raw: string;
47
+ try {
48
+ raw = await readFile(portFile, "utf8");
49
+ } catch {
50
+ continue;
51
+ }
52
+ tried.push(base);
53
+ const lines = raw.trim().split("\n");
54
+ if (lines.length < 2) continue;
55
+ const port = lines[0].trim();
56
+ const path = lines[1].trim();
57
+ if (!port || !path) continue;
58
+
59
+ await waitForPort(Number(port));
60
+ return `ws://127.0.0.1:${port}${path}`;
61
+ }
62
+ throw new Error(
63
+ `DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.`,
64
+ );
65
+ }
66
+
67
+ /** Probe TCP port until reachable or deadline expires. */
68
+ async function waitForPort(port: number): Promise<void> {
69
+ const deadline = Date.now() + PORT_PROBE_DEADLINE_MS;
70
+ let lastErr: Error | null = null;
71
+ while (Date.now() < deadline) {
72
+ try {
73
+ await new Promise<void>((resolve, reject) => {
74
+ const sock = netConnect({ host: "127.0.0.1", port });
75
+ const onError = (err: Error) => {
76
+ sock.destroy();
77
+ reject(err);
78
+ };
79
+ sock.setTimeout(1000, () => onError(new Error("probe timeout")));
80
+ sock.once("error", onError);
81
+ sock.once("connect", () => {
82
+ sock.end();
83
+ resolve();
84
+ });
85
+ });
86
+ return;
87
+ } catch (err) {
88
+ lastErr = err instanceof Error ? err : new Error(String(err));
89
+ await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS));
90
+ }
91
+ }
92
+ throw new Error(
93
+ `Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastErr?.message ?? "unknown"})`,
94
+ );
95
+ }
96
+
97
+ // Chrome / Edge / Chromium profile directories that may contain DevToolsActivePort.
98
+ // Mirrors browser-harness/daemon.py PROFILES.
99
+ function chromeProfileDirs(): string[] {
100
+ const home = homedir();
101
+ return [
102
+ join(home, "Library/Application Support/Google/Chrome"),
103
+ join(home, "Library/Application Support/Microsoft Edge"),
104
+ join(home, "Library/Application Support/Microsoft Edge Beta"),
105
+ join(home, "Library/Application Support/Microsoft Edge Dev"),
106
+ join(home, "Library/Application Support/Microsoft Edge Canary"),
107
+ join(home, ".config/google-chrome"),
108
+ join(home, ".config/chromium"),
109
+ join(home, ".config/chromium-browser"),
110
+ join(home, ".config/microsoft-edge"),
111
+ join(home, ".config/microsoft-edge-beta"),
112
+ join(home, ".config/microsoft-edge-dev"),
113
+ join(home, ".var/app/org.chromium.Chromium/config/chromium"),
114
+ join(home, ".var/app/com.google.Chrome/config/google-chrome"),
115
+ join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
116
+ join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
117
+ join(home, "AppData/Local/Google/Chrome/User Data"),
118
+ join(home, "AppData/Local/Chromium/User Data"),
119
+ join(home, "AppData/Local/Microsoft/Edge/User Data"),
120
+ join(home, "AppData/Local/Microsoft/Edge Beta/User Data"),
121
+ join(home, "AppData/Local/Microsoft/Edge Dev/User Data"),
122
+ join(home, "AppData/Local/Microsoft/Edge SxS/User Data"),
123
+ ];
124
+ }
125
+
126
+ const VIRTUAL_KEY_CODES: Record<string, number> = {
127
+ Enter: 13,
128
+ Tab: 9,
129
+ Backspace: 8,
130
+ Escape: 27,
131
+ Delete: 46,
132
+ " ": 32,
133
+ ArrowLeft: 37,
134
+ ArrowUp: 38,
135
+ ArrowRight: 39,
136
+ ArrowDown: 40,
137
+ Home: 36,
138
+ End: 35,
139
+ PageUp: 33,
140
+ PageDown: 34,
141
+ };
142
+
143
+ // ── BrowserDaemon ────────────────────────────────────────────────────────────
144
+
145
+ export class BrowserDaemon {
146
+ readonly namespace: string;
147
+
148
+ private ws: WebSocket | null = null;
149
+ private _sessionId: string | null = null;
150
+ private _targetId: string | null = null;
151
+ private _currentDialog: DialogInfo | null = null;
152
+ private _eventBuffer: CDPEvent[] = [];
153
+ private _pending = new Map<number, PendingRequest>();
154
+ private _nextId = 1;
155
+ private _connectResolve: ((v: void) => void) | null = null;
156
+ private _connectReject: ((err: Error) => void) | null = null;
157
+ private _connectTimer: ReturnType<typeof setTimeout> | null = null;
158
+ private _reconnectAttempts = 0;
159
+ private _stopping = false;
160
+ private _consentObserved = false;
161
+ private remote: RemoteConfig | null = null;
162
+
163
+ constructor(namespace = "default", remote?: RemoteConfig) {
164
+ this.namespace = namespace;
165
+ this.remote = remote ?? null;
166
+ }
167
+
168
+ // ── Public getters ───────────────────────────────────────────────────────
169
+
170
+ get sessionId(): string | null {
171
+ return this._sessionId;
172
+ }
173
+
174
+ getStatus(): DaemonStatus {
175
+ return {
176
+ alive: this._sessionId !== null && this.ws?.readyState === WebSocket.OPEN,
177
+ sessionId: this._sessionId,
178
+ pid: null,
179
+ namespace: this.namespace,
180
+ socketPath: "",
181
+ remoteBrowserId: this.remote?.browserId,
182
+ };
183
+ }
184
+
185
+ // ── Lifecycle ────────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Connect to Chrome via CDP WebSocket.
189
+ * Idempotent — returns immediately if already connected and healthy.
190
+ */
191
+ async start(): Promise<void> {
192
+ if (this._sessionId && this.ws?.readyState === WebSocket.OPEN) {
193
+ return; // Already connected
194
+ }
195
+
196
+ this._stopping = false;
197
+ this._reconnectAttempts = 0;
198
+
199
+ try {
200
+ await this.connect();
201
+ } catch (err) {
202
+ const msg = err instanceof Error ? err.message : String(err);
203
+ if (this.needsChromeSetup(msg)) {
204
+ throw new Error(
205
+ `Chrome remote debugging not enabled. Open chrome://inspect/#remote-debugging and tick the Allow checkbox, then retry.\n\nDetails: ${msg}`,
206
+ );
207
+ }
208
+ throw err;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Gracefully disconnect from Chrome.
214
+ */
215
+ async stop(): Promise<void> {
216
+ this._stopping = true;
217
+
218
+ // Reject any pending connect
219
+ if (this._connectReject) {
220
+ this._connectReject(new Error("Daemon stopped"));
221
+ this._connectReject = null;
222
+ }
223
+ if (this._connectTimer) {
224
+ clearTimeout(this._connectTimer);
225
+ this._connectTimer = null;
226
+ }
227
+
228
+ // Reject all pending requests
229
+ for (const [id, req] of this._pending) {
230
+ req.reject(new Error("Daemon stopped"));
231
+ this._pending.delete(id);
232
+ }
233
+
234
+ // Close the WebSocket
235
+ if (this.ws) {
236
+ try {
237
+ this.ws.close(1000, "Shutdown");
238
+ } catch {
239
+ // best-effort
240
+ }
241
+ this.ws = null;
242
+ }
243
+
244
+ this._sessionId = null;
245
+ this._targetId = null;
246
+ this._currentDialog = null;
247
+ this._eventBuffer = [];
248
+ this._consentObserved = false;
249
+ }
250
+
251
+ /**
252
+ * Ensure daemon is alive and healthy. Reconnects if stale.
253
+ * Call before every tool execution.
254
+ */
255
+ async ensureAlive(): Promise<void> {
256
+ const isOpen = this.ws?.readyState === WebSocket.OPEN;
257
+ if (isOpen && this._sessionId) {
258
+ // Two-stage health check. The first probe uses a short timeout so we
259
+ // don't spend 15s waiting on a ws that's been silenced by Chrome's
260
+ // consent dialog. A short-timeout failure is not proof the ws is
261
+ // dead — give it one more chance with the full timeout before
262
+ // tearing down. Tearing down here is what causes the popup storm:
263
+ // every reconnect re-prompts the user for remote-debugging consent.
264
+ try {
265
+ await this.sendRawCdp("Target.getTargets", {}, null, 2_000);
266
+ return;
267
+ } catch {
268
+ // probe 1 failed — could be transient, could be consent-pending
269
+ }
270
+ try {
271
+ await this.sendRawCdp("Target.getTargets", {}, null);
272
+ return;
273
+ } catch {
274
+ // genuinely stale — fall through to reconnect
275
+ }
276
+ }
277
+
278
+ await this.stop();
279
+ await this.start();
280
+ }
281
+
282
+ // ── CDP Communication ────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Send a CDP method and return the result.
286
+ * Handles session management — Target.* methods skip session,
287
+ * others use explicit sessionId or the daemon's default session.
288
+ */
289
+ async cdp(
290
+ method: string,
291
+ params?: Record<string, unknown>,
292
+ sessionId?: string | null,
293
+ ): Promise<unknown> {
294
+ // Browser-level Target.* calls must NOT use a session.
295
+ // Everything else uses the explicit sessionId (any session attached
296
+ // with flatten:true on this connection is reachable via the top-level
297
+ // sessionId field — that's what flat-protocol mode means).
298
+ const effectiveSid = method.startsWith("Target.") ? null : (sessionId ?? this._sessionId);
299
+
300
+ try {
301
+ return await this.sendRawCdp(method, params ?? {}, effectiveSid);
302
+ } catch (err) {
303
+ // Auto-recover from stale default session — retry once
304
+ if (err instanceof SessionNotFoundError && effectiveSid === this._sessionId) {
305
+ await this.attachFirstPage();
306
+ return this.sendRawCdp(method, params ?? {}, this._sessionId);
307
+ }
308
+ throw err;
309
+ }
310
+ }
311
+
312
+ // ── High-level Browser Helpers ────────────────────────────────────────────
313
+
314
+ /** Get page state or dialog info */
315
+ async getPageInfo(): Promise<PageInfoResult> {
316
+ // Check for buffered dialog first
317
+ if (this._currentDialog) {
318
+ const dialog = this._currentDialog;
319
+ this._currentDialog = null; // consume it
320
+ return { dialog };
321
+ }
322
+
323
+ const js = `JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
324
+ const raw = await this.evaluateJS(js);
325
+ return JSON.parse(raw as string) as PageInfo;
326
+ }
327
+
328
+ /** Capture a PNG screenshot, write to path, return path */
329
+ async captureScreenshot(outputPath: string, fullPage = false): Promise<string> {
330
+ const result = (await this.cdp("Page.captureScreenshot", {
331
+ format: "png",
332
+ captureBeyondViewport: fullPage,
333
+ })) as { data: string };
334
+
335
+ const buf = Buffer.from(result.data, "base64");
336
+ await writeFile(outputPath, buf);
337
+ return outputPath;
338
+ }
339
+
340
+ /** List browser tabs */
341
+ async listTabs(includeChrome = true): Promise<TabInfo[]> {
342
+ const result = (await this.cdp("Target.getTargets")) as {
343
+ targetInfos: Array<{ targetId: string; type: string; title: string; url: string }>;
344
+ };
345
+
346
+ const tabs: TabInfo[] = [];
347
+ for (const t of result.targetInfos) {
348
+ if (t.type !== "page") continue;
349
+ if (!includeChrome && isInternalUrl(t.url)) continue;
350
+ tabs.push({ targetId: t.targetId, title: t.title || "", url: t.url || "" });
351
+ }
352
+ return tabs;
353
+ }
354
+
355
+ /** Get current tab info — uses the targetId we attached to. */
356
+ async currentTab(): Promise<TabInfo> {
357
+ if (!this._targetId) {
358
+ throw new Error("No tab attached. Call newTab/switchTab first.");
359
+ }
360
+ const result = (await this.cdp("Target.getTargetInfo", {
361
+ targetId: this._targetId,
362
+ })) as {
363
+ targetInfo: { targetId: string; url: string; title: string };
364
+ };
365
+ const t = result.targetInfo;
366
+ return { targetId: t.targetId, url: t.url || "", title: t.title || "" };
367
+ }
368
+
369
+ /** Create a new tab and switch to it. Optionally navigate. */
370
+ async newTab(url?: string): Promise<string> {
371
+ const result = (await this.cdp("Target.createTarget", {
372
+ url: "about:blank",
373
+ })) as { targetId: string };
374
+
375
+ await this.switchTab(result.targetId);
376
+ if (url && url !== "about:blank") {
377
+ await this.cdp("Page.navigate", { url });
378
+ }
379
+ return result.targetId;
380
+ }
381
+
382
+ /** Activate and attach to a tab */
383
+ async switchTab(targetId: string): Promise<void> {
384
+ await this.cdp("Target.activateTarget", { targetId });
385
+ const result = (await this.cdp("Target.attachToTarget", {
386
+ targetId,
387
+ flatten: true,
388
+ })) as { sessionId: string };
389
+
390
+ this._sessionId = result.sessionId;
391
+ this._targetId = targetId;
392
+
393
+ // Enable core domains for the new session
394
+ for (const domain of ["Page", "DOM", "Runtime", "Network"]) {
395
+ try {
396
+ await this.cdp(`${domain}.enable`);
397
+ } catch {
398
+ // best-effort
399
+ }
400
+ }
401
+
402
+ // Mark the tab with a green circle for user visibility
403
+ try {
404
+ await this.cdp("Runtime.evaluate", {
405
+ expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
406
+ });
407
+ } catch {
408
+ // best-effort
409
+ }
410
+ }
411
+
412
+ /** Evaluate JavaScript in the page (or a specific iframe target) */
413
+ async evaluateJS(expression: string, targetId?: string): Promise<unknown> {
414
+ // Auto-wrap return statements in IIFE
415
+ if (expression.includes("return ") && !expression.trim().startsWith("(")) {
416
+ expression = `(function(){${expression}})()`;
417
+ }
418
+
419
+ const sid = targetId || null;
420
+ const result = (await this.cdp(
421
+ "Runtime.evaluate",
422
+ {
423
+ expression,
424
+ returnByValue: true,
425
+ awaitPromise: true,
426
+ },
427
+ targetId ? sid : undefined,
428
+ )) as { result?: { value?: unknown }; exceptionDetails?: unknown };
429
+
430
+ if (result.exceptionDetails) {
431
+ throw new Error(`JS evaluation failed: ${JSON.stringify(result.exceptionDetails)}`);
432
+ }
433
+ return result.result?.value;
434
+ }
435
+
436
+ /** Drain buffered CDP events from the internal buffer */
437
+ async drainEvents(): Promise<CDPEvent[]> {
438
+ const events = this._eventBuffer;
439
+ this._eventBuffer = [];
440
+ return events;
441
+ }
442
+
443
+ /** Get virtual key code for a key name or character */
444
+ getVirtualKeyCode(key: string): number {
445
+ return VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
446
+ }
447
+
448
+ /** Get the key code string for CDP */
449
+ getKeyCode(key: string): string {
450
+ return key.length === 1 && !VIRTUAL_KEY_CODES[key] ? key : key;
451
+ }
452
+
453
+ // ── Internal: WebSocket Connection ────────────────────────────────────────
454
+
455
+ private async connect(): Promise<void> {
456
+ const timeoutMs = CDP_CONNECT_TIMEOUT_MS;
457
+ const wsUrl = this.remote?.cdpUrl
458
+ ? this.remote.cdpUrl
459
+ : process.env.BU_CDP_WS
460
+ ? process.env.BU_CDP_WS
461
+ : await discoverWsUrl();
462
+
463
+ if (!this.remote) {
464
+ this.remote = {
465
+ cdpUrl: wsUrl,
466
+ browserId: wsUrl.split("/").pop() || "unknown",
467
+ };
468
+ }
469
+
470
+ await this.openWebSocket(wsUrl, timeoutMs);
471
+ await this.attachFirstPage();
472
+ }
473
+
474
+ private openWebSocket(url: string, timeoutMs: number): Promise<void> {
475
+ return new Promise((resolve, reject) => {
476
+ this._connectResolve = resolve;
477
+ this._connectReject = reject;
478
+
479
+ this._connectTimer = setTimeout(() => {
480
+ this._connectTimer = null;
481
+ this._connectReject = null;
482
+ if (this.ws) {
483
+ try { this.ws.close(); } catch { /* ignore */ }
484
+ this.ws = null;
485
+ }
486
+ reject(new Error(`CDP WebSocket connection timed out after ${timeoutMs}ms`));
487
+ }, timeoutMs);
488
+
489
+ try {
490
+ this.ws = new WebSocket(url);
491
+ } catch (err) {
492
+ clearTimeout(this._connectTimer!);
493
+ this._connectTimer = null;
494
+ reject(err);
495
+ return;
496
+ }
497
+
498
+ this.ws.onopen = () => {
499
+ if (this._connectTimer) {
500
+ clearTimeout(this._connectTimer);
501
+ this._connectTimer = null;
502
+ }
503
+ this._connectResolve = null;
504
+ this._connectReject = null;
505
+ // NOTE: do not clear _reconnectAttempts here. Chrome accepts the
506
+ // WS upgrade before the user clicks Allow on the remote-debugging
507
+ // consent dialog, so a successful onopen does not prove the
508
+ // connection is usable. We only consider the connection healthy
509
+ // once a CDP roundtrip completes — see attachFirstPage.
510
+ resolve();
511
+ };
512
+
513
+ this.ws.onmessage = (event: WebSocket.MessageEvent) => {
514
+ const data = event.data;
515
+ this.handleMessage(typeof data === "string" ? data : data.toString());
516
+ };
517
+
518
+ this.ws.onerror = (_event: WebSocket.ErrorEvent) => {
519
+ // Only reject during the connect phase; otherwise it means a disconnect
520
+ if (this._connectReject) {
521
+ const err = new Error("CDP WebSocket error during connection");
522
+ this._connectReject(err);
523
+ this._connectReject = null;
524
+ if (this._connectTimer) {
525
+ clearTimeout(this._connectTimer);
526
+ this._connectTimer = null;
527
+ }
528
+ }
529
+ };
530
+
531
+ this.ws.onclose = (_event: WebSocket.CloseEvent) => {
532
+ // If we're shutting down intentionally, don't reconnect
533
+ if (this._stopping) return;
534
+
535
+ // If we never saw a successful CDP roundtrip on this connection,
536
+ // the close almost certainly means the user denied (or hasn't yet
537
+ // accepted) Chrome's "Allow remote debugging?" prompt. Reconnecting
538
+ // would just spawn another popup. Bail out and let the next tool
539
+ // call surface a clear error.
540
+ if (!this._consentObserved) {
541
+ this._sessionId = null;
542
+ return;
543
+ }
544
+
545
+ // Auto-reconnect with backoff
546
+ if (this._reconnectAttempts < CDP_MAX_RECONNECT_ATTEMPTS) {
547
+ this._sessionId = null;
548
+ // Reset for the new connection — it must re-prove itself.
549
+ this._consentObserved = false;
550
+ const delay = Math.min(
551
+ CDP_RECONNECT_BASE_MS * 2 ** this._reconnectAttempts,
552
+ 15_000,
553
+ );
554
+ this._reconnectAttempts++;
555
+ setTimeout(() => {
556
+ if (!this._stopping) {
557
+ this.start().catch(() => {
558
+ // Reconnection ultimately failed — tools will report errors
559
+ });
560
+ }
561
+ }, delay);
562
+ }
563
+ };
564
+ });
565
+ }
566
+
567
+ // ── Internal: Message Handling ────────────────────────────────────────────
568
+
569
+ private handleMessage(data: string): void {
570
+ let msg: CDPMessage;
571
+ try {
572
+ msg = JSON.parse(data);
573
+ } catch {
574
+ return; // Corrupt frame, ignore
575
+ }
576
+
577
+ // CDP events have no `id` — they have a `method` field
578
+ if (msg.id === undefined) {
579
+ // Dialog detection
580
+ if (msg.method === "Page.javascriptDialogOpening") {
581
+ this._currentDialog = {
582
+ type: (msg.params?.type as DialogInfo["type"]) || "alert",
583
+ message: (msg.params?.message as string) || "",
584
+ defaultPrompt: msg.params?.defaultPrompt as string | undefined,
585
+ };
586
+ return;
587
+ }
588
+
589
+ // Dialog closed — clear the stored dialog
590
+ if (msg.method === "Page.javascriptDialogClosed") {
591
+ this._currentDialog = null;
592
+ return;
593
+ }
594
+
595
+ // Buffer other events for drainEvents()
596
+ this._eventBuffer.push({
597
+ method: msg.method!,
598
+ params: msg.params as unknown,
599
+ session_id: msg.sessionId as string | undefined,
600
+ });
601
+ return;
602
+ }
603
+
604
+ // It's a response to a pending request
605
+ const pending = this._pending.get(msg.id);
606
+ if (!pending) return; // Stale response, ignore
607
+
608
+ this._pending.delete(msg.id);
609
+ clearTimeout(pending.timer);
610
+
611
+ if (msg.error) {
612
+ // Handle stale session recovery
613
+ const errMsg = msg.error.message || String(msg.error);
614
+ if (
615
+ errMsg.includes("Session with given id not found") &&
616
+ pending.sessionId === this._sessionId
617
+ ) {
618
+ // Schedule recovery and reject — caller can retry
619
+ pending.reject(new SessionNotFoundError(errMsg));
620
+ return;
621
+ }
622
+ pending.reject(new Error(`CDP error (${pending.method}): ${errMsg}`));
623
+ return;
624
+ }
625
+
626
+ pending.resolve(msg.result);
627
+ }
628
+
629
+ /** Send a CDP command directly to the browser session */
630
+ private sendRawCdp(
631
+ method: string,
632
+ params: Record<string, unknown>,
633
+ sessionId: string | null,
634
+ timeoutMs: number = CDP_REQUEST_TIMEOUT_MS,
635
+ ): Promise<unknown> {
636
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
637
+ return Promise.reject(new Error("Browser not connected. Is Chrome running?"));
638
+ }
639
+
640
+ const id = this._nextId++;
641
+ const payload: Record<string, unknown> = { id, method, params };
642
+ if (sessionId) payload.sessionId = sessionId;
643
+ const msg = JSON.stringify(payload);
644
+
645
+ return new Promise((resolve, reject) => {
646
+ const timer = setTimeout(() => {
647
+ this._pending.delete(id);
648
+ reject(new Error(`CDP timeout after ${timeoutMs}ms: ${method}`));
649
+ }, timeoutMs);
650
+
651
+ this._pending.set(id, { resolve, reject, timer, method, sessionId });
652
+ try {
653
+ this.ws!.send(msg);
654
+ } catch (err) {
655
+ clearTimeout(timer);
656
+ this._pending.delete(id);
657
+ reject(err);
658
+ }
659
+ });
660
+ }
661
+
662
+ /** Attach to the first real page target. Creates about:blank if none exist. */
663
+ private async attachFirstPage(): Promise<string> {
664
+ const result = (await this.sendRawCdp("Target.getTargets", {}, null)) as {
665
+ targetInfos: Array<{ targetId: string; type: string; url: string }>;
666
+ };
667
+
668
+ // First CDP roundtrip succeeded — consent has been granted. Only now
669
+ // is it safe to reset the reconnect counter; otherwise a ws that opens
670
+ // but is silently held by Chrome's consent dialog would let us loop
671
+ // forever stacking popups on top of each other.
672
+ this._consentObserved = true;
673
+ this._reconnectAttempts = 0;
674
+
675
+ const pages = result.targetInfos.filter(
676
+ (t) => t.type === "page" && !isInternalUrl(t.url),
677
+ );
678
+
679
+ if (pages.length === 0) {
680
+ const created = (await this.sendRawCdp(
681
+ "Target.createTarget",
682
+ { url: "about:blank" },
683
+ null,
684
+ )) as { targetId: string };
685
+ pages.push({ targetId: created.targetId, type: "page", url: "about:blank" });
686
+ }
687
+
688
+ const attachResult = (await this.sendRawCdp(
689
+ "Target.attachToTarget",
690
+ { targetId: pages[0].targetId, flatten: true },
691
+ null,
692
+ )) as { sessionId: string };
693
+
694
+ this._sessionId = attachResult.sessionId;
695
+ this._targetId = pages[0].targetId;
696
+
697
+ // Enable core domains
698
+ for (const domain of ["Page", "DOM", "Runtime", "Network"]) {
699
+ try {
700
+ await this.sendRawCdp(`${domain}.enable`, {}, this._sessionId);
701
+ } catch {
702
+ // best-effort
703
+ }
704
+ }
705
+
706
+ // Mark tab
707
+ try {
708
+ await this.sendRawCdp(
709
+ "Runtime.evaluate",
710
+ {
711
+ expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
712
+ },
713
+ this._sessionId,
714
+ );
715
+ } catch {
716
+ // best-effort
717
+ }
718
+
719
+ return attachResult.sessionId;
720
+ }
721
+
722
+ /** Auto-recover from a stale session error by re-attaching */
723
+ async recoverSession(): Promise<void> {
724
+ await this.attachFirstPage();
725
+ }
726
+
727
+ // ── Error diagnostics ────────────────────────────────────────────────────
728
+
729
+ private needsChromeSetup(errorMsg: string): boolean {
730
+ const lower = errorMsg.toLowerCase();
731
+ return (
732
+ lower.includes("devtoolsactiveport") ||
733
+ lower.includes("cannot reach chrome") ||
734
+ lower.includes("ecode") && lower.includes("econnrefused") ||
735
+ (lower.includes("ws handshake") &&
736
+ (lower.includes("403") || lower.includes("timed out")))
737
+ );
738
+ }
739
+ }
740
+
741
+ // ── Internal Types ───────────────────────────────────────────────────────────
742
+
743
+ interface PendingRequest {
744
+ resolve: (value: unknown) => void;
745
+ reject: (err: Error) => void;
746
+ timer: ReturnType<typeof setTimeout>;
747
+ method: string;
748
+ sessionId: string | null;
749
+ }
750
+
751
+ interface CDPMessage {
752
+ id?: number;
753
+ method?: string;
754
+ params?: Record<string, unknown>;
755
+ result?: unknown;
756
+ error?: { message: string; code?: number };
757
+ sessionId?: string;
758
+ }
759
+
760
+ // ── Error Classes ────────────────────────────────────────────────────────────
761
+
762
+ class SessionNotFoundError extends Error {
763
+ constructor(msg: string) {
764
+ super(msg);
765
+ this.name = "SessionNotFoundError";
766
+ }
767
+ }