pi-browser-harness 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -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 +375 -265
  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 +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  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 -37
  28. package/src/domains/console.ts +48 -4
  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 +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
@@ -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();
@@ -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
  };