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,32 +1,15 @@
1
- /**
2
- * Unix socket daemon protocol — wire message types, type guards, and constants.
3
- *
4
- * The daemon speaks JSON-line protocol over a Unix domain socket.
5
- * One JSON object per newline. Four message types: request, response, event, control.
6
- *
7
- * References:
8
- * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals
9
- * - net.createServer IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
10
- * - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line
11
- */
1
+ import { isRecord } from "../util/guards";
12
2
 
13
- // ── Message types ──────────────────────────────────────────────────────────────
14
-
15
- /** A CDP command sent from a pi client to the daemon. */
16
3
  export type WireRequest = {
17
4
  readonly type: "request";
18
- /** Client-local CDP id. The daemon remaps this to a globally-unique id for Chrome. */
19
5
  readonly id: number;
20
6
  readonly method: string;
21
7
  readonly params?: Record<string, unknown>;
22
- /** Optional CDP session id. Omitted for browser-level commands. */
23
8
  readonly sessionId?: string;
24
9
  };
25
10
 
26
- /** A CDP response (result or error) sent from the daemon back to the pi client. */
27
11
  export type WireResponse = {
28
12
  readonly type: "response";
29
- /** Matches the id from the corresponding WireRequest. */
30
13
  readonly id: number;
31
14
  readonly result?: unknown;
32
15
  readonly error?: {
@@ -36,39 +19,24 @@ export type WireResponse = {
36
19
  };
37
20
  };
38
21
 
39
- /** A CDP event forwarded from Chrome through the daemon to pi client(s). */
40
22
  export type WireEvent = {
41
23
  readonly type: "event";
42
24
  readonly method: string;
43
25
  readonly params?: Record<string, unknown>;
44
- /** Session-scoped events carry this; browser-level events omit it. */
45
26
  readonly sessionId?: string;
46
27
  };
47
28
 
48
- /** Daemon lifecycle control messages. Bidirectional. */
49
29
  export type WireControl = {
50
30
  readonly type: "control";
51
31
  readonly action: "register" | "registered" | "deregister" | "shutdown";
52
- /** pi client namespace (required for register/registered/deregister). */
53
32
  readonly clientId?: string;
54
- /** Human-readable reason for shutdown. */
55
33
  readonly reason?: string;
56
34
  };
57
35
 
58
- /** All messages that may appear on the Unix socket. */
59
36
  export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl;
60
37
 
61
- // ── Type guard ─────────────────────────────────────────────────────────────────
62
-
63
- const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
64
- typeof v === "object" && v !== null && !Array.isArray(v);
65
-
66
- /**
67
- * Validates that an unknown parsed-JSON value is a well-formed WireMessage.
68
- * Does NOT validate the full CDP semantics — only structural shape.
69
- */
70
- export const isWireMessage = (v: unknown): v is WireMessage => {
71
- if (!isObject(v)) return false;
38
+ const isWireMessage = (v: unknown): v is WireMessage => {
39
+ if (!isRecord(v)) return false;
72
40
  const t = v["type"];
73
41
  if (typeof t !== "string") return false;
74
42
 
@@ -84,7 +52,7 @@ export const isWireMessage = (v: unknown): v is WireMessage => {
84
52
  if (typeof v["id"] !== "number") return false;
85
53
  const errVal = v["error"];
86
54
  if (errVal !== undefined) {
87
- if (!isObject(errVal)) return false;
55
+ if (!isRecord(errVal)) return false;
88
56
  if (typeof errVal["message"] !== "string") return false;
89
57
  if (typeof errVal["code"] !== "number") return false;
90
58
  }
@@ -114,12 +82,8 @@ export const isWireMessage = (v: unknown): v is WireMessage => {
114
82
  }
115
83
  };
116
84
 
117
- // ── Wire message constructors ──────────────────────────────────────────────────
118
-
119
- /** Serialize a WireMessage to a JSON line (no trailing newline — caller adds \\n). */
120
85
  export const serialize = (msg: WireMessage): string => JSON.stringify(msg);
121
86
 
122
- /** Deserialize a single JSON-line string to a WireMessage. Returns null on parse failure. */
123
87
  export const deserialize = (line: string): WireMessage | null => {
124
88
  let parsed: unknown;
125
89
  try {
@@ -131,42 +95,16 @@ export const deserialize = (line: string): WireMessage | null => {
131
95
  return parsed;
132
96
  };
133
97
 
134
- // ── Constants ──────────────────────────────────────────────────────────────────
135
-
136
- /**
137
- * IPC endpoint for the daemon.
138
- *
139
- * On POSIX platforms this is a Unix domain socket path. On Windows, Node's IPC
140
- * requires a named pipe of the form `\\.\pipe\<name>` — a Unix path like
141
- * `/tmp/…` is not a valid `net` listen/connect target there and causes setup
142
- * to fail. See https://nodejs.org/api/net.html#ipc-support.
143
- */
98
+ // Windows requires a named pipe (`\\.\pipe\<name>`); a Unix path is not a valid `net` listen/connect target there.
144
99
  export const DAEMON_SOCKET_PATH =
145
100
  process.platform === "win32"
146
101
  ? "\\\\.\\pipe\\pi-browser-daemon"
147
102
  : "/tmp/pi-browser-daemon.sock";
148
103
 
149
- /**
150
- * How long the daemon stays alive with zero connected clients before exiting
151
- * (milliseconds). After this period, the daemon cleans up and shuts down.
152
- */
153
- export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
104
+ export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
154
105
 
155
- /**
156
- * Hard cap on concurrent pi client connections. Extra connections are rejected
157
- * immediately to prevent resource exhaustion.
158
- */
159
106
  export const DAEMON_MAX_CLIENTS = 16;
160
107
 
161
- /**
162
- * If true, the daemon unlinks (deletes) any stale socket file from a previous
163
- * daemon crash before calling server.listen(). Without this, a crashed daemon
164
- * leaves a socket inode that blocks the next listen() call with EADDRINUSE.
165
- */
166
- export const DAEMON_STALE_SOCKET_CLEANUP = true;
167
-
168
- /** CDP WebSocket connection timeout (ms). */
169
108
  export const CDP_CONNECT_TIMEOUT_MS = 10_000;
170
109
 
171
- /** CDP command timeout when proxying through daemon (ms). */
172
110
  export const CDP_COMMAND_TIMEOUT_MS = 10_000;
@@ -0,0 +1,85 @@
1
+ import { type Result, err } from "../util/result";
2
+ import { type CdpError, cdpError } from "../cdp/errors";
3
+ import type { CdpEvent } from "../cdp/types";
4
+
5
+ export type EventQueue = {
6
+ readonly push: (e: CdpEvent) => void;
7
+ readonly end: () => void;
8
+ readonly iter: AsyncIterable<CdpEvent>;
9
+ };
10
+
11
+ export const makeEventQueue = (): EventQueue => {
12
+ const buf: CdpEvent[] = [];
13
+ const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = [];
14
+ let ended = false;
15
+ return {
16
+ push(e) {
17
+ if (ended) return;
18
+ const w = waiters.shift();
19
+ if (w) w({ value: e, done: false });
20
+ else buf.push(e);
21
+ },
22
+ end() {
23
+ ended = true;
24
+ for (const w of waiters.splice(0)) w({ value: undefined, done: true });
25
+ },
26
+ iter: {
27
+ [Symbol.asyncIterator](): AsyncIterator<CdpEvent, undefined, undefined> {
28
+ return {
29
+ next: (): Promise<IteratorResult<CdpEvent, undefined>> =>
30
+ new Promise((resolve) => {
31
+ const next = buf.shift();
32
+ if (next) resolve({ value: next, done: false });
33
+ else if (ended) resolve({ value: undefined, done: true });
34
+ else waiters.push(resolve);
35
+ }),
36
+ };
37
+ },
38
+ },
39
+ };
40
+ };
41
+
42
+ export type CdpResult = Result<unknown, CdpError>;
43
+
44
+ export type Pending = {
45
+ readonly resolve: (v: Result<unknown, CdpError>) => void;
46
+ readonly timer: ReturnType<typeof setTimeout>;
47
+ readonly method: string;
48
+ };
49
+
50
+ export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => {
51
+ for (const [, p] of pending) {
52
+ clearTimeout(p.timer);
53
+ p.resolve(err(cdpError("transport_closed", reason, p.method)));
54
+ }
55
+ pending.clear();
56
+ };
57
+
58
+ export const sendWithTimeout = (
59
+ pending: Map<number, Pending>,
60
+ id: number,
61
+ method: string,
62
+ timeoutMs: number,
63
+ timeoutLabel: string,
64
+ send: () => void,
65
+ ): Promise<CdpResult> =>
66
+ new Promise((resolve) => {
67
+ const timer = setTimeout(() => {
68
+ pending.delete(id);
69
+ resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method)));
70
+ }, timeoutMs);
71
+ pending.set(id, { resolve, timer, method });
72
+ try {
73
+ send();
74
+ } catch (e) {
75
+ clearTimeout(timer);
76
+ pending.delete(id);
77
+ resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
78
+ }
79
+ });
80
+
81
+ export const makeOnClose = (closeListeners: Set<() => void>) =>
82
+ (cb: () => void): (() => void) => {
83
+ closeListeners.add(cb);
84
+ return () => closeListeners.delete(cb);
85
+ };
@@ -1,76 +1,40 @@
1
- /**
2
- * IpcServer — Unix socket server for daemon ↔ pi client communication.
3
- *
4
- * Accepts multiple concurrent pi client connections over a Unix domain
5
- * stream socket. Uses newline-delimited JSON framing (one WireMessage per
6
- * line). Clients are anonymous until they send a control.register message.
7
- *
8
- * References:
9
- * - net.createServer: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateserveroptions-connectionlistener
10
- * - server.listen(path): https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
11
- * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions
12
- */
13
-
14
1
  import { createServer, type Server, type Socket } from "node:net";
15
2
  import { createInterface, type Interface } from "node:readline";
16
3
  import { unlinkSync } from "node:fs";
17
4
  import {
18
5
  type WireMessage,
19
6
  type WireControl,
20
- isWireMessage,
7
+ deserialize,
8
+ serialize,
21
9
  DAEMON_SOCKET_PATH,
22
10
  DAEMON_MAX_CLIENTS,
23
- DAEMON_STALE_SOCKET_CLEANUP,
24
11
  } from "./protocol";
25
12
 
26
- // ── Types ──────────────────────────────────────────────────────────────────────
27
-
28
- /** A connected pi client with its socket and readline interface. */
29
13
  export type ClientSocket = {
30
- /** Client namespace (set after registration). */
31
14
  id: string;
32
15
  readonly socket: Socket;
33
16
  readonly rl: Interface;
34
- /** True once a valid control.register message has been received. */
35
17
  registered: boolean;
36
18
  };
37
19
 
38
- /** Handler called for every valid WireMessage from any client. */
39
20
  export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void;
40
21
 
41
- /** Handler called when a client connects or disconnects. */
42
22
  export type ConnectionHandler = (client: ClientSocket) => void;
43
23
 
44
24
  export type IpcServer = {
45
- /** Start listening on the Unix socket. Resolves when the server is ready. */
46
25
  start(): Promise<void>;
47
- /** Close the server and all client connections. Resolves when cleaned up. */
48
26
  stop(): Promise<void>;
49
- /** Register a handler for incoming WireMessages from any client. */
50
27
  onMessage(handler: MessageHandler): void;
51
- /** Register a handler for client connect events. */
52
28
  onConnect(handler: ConnectionHandler): void;
53
- /** Register a handler for client disconnect events. */
54
29
  onDisconnect(handler: ConnectionHandler): void;
55
- /** Send a WireMessage to a specific client by clientId. No-op if client not found. */
56
30
  send(clientId: string, msg: WireMessage): void;
57
- /** Send a WireMessage to all connected (registered) clients. */
58
31
  broadcast(msg: WireMessage): void;
59
- /** Force-disconnect a client by clientId. */
60
- disconnectClient(clientId: string): void;
61
- /** All currently connected clients (by clientId). */
62
- clients(): ReadonlyMap<string, ClientSocket>;
63
- /** Number of connected clients. */
64
32
  clientCount(): number;
65
33
  };
66
34
 
67
- // ── Implementation ─────────────────────────────────────────────────────────────
68
-
69
35
  export const createIpcServer = (): IpcServer => {
70
36
  let server: Server | null = null;
71
- // Anonymous temp-id → ClientSocket. Temp ids are assigned before registration.
72
37
  const sockets = new Map<string, ClientSocket>();
73
- // clientId → ClientSocket (only registered clients).
74
38
  const clients = new Map<string, ClientSocket>();
75
39
  let messageHandler: MessageHandler | null = null;
76
40
  let connectHandler: ConnectionHandler | null = null;
@@ -79,17 +43,11 @@ export const createIpcServer = (): IpcServer => {
79
43
 
80
44
  const start = (): Promise<void> =>
81
45
  new Promise((resolve, reject) => {
82
- // Clean up stale socket file from a previous crashed daemon.
83
- if (DAEMON_STALE_SOCKET_CLEANUP) {
84
- try {
85
- unlinkSync(DAEMON_SOCKET_PATH);
86
- } catch {
87
- // ENOENT is expected — no stale socket to clean.
88
- }
89
- }
46
+ try {
47
+ unlinkSync(DAEMON_SOCKET_PATH);
48
+ } catch {}
90
49
 
91
50
  server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
92
- // Reject if at capacity (check registered clients only).
93
51
  if (clients.size >= DAEMON_MAX_CLIENTS) {
94
52
  socket.destroy();
95
53
  return;
@@ -101,77 +59,54 @@ export const createIpcServer = (): IpcServer => {
101
59
 
102
60
  sockets.set(tempId, currentClient);
103
61
 
104
- // Buffer for partial lines. readline handles line splitting, but
105
- // we store a reference in case we need it for diagnostics.
106
- let lineCount = 0;
107
-
108
62
  rl.on("line", (line: string) => {
109
- lineCount++;
110
- let msg: WireMessage | null;
111
- try {
112
- const parsed: unknown = JSON.parse(line);
113
- if (!isWireMessage(parsed)) return;
114
- msg = parsed;
115
- } catch {
116
- // Malformed JSON — ignore silently, don't crash the daemon.
117
- return;
118
- }
63
+ const msg = deserialize(line);
64
+ if (!msg) return;
119
65
 
120
- // Handle registration before forwarding to the general handler.
121
66
  if (msg.type === "control" && msg.action === "register" && !currentClient.registered) {
122
67
  const clientId = msg.clientId;
123
68
  if (!clientId) return;
124
69
 
125
- // If a client with this id is already connected, reject.
126
70
  if (clients.has(clientId)) {
127
71
  const reply: WireControl = {
128
72
  type: "control",
129
73
  action: "shutdown",
130
74
  reason: `clientId ${clientId} is already connected`,
131
75
  };
132
- socket.write(JSON.stringify(reply) + "\n");
76
+ socket.write(serialize(reply) + "\n");
133
77
  socket.destroy();
134
78
  return;
135
79
  }
136
80
 
137
- // Promote from anonymous to registered — mutate in place so the
138
- // closure captures the updated client reference.
81
+ // Mutate in place so the already-captured closure sees the promoted client.
139
82
  sockets.delete(tempId);
140
83
  currentClient.id = clientId;
141
84
  currentClient.registered = true;
142
85
  clients.set(clientId, currentClient);
143
86
 
144
- // Acknowledge registration.
145
87
  const ack: WireControl = { type: "control", action: "registered", clientId };
146
- socket.write(JSON.stringify(ack) + "\n");
88
+ socket.write(serialize(ack) + "\n");
147
89
 
148
90
  connectHandler?.(currentClient);
149
91
  return;
150
92
  }
151
93
 
152
- // Require registration for non-control messages.
153
94
  if (!currentClient.registered) return;
154
95
 
155
- // Forward to the general handler.
156
96
  messageHandler?.(msg, currentClient);
157
97
  });
158
98
 
159
- socket.on("error", () => {
160
- // Socket errors (ECONNRESET, etc.) are handled by the 'close' event.
161
- });
99
+ socket.on("error", () => {});
162
100
 
163
101
  socket.on("close", () => {
164
- // Clean up whichever map the client is in.
165
102
  const found = clients.get(currentClient.id) ?? sockets.get(tempId);
166
103
  if (found) {
167
104
  clients.delete(found.id);
168
105
  sockets.delete(tempId);
169
- // Notify if this was a registered client.
170
106
  if (found.registered) {
171
107
  disconnectHandler?.(found);
172
108
  }
173
109
  }
174
- // Clean up readline to prevent memory leaks.
175
110
  rl.close();
176
111
  });
177
112
  });
@@ -187,20 +122,18 @@ export const createIpcServer = (): IpcServer => {
187
122
 
188
123
  const stop = (): Promise<void> =>
189
124
  new Promise((resolve) => {
190
- // Close all client sockets.
191
125
  for (const [, client] of clients) {
192
- try { client.socket.destroy(); } catch { /* best-effort */ }
126
+ try { client.socket.destroy(); } catch {}
193
127
  }
194
128
  for (const [, client] of sockets) {
195
- try { client.socket.destroy(); } catch { /* best-effort */ }
129
+ try { client.socket.destroy(); } catch {}
196
130
  }
197
131
  clients.clear();
198
132
  sockets.clear();
199
133
 
200
134
  if (server) {
201
135
  server.close(() => {
202
- // Clean up the socket file.
203
- try { unlinkSync(DAEMON_SOCKET_PATH); } catch { /* ENOENT ok */ }
136
+ try { unlinkSync(DAEMON_SOCKET_PATH); } catch {}
204
137
  resolve();
205
138
  });
206
139
  } else {
@@ -208,6 +141,14 @@ export const createIpcServer = (): IpcServer => {
208
141
  }
209
142
  });
210
143
 
144
+ const send = (clientId: string, msg: WireMessage): void => {
145
+ const client = clients.get(clientId);
146
+ if (!client) return;
147
+ try {
148
+ client.socket.write(serialize(msg) + "\n");
149
+ } catch {}
150
+ };
151
+
211
152
  return {
212
153
  start,
213
154
  stop,
@@ -220,26 +161,10 @@ export const createIpcServer = (): IpcServer => {
220
161
  onDisconnect(handler) {
221
162
  disconnectHandler = handler;
222
163
  },
223
- send(clientId, msg) {
224
- const client = clients.get(clientId);
225
- if (!client) return;
226
- try {
227
- client.socket.write(JSON.stringify(msg) + "\n");
228
- } catch {
229
- // Socket write error — the close handler will clean up.
230
- }
231
- },
164
+ send,
232
165
  broadcast(msg) {
233
- for (const [clientId] of clients) {
234
- this.send(clientId, msg);
235
- }
236
- },
237
- disconnectClient(clientId) {
238
- const client = clients.get(clientId);
239
- if (!client) return;
240
- client.socket.destroy();
166
+ for (const [clientId] of clients) send(clientId, msg);
241
167
  },
242
- clients: (): ReadonlyMap<string, ClientSocket> => clients,
243
168
  clientCount: (): number => clients.size,
244
169
  };
245
170
  };
@@ -1,14 +1,3 @@
1
- /**
2
- * Daemon lifecycle helpers — spawn detection and lazy launch.
3
- *
4
- * The daemon is a standalone long-lived process that owns the single WebSocket
5
- * to Chrome. pi instances connect to it via a Unix domain socket. This module
6
- * provides the plumbing to detect a running daemon and spawn one if needed.
7
- *
8
- * Reference:
9
- * - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached
10
- */
11
-
12
1
  import { spawn, type ChildProcess } from "node:child_process";
13
2
  import { access, unlink } from "node:fs/promises";
14
3
  import { existsSync } from "node:fs";
@@ -21,34 +10,27 @@ const moduleDir = dirname(fileURLToPath(import.meta.url));
21
10
 
22
11
  const isWindows = process.platform === "win32";
23
12
 
24
- /**
25
- * Remove a stale POSIX socket file. On Windows the daemon endpoint is a named
26
- * pipe, which is not a filesystem entry and is torn down automatically when the
27
- * owning process exits — so there is nothing to unlink, and calling unlink on a
28
- * pipe path throws. This is a no-op on Windows.
29
- */
13
+ // No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
30
14
  const cleanupStaleSocket = (): void => {
31
15
  if (isWindows) return;
32
16
  unlink(DAEMON_SOCKET_PATH).catch(() => {});
33
17
  };
34
18
 
35
- /**
36
- * Check whether the daemon is running by probing its Unix socket.
37
- * A file-existence check is unreliable — a crashed daemon leaves a stale socket.
38
- * We connect, send a register, wait for the registered ack, then disconnect.
39
- * On failure, clean up the stale socket so ensureDaemon can spawn a fresh one.
40
- */
41
- export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
42
- // On POSIX, a missing socket file means the daemon definitely isn't running,
43
- // so we can short-circuit. Windows named pipes are not filesystem entries —
44
- // access() always fails there — so skip the pre-check and probe directly.
45
- if (!isWindows) {
46
- try {
47
- await access(DAEMON_SOCKET_PATH);
48
- } catch {
49
- return false;
50
- }
19
+ export const daemonSocketMayExist = async (
20
+ platform: NodeJS.Platform = process.platform,
21
+ socketPath: string = DAEMON_SOCKET_PATH,
22
+ ): Promise<boolean> => {
23
+ if (platform === "win32") return true;
24
+ try {
25
+ await access(socketPath);
26
+ return true;
27
+ } catch {
28
+ return false;
51
29
  }
30
+ };
31
+
32
+ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
33
+ if (!(await daemonSocketMayExist())) return false;
52
34
 
53
35
  return new Promise((resolve) => {
54
36
  let settled = false;
@@ -61,7 +43,6 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
61
43
  const sock = createConnection(DAEMON_SOCKET_PATH);
62
44
  const timer = setTimeout(() => {
63
45
  sock.destroy();
64
- // ponytail: stale socket cleanup; ENOENT = already gone, fine
65
46
  cleanupStaleSocket();
66
47
  done(false);
67
48
  }, timeoutMs);
@@ -75,7 +56,6 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
75
56
  const text = chunk.toString();
76
57
  if (text.includes('"registered"')) {
77
58
  clearTimeout(timer);
78
- // Send deregister so the probe doesn't hog a slot
79
59
  const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
80
60
  try { sock.write(serialize(deregMsg) + "\n"); } catch {}
81
61
  sock.destroy();
@@ -86,13 +66,11 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
86
66
  sock.on("error", () => {
87
67
  clearTimeout(timer);
88
68
  sock.destroy();
89
- // ponytail: connection refused = daemon dead, clean up stale socket
90
69
  cleanupStaleSocket();
91
70
  done(false);
92
71
  });
93
72
 
94
- // close without 'registered' ack = daemon alive but rejected us (e.g. max clients);
95
- // don't unlink — the daemon is still using that socket.
73
+ // Closed without a 'registered' ack: the daemon is alive but rejected us, so do NOT unlink its socket.
96
74
  sock.on("close", () => {
97
75
  clearTimeout(timer);
98
76
  done(false);
@@ -100,45 +78,24 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
100
78
  });
101
79
  };
102
80
 
103
- /**
104
- * Spawn the daemon as a detached child process. The daemon will continue
105
- * running after the parent pi process exits.
106
- *
107
- * The daemon entry point is resolved relative to this module. When running
108
- * from source (tsx), the entry is src/daemon/index.ts. When running compiled,
109
- * it's dist/daemon/index.js.
110
- *
111
- * Returns the child process handle (caller should call child.unref()).
112
- */
113
81
  export const spawnDaemon = (): ChildProcess | null => {
114
- // Resolve the daemon entry point and tsx binary.
115
82
  const daemonScript = join(moduleDir, "index.ts");
116
83
 
117
- // On Windows, npm installs binaries as `.cmd` shims, not bare executables,
118
- // and the npx fallback is `npx.cmd`. A `.cmd` file can only be launched
119
- // through a shell (spawning it directly yields EINVAL), so set shell: true
120
- // whenever we invoke one.
84
+ // Windows npm binaries are `.cmd` shims, which only launch through a shell — spawning one directly yields EINVAL.
121
85
  const tsxBinBase = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
122
86
  const tsxBin = isWindows ? `${tsxBinBase}.cmd` : tsxBinBase;
123
87
  const hasLocalTsx = existsSync(tsxBin);
124
88
 
125
- // Use the local tsx binary to run the daemon TypeScript source directly.
126
- // The spawned process inherits the parent's env so module resolution works.
127
- // Fall back to `npx tsx` if the local binary isn't available.
128
89
  const cmd = hasLocalTsx ? tsxBin : isWindows ? "npx.cmd" : "npx";
129
90
  const args = hasLocalTsx ? [daemonScript] : ["tsx", daemonScript];
130
91
 
131
- // With shell: true, Node passes the command line to the shell verbatim
132
- // without quoting, so any path containing spaces (e.g. C:\Users\Some Name)
133
- // would be split into multiple tokens. Wrap each token in double quotes on
134
- // Windows to keep space-containing paths intact.
92
+ // With shell: true Node passes the command line verbatim, so a path containing spaces must be quoted or it splits into tokens.
135
93
  const quote = (s: string): string => (isWindows ? `"${s}"` : s);
136
94
 
137
95
  const child = spawn(quote(cmd), args.map(quote), {
138
96
  detached: true,
139
97
  stdio: "ignore",
140
98
  env: { ...process.env },
141
- // `.cmd` shims (local tsx or npx.cmd on Windows) must run via a shell.
142
99
  shell: isWindows,
143
100
  windowsVerbatimArguments: isWindows,
144
101
  });
@@ -147,19 +104,12 @@ export const spawnDaemon = (): ChildProcess | null => {
147
104
  return child;
148
105
  };
149
106
 
150
- /**
151
- * Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs)
152
- * for the socket file to appear.
153
- *
154
- * Returns true if the daemon is running (or was successfully spawned).
155
- */
156
107
  export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => {
157
108
  if (await isDaemonRunning()) return true;
158
109
 
159
110
  const child = spawnDaemon();
160
111
  if (!child) return false;
161
112
 
162
- // Poll for the socket file to appear
163
113
  const deadline = Date.now() + timeoutMs;
164
114
  while (Date.now() < deadline) {
165
115
  if (await isDaemonRunning()) return true;