pi-browser-harness 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +102 -1
  3. package/package.json +13 -7
  4. package/skills/pi-browser-harness/SKILL.md +84 -0
  5. package/src/cdp/commands.ts +204 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +72 -37
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +115 -191
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +84 -27
  25. package/src/daemon/transport.ts +88 -52
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -7
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -66
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +64 -11
  34. package/src/domains/form-batch.ts +13 -11
  35. package/src/domains/form.ts +79 -116
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +7 -7
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +68 -53
  41. package/src/domains/keyboard.ts +16 -18
  42. package/src/domains/navigate.ts +102 -69
  43. package/src/domains/network.ts +56 -98
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +19 -58
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -54
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -14
  59. package/src/domains/scroll.ts +4 -5
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -34
  62. package/src/domains/setup.ts +0 -2
  63. package/src/domains/snapshot.ts +72 -77
  64. package/src/domains/tab-resolve.ts +36 -0
  65. package/src/domains/tabs.ts +19 -78
  66. package/src/domains/target.ts +1 -1
  67. package/src/domains/viewport.ts +0 -1
  68. package/src/index.ts +100 -39
  69. package/src/profile/bind.ts +45 -14
  70. package/src/profile/browser-process.ts +50 -12
  71. package/src/profile/command.ts +20 -12
  72. package/src/profile/launch.ts +13 -9
  73. package/src/profile/list.ts +9 -13
  74. package/src/profile/store.ts +2 -1
  75. package/src/prompt.ts +42 -117
  76. package/src/registry.ts +68 -4
  77. package/src/schemas/parse.ts +2 -2
  78. package/src/setup.ts +28 -19
  79. package/src/state.ts +33 -2
  80. package/src/util/debug.ts +7 -1
  81. package/src/util/guards.ts +3 -0
  82. package/src/util/mutex.ts +1 -1
  83. package/src/util/paths.ts +12 -1
  84. package/src/util/result.ts +8 -0
  85. package/src/util/sharp-shim.ts +18 -11
  86. package/src/util/sleep.ts +18 -0
  87. package/src/util/tool.ts +163 -114
  88. package/src/util/truncate.ts +1 -1
  89. package/tsconfig.json +2 -1
  90. package/src/domains/fill-engine.ts +0 -80
@@ -1,10 +1,28 @@
1
- import WebSocket from "ws";
2
- import { discoverWsUrl } from "../cdp/discovery";
3
1
  import { isCdpRawMessage } from "../cdp/types";
4
2
  import type { CdpRawMessage } from "../cdp/types";
5
3
  import type { WireRequest, WireResponse, WireEvent } from "./protocol";
6
- import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
4
+ import { CDP_COMMAND_TIMEOUT_MS, DAEMON_RECONNECT_METHOD } from "./protocol";
7
5
  import { asString, isRecord } from "../util/guards";
6
+ import { type ChromeConnectionDependencies, type ConnectionStatus, createChromeConnection } from "./connection";
7
+
8
+ // The text a tool sees when the daemon has no Chrome connection to offer; it tells the model
9
+ // (and through it the user) whether to wait, to look at Chrome, or to run /browser-setup.
10
+ export const describeDisconnected = (status: ConnectionStatus): string => {
11
+ switch (status.state) {
12
+ case "open":
13
+ return "Chrome not connected";
14
+ case "connecting":
15
+ return `Chrome not connected: a connection attempt has been waiting ${Math.round(status.pendingForMs / 1000)}s. `
16
+ + "If Chrome is showing an \"Allow remote debugging\" prompt, accept it; no further prompts will be opened while this attempt is pending.";
17
+ case "cooldown":
18
+ return `Chrome not connected: the last attempt failed (${status.lastFailure}). `
19
+ + `The next automatic attempt is in ${Math.ceil(status.retryInMs / 1000)}s; run /browser-setup to retry now.`;
20
+ case "disconnected":
21
+ return status.lastFailure ? `Chrome not connected: ${status.lastFailure}` : "Chrome not connected";
22
+ case "stopped":
23
+ return "Chrome not connected: the daemon is shutting down";
24
+ }
25
+ };
8
26
 
9
27
  export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void;
10
28
 
@@ -13,14 +31,16 @@ export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void
13
31
  export type CloseHandler = () => void;
14
32
 
15
33
  export type CdpBridge = {
16
- start(): Promise<void>;
17
- stop(): Promise<void>;
18
- handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>;
19
- isAlive(): boolean;
20
- getSessionOwner(sessionId: string): string | undefined;
21
- removeClient(clientId: string): void;
22
- onEvent(handler: EventHandler): void;
23
- onClose(handler: CloseHandler): void;
34
+ readonly start: () => Promise<void>;
35
+ readonly stop: () => Promise<void>;
36
+ readonly handleRequest: (req: WireRequest, clientId: string, send: SendToClient) => Promise<void>;
37
+ readonly isAlive: () => boolean;
38
+ // Not used by the daemon itself; test/manual/bridge-test.ts asserts session ownership through it.
39
+ readonly getSessionOwner: (sessionId: string) => string | undefined;
40
+ readonly removeClient: (clientId: string) => void;
41
+ readonly onEvent: (handler: EventHandler) => void;
42
+ // Fires when an established Chrome connection drops; never for an attempt that failed to open.
43
+ readonly onClose: (handler: CloseHandler) => void;
24
44
  };
25
45
 
26
46
  export type InFlight = {
@@ -29,14 +49,16 @@ export type InFlight = {
29
49
  readonly send: SendToClient;
30
50
  readonly timer: ReturnType<typeof setTimeout>;
31
51
  readonly isAttach: boolean;
52
+ // The Chrome connection the request went out on, so that only its loss fails the request.
53
+ readonly generation: number;
32
54
  };
33
55
 
34
56
  // One map, keyed by the daemon-side id: the client that asked, the id it used, and everything needed to answer it.
35
57
  export type IdMultiplexer = {
36
- allocate(entry: Omit<InFlight, "timer">, arm: (daemonId: number) => InFlight["timer"]): number;
37
- take(daemonId: number): InFlight | undefined;
38
- takeAll(): ReadonlyArray<InFlight>;
39
- clearClient(clientId: string): void;
58
+ readonly allocate: (entry: Omit<InFlight, "timer">, arm: (daemonId: number) => InFlight["timer"]) => number;
59
+ readonly take: (daemonId: number) => InFlight | undefined;
60
+ // Removes every matching entry and cancels its timeout, so nothing taken can be answered later.
61
+ readonly takeWhere: (pred: (entry: InFlight) => boolean) => ReadonlyArray<InFlight>;
40
62
  };
41
63
 
42
64
  export const createIdMultiplexer = (): IdMultiplexer => {
@@ -56,29 +78,25 @@ export const createIdMultiplexer = (): IdMultiplexer => {
56
78
  clearTimeout(e.timer);
57
79
  return e;
58
80
  },
59
- takeAll() {
60
- const all = [...inFlight.values()];
61
- inFlight.clear();
62
- for (const e of all) clearTimeout(e.timer);
63
- return all;
64
- },
65
- // A disconnected client must not be answered later: dropping its entries also cancels their timeouts.
66
- clearClient(clientId) {
81
+ takeWhere(pred) {
82
+ const taken: InFlight[] = [];
67
83
  for (const [daemonId, e] of inFlight) {
68
- if (e.clientId !== clientId) continue;
84
+ if (!pred(e)) continue;
69
85
  clearTimeout(e.timer);
70
86
  inFlight.delete(daemonId);
87
+ taken.push(e);
71
88
  }
89
+ return taken;
72
90
  },
73
91
  };
74
92
  };
75
93
 
76
94
  export type EventRouter = {
77
- record(clientId: string, sessionId: string): void;
78
- release(sessionId: string): void;
79
- getOwner(sessionId: string): string | undefined;
80
- removeClient(clientId: string): void;
81
- route(event: WireEvent): string[];
95
+ readonly record: (clientId: string, sessionId: string) => void;
96
+ readonly release: (sessionId: string) => void;
97
+ readonly getOwner: (sessionId: string) => string | undefined;
98
+ readonly removeClient: (clientId: string) => void;
99
+ readonly route: (event: WireEvent) => string[];
82
100
  };
83
101
 
84
102
  export const createEventRouter = (): EventRouter => {
@@ -117,34 +135,22 @@ export const createEventRouter = (): EventRouter => {
117
135
  };
118
136
  };
119
137
 
120
- const CONNECT_WAIT_MS = 15_000;
138
+ const errorResponse = (id: number, message: string): WireResponse => ({
139
+ type: "response",
140
+ id,
141
+ error: { code: -32000, message },
142
+ });
121
143
 
122
- export const createCdpBridge = (): CdpBridge => {
123
- let ws: WebSocket | null = null;
124
- let wsUrl: string | null = null;
144
+ const failAll = (entries: ReadonlyArray<InFlight>, message: string): void => {
145
+ for (const e of entries) e.send(e.clientId, errorResponse(e.localId, message));
146
+ };
147
+
148
+ export const createCdpBridge = (options: ChromeConnectionDependencies = {}): CdpBridge => {
125
149
  const mux = createIdMultiplexer();
126
150
  const router = createEventRouter();
127
151
  let eventHandler: EventHandler | null = null;
128
152
  let closeHandler: CloseHandler | null = null;
129
153
 
130
- const connectedWaiters: Array<() => void> = [];
131
- const signalConnected = (): void => {
132
- for (const w of connectedWaiters.splice(0)) w();
133
- };
134
-
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
- });
147
-
148
154
  const onChromeMessage = (raw: string): void => {
149
155
  let parsed: unknown;
150
156
  try { parsed = JSON.parse(raw); } catch { return; }
@@ -154,23 +160,20 @@ export const createCdpBridge = (): CdpBridge => {
154
160
  if (msg.id !== undefined) {
155
161
  const cb = mux.take(msg.id);
156
162
  if (!cb) return;
157
- const localId = cb.localId;
158
163
 
159
164
  if (msg.error) {
160
165
  cb.send(cb.clientId, {
161
166
  type: "response",
162
- id: localId,
167
+ id: cb.localId,
163
168
  error: { code: msg.error.code ?? -1, message: msg.error.message },
164
169
  });
165
- } else {
166
- if (cb.isAttach && msg.result) {
167
- const sessionId = isRecord(msg.result) ? asString(msg.result["sessionId"]) : undefined;
168
- if (sessionId) {
169
- router.record(cb.clientId, sessionId);
170
- }
171
- }
172
- cb.send(cb.clientId, { type: "response", id: localId, result: msg.result });
170
+ return;
173
171
  }
172
+ if (cb.isAttach && isRecord(msg.result)) {
173
+ const sessionId = asString(msg.result["sessionId"]);
174
+ if (sessionId !== undefined) router.record(cb.clientId, sessionId);
175
+ }
176
+ cb.send(cb.clientId, { type: "response", id: cb.localId, result: msg.result });
174
177
  return;
175
178
  }
176
179
 
@@ -195,112 +198,27 @@ export const createCdpBridge = (): CdpBridge => {
195
198
  }
196
199
  };
197
200
 
198
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
199
- let reconnectAttempt = 0;
200
- let stopped = false;
201
-
202
- const tryConnect = (): void => {
203
- if (stopped) return;
204
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
205
-
206
- const attempt = async (): Promise<void> => {
207
- if (ws && ws.readyState === WebSocket.OPEN) return;
208
-
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
- }
219
-
220
- const settledPromise = new Promise<void>((settle) => {
221
- let settled = false;
222
- const settleOnce = (): void => {
223
- if (settled) return;
224
- settled = true;
225
- settle();
226
- };
227
-
228
- let sock: WebSocket;
229
- try {
230
- sock = new WebSocket(url, { perMessageDeflate: false });
231
- } catch {
232
- scheduleRetry();
233
- return;
234
- }
235
-
236
- const timer = setTimeout(() => {
237
- sock.close();
238
- settleOnce();
239
- }, CDP_CONNECT_TIMEOUT_MS);
240
-
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
- });
250
-
251
- sock.on("message", (data: WebSocket.Data) => {
252
- onChromeMessage(typeof data === "string" ? data : data.toString());
253
- });
254
-
255
- sock.on("error", () => {
256
- clearTimeout(timer);
257
- settleOnce();
258
- });
259
-
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
- });
269
- }
270
- closeHandler?.();
271
- scheduleRetry();
272
- });
273
- });
274
-
275
- await settledPromise;
276
- if (!ws || ws.readyState !== WebSocket.OPEN) {
277
- scheduleRetry();
278
- }
279
- };
280
-
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);
290
- };
201
+ const connection = createChromeConnection(options, {
202
+ onMessage: onChromeMessage,
203
+ onEnd: (generation, reason) => {
204
+ // Requests that went out on this socket can never be answered now; fail them at once
205
+ // rather than leaving them to the command timeout.
206
+ failAll(mux.takeWhere((e) => e.generation === generation), "Chrome disconnected");
207
+ if (reason === "dropped") closeHandler?.();
208
+ },
209
+ });
291
210
 
292
- const start = async (): Promise<void> => {
293
- stopped = false;
294
- tryConnect();
211
+ // Kicks off the first attempt without waiting for it: the daemon serves its IPC socket
212
+ // meanwhile, and handleRequest() joins the attempt in flight.
213
+ const start = (): Promise<void> => {
214
+ void connection.start();
215
+ return Promise.resolve();
295
216
  };
296
217
 
297
- const stop = async (): Promise<void> => {
298
- 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; }
303
- wsUrl = null;
218
+ const stop = (): Promise<void> => {
219
+ connection.stop();
220
+ failAll(mux.takeWhere(() => true), "Chrome disconnected");
221
+ return Promise.resolve();
304
222
  };
305
223
 
306
224
  const handleRequest = async (
@@ -308,31 +226,38 @@ export const createCdpBridge = (): CdpBridge => {
308
226
  clientId: string,
309
227
  send: SendToClient,
310
228
  ): 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);
313
-
314
- if (!ws || ws.readyState !== WebSocket.OPEN) {
315
- send(clientId, {
316
- type: "response",
317
- id: req.id,
318
- error: { code: -32000, message: "Chrome not connected" },
319
- });
229
+ // The one request the daemon answers itself: a human asked for a fresh dial.
230
+ if (req.method === DAEMON_RECONNECT_METHOD) {
231
+ const open = await connection.reconnectNow();
232
+ send(clientId, open
233
+ ? { type: "response", id: req.id, result: { connected: true } }
234
+ : errorResponse(req.id, describeDisconnected(connection.status())));
235
+ return;
236
+ }
237
+ // Connection attempts happen only in response to explicit browser work.
238
+ if (!connection.isOpen() && !(await connection.connect())) {
239
+ send(clientId, errorResponse(req.id, describeDisconnected(connection.status())));
320
240
  return;
321
241
  }
322
242
 
323
- const isDetach = req.method === "Target.detachFromTarget";
324
- if (isDetach && req.sessionId) router.release(req.sessionId);
243
+ // The session being detached travels in params (src/client.ts sends the envelope sessionId as null).
244
+ if (req.method === "Target.detachFromTarget") {
245
+ const sessionId = asString(req.params?.["sessionId"]);
246
+ if (sessionId !== undefined) router.release(sessionId);
247
+ }
325
248
 
326
249
  const daemonId = mux.allocate(
327
- { clientId, localId: req.id, send, isAttach: req.method === "Target.attachToTarget" },
250
+ {
251
+ clientId,
252
+ localId: req.id,
253
+ send,
254
+ isAttach: req.method === "Target.attachToTarget",
255
+ generation: connection.generation(),
256
+ },
328
257
  (id) =>
329
258
  setTimeout(() => {
330
259
  mux.take(id);
331
- send(clientId, {
332
- type: "response",
333
- id: req.id,
334
- error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` },
335
- });
260
+ send(clientId, errorResponse(req.id, `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}`));
336
261
  }, CDP_COMMAND_TIMEOUT_MS),
337
262
  );
338
263
 
@@ -343,15 +268,10 @@ export const createCdpBridge = (): CdpBridge => {
343
268
  };
344
269
  if (req.sessionId) payload["sessionId"] = req.sessionId;
345
270
 
346
- try {
347
- ws.send(JSON.stringify(payload));
348
- } catch (e) {
271
+ const sent = connection.send(JSON.stringify(payload));
272
+ if (!sent.success) {
349
273
  mux.take(daemonId);
350
- send(clientId, {
351
- type: "response",
352
- id: req.id,
353
- error: { code: -32000, message: e instanceof Error ? e.message : String(e) },
354
- });
274
+ send(clientId, errorResponse(req.id, sent.error));
355
275
  }
356
276
  };
357
277
 
@@ -359,9 +279,13 @@ export const createCdpBridge = (): CdpBridge => {
359
279
  start,
360
280
  stop,
361
281
  handleRequest,
362
- isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN,
282
+ isAlive: () => connection.isOpen(),
363
283
  getSessionOwner: (sid) => router.getOwner(sid),
364
- removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); },
284
+ removeClient: (cid) => {
285
+ // A gone client must never be answered: drop its requests silently and release its sessions.
286
+ mux.takeWhere((e) => e.clientId === cid);
287
+ router.removeClient(cid);
288
+ },
365
289
  onEvent: (h) => { eventHandler = h; },
366
290
  onClose: (h) => { closeHandler = h; },
367
291
  };