pi-browser-harness 0.10.2 → 0.11.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 (88) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -9
  3. package/package.json +5 -2
  4. package/skills/pi-browser-harness/SKILL.md +1 -1
  5. package/src/cdp/commands.ts +256 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +28 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +150 -203
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +18 -68
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -32
  28. package/src/domains/console.ts +48 -2
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +22 -26
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +65 -0
  35. package/src/domains/form.ts +116 -105
  36. package/src/domains/history.ts +13 -9
  37. package/src/domains/isolated-tab.ts +26 -55
  38. package/src/domains/js.ts +29 -42
  39. package/src/domains/keyboard.ts +36 -27
  40. package/src/domains/navigate.ts +16 -23
  41. package/src/domains/network.ts +60 -10
  42. package/src/domains/page.ts +24 -3
  43. package/src/domains/readpage/read-page.ts +74 -19
  44. package/src/domains/readpage/readability.ts +0 -24
  45. package/src/domains/ref-resolve.ts +24 -58
  46. package/src/domains/render.ts +6 -27
  47. package/src/domains/screenshot-capture.ts +80 -0
  48. package/src/domains/screenshot.ts +9 -86
  49. package/src/domains/scroll.ts +10 -11
  50. package/src/domains/search/google-serp.ts +0 -29
  51. package/src/domains/search/web-search.ts +48 -20
  52. package/src/domains/setup.ts +1 -11
  53. package/src/domains/snapshot.ts +35 -276
  54. package/src/domains/tabs.ts +14 -7
  55. package/src/domains/target.ts +35 -0
  56. package/src/domains/viewport.ts +5 -3
  57. package/src/index.ts +8 -58
  58. package/src/profile/bind.ts +8 -57
  59. package/src/profile/browser-process.ts +75 -126
  60. package/src/profile/command.ts +50 -29
  61. package/src/profile/launch.ts +22 -40
  62. package/src/profile/list.ts +14 -54
  63. package/src/profile/paths.ts +2 -65
  64. package/src/profile/store.ts +35 -56
  65. package/src/registry.ts +30 -33
  66. package/src/schemas/parse.ts +23 -0
  67. package/src/setup.ts +0 -25
  68. package/src/state.ts +16 -16
  69. package/src/util/debug.ts +8 -0
  70. package/src/util/guards.ts +30 -0
  71. package/src/util/js-template.ts +1 -16
  72. package/src/util/mutex.ts +0 -5
  73. package/src/util/result.ts +0 -6
  74. package/src/util/sharp-shim.ts +12 -19
  75. package/src/util/tool.ts +17 -42
  76. package/src/util/truncate.ts +6 -0
  77. package/tsconfig.json +1 -16
  78. package/skills/deep-research/SKILL.md +0 -66
  79. package/src/cdp/event-queue.ts +0 -128
  80. package/src/cdp/transport.ts +0 -156
  81. package/src/cdp/window-binding.ts +0 -15
  82. package/src/deep-research.ts +0 -41
  83. package/src/domains/forms.ts +0 -355
  84. package/src/domains/readpage/capture.ts +0 -46
  85. package/src/domains/search/extract.ts +0 -44
  86. package/src/profile/picker.ts +0 -73
  87. package/src/util/keycodes.ts +0 -10
  88. package/src/util/time.ts +0 -29
@@ -1,22 +1,10 @@
1
- /**
2
- * CdpBridge — persistent WebSocket to Chrome, ID multiplexing, event routing.
3
- *
4
- * A single long-lived process connection to Chrome that proxies CDP traffic
5
- * for multiple pi clients connected via the IpcServer. One Allow dialog ever.
6
- *
7
- * References:
8
- * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals
9
- * - CDP sessions + IDs: https://github.com/aslushnikov/getting-started-with-cdp#targets--sessions
10
- * - Target domain: https://chromedevtools.github.io/devtools-protocol/tot/Target/
11
- */
12
-
13
1
  import WebSocket from "ws";
14
2
  import { discoverWsUrl } from "../cdp/discovery";
3
+ import { isCdpRawMessage } from "../cdp/types";
15
4
  import type { CdpRawMessage } from "../cdp/types";
16
5
  import type { WireRequest, WireResponse, WireEvent } from "./protocol";
17
6
  import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
18
-
19
- // ── Types ──────────────────────────────────────────────────────────────────────
7
+ import { asString, isRecord } from "../util/guards";
20
8
 
21
9
  export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void;
22
10
 
@@ -25,146 +13,147 @@ export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void
25
13
  export type CloseHandler = () => void;
26
14
 
27
15
  export type CdpBridge = {
28
- /** Start the bridge. Resolves when the daemon IPC + Chrome discovery loop
29
- * are initialized (not when Chrome is connected — that may take retries). */
30
16
  start(): Promise<void>;
31
17
  stop(): Promise<void>;
32
18
  handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>;
33
- /** True when the Chrome WebSocket is open and ready. */
34
19
  isAlive(): boolean;
35
- recordSession(clientId: string, sessionId: string): void;
36
- releaseSession(sessionId: string): void;
37
20
  getSessionOwner(sessionId: string): string | undefined;
38
21
  removeClient(clientId: string): void;
39
22
  onEvent(handler: EventHandler): void;
40
23
  onClose(handler: CloseHandler): void;
41
24
  };
42
25
 
43
- // ── ID Multiplexer ────────────────────────────────────────────────────────────
44
-
45
- interface PendingEntry {
46
- clientId: string;
47
- localId: number;
48
- }
49
-
50
- class IdMultiplexer {
51
- private nextId = 1;
52
- private localToRemote = new Map<string, Map<number, number>>();
53
- private pending = new Map<number, PendingEntry>();
54
-
55
- allocate(clientId: string, localId: number): number {
56
- const daemonId = this.nextId++;
57
- let m = this.localToRemote.get(clientId);
58
- if (!m) { m = new Map(); this.localToRemote.set(clientId, m); }
59
- m.set(localId, daemonId);
60
- this.pending.set(daemonId, { clientId, localId });
61
- return daemonId;
62
- }
63
-
64
- resolve(daemonId: number): PendingEntry | null {
65
- const e = this.pending.get(daemonId);
66
- if (!e) return null;
67
- this.pending.delete(daemonId);
68
- const m = this.localToRemote.get(e.clientId);
69
- if (m) m.delete(e.localId);
70
- return e;
71
- }
72
-
73
- clearClient(clientId: string): void {
74
- const m = this.localToRemote.get(clientId);
75
- if (m) { for (const [, did] of m) this.pending.delete(did); }
76
- this.localToRemote.delete(clientId);
77
- }
78
- }
79
-
80
- // ── Event Router ───────────────────────────────────────────────────────────────
81
-
82
- class EventRouter {
83
- private owners = new Map<string, string>(); // sessionId → clientId
84
- private clientSessions = new Map<string, Set<string>>(); // clientId → sessions
85
-
86
- record(clientId: string, sessionId: string): void {
87
- const prev = this.owners.get(sessionId);
88
- if (prev && prev !== clientId) {
89
- this.clientSessions.get(prev)?.delete(sessionId);
90
- }
91
- this.owners.set(sessionId, clientId);
92
- let s = this.clientSessions.get(clientId);
93
- if (!s) { s = new Set(); this.clientSessions.set(clientId, s); }
94
- s.add(sessionId);
95
- }
96
-
97
- release(sessionId: string): void {
98
- const owner = this.owners.get(sessionId);
99
- if (owner) this.clientSessions.get(owner)?.delete(sessionId);
100
- this.owners.delete(sessionId);
101
- }
102
-
103
- getOwner(sessionId: string): string | undefined {
104
- return this.owners.get(sessionId);
105
- }
106
-
107
- removeClient(clientId: string): void {
108
- const sessions = this.clientSessions.get(clientId);
109
- if (sessions) { for (const sid of sessions) this.owners.delete(sid); }
110
- this.clientSessions.delete(clientId);
111
- }
112
-
113
- /**
114
- * Route a CDP event to client(s).
115
- * Session-scoped → only the owning client.
116
- * Browser-level (no sessionId) → all connected clients.
117
- */
118
- route(event: WireEvent): string[] {
119
- if (event.sessionId) {
120
- const owner = this.owners.get(event.sessionId);
121
- return owner ? [owner] : [];
122
- }
123
- return [...this.clientSessions.keys()];
124
- }
26
+ export type InFlight = {
27
+ readonly clientId: string;
28
+ readonly localId: number;
29
+ readonly send: SendToClient;
30
+ readonly timer: ReturnType<typeof setTimeout>;
31
+ readonly isAttach: boolean;
32
+ };
125
33
 
126
- allClients(): string[] {
127
- return [...this.clientSessions.keys()];
128
- }
129
- }
34
+ // One map, keyed by the daemon-side id: the client that asked, the id it used, and everything needed to answer it.
35
+ 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;
40
+ };
41
+
42
+ export const createIdMultiplexer = (): IdMultiplexer => {
43
+ let nextId = 1;
44
+ const inFlight = new Map<number, InFlight>();
45
+
46
+ return {
47
+ allocate(entry, arm) {
48
+ const daemonId = nextId++;
49
+ inFlight.set(daemonId, { ...entry, timer: arm(daemonId) });
50
+ return daemonId;
51
+ },
52
+ take(daemonId) {
53
+ const e = inFlight.get(daemonId);
54
+ if (!e) return undefined;
55
+ inFlight.delete(daemonId);
56
+ clearTimeout(e.timer);
57
+ return e;
58
+ },
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) {
67
+ for (const [daemonId, e] of inFlight) {
68
+ if (e.clientId !== clientId) continue;
69
+ clearTimeout(e.timer);
70
+ inFlight.delete(daemonId);
71
+ }
72
+ },
73
+ };
74
+ };
75
+
76
+ 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[];
82
+ };
83
+
84
+ export const createEventRouter = (): EventRouter => {
85
+ const owners = new Map<string, string>();
86
+ const clientSessions = new Map<string, Set<string>>();
87
+
88
+ return {
89
+ record(clientId, sessionId) {
90
+ const prev = owners.get(sessionId);
91
+ if (prev && prev !== clientId) clientSessions.get(prev)?.delete(sessionId);
92
+ owners.set(sessionId, clientId);
93
+ let s = clientSessions.get(clientId);
94
+ if (!s) { s = new Set(); clientSessions.set(clientId, s); }
95
+ s.add(sessionId);
96
+ },
97
+ release(sessionId) {
98
+ const owner = owners.get(sessionId);
99
+ if (owner) clientSessions.get(owner)?.delete(sessionId);
100
+ owners.delete(sessionId);
101
+ },
102
+ getOwner(sessionId) {
103
+ return owners.get(sessionId);
104
+ },
105
+ removeClient(clientId) {
106
+ const sessions = clientSessions.get(clientId);
107
+ if (sessions) { for (const sid of sessions) owners.delete(sid); }
108
+ clientSessions.delete(clientId);
109
+ },
110
+ route(event) {
111
+ if (event.sessionId) {
112
+ const owner = owners.get(event.sessionId);
113
+ return owner ? [owner] : [];
114
+ }
115
+ return [...clientSessions.keys()];
116
+ },
117
+ };
118
+ };
130
119
 
131
- // ── Implementation ─────────────────────────────────────────────────────────────
120
+ const CONNECT_WAIT_MS = 15_000;
132
121
 
133
122
  export const createCdpBridge = (): CdpBridge => {
134
123
  let ws: WebSocket | null = null;
135
124
  let wsUrl: string | null = null;
136
- const mux = new IdMultiplexer();
137
- const router = new EventRouter();
125
+ const mux = createIdMultiplexer();
126
+ const router = createEventRouter();
138
127
  let eventHandler: EventHandler | null = null;
139
128
  let closeHandler: CloseHandler | null = null;
140
129
 
141
- // daemonId → { send, timer, isAttach }
142
- const callbacks = new Map<number, {
143
- send: SendToClient;
144
- clientId: string;
145
- localId: number;
146
- timer: ReturnType<typeof setTimeout>;
147
- isAttach: boolean;
148
- }>();
130
+ const connectedWaiters: Array<() => void> = [];
131
+ const signalConnected = (): void => {
132
+ for (const w of connectedWaiters.splice(0)) w();
133
+ };
149
134
 
150
- // ── Handle raw CDP messages from Chrome ─────────────────────────────────
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
+ });
151
147
 
152
148
  const onChromeMessage = (raw: string): void => {
153
- let msg: CdpRawMessage;
154
- try { msg = JSON.parse(raw); } catch { return; }
149
+ let parsed: unknown;
150
+ try { parsed = JSON.parse(raw); } catch { return; }
151
+ if (!isCdpRawMessage(parsed)) return;
152
+ const msg: CdpRawMessage = parsed;
155
153
 
156
- // ---- Response (has id) ----
157
154
  if (msg.id !== undefined) {
158
- const cb = callbacks.get(msg.id);
159
- if (!cb) {
160
- // Stale response — client already timed out or disconnected.
161
- // Still clean up the multiplexer mapping.
162
- mux.resolve(msg.id);
163
- return;
164
- }
165
- clearTimeout(cb.timer);
166
- callbacks.delete(msg.id);
167
-
155
+ const cb = mux.take(msg.id);
156
+ if (!cb) return;
168
157
  const localId = cb.localId;
169
158
 
170
159
  if (msg.error) {
@@ -174,24 +163,19 @@ export const createCdpBridge = (): CdpBridge => {
174
163
  error: { code: msg.error.code ?? -1, message: msg.error.message },
175
164
  });
176
165
  } else {
177
- // Track session ownership for attach responses
178
166
  if (cb.isAttach && msg.result) {
179
- const result = msg.result as { sessionId?: string };
180
- if (result.sessionId) {
181
- router.record(cb.clientId, result.sessionId);
167
+ const sessionId = isRecord(msg.result) ? asString(msg.result["sessionId"]) : undefined;
168
+ if (sessionId) {
169
+ router.record(cb.clientId, sessionId);
182
170
  }
183
171
  }
184
172
  cb.send(cb.clientId, { type: "response", id: localId, result: msg.result });
185
173
  }
186
-
187
- mux.resolve(msg.id);
188
174
  return;
189
175
  }
190
176
 
191
- // ---- Event (no id, has method) ----
192
177
  if (!msg.method) return;
193
178
 
194
- // Inspector.detached — release session ownership
195
179
  if (msg.method === "Inspector.detached" && msg.sessionId) {
196
180
  router.release(msg.sessionId);
197
181
  }
@@ -203,7 +187,6 @@ export const createCdpBridge = (): CdpBridge => {
203
187
  ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
204
188
  };
205
189
 
206
- // Route through the event handler for the IpcServer to broadcast
207
190
  if (eventHandler) {
208
191
  const targets = router.route(wireEvent);
209
192
  if (targets.length > 0) {
@@ -212,35 +195,26 @@ export const createCdpBridge = (): CdpBridge => {
212
195
  }
213
196
  };
214
197
 
215
- // ── Connect to Chrome ───────────────────────────────────────────────────
216
-
217
- // ── Auto-reconnecting Chrome connection ───────────────────────────────
218
-
219
198
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
220
199
  let reconnectAttempt = 0;
221
200
  let stopped = false;
222
201
 
223
- /**
224
- * Try to discover Chrome and open a WebSocket. On failure, schedule
225
- * an exponential backoff retry (capped at 60s). When Chrome finally
226
- * becomes available, the bridge connects automatically — no manual
227
- * daemon restart needed.
228
- */
229
202
  const tryConnect = (): void => {
230
203
  if (stopped) return;
231
204
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
232
205
 
233
206
  const attempt = async (): Promise<void> => {
234
- // Already connected — nothing to do.
235
207
  if (ws && ws.readyState === WebSocket.OPEN) return;
236
208
 
237
- // Resolve URL (discover if not cached, or clear cache on retry)
238
- let url = wsUrl;
239
- if (!url || reconnectAttempt > 0) {
209
+ const cachedUrl = wsUrl;
210
+ let url: string;
211
+ if (cachedUrl === null || cachedUrl === "" || reconnectAttempt > 0) {
240
212
  const d = await discoverWsUrl();
241
213
  if (!d.success) { scheduleRetry(); return; }
242
214
  url = d.data;
243
215
  wsUrl = url;
216
+ } else {
217
+ url = cachedUrl;
244
218
  }
245
219
 
246
220
  const settledPromise = new Promise<void>((settle) => {
@@ -253,7 +227,7 @@ export const createCdpBridge = (): CdpBridge => {
253
227
 
254
228
  let sock: WebSocket;
255
229
  try {
256
- sock = new WebSocket(url!, { perMessageDeflate: false });
230
+ sock = new WebSocket(url, { perMessageDeflate: false });
257
231
  } catch {
258
232
  scheduleRetry();
259
233
  return;
@@ -268,9 +242,9 @@ export const createCdpBridge = (): CdpBridge => {
268
242
  clearTimeout(timer);
269
243
  ws = sock;
270
244
  reconnectAttempt = 0;
271
- // Enable target discovery so we see page events
272
245
  ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
273
246
  console.log("[pi-browser-daemon] Connected to Chrome ✓");
247
+ signalConnected();
274
248
  settleOnce();
275
249
  });
276
250
 
@@ -286,25 +260,19 @@ export const createCdpBridge = (): CdpBridge => {
286
260
  sock.on("close", () => {
287
261
  clearTimeout(timer);
288
262
  ws = null;
289
- // Reject all pending callbacks
290
- for (const [daemonId, cb] of callbacks) {
291
- clearTimeout(cb.timer);
263
+ for (const cb of mux.takeAll()) {
292
264
  cb.send(cb.clientId, {
293
265
  type: "response",
294
266
  id: cb.localId,
295
267
  error: { code: -32000, message: "Chrome disconnected" },
296
268
  });
297
- callbacks.delete(daemonId);
298
- mux.resolve(daemonId);
299
269
  }
300
270
  closeHandler?.();
301
- // Auto-reconnect on unexpected close
302
271
  scheduleRetry();
303
272
  });
304
273
  });
305
274
 
306
275
  await settledPromise;
307
- // If connection didn't succeed, schedule a retry
308
276
  if (!ws || ws.readyState !== WebSocket.OPEN) {
309
277
  scheduleRetry();
310
278
  }
@@ -313,28 +281,24 @@ export const createCdpBridge = (): CdpBridge => {
313
281
  attempt().catch(() => scheduleRetry());
314
282
  };
315
283
 
316
- /** Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped). */
317
284
  const scheduleRetry = (): void => {
318
285
  if (stopped) return;
319
286
  const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000);
320
287
  reconnectAttempt++;
321
- wsUrl = null; // force re-discovery on next attempt
288
+ wsUrl = null;
322
289
  reconnectTimer = setTimeout(tryConnect, delay);
323
290
  };
324
291
 
325
292
  const start = async (): Promise<void> => {
326
293
  stopped = false;
327
- // Don't await — the retry loop runs in the background.
328
- // The daemon is "started" when the IPC server is listening, not when
329
- // Chrome is connected.
330
294
  tryConnect();
331
295
  };
332
296
 
333
297
  const stop = async (): Promise<void> => {
334
298
  stopped = true;
335
299
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
336
- for (const [, cb] of callbacks) clearTimeout(cb.timer);
337
- callbacks.clear();
300
+ mux.takeAll();
301
+ signalConnected();
338
302
  if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; }
339
303
  wsUrl = null;
340
304
  };
@@ -344,15 +308,8 @@ export const createCdpBridge = (): CdpBridge => {
344
308
  clientId: string,
345
309
  send: SendToClient,
346
310
  ): Promise<void> => {
347
- // ponytail: wait for Chrome to connect (user may still be clicking "Allow").
348
- // Bridge retries in background; this polls so the client doesn't fail first try.
349
- if (!ws || ws.readyState !== WebSocket.OPEN) {
350
- const deadline = Date.now() + 15_000;
351
- while (Date.now() < deadline) {
352
- await new Promise((r) => setTimeout(r, 250));
353
- if (ws && ws.readyState === WebSocket.OPEN) break;
354
- }
355
- }
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);
356
313
 
357
314
  if (!ws || ws.readyState !== WebSocket.OPEN) {
358
315
  send(clientId, {
@@ -363,27 +320,21 @@ export const createCdpBridge = (): CdpBridge => {
363
320
  return;
364
321
  }
365
322
 
366
- const daemonId = mux.allocate(clientId, req.id);
367
-
368
- // Store callback for the response
369
- const timer = setTimeout(() => {
370
- callbacks.delete(daemonId);
371
- mux.resolve(daemonId);
372
- send(clientId, {
373
- type: "response",
374
- id: req.id,
375
- error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` },
376
- });
377
- }, CDP_COMMAND_TIMEOUT_MS);
378
-
379
- const isAttach = req.method === "Target.attachToTarget";
380
323
  const isDetach = req.method === "Target.detachFromTarget";
381
-
382
- if (isDetach && req.sessionId) {
383
- router.release(req.sessionId);
384
- }
385
-
386
- callbacks.set(daemonId, { send, clientId, localId: req.id, timer, isAttach });
324
+ if (isDetach && req.sessionId) router.release(req.sessionId);
325
+
326
+ const daemonId = mux.allocate(
327
+ { clientId, localId: req.id, send, isAttach: req.method === "Target.attachToTarget" },
328
+ (id) =>
329
+ setTimeout(() => {
330
+ 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
+ });
336
+ }, CDP_COMMAND_TIMEOUT_MS),
337
+ );
387
338
 
388
339
  const payload: Record<string, unknown> = {
389
340
  id: daemonId,
@@ -395,9 +346,7 @@ export const createCdpBridge = (): CdpBridge => {
395
346
  try {
396
347
  ws.send(JSON.stringify(payload));
397
348
  } catch (e) {
398
- clearTimeout(timer);
399
- callbacks.delete(daemonId);
400
- mux.resolve(daemonId);
349
+ mux.take(daemonId);
401
350
  send(clientId, {
402
351
  type: "response",
403
352
  id: req.id,
@@ -411,8 +360,6 @@ export const createCdpBridge = (): CdpBridge => {
411
360
  stop,
412
361
  handleRequest,
413
362
  isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN,
414
- recordSession: (cid, sid) => router.record(cid, sid),
415
- releaseSession: (sid) => router.release(sid),
416
363
  getSessionOwner: (sid) => router.getOwner(sid),
417
364
  removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); },
418
365
  onEvent: (h) => { eventHandler = h; },
@@ -1,13 +1,3 @@
1
- /**
2
- * Browser daemon entry point — standalone long-lived process.
3
- *
4
- * Owns a single persistent WebSocket to Chrome. Exposes a Unix domain socket
5
- * for pi instances to connect through. One Allow dialog ever (per Chrome
6
- * lifetime), zero repeated prompts.
7
- *
8
- * Run: npx tsx src/daemon/index.ts
9
- */
10
-
11
1
  import { createIpcServer } from "./server";
12
2
  import { createCdpBridge, type SendToClient } from "./bridge";
13
3
  import { DAEMON_IDLE_TIMEOUT_MS } from "./protocol";
@@ -18,9 +8,6 @@ async function main() {
18
8
  const ipcServer = createIpcServer();
19
9
  const cdpBridge = createCdpBridge();
20
10
 
21
- // ── Wire IpcServer → CdpBridge ─────────────────────────────────────────
22
-
23
- // When a pi client sends a CDP request, proxy it through the bridge to Chrome.
24
11
  ipcServer.onMessage((msg, client) => {
25
12
  if (msg.type !== "request") return;
26
13
 
@@ -31,19 +18,16 @@ async function main() {
31
18
  cdpBridge.handleRequest(msg, client.id, send);
32
19
  });
33
20
 
34
- // When Chrome sends an event, route it to the correct pi client(s).
35
21
  cdpBridge.onEvent((event, targetClientIds) => {
36
22
  for (const cid of targetClientIds) {
37
23
  ipcServer.send(cid, event);
38
24
  }
39
25
  });
40
26
 
41
- // When a client disconnects, clean up its CDP state.
42
27
  ipcServer.onDisconnect((client) => {
43
28
  cdpBridge.removeClient(client.id);
44
29
  });
45
30
 
46
- // When Chrome disconnects, notify all clients.
47
31
  cdpBridge.onClose(() => {
48
32
  console.log("[pi-browser-daemon] Chrome disconnected");
49
33
  ipcServer.broadcast({
@@ -53,11 +37,6 @@ async function main() {
53
37
  });
54
38
  });
55
39
 
56
- // ── Idle timeout ───────────────────────────────────────────────────────
57
-
58
- // The daemon exits after DAEMON_IDLE_TIMEOUT_MS with zero connected clients.
59
- // This prevents zombie processes when pi sessions end and never reconnect.
60
- // The timeout resets whenever a client connects.
61
40
  let idleTimer: ReturnType<typeof setTimeout> | null = null;
62
41
 
63
42
  const resetIdleTimer = (): void => {
@@ -78,8 +57,6 @@ async function main() {
78
57
  ipcServer.onConnect(() => { cancelIdleTimer(); });
79
58
  ipcServer.onDisconnect(() => { resetIdleTimer(); });
80
59
 
81
- // ── Start ──────────────────────────────────────────────────────────────
82
-
83
60
  try {
84
61
  await ipcServer.start();
85
62
  console.log("[pi-browser-daemon] IPC server listening");
@@ -88,15 +65,10 @@ async function main() {
88
65
  process.exit(1);
89
66
  }
90
67
 
91
- // Connect to Chrome (auto-retries with backoff in the background).
92
- // The daemon is ready immediately — Chrome may connect later.
93
68
  await cdpBridge.start();
94
69
 
95
- // Start the idle timer (no clients yet at startup).
96
70
  resetIdleTimer();
97
71
 
98
- // ── Graceful shutdown ──────────────────────────────────────────────────
99
-
100
72
  const shutdown = async () => {
101
73
  console.log("[pi-browser-daemon] Shutting down...");
102
74
  await cdpBridge.stop();