pi-browser-harness 0.12.0 → 0.14.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 (102) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +132 -0
  3. package/package.json +15 -7
  4. package/skills/pi-browser-harness/SKILL.md +89 -2
  5. package/src/cdp/commands.ts +205 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +9 -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 +358 -216
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +127 -54
  16. package/src/daemon/bridge.ts +110 -349
  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 +21 -22
  25. package/src/daemon/transport.ts +80 -49
  26. package/src/domains/ax-tree.ts +27 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -2
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -64
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +13 -9
  34. package/src/domains/form-batch.ts +13 -10
  35. package/src/domains/form.ts +78 -107
  36. package/src/domains/headers.ts +2 -2
  37. package/src/domains/history.ts +4 -4
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +67 -46
  41. package/src/domains/keyboard.ts +14 -11
  42. package/src/domains/navigate.ts +102 -66
  43. package/src/domains/network.ts +55 -95
  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/recipe.page.ts +82 -0
  51. package/src/domains/page/serp.page.ts +42 -0
  52. package/src/domains/page.ts +18 -51
  53. package/src/domains/poll.ts +42 -0
  54. package/src/domains/readpage/read-page.ts +48 -53
  55. package/src/domains/ref-resolve.ts +43 -29
  56. package/src/domains/ref-signature.ts +11 -0
  57. package/src/domains/render.ts +12 -23
  58. package/src/domains/screenshot-capture.ts +4 -2
  59. package/src/domains/screenshot.ts +13 -10
  60. package/src/domains/scroll.ts +4 -4
  61. package/src/domains/search/google-serp.ts +78 -8
  62. package/src/domains/search/redirect-resolver.ts +41 -0
  63. package/src/domains/search/web-search.ts +103 -40
  64. package/src/domains/snapshot.ts +81 -78
  65. package/src/domains/tab-resolve.ts +36 -0
  66. package/src/domains/tabs.ts +18 -69
  67. package/src/domains/target.ts +1 -1
  68. package/src/index.ts +101 -33
  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 +34 -8
  76. package/src/recipes/command.ts +96 -0
  77. package/src/recipes/compile.ts +198 -0
  78. package/src/recipes/find.ts +49 -0
  79. package/src/recipes/journal.ts +125 -0
  80. package/src/recipes/recipe.ts +215 -0
  81. package/src/recipes/recordable.ts +359 -0
  82. package/src/recipes/replay.ts +354 -0
  83. package/src/recipes/run-tool.ts +132 -0
  84. package/src/recipes/save-tool.ts +247 -0
  85. package/src/recipes/store.ts +103 -0
  86. package/src/recipes/trust.ts +172 -0
  87. package/src/registry.ts +69 -4
  88. package/src/schemas/parse.ts +2 -2
  89. package/src/setup.ts +28 -19
  90. package/src/state.ts +2 -1
  91. package/src/util/debug.ts +7 -1
  92. package/src/util/guards.ts +3 -0
  93. package/src/util/js-source.ts +28 -0
  94. package/src/util/mutex.ts +1 -1
  95. package/src/util/paths.ts +3 -2
  96. package/src/util/result.ts +8 -0
  97. package/src/util/sharp-shim.ts +18 -11
  98. package/src/util/sleep.ts +18 -0
  99. package/src/util/tool.ts +185 -114
  100. package/src/util/truncate.ts +1 -1
  101. package/tsconfig.json +2 -1
  102. package/src/domains/fill-engine.ts +0 -80
@@ -0,0 +1,323 @@
1
+ import WebSocket from "ws";
2
+ import { discoverWsUrl } from "../cdp/discovery";
3
+ import {
4
+ CDP_ATTEMPT_LIFETIME_MS,
5
+ CDP_CONNECT_TIMEOUT_MS,
6
+ CDP_RECONNECT_COOLDOWN_MAX_MS,
7
+ CDP_RECONNECT_COOLDOWN_MS,
8
+ } from "./protocol";
9
+ import type { CdpError } from "../cdp/errors";
10
+ import { type Result, err, ok } from "../util/result";
11
+
12
+ // Why an attempt ended. Only "dropped" means clients were relying on a live Chrome connection
13
+ // that went away unannounced; "failed" never got that far, "stopped" is the daemon's own doing,
14
+ // and "superseded" is a closing socket that a newer attempt had already replaced.
15
+ export type ConnectionEndReason = "failed" | "dropped" | "stopped" | "superseded";
16
+
17
+ export type ConnectionEvents = {
18
+ readonly onMessage: (raw: string) => void;
19
+ // Fires exactly once per attempt. `generation` identifies the attempt so the caller can
20
+ // fail only the requests it sent on that socket.
21
+ readonly onEnd: (generation: number, reason: ConnectionEndReason) => void;
22
+ };
23
+
24
+ export type ChromeConnectionDependencies = {
25
+ readonly discoverWsUrl?: () => Promise<Result<string, CdpError>>;
26
+ readonly createWebSocket?: (url: string) => ChromeSocket;
27
+ // How long connect() waits before answering false while the dial carries on.
28
+ readonly connectTimeoutMs?: number;
29
+ // How long a dial stays open before it is abandoned as failed.
30
+ readonly attemptLifetimeMs?: number;
31
+ readonly cooldownMs?: number;
32
+ readonly cooldownMaxMs?: number;
33
+ readonly onAttemptSettled?: () => void;
34
+ readonly now?: () => number;
35
+ };
36
+
37
+ // What a caller that could not get a connection is looking at.
38
+ export type ConnectionStatus =
39
+ | { readonly state: "open" }
40
+ // A dial is in progress; `pendingForMs` says for how long. No new prompt will be opened.
41
+ | { readonly state: "connecting"; readonly pendingForMs: number }
42
+ // The last dial failed; automatic re-dials resume after `retryInMs`.
43
+ | { readonly state: "cooldown"; readonly retryInMs: number; readonly lastFailure: string }
44
+ | { readonly state: "disconnected"; readonly lastFailure: string | null }
45
+ | { readonly state: "stopped" };
46
+
47
+ export type ChromeConnection = {
48
+ // Clears a previous stop() and dials Chrome. Resolves true once the socket is open.
49
+ readonly start: () => Promise<boolean>;
50
+ // Dials Chrome unless already open, stopped or cooling down. Concurrent callers share the
51
+ // attempt in flight. Resolves true when open, or false once the attempt fails or the caller
52
+ // wait passes; in the latter case the dial itself keeps going.
53
+ readonly connect: () => Promise<boolean>;
54
+ // Explicit human demand: clears the cooldown and backoff, then connect().
55
+ readonly reconnectNow: () => Promise<boolean>;
56
+ readonly status: () => ConnectionStatus;
57
+ readonly isOpen: () => boolean;
58
+ // The generation of the current attempt or open socket; tag requests with it.
59
+ readonly generation: () => number;
60
+ readonly send: (payload: string) => Result<void, string>;
61
+ readonly stop: () => void;
62
+ };
63
+
64
+ type Attempt = {
65
+ readonly generation: number;
66
+ readonly startedAt: number;
67
+ readonly settledPromise: Promise<boolean>;
68
+ socket: ChromeSocket | null;
69
+ opened: boolean;
70
+ ended: boolean;
71
+ superseded: boolean;
72
+ failure: string | null;
73
+ readonly settle: (opened: boolean) => void;
74
+ readonly end: (reason: ConnectionEndReason) => void;
75
+ };
76
+
77
+ // The slice of ws's WebSocket the connection drives. Expressed structurally so a test can hand in
78
+ // an EventEmitter-based fake without claiming to be the whole ws class.
79
+ export type ChromeSocket = {
80
+ readonly readyState: number;
81
+ on(event: "open" | "close", listener: () => void): unknown;
82
+ on(event: "message", listener: (data: WebSocket.Data) => void): unknown;
83
+ on(event: "error", listener: (error: Error) => void): unknown;
84
+ send(data: string): void;
85
+ close(): void;
86
+ };
87
+
88
+ // ws delivers frames as a string, a Buffer, an ArrayBuffer or a list of Buffers.
89
+ const wsDataToString = (data: WebSocket.Data): string => {
90
+ if (typeof data === "string") return data;
91
+ if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
92
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
93
+ return data.toString("utf8");
94
+ };
95
+
96
+ const closeSocket = (socket: ChromeSocket): void => {
97
+ if (socket.readyState === WebSocket.CLOSED) return;
98
+ try {
99
+ socket.close();
100
+ } catch {
101
+ // Closing an already-closing socket throws; the socket is gone either way.
102
+ }
103
+ };
104
+
105
+ // Resolves with the attempt's outcome, or false once `waitMs` passes while it is still dialing.
106
+ const waitAtMost = (settled: Promise<boolean>, waitMs: number): Promise<boolean> => {
107
+ let timer: ReturnType<typeof setTimeout> | null = null;
108
+ const gaveUp = new Promise<boolean>((resolve) => {
109
+ timer = setTimeout(() => { resolve(false); }, waitMs);
110
+ });
111
+ return Promise.race([settled, gaveUp]).finally(() => {
112
+ if (timer) clearTimeout(timer);
113
+ });
114
+ };
115
+
116
+ // On-demand Chrome connection. Attempts happen only from start(), reconnectNow() and from
117
+ // connect() while disconnected; a failed or dropped connection stays disconnected until the
118
+ // next explicit browser demand. No background retry loop.
119
+ //
120
+ // One dial is one "Allow remote debugging" prompt in Chrome, so the design keeps prompts rare:
121
+ // a dial lives for attemptLifetimeMs however long callers are willing to wait, every caller
122
+ // joins the dial in flight instead of opening another, and after an outright failure no
123
+ // automatic dial happens until a doubling cooldown passes. A dropped connection (Chrome closed,
124
+ // the machine slept) carries no cooldown: the first re-dial after that is legitimate.
125
+ export const createChromeConnection = (
126
+ deps: ChromeConnectionDependencies,
127
+ events: ConnectionEvents,
128
+ ): ChromeConnection => {
129
+ const {
130
+ discoverWsUrl: discover = discoverWsUrl,
131
+ createWebSocket = (url: string): ChromeSocket => new WebSocket(url, { perMessageDeflate: false }),
132
+ connectTimeoutMs = CDP_CONNECT_TIMEOUT_MS,
133
+ attemptLifetimeMs = CDP_ATTEMPT_LIFETIME_MS,
134
+ cooldownMs = CDP_RECONNECT_COOLDOWN_MS,
135
+ cooldownMaxMs = CDP_RECONNECT_COOLDOWN_MAX_MS,
136
+ onAttemptSettled,
137
+ now = Date.now,
138
+ } = deps;
139
+
140
+ let current: Attempt | null = null;
141
+ let stopped = false;
142
+ let lastGeneration = 0;
143
+ let cooldownUntil = 0;
144
+ let nextCooldownMs = cooldownMs;
145
+ let lastFailure: string | null = null;
146
+
147
+ const inCooldown = (): boolean => now() < cooldownUntil;
148
+
149
+ const recordFailure = (attempt: Attempt): void => {
150
+ lastFailure = attempt.failure ?? "connection attempt failed";
151
+ cooldownUntil = now() + nextCooldownMs;
152
+ nextCooldownMs = Math.min(nextCooldownMs * 2, cooldownMaxMs);
153
+ };
154
+
155
+ const recordSuccess = (): void => {
156
+ lastFailure = null;
157
+ cooldownUntil = 0;
158
+ nextCooldownMs = cooldownMs;
159
+ };
160
+
161
+ const startAttempt = (): Attempt => {
162
+ const generation = ++lastGeneration;
163
+ let resolveSettled: (opened: boolean) => void = () => undefined;
164
+ const settledPromise = new Promise<boolean>((resolve) => { resolveSettled = resolve; });
165
+ let settled = false;
166
+ // The whole dial, discovery included, is bounded by one lifetime timer.
167
+ let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
168
+ attempt.failure ??= `no answer from Chrome within ${Math.round(attemptLifetimeMs / 1000)}s (was its "Allow remote debugging" prompt answered?)`;
169
+ attempt.end("failed");
170
+ }, attemptLifetimeMs);
171
+
172
+ const attempt: Attempt = {
173
+ generation,
174
+ startedAt: now(),
175
+ settledPromise,
176
+ socket: null,
177
+ opened: false,
178
+ ended: false,
179
+ superseded: false,
180
+ failure: null,
181
+ settle: (opened) => {
182
+ if (settled) return;
183
+ settled = true;
184
+ if (timer) { clearTimeout(timer); timer = null; }
185
+ resolveSettled(opened);
186
+ onAttemptSettled?.();
187
+ },
188
+ end: (reason) => {
189
+ if (attempt.ended) return;
190
+ attempt.ended = true;
191
+ if (current === attempt) current = null;
192
+ if (attempt.socket) closeSocket(attempt.socket);
193
+ if (reason === "failed") recordFailure(attempt);
194
+ attempt.settle(false);
195
+ events.onEnd(generation, reason);
196
+ },
197
+ };
198
+ return attempt;
199
+ };
200
+
201
+ const run = async (attempt: Attempt): Promise<void> => {
202
+ const d = await discover();
203
+ if (attempt.ended) return;
204
+ if (!d.success) {
205
+ attempt.failure = d.error.message;
206
+ attempt.end("failed");
207
+ return;
208
+ }
209
+
210
+ let socket: ChromeSocket;
211
+ try {
212
+ socket = createWebSocket(d.data);
213
+ } catch (e) {
214
+ attempt.failure = e instanceof Error ? e.message : String(e);
215
+ attempt.end("failed");
216
+ return;
217
+ }
218
+ attempt.socket = socket;
219
+
220
+ const endFromSocket = (): void => {
221
+ attempt.end(!attempt.opened ? "failed" : attempt.superseded ? "superseded" : "dropped");
222
+ };
223
+
224
+ socket.on("open", () => {
225
+ if (attempt.ended) { closeSocket(socket); return; }
226
+ try {
227
+ socket.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
228
+ } catch (e) {
229
+ attempt.failure = e instanceof Error ? e.message : String(e);
230
+ attempt.end("failed");
231
+ return;
232
+ }
233
+ attempt.opened = true;
234
+ recordSuccess();
235
+ console.log("[pi-browser-daemon] Connected to Chrome ✓");
236
+ attempt.settle(true);
237
+ });
238
+ socket.on("message", (data: WebSocket.Data) => {
239
+ if (attempt.ended || !attempt.opened) return;
240
+ events.onMessage(wsDataToString(data));
241
+ });
242
+ socket.on("error", (e: Error) => {
243
+ attempt.failure ??= e.message;
244
+ endFromSocket();
245
+ });
246
+ socket.on("close", () => {
247
+ attempt.failure ??= "Chrome closed the connection before it opened (was its \"Allow remote debugging\" prompt denied?)";
248
+ endFromSocket();
249
+ });
250
+ };
251
+
252
+ const isOpen = (): boolean =>
253
+ current !== null && current.opened && current.socket?.readyState === WebSocket.OPEN;
254
+
255
+ const connect = (): Promise<boolean> => {
256
+ if (stopped) return Promise.resolve(false);
257
+ if (current) {
258
+ // A dial in flight is shared: joining it is what keeps Chrome's prompt count at one.
259
+ if (!current.opened) return waitAtMost(current.settledPromise, connectTimeoutMs);
260
+ if (isOpen()) return Promise.resolve(true);
261
+ // Opened once but closing now: Chrome is going away and the close event has not arrived
262
+ // yet. Start a fresh attempt at once; the old socket's close still fails its own requests.
263
+ current.superseded = true;
264
+ current = null;
265
+ }
266
+ if (inCooldown()) return Promise.resolve(false);
267
+
268
+ const attempt = startAttempt();
269
+ current = attempt;
270
+ run(attempt).catch((e: unknown) => {
271
+ attempt.failure ??= e instanceof Error ? e.message : String(e);
272
+ attempt.end("failed");
273
+ });
274
+ return waitAtMost(attempt.settledPromise, connectTimeoutMs);
275
+ };
276
+
277
+ const start = (): Promise<boolean> => {
278
+ stopped = false;
279
+ return connect();
280
+ };
281
+
282
+ const reconnectNow = (): Promise<boolean> => {
283
+ recordSuccess();
284
+ return start();
285
+ };
286
+
287
+ const stop = (): void => {
288
+ stopped = true;
289
+ const attempt = current;
290
+ current = null;
291
+ attempt?.end("stopped");
292
+ };
293
+
294
+ const status = (): ConnectionStatus => {
295
+ if (stopped) return { state: "stopped" };
296
+ if (isOpen()) return { state: "open" };
297
+ if (current && !current.opened) return { state: "connecting", pendingForMs: now() - current.startedAt };
298
+ if (inCooldown() && lastFailure !== null) return { state: "cooldown", retryInMs: cooldownUntil - now(), lastFailure };
299
+ return { state: "disconnected", lastFailure };
300
+ };
301
+
302
+ const send = (payload: string): Result<void, string> => {
303
+ const socket = current?.socket ?? null;
304
+ if (!socket || !isOpen()) return err("Chrome not connected");
305
+ try {
306
+ socket.send(payload);
307
+ return ok(undefined);
308
+ } catch (e) {
309
+ return err(e instanceof Error ? e.message : String(e));
310
+ }
311
+ };
312
+
313
+ return {
314
+ start,
315
+ connect,
316
+ reconnectNow,
317
+ status,
318
+ isOpen,
319
+ generation: () => current?.generation ?? 0,
320
+ send,
321
+ stop,
322
+ };
323
+ };
@@ -1,21 +1,77 @@
1
- import { createIpcServer } from "./server";
2
- import { createCdpBridge, type SendToClient } from "./bridge";
1
+ import { createIpcServer, type IpcServer } from "./server";
2
+ import { createCdpBridge, type CdpBridge, type SendToClient } from "./bridge";
3
3
  import { DAEMON_IDLE_TIMEOUT_MS } from "./protocol";
4
4
 
5
- async function main() {
6
- console.log("[pi-browser-daemon] Starting...");
5
+ export type DaemonDeps = {
6
+ readonly ipcServer?: IpcServer;
7
+ readonly cdpBridge?: CdpBridge;
8
+ readonly idleTimeoutMs?: number;
9
+ // Ends the process once shutdown has released the socket and the Chrome connection.
10
+ readonly exit?: (code: number) => void;
11
+ // One line per lifecycle event; defaults to the process's stdout.
12
+ readonly log?: (line: string) => void;
13
+ };
14
+
15
+ export type Daemon = {
16
+ // Listens on the IPC socket, connects to Chrome and arms the idle timer. Resolves once both are up.
17
+ readonly start: () => Promise<void>;
18
+ // Releases Chrome and the socket, then calls `exit(0)`. Idempotent.
19
+ readonly stop: () => Promise<void>;
20
+ readonly idleTimerArmed: () => boolean;
21
+ };
22
+
23
+ export const LOG_PREFIX = "[pi-browser-daemon]";
24
+
25
+ export const describeError = (e: unknown): string => (e instanceof Error ? (e.stack ?? e.message) : String(e));
26
+
27
+ // The composition the daemon process runs: request routing, event fan-out, client removal, the
28
+ // idle timer and shutdown. Every collaborator is injectable so the wiring can be driven in-process.
29
+ // The process entry that starts it lives in ./main.ts, so importing this module never launches a daemon.
30
+ export const createDaemon = (deps: DaemonDeps = {}): Daemon => {
31
+ const ipcServer = deps.ipcServer ?? createIpcServer();
32
+ const cdpBridge = deps.cdpBridge ?? createCdpBridge();
33
+ const idleTimeoutMs = deps.idleTimeoutMs ?? DAEMON_IDLE_TIMEOUT_MS;
34
+ const exit = deps.exit ?? ((code: number): void => { process.exit(code); });
35
+ const log = deps.log ?? ((line: string): void => { console.log(`${LOG_PREFIX} ${line}`); });
7
36
 
8
- const ipcServer = createIpcServer();
9
- const cdpBridge = createCdpBridge();
37
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
38
+ let stopping: Promise<void> | null = null;
39
+
40
+ const cancelIdleTimer = (): void => {
41
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
42
+ };
43
+
44
+ const stop = (): Promise<void> => {
45
+ stopping ??= (async () => {
46
+ log("Shutting down...");
47
+ cancelIdleTimer();
48
+ await cdpBridge.stop();
49
+ await ipcServer.stop();
50
+ exit(0);
51
+ })();
52
+ return stopping;
53
+ };
54
+
55
+ const resetIdleTimer = (): void => {
56
+ cancelIdleTimer();
57
+ if (ipcServer.clientCount() === 0) {
58
+ idleTimer = setTimeout(() => {
59
+ idleTimer = null;
60
+ log(`Idle timeout — no clients for ${idleTimeoutMs / 60000} minutes. Shutting down.`);
61
+ void stop();
62
+ }, idleTimeoutMs);
63
+ }
64
+ };
10
65
 
11
66
  ipcServer.onMessage((msg, client) => {
67
+ // Control messages (register/deregister) are consumed by the server itself.
12
68
  if (msg.type !== "request") return;
13
69
 
14
70
  const send: SendToClient = (cid, resp) => {
15
71
  ipcServer.send(cid, resp);
16
72
  };
17
73
 
18
- cdpBridge.handleRequest(msg, client.id, send);
74
+ void cdpBridge.handleRequest(msg, client.id, send);
19
75
  });
20
76
 
21
77
  cdpBridge.onEvent((event, targetClientIds) => {
@@ -29,7 +85,7 @@ async function main() {
29
85
  });
30
86
 
31
87
  cdpBridge.onClose(() => {
32
- console.log("[pi-browser-daemon] Chrome disconnected");
88
+ log("Chrome disconnected");
33
89
  ipcServer.broadcast({
34
90
  type: "control",
35
91
  action: "shutdown",
@@ -37,50 +93,23 @@ async function main() {
37
93
  });
38
94
  });
39
95
 
40
- let idleTimer: ReturnType<typeof setTimeout> | null = null;
41
-
42
- const resetIdleTimer = (): void => {
43
- if (idleTimer) clearTimeout(idleTimer);
44
- if (ipcServer.clientCount() === 0) {
45
- idleTimer = setTimeout(() => {
46
- console.log("[pi-browser-daemon] Idle timeout — no clients for " +
47
- `${DAEMON_IDLE_TIMEOUT_MS / 60000} minutes. Shutting down.`);
48
- shutdown();
49
- }, DAEMON_IDLE_TIMEOUT_MS);
50
- }
51
- };
52
-
53
- const cancelIdleTimer = (): void => {
54
- if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
55
- };
56
-
57
96
  ipcServer.onConnect(() => { cancelIdleTimer(); });
58
97
  ipcServer.onDisconnect(() => { resetIdleTimer(); });
59
98
 
60
- try {
61
- await ipcServer.start();
62
- console.log("[pi-browser-daemon] IPC server listening");
63
- } catch (e) {
64
- console.error("[pi-browser-daemon] Failed to start IPC server:", e);
65
- process.exit(1);
66
- }
67
-
68
- await cdpBridge.start();
99
+ const start = async (): Promise<void> => {
100
+ try {
101
+ await ipcServer.start();
102
+ log("IPC server listening");
103
+ } catch (e) {
104
+ log(`Failed to start IPC server: ${describeError(e)}`);
105
+ exit(1);
106
+ return;
107
+ }
69
108
 
70
- resetIdleTimer();
109
+ await cdpBridge.start();
71
110
 
72
- const shutdown = async () => {
73
- console.log("[pi-browser-daemon] Shutting down...");
74
- await cdpBridge.stop();
75
- await ipcServer.stop();
76
- process.exit(0);
111
+ resetIdleTimer();
77
112
  };
78
113
 
79
- process.on("SIGINT", shutdown);
80
- process.on("SIGTERM", shutdown);
81
- }
82
-
83
- main().catch((e) => {
84
- console.error("[pi-browser-daemon] Fatal:", e);
85
- process.exit(1);
86
- });
114
+ return { start, stop, idleTimerArmed: () => idleTimer !== null };
115
+ };
@@ -0,0 +1,40 @@
1
+ import type { Socket } from "node:net";
2
+
3
+ // Newline-framed reader for the wire protocol. node:readline was used before, but it re-emits the
4
+ // input socket's errors on its own Interface, and an Interface without an "error" listener turns an
5
+ // ECONNRESET into an uncaught exception (it took pi down when the daemon was killed mid-probe).
6
+ // This splitter never emits anything but lines; socket errors stay on the socket, where every
7
+ // caller already handles them. A trailing partial line is delivered when the socket ends.
8
+ export const readLines = (socket: Socket, onLine: (line: string) => void): (() => void) => {
9
+ // Held in an object: a plain `let` closed over here is narrowed to `true` by the compiler.
10
+ const state = { rest: "", active: true };
11
+ // Read through a call: `onLine` may dispose the reader mid-loop, which flow narrowing cannot see.
12
+ const stopped = (): boolean => !state.active;
13
+
14
+ const onData = (chunk: Buffer | string): void => {
15
+ if (stopped()) return;
16
+ state.rest += typeof chunk === "string" ? chunk : chunk.toString("utf8");
17
+ let nl = state.rest.indexOf("\n");
18
+ while (nl !== -1 && !stopped()) {
19
+ const line = state.rest.slice(0, nl);
20
+ state.rest = state.rest.slice(nl + 1);
21
+ onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
22
+ nl = state.rest.indexOf("\n");
23
+ }
24
+ };
25
+ const onEnd = (): void => {
26
+ if (stopped() || state.rest === "") return;
27
+ const line = state.rest;
28
+ state.rest = "";
29
+ onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
30
+ };
31
+
32
+ socket.setEncoding("utf8");
33
+ socket.on("data", onData);
34
+ socket.on("end", onEnd);
35
+ return () => {
36
+ state.active = false;
37
+ socket.off("data", onData);
38
+ socket.off("end", onEnd);
39
+ };
40
+ };
@@ -0,0 +1,12 @@
1
+ // The daemon process entry. spawn.ts launches this file under tsx; it starts the daemon
2
+ // unconditionally, so nothing but the spawned process should import it.
3
+ import { createDaemon, describeError, LOG_PREFIX } from "./index";
4
+
5
+ console.log(`${LOG_PREFIX} Starting...`);
6
+ const daemon = createDaemon();
7
+ process.on("SIGINT", () => { void daemon.stop(); });
8
+ process.on("SIGTERM", () => { void daemon.stop(); });
9
+ daemon.start().catch((e: unknown) => {
10
+ console.error(`${LOG_PREFIX} Fatal: ${describeError(e)}`);
11
+ process.exit(1);
12
+ });
@@ -26,15 +26,41 @@ export type WireEvent = {
26
26
  readonly sessionId?: string;
27
27
  };
28
28
 
29
- export type WireControl = {
30
- readonly type: "control";
31
- readonly action: "register" | "registered" | "deregister" | "shutdown";
32
- readonly clientId?: string;
33
- readonly reason?: string;
34
- };
29
+ export const CONTROL_ACTIONS = ["register", "registered", "deregister", "shutdown"] as const;
30
+
31
+ export type ControlAction = (typeof CONTROL_ACTIONS)[number];
32
+
33
+ // Each action carries exactly the fields it needs, so a handler that has matched on `action`
34
+ // never has to re-check what the guard already proved.
35
+ export type WireRegister = { readonly type: "control"; readonly action: "register"; readonly clientId: string };
36
+ export type WireRegistered = { readonly type: "control"; readonly action: "registered"; readonly clientId: string };
37
+ export type WireDeregister = { readonly type: "control"; readonly action: "deregister"; readonly clientId: string };
38
+ export type WireShutdown = { readonly type: "control"; readonly action: "shutdown"; readonly reason?: string };
39
+
40
+ export type WireControl = WireRegister | WireRegistered | WireDeregister | WireShutdown;
35
41
 
36
42
  export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl;
37
43
 
44
+ const isControlAction = (v: unknown): v is ControlAction =>
45
+ typeof v === "string" && CONTROL_ACTIONS.some((a) => a === v);
46
+
47
+ const isOptionalString = (v: unknown): v is string | undefined => v === undefined || typeof v === "string";
48
+
49
+ const isOptionalRecord = (v: unknown): v is Record<string, unknown> | undefined => v === undefined || isRecord(v);
50
+
51
+ const isWireControl = (v: Record<string, unknown>): v is WireControl => {
52
+ const action = v["action"];
53
+ if (!isControlAction(action)) return false;
54
+ switch (action) {
55
+ case "register":
56
+ case "registered":
57
+ case "deregister":
58
+ return typeof v["clientId"] === "string";
59
+ case "shutdown":
60
+ return isOptionalString(v["reason"]);
61
+ }
62
+ };
63
+
38
64
  const isWireMessage = (v: unknown): v is WireMessage => {
39
65
  if (!isRecord(v)) return false;
40
66
  const t = v["type"];
@@ -44,9 +70,8 @@ const isWireMessage = (v: unknown): v is WireMessage => {
44
70
  case "request": {
45
71
  if (typeof v["id"] !== "number") return false;
46
72
  if (typeof v["method"] !== "string") return false;
47
- const sid = v["sessionId"];
48
- if (sid !== undefined && typeof sid !== "string") return false;
49
- return true;
73
+ if (!isOptionalRecord(v["params"])) return false;
74
+ return isOptionalString(v["sessionId"]);
50
75
  }
51
76
  case "response": {
52
77
  if (typeof v["id"] !== "number") return false;
@@ -55,28 +80,17 @@ const isWireMessage = (v: unknown): v is WireMessage => {
55
80
  if (!isRecord(errVal)) return false;
56
81
  if (typeof errVal["message"] !== "string") return false;
57
82
  if (typeof errVal["code"] !== "number") return false;
83
+ if (!isOptionalString(errVal["data"])) return false;
58
84
  }
59
85
  return true;
60
86
  }
61
87
  case "event": {
62
88
  if (typeof v["method"] !== "string") return false;
63
- const sid = v["sessionId"];
64
- if (sid !== undefined && typeof sid !== "string") return false;
65
- return true;
66
- }
67
- case "control": {
68
- const action = v["action"];
69
- if (typeof action !== "string") return false;
70
- if (!["register", "registered", "deregister", "shutdown"].includes(action)) return false;
71
- const cid = v["clientId"];
72
- if (
73
- (action === "register" || action === "registered" || action === "deregister") &&
74
- typeof cid !== "string"
75
- ) {
76
- return false;
77
- }
78
- return true;
89
+ if (!isOptionalRecord(v["params"])) return false;
90
+ return isOptionalString(v["sessionId"]);
79
91
  }
92
+ case "control":
93
+ return isWireControl(v);
80
94
  default:
81
95
  return false;
82
96
  }
@@ -105,6 +119,22 @@ export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
105
119
 
106
120
  export const DAEMON_MAX_CLIENTS = 16;
107
121
 
122
+ // How long a caller waits for a Chrome connection before being told "not connected". The dial
123
+ // itself keeps going (see CDP_ATTEMPT_LIFETIME_MS): a human answering Chrome's "Allow remote
124
+ // debugging" prompt takes longer than any tool call should block.
108
125
  export const CDP_CONNECT_TIMEOUT_MS = 10_000;
109
126
 
127
+ // How long one dial stays open waiting for Chrome. One dial is one "Allow" prompt, so the attempt
128
+ // must outlive a user who is away from the keyboard rather than be replaced by a fresh prompt.
129
+ export const CDP_ATTEMPT_LIFETIME_MS = 5 * 60 * 1000;
130
+
131
+ // After a dial fails outright (discovery failed, refused, denied, lifetime passed), no automatic
132
+ // re-dial happens until this cooldown passes; it doubles per consecutive failure up to the cap.
133
+ // An explicit Daemon.reconnect request (/browser-setup, /browser-reload-daemon) bypasses it.
134
+ export const CDP_RECONNECT_COOLDOWN_MS = 30_000;
135
+ export const CDP_RECONNECT_COOLDOWN_MAX_MS = 5 * 60 * 1000;
136
+
137
+ // The one request the daemon answers itself: clears the reconnect cooldown and dials Chrome now.
138
+ export const DAEMON_RECONNECT_METHOD = "Daemon.reconnect";
139
+
110
140
  export const CDP_COMMAND_TIMEOUT_MS = 10_000;