pi-browser-harness 0.11.0 → 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.
@@ -5,6 +5,8 @@ import type { CdpRawMessage } from "../cdp/types";
5
5
  import type { WireRequest, WireResponse, WireEvent } from "./protocol";
6
6
  import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
7
7
  import { asString, isRecord } from "../util/guards";
8
+ import type { CdpError } from "../cdp/errors";
9
+ import type { Result } from "../util/result";
8
10
 
9
11
  export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void;
10
12
 
@@ -117,9 +119,22 @@ export const createEventRouter = (): EventRouter => {
117
119
  };
118
120
  };
119
121
 
120
- const CONNECT_WAIT_MS = 15_000;
122
+ type CdpBridgeDependencies = {
123
+ discoverWsUrl?: () => Promise<Result<string, CdpError>>;
124
+ createWebSocket?: (url: string) => WebSocket;
125
+ connectTimeoutMs?: number;
126
+ onAttemptSettled?: () => void;
127
+ };
128
+
129
+ export const createCdpBridge = (options: CdpBridgeDependencies = {}): CdpBridge => {
130
+ const {
131
+ discoverWsUrl: discoverWsUrlImpl = discoverWsUrl,
132
+ createWebSocket: createWebSocketImpl = (url: string): WebSocket =>
133
+ new WebSocket(url, { perMessageDeflate: false }),
134
+ connectTimeoutMs = CDP_CONNECT_TIMEOUT_MS,
135
+ onAttemptSettled,
136
+ } = options;
121
137
 
122
- export const createCdpBridge = (): CdpBridge => {
123
138
  let ws: WebSocket | null = null;
124
139
  let wsUrl: string | null = null;
125
140
  const mux = createIdMultiplexer();
@@ -127,23 +142,15 @@ export const createCdpBridge = (): CdpBridge => {
127
142
  let eventHandler: EventHandler | null = null;
128
143
  let closeHandler: CloseHandler | null = null;
129
144
 
130
- const connectedWaiters: Array<() => void> = [];
131
- const signalConnected = (): void => {
132
- for (const w of connectedWaiters.splice(0)) w();
133
- };
145
+ // daemonId -> { generation, clientId }: lets a failed attempt reject only its own
146
+ // in-flight requests, and a gone client's entries be dropped without late answers.
147
+ const generations = new Map<number, { generation: number; clientId: string }>();
134
148
 
135
- const waitForConnection = (timeoutMs: number): Promise<void> =>
136
- new Promise((resolve) => {
137
- let settled = false;
138
- const finish = (): void => {
139
- if (settled) return;
140
- settled = true;
141
- clearTimeout(timer);
142
- resolve();
143
- };
144
- const timer = setTimeout(finish, timeoutMs);
145
- connectedWaiters.push(finish);
146
- });
149
+ let stopped = false;
150
+ let connecting = false;
151
+ let attemptGeneration = 0;
152
+ let activeAttempt = 0;
153
+ let activeSocket: WebSocket | null = null;
147
154
 
148
155
  const onChromeMessage = (raw: string): void => {
149
156
  let parsed: unknown;
@@ -152,6 +159,7 @@ export const createCdpBridge = (): CdpBridge => {
152
159
  const msg: CdpRawMessage = parsed;
153
160
 
154
161
  if (msg.id !== undefined) {
162
+ generations.delete(msg.id);
155
163
  const cb = mux.take(msg.id);
156
164
  if (!cb) return;
157
165
  const localId = cb.localId;
@@ -195,98 +203,215 @@ export const createCdpBridge = (): CdpBridge => {
195
203
  }
196
204
  };
197
205
 
198
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
199
- let reconnectAttempt = 0;
200
- let stopped = false;
206
+ const rejectCallbacksWhere = (
207
+ pred: (info: { generation: number; clientId: string }) => boolean,
208
+ ): void => {
209
+ for (const [daemonId, info] of generations) {
210
+ if (!pred(info)) continue;
211
+ generations.delete(daemonId);
212
+ const cb = mux.take(daemonId);
213
+ if (!cb) continue;
214
+ cb.send(cb.clientId, {
215
+ type: "response",
216
+ id: cb.localId,
217
+ error: { code: -32000, message: "Chrome disconnected" },
218
+ });
219
+ }
220
+ };
221
+
222
+ const rejectAttemptCallbacks = (generation: number): void => {
223
+ rejectCallbacksWhere((info) => info.generation === generation);
224
+ };
225
+
226
+ const rejectAllPendingCallbacks = (): void => {
227
+ rejectCallbacksWhere(() => true);
228
+ };
229
+
230
+ const closeSocket = (socket: WebSocket): void => {
231
+ try {
232
+ socket.close();
233
+ } catch {}
234
+ };
235
+
236
+ const isCurrentAttempt = (generation: number): boolean =>
237
+ generation === activeAttempt && !stopped;
238
+
239
+ const isCurrentAttemptSocket = (generation: number, socket: WebSocket): boolean =>
240
+ isCurrentAttempt(generation) && activeSocket === socket;
241
+
242
+ const clearActiveAttempt = (generation: number): void => {
243
+ if (activeAttempt === generation) {
244
+ activeAttempt = 0;
245
+ activeSocket = null;
246
+ }
247
+ };
201
248
 
249
+ // On-demand Chrome connection. Attempts happen only from start() and from
250
+ // handleRequest() while disconnected; a failed or disconnected bridge stays
251
+ // disconnected until the next explicit browser demand. No background retry loop.
202
252
  const tryConnect = (): void => {
203
- if (stopped) return;
204
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
253
+ if (stopped || connecting || (ws && ws.readyState === WebSocket.OPEN)) return;
205
254
 
206
- const attempt = async (): Promise<void> => {
207
- if (ws && ws.readyState === WebSocket.OPEN) return;
255
+ const generation = ++attemptGeneration;
256
+ activeAttempt = generation;
257
+ connecting = true;
208
258
 
209
- const cachedUrl = wsUrl;
210
- let url: string;
211
- if (cachedUrl === null || cachedUrl === "" || reconnectAttempt > 0) {
212
- const d = await discoverWsUrl();
213
- if (!d.success) { scheduleRetry(); return; }
214
- url = d.data;
215
- wsUrl = url;
216
- } else {
217
- url = cachedUrl;
218
- }
259
+ const attempt = async (): Promise<void> => {
260
+ let settled = false;
261
+ let sock: WebSocket | null = null;
262
+ let timeout: ReturnType<typeof setTimeout> | null = null;
263
+ let settleAttempt: () => void = () => {};
219
264
 
220
- const settledPromise = new Promise<void>((settle) => {
221
- let settled = false;
222
- const settleOnce = (): void => {
265
+ const settledPromise = new Promise<void>((resolve) => {
266
+ settleAttempt = (): void => {
223
267
  if (settled) return;
224
268
  settled = true;
225
- settle();
269
+ if (timeout) {
270
+ clearTimeout(timeout);
271
+ timeout = null;
272
+ }
273
+ if (generation === activeAttempt && !stopped) {
274
+ wsUrl = null;
275
+ connecting = false;
276
+ }
277
+ rejectAttemptCallbacks(generation);
278
+ onAttemptSettled?.();
279
+ resolve();
226
280
  };
281
+ });
227
282
 
228
- let sock: WebSocket;
229
- try {
230
- sock = new WebSocket(url, { perMessageDeflate: false });
231
- } catch {
232
- scheduleRetry();
283
+ const clearAttemptTimeout = (): void => {
284
+ if (timeout) {
285
+ clearTimeout(timeout);
286
+ timeout = null;
287
+ }
288
+ };
289
+
290
+ const failCurrentAttempt = (notifyClose: boolean): void => {
291
+ clearAttemptTimeout();
292
+ const isCurrent = isCurrentAttempt(generation);
293
+ if (!isCurrent) {
294
+ rejectAttemptCallbacks(generation);
233
295
  return;
234
296
  }
235
297
 
236
- const timer = setTimeout(() => {
237
- sock.close();
238
- settleOnce();
239
- }, CDP_CONNECT_TIMEOUT_MS);
298
+ clearActiveAttempt(generation);
299
+ if (ws === sock) ws = null;
300
+ wsUrl = null;
301
+ connecting = false;
302
+ // Not via settleAttempt: a connection that opened already settled its attempt, so
303
+ // its requests would otherwise hang to the command timeout instead of failing now.
304
+ rejectAttemptCallbacks(generation);
305
+ if (notifyClose) closeHandler?.();
306
+ settleAttempt();
307
+ };
240
308
 
241
- sock.on("open", () => {
242
- clearTimeout(timer);
243
- ws = sock;
244
- reconnectAttempt = 0;
245
- ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
246
- console.log("[pi-browser-daemon] Connected to Chrome ✓");
247
- signalConnected();
248
- settleOnce();
249
- });
309
+ timeout = setTimeout(() => {
310
+ if (!isCurrentAttempt(generation) || stopped) {
311
+ failCurrentAttempt(false);
312
+ return;
313
+ }
250
314
 
251
- sock.on("message", (data: WebSocket.Data) => {
252
- onChromeMessage(typeof data === "string" ? data : data.toString());
253
- });
315
+ if (sock && sock.readyState !== WebSocket.CLOSED) {
316
+ closeSocket(sock);
317
+ }
318
+ failCurrentAttempt(true);
319
+ }, connectTimeoutMs);
320
+
321
+ let url = wsUrl;
322
+ if (!url) {
323
+ const d = await discoverWsUrlImpl();
324
+ if (!isCurrentAttempt(generation) || stopped) {
325
+ failCurrentAttempt(false);
326
+ return;
327
+ }
328
+ if (!d.success) {
329
+ failCurrentAttempt(false);
330
+ return;
331
+ }
332
+ url = d.data;
333
+ }
254
334
 
255
- sock.on("error", () => {
256
- clearTimeout(timer);
257
- settleOnce();
258
- });
335
+ if (!isCurrentAttempt(generation) || stopped) {
336
+ failCurrentAttempt(false);
337
+ return;
338
+ }
259
339
 
260
- sock.on("close", () => {
261
- clearTimeout(timer);
262
- ws = null;
263
- for (const cb of mux.takeAll()) {
264
- cb.send(cb.clientId, {
265
- type: "response",
266
- id: cb.localId,
267
- error: { code: -32000, message: "Chrome disconnected" },
268
- });
340
+ try {
341
+ sock = createWebSocketImpl(url);
342
+ } catch {
343
+ failCurrentAttempt(false);
344
+ return;
345
+ }
346
+
347
+ if (!isCurrentAttempt(generation) || stopped) {
348
+ closeSocket(sock);
349
+ failCurrentAttempt(false);
350
+ return;
351
+ }
352
+
353
+ wsUrl = url;
354
+ activeSocket = sock;
355
+
356
+ const onOpen = (): void => {
357
+ if (!sock || !isCurrentAttemptSocket(generation, sock)) {
358
+ clearAttemptTimeout();
359
+ if (sock && sock.readyState !== WebSocket.CLOSED) {
360
+ closeSocket(sock);
269
361
  }
270
- closeHandler?.();
271
- scheduleRetry();
272
- });
273
- });
362
+ failCurrentAttempt(false);
363
+ return;
364
+ }
365
+
366
+ clearAttemptTimeout();
367
+ ws = sock;
368
+ try {
369
+ ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
370
+ } catch {
371
+ failCurrentAttempt(true);
372
+ if (sock.readyState !== WebSocket.CLOSED) {
373
+ closeSocket(sock);
374
+ }
375
+ return;
376
+ }
377
+ console.log("[pi-browser-daemon] Connected to Chrome ✓");
378
+ settleAttempt();
379
+ };
274
380
 
381
+ const onMessage = (data: WebSocket.Data): void => {
382
+ if (!sock || !isCurrentAttemptSocket(generation, sock)) return;
383
+ onChromeMessage(typeof data === "string" ? data : data.toString());
384
+ };
385
+
386
+ const onError = (): void => {
387
+ clearAttemptTimeout();
388
+ const notifyClose = !!sock && isCurrentAttemptSocket(generation, sock);
389
+ failCurrentAttempt(notifyClose);
390
+ if (sock && sock.readyState !== WebSocket.CLOSED) {
391
+ closeSocket(sock);
392
+ }
393
+ };
394
+
395
+ const onClose = (): void => {
396
+ clearAttemptTimeout();
397
+ const notifyClose = !!sock && isCurrentAttemptSocket(generation, sock);
398
+ failCurrentAttempt(notifyClose);
399
+ };
400
+
401
+ sock.on("open", onOpen);
402
+ sock.on("message", onMessage);
403
+ sock.on("error", onError);
404
+ sock.on("close", onClose);
405
+
406
+ // The promise must always settle even on constructor errors.
275
407
  await settledPromise;
276
- if (!ws || ws.readyState !== WebSocket.OPEN) {
277
- scheduleRetry();
278
- }
279
408
  };
280
409
 
281
- attempt().catch(() => scheduleRetry());
282
- };
283
-
284
- const scheduleRetry = (): void => {
285
- if (stopped) return;
286
- const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000);
287
- reconnectAttempt++;
288
- wsUrl = null;
289
- reconnectTimer = setTimeout(tryConnect, delay);
410
+ attempt().catch(() => {
411
+ if (isCurrentAttempt(generation)) {
412
+ connecting = false;
413
+ }
414
+ });
290
415
  };
291
416
 
292
417
  const start = async (): Promise<void> => {
@@ -296,22 +421,51 @@ export const createCdpBridge = (): CdpBridge => {
296
421
 
297
422
  const stop = async (): Promise<void> => {
298
423
  stopped = true;
299
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
300
- mux.takeAll();
301
- signalConnected();
302
- if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; }
424
+ attemptGeneration += 1;
425
+ activeAttempt = 0;
426
+ activeSocket = null;
427
+ connecting = false;
428
+ rejectAllPendingCallbacks();
429
+ if (ws) {
430
+ try {
431
+ ws.close(1000, "Shutdown");
432
+ } catch {}
433
+ ws = null;
434
+ }
303
435
  wsUrl = null;
304
436
  };
305
437
 
438
+ const waitForConnection = async (): Promise<boolean> => {
439
+ const deadline = Date.now() + connectTimeoutMs;
440
+ while (connecting && Date.now() < deadline) {
441
+ if (ws && ws.readyState === WebSocket.OPEN) return true;
442
+ await new Promise((r) => setTimeout(r, 5));
443
+ }
444
+ return ws !== null && ws.readyState === WebSocket.OPEN;
445
+ };
446
+
306
447
  const handleRequest = async (
307
448
  req: WireRequest,
308
449
  clientId: string,
309
450
  send: SendToClient,
310
451
  ): Promise<void> => {
311
- // Every queued request awaits the same connect signal rather than each spinning its own poll loop.
312
- if (!ws || ws.readyState !== WebSocket.OPEN) await waitForConnection(CONNECT_WAIT_MS);
452
+ let activeSocket = ws;
453
+ if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
454
+ // Connection attempts happen only in response to explicit browser work.
455
+ tryConnect();
456
+ const ready = await waitForConnection();
457
+ if (!ready) {
458
+ send(clientId, {
459
+ type: "response",
460
+ id: req.id,
461
+ error: { code: -32000, message: "Chrome not connected" },
462
+ });
463
+ return;
464
+ }
465
+ }
313
466
 
314
- if (!ws || ws.readyState !== WebSocket.OPEN) {
467
+ activeSocket = ws;
468
+ if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
315
469
  send(clientId, {
316
470
  type: "response",
317
471
  id: req.id,
@@ -323,11 +477,14 @@ export const createCdpBridge = (): CdpBridge => {
323
477
  const isDetach = req.method === "Target.detachFromTarget";
324
478
  if (isDetach && req.sessionId) router.release(req.sessionId);
325
479
 
480
+ const isAttach = req.method === "Target.attachToTarget";
481
+
326
482
  const daemonId = mux.allocate(
327
- { clientId, localId: req.id, send, isAttach: req.method === "Target.attachToTarget" },
483
+ { clientId, localId: req.id, send, isAttach },
328
484
  (id) =>
329
485
  setTimeout(() => {
330
486
  mux.take(id);
487
+ generations.delete(id);
331
488
  send(clientId, {
332
489
  type: "response",
333
490
  id: req.id,
@@ -335,6 +492,7 @@ export const createCdpBridge = (): CdpBridge => {
335
492
  });
336
493
  }, CDP_COMMAND_TIMEOUT_MS),
337
494
  );
495
+ generations.set(daemonId, { generation: attemptGeneration, clientId });
338
496
 
339
497
  const payload: Record<string, unknown> = {
340
498
  id: daemonId,
@@ -344,9 +502,10 @@ export const createCdpBridge = (): CdpBridge => {
344
502
  if (req.sessionId) payload["sessionId"] = req.sessionId;
345
503
 
346
504
  try {
347
- ws.send(JSON.stringify(payload));
505
+ activeSocket.send(JSON.stringify(payload));
348
506
  } catch (e) {
349
507
  mux.take(daemonId);
508
+ generations.delete(daemonId);
350
509
  send(clientId, {
351
510
  type: "response",
352
511
  id: req.id,
@@ -361,7 +520,11 @@ export const createCdpBridge = (): CdpBridge => {
361
520
  handleRequest,
362
521
  isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN,
363
522
  getSessionOwner: (sid) => router.getOwner(sid),
364
- removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); },
523
+ removeClient: (cid) => {
524
+ rejectCallbacksWhere((info) => info.clientId === cid);
525
+ mux.clearClient(cid);
526
+ router.removeClient(cid);
527
+ },
365
528
  onEvent: (h) => { eventHandler = h; },
366
529
  onClose: (h) => { closeHandler = h; },
367
530
  };
@@ -1,6 +1,7 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
+ import { accessSync } from "node:fs";
2
3
  import { access, unlink } from "node:fs/promises";
3
- import { existsSync } from "node:fs";
4
+ import { createRequire } from "node:module";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { dirname, join } from "node:path";
6
7
  import { createConnection } from "node:net";
@@ -8,6 +9,8 @@ import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
8
9
 
9
10
  const moduleDir = dirname(fileURLToPath(import.meta.url));
10
11
 
12
+ const requireFromHere = createRequire(import.meta.url);
13
+
11
14
  const isWindows = process.platform === "win32";
12
15
 
13
16
  // No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
@@ -78,28 +81,83 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
78
81
  });
79
82
  };
80
83
 
81
- export const spawnDaemon = (): ChildProcess | null => {
82
- const daemonScript = join(moduleDir, "index.ts");
84
+ // Walks upward like Node's own node_modules lookup. Exported with an injectable start so the
85
+ // hoisted layout pi uses can be exercised against a fixture instead of this checkout.
86
+ export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): string | null => {
87
+ let dir = startDir;
88
+
89
+ while (true) {
90
+ const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs");
91
+ try {
92
+ accessSync(candidate);
93
+ return candidate;
94
+ } catch {
95
+ // Keep walking toward the filesystem root. This mirrors Node's node_modules lookup
96
+ // without relying on createRequire().resolve(), which is broken for external modules
97
+ // loaded through jiti/static inside a Bun-compiled executable such as pi.exe.
98
+ }
99
+
100
+ const parent = dirname(dir);
101
+ if (parent === dir) return null;
102
+ dir = parent;
103
+ }
104
+ };
83
105
 
84
- // Windows npm binaries are `.cmd` shims, which only launch through a shell — spawning one directly yields EINVAL.
85
- const tsxBinBase = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
86
- const tsxBin = isWindows ? `${tsxBinBase}.cmd` : tsxBinBase;
87
- const hasLocalTsx = existsSync(tsxBin);
106
+ const resolveTsxCli = (): string | null => {
107
+ try {
108
+ const resolved = requireFromHere.resolve("tsx/cli");
109
+ // A compiled runtime may theoretically return a virtual path that a separately spawned
110
+ // Node process cannot read. Only accept a resolver result that exists on the host FS.
111
+ accessSync(resolved);
112
+ return resolved;
113
+ } catch {
114
+ return resolveTsxCliFromFilesystem();
115
+ }
116
+ };
88
117
 
89
- const cmd = hasLocalTsx ? tsxBin : isWindows ? "npx.cmd" : "npx";
90
- const args = hasLocalTsx ? [daemonScript] : ["tsx", daemonScript];
118
+ export const daemonNodeExecutable = (
119
+ env: NodeJS.ProcessEnv = process.env,
120
+ versions: Readonly<Record<string, string | undefined>> = process.versions,
121
+ platform: NodeJS.Platform = process.platform,
122
+ execPath: string = process.execPath,
123
+ ): string => {
124
+ const override = env["PI_BROWSER_NODE"];
125
+ if (override) return override;
91
126
 
92
- // With shell: true Node passes the command line verbatim, so a path containing spaces must be quoted or it splits into tokens.
93
- const quote = (s: string): string => (isWindows ? `"${s}"` : s);
127
+ // Under normal Node keep the exact executable that launched pi/the extension. Under Bun,
128
+ // process.execPath is bun/pi.exe, not Node, so run the tsx CLI with the required Node runtime.
129
+ if ("bun" in versions) return platform === "win32" ? "node.exe" : "node";
94
130
 
95
- const child = spawn(quote(cmd), args.map(quote), {
131
+ return execPath;
132
+ };
133
+
134
+ export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => {
135
+ const daemonScript = join(moduleDir, "index.ts");
136
+ const tsxCli = resolveTsxCli();
137
+ if (!tsxCli) return null;
138
+
139
+ return {
140
+ command: daemonNodeExecutable(),
141
+ args: [tsxCli, daemonScript],
142
+ };
143
+ };
144
+
145
+ export const spawnDaemon = (): ChildProcess | null => {
146
+ const spec = daemonSpawnCommand();
147
+ if (!spec) return null;
148
+
149
+ const child = spawn(spec.command, [...spec.args], {
96
150
  detached: true,
97
151
  stdio: "ignore",
98
152
  env: { ...process.env },
99
- shell: isWindows,
100
- windowsVerbatimArguments: isWindows,
153
+ windowsHide: true,
101
154
  });
102
155
 
156
+ // `spawn` reports a missing executable (a bare `node` that is not on PATH under a Bun host)
157
+ // asynchronously via `error`; without a listener that is an uncaught exception in pi.
158
+ // ensureDaemon's poll loop reports the failure as a timeout, which is what the user sees.
159
+ child.on("error", () => {});
160
+
103
161
  child.unref();
104
162
  return child;
105
163
  };
@@ -13,7 +13,7 @@ import {
13
13
  serialize,
14
14
  } from "./protocol";
15
15
 
16
- export const createDaemonTransport = (clientId: string): CdpTransport => {
16
+ export const createDaemonTransport = (clientId: string, socketPath: string = DAEMON_SOCKET_PATH): CdpTransport => {
17
17
  let socket: Socket | null = null;
18
18
  let rl: ReturnType<typeof createInterface> | null = null;
19
19
  let queue = makeEventQueue();
@@ -51,12 +51,17 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
51
51
 
52
52
  let sock: Socket;
53
53
  try {
54
- sock = createConnection(DAEMON_SOCKET_PATH);
54
+ sock = createConnection(socketPath);
55
55
  } catch (e) {
56
56
  settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
57
57
  return;
58
58
  }
59
59
  socket = sock;
60
+ // The client is kept connected across sessions on purpose (index.ts session_shutdown), so this
61
+ // socket must not be what keeps the process alive: in `pi -p` it was the last handle standing
62
+ // and pi never exited (#39). In-flight requests hold their own ref'd timers, so nothing a
63
+ // caller is awaiting can be dropped by this.
64
+ sock.unref();
60
65
 
61
66
  const connectTimer = setTimeout(() => {
62
67
  cleanup("Connection timeout");
@@ -58,11 +58,6 @@ export const clickTool = defineBrowserTool({
58
58
  "Click an element. PREFERRED: pass `ref` (e.g. 'e12') from browser_snapshot — it re-resolves the element's position at click time, so it works even after the page re-renders and moves things. Fallback: pass viewport CSS-pixel `x`/`y`. Compositor-level click works through iframes, shadow DOM, and cross-origin content. After clicking, a compact diff of page changes is appended.",
59
59
  promptSnippet: "Click an element by ref (preferred) or pixel coordinates",
60
60
  promptGuidelines: [
61
- "PREFER `ref` from browser_snapshot (the outline shows '[eN]' for every interactive element) — it survives re-renders, unlike coordinates which go stale after a save/edit reflows the page.",
62
- "Fallback: pass (x, y) from the snapshot's '@(x,y)' hint when there's no ref.",
63
- "A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
64
- "After clicking, read the appended page-changes diff to confirm the action landed before moving on (no separate snapshot needed for a quick check).",
65
- "Coordinates are viewport CSS pixels (not device pixels). Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content.",
66
61
  "If a click doesn't register, run pi with --browser-debug-clicks (or set BH_DEBUG_CLICKS=1) to get annotated screenshots. For React/Vue components ignoring clicks, try browser_dispatch_key.",
67
62
  ],
68
63
  parameters: ClickArgs,
@@ -158,8 +158,6 @@ export const consoleTool = defineBrowserTool({
158
158
  "Read JS errors and console messages from the active tab. Diagnostic — use when an action looks broken or silent.",
159
159
  promptSnippet: "Read JS errors / console output on the current tab",
160
160
  promptGuidelines: [
161
- "Use after a click, submit, or navigate produced no visible change — errors often explain it.",
162
- "Pass `sinceSeq` from the previous call's `nextCursor` to see only new messages after an action.",
163
161
  "Buffer is reset on tab switch (capacity 500 records). bufferOverflowed:true means older entries were dropped.",
164
162
  "Don't use as a default observation tool — prefer browser_snapshot for page structure.",
165
163
  ],