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.
- package/CHANGELOG.md +26 -0
- package/README.md +16 -1
- package/package.json +2 -2
- package/skills/pi-browser-harness/SKILL.md +29 -0
- package/src/cdp/commands.ts +4 -0
- package/src/cdp/discovery.ts +15 -12
- package/src/daemon/bridge.ts +262 -99
- package/src/daemon/spawn.ts +72 -14
- package/src/daemon/transport.ts +7 -2
- package/src/domains/click.ts +0 -5
- package/src/domains/console.ts +0 -2
- package/src/domains/files.ts +53 -4
- package/src/domains/form-batch.ts +0 -1
- package/src/domains/form.ts +1 -9
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +3 -3
- package/src/domains/js.ts +3 -9
- package/src/domains/keyboard.ts +2 -7
- package/src/domains/navigate.ts +0 -3
- package/src/domains/network.ts +1 -3
- package/src/domains/page.ts +1 -7
- package/src/domains/readpage/read-page.ts +0 -1
- package/src/domains/screenshot.ts +0 -4
- package/src/domains/scroll.ts +0 -1
- package/src/domains/search/web-search.ts +0 -1
- package/src/domains/setup.ts +0 -2
- package/src/domains/snapshot.ts +1 -4
- package/src/domains/tabs.ts +1 -9
- package/src/domains/viewport.ts +0 -1
- package/src/index.ts +16 -12
- package/src/prompt.ts +22 -122
- package/src/registry.ts +3 -0
- package/src/state.ts +32 -2
- package/src/util/paths.ts +11 -1
package/src/daemon/bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
207
|
-
|
|
255
|
+
const generation = ++attemptGeneration;
|
|
256
|
+
activeAttempt = generation;
|
|
257
|
+
connecting = true;
|
|
208
258
|
|
|
209
|
-
|
|
210
|
-
let
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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>((
|
|
221
|
-
|
|
222
|
-
const settleOnce = (): void => {
|
|
265
|
+
const settledPromise = new Promise<void>((resolve) => {
|
|
266
|
+
settleAttempt = (): void => {
|
|
223
267
|
if (settled) return;
|
|
224
268
|
settled = true;
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
335
|
+
if (!isCurrentAttempt(generation) || stopped) {
|
|
336
|
+
failCurrentAttempt(false);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
259
339
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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(() =>
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
312
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) => {
|
|
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
|
};
|
package/src/daemon/spawn.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
//
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/daemon/transport.ts
CHANGED
|
@@ -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(
|
|
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");
|
package/src/domains/click.ts
CHANGED
|
@@ -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,
|
package/src/domains/console.ts
CHANGED
|
@@ -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
|
],
|