aboutsummaryrefslogtreecommitdiff
path: root/src/daemon/server.ts
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-06-21 00:46:44 +0530
committerGitHub <noreply@github.com>2026-06-21 00:46:44 +0530
commitda5d31af999b728c31ab0169373531cab4543f0c (patch)
tree5d350ad1428ff332372a9c724ac46c964d8ca477 /src/daemon/server.ts
parent8db684d43366d335760e106eae39a74376bd7640 (diff)
release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init (#6)
* feat: Unix socket daemon with auto-reconnect for persistent Chrome CDP Eliminates the 'Allow Remote Debugging' Chrome prompt that appeared on every pi session by multiplexing pi instances through a single persistent daemon process. Key additions: - Unix socket IPC (net.createServer + readline JSON framing) - CdpBridge with ID multiplexer + event router - DaemonTransport implementing CdpTransport interface - Auto-spawn with ensureDaemon() (lazy, checks socket) Fixes since initial implementation: - Bridge auto-reconnects to Chrome with exponential backoff (1s-60s). Previously the bridge tried once at startup and permanently failed if Chrome wasn't ready — causing 'Chrome not connected' on every browser tool call. - Daemon spawn uses project-local tsx binary instead of global --import flag, fixing silent spawn failure (ERR_MODULE_NOT_FOUND). - Stale ctx crash: persistState in onOwnershipChange wrapped in try/catch to prevent CDP event consumer crashes. 49 test assertions across 4 test files. Zero TypeScript errors. * feat: on-demand browser initialization Browser daemon no longer auto-starts on session_start. Instead: - session_start creates the client lazily but does NOT connect or spawn the daemon. Zero Chrome prompts for non-browser pi sessions. - Browser tool wrapper checks for daemon socket before each call. Socket missing → clear error: 'Run /browser-setup first'. Socket exists → proceeds silently (daemon already running). - /browser-setup is the explicit, one-time init point. It spawns the daemon and connects to Chrome only when the user opts in. - SKILL.md updated with Connection Setup instructions telling agents to direct users to /browser-setup when socket is missing. * fix: checkChromeRunning on macOS — match by substring not exact ps -A -o comm= returns full executable paths on modern macOS (e.g. /Applications/Google Chrome.app/Contents/MacOS/Google Chrome), not the basename. The old exact-match check (browserProcesses.includes(l)) never matched, causing false 'No browser instance running' errors. Changed to substring includes() with helper/renderer/crashpad filtering. * feat: browser_setup as agent-callable tool The agent can now self-recover from 'Browser harness not initialized' errors by calling browser_setup directly — no user mediation needed. - Extracted shared performSetup() from the slash command into setup.ts - Created src/domains/setup.ts with ensureAlive:false to bypass the socket guard (this tool IS the setup, can't require itself) - Registered browser_setup in the tool registry - Updated SKILL.md: agent calls browser_setup directly, not ask user - Removed stale test/manual/spawn-test.ts * release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init
Diffstat (limited to 'src/daemon/server.ts')
-rw-r--r--src/daemon/server.ts245
1 files changed, 245 insertions, 0 deletions
diff --git a/src/daemon/server.ts b/src/daemon/server.ts
new file mode 100644
index 0000000..fea9bb7
--- /dev/null
+++ b/src/daemon/server.ts
@@ -0,0 +1,245 @@
+/**
+ * IpcServer — Unix socket server for daemon ↔ pi client communication.
+ *
+ * Accepts multiple concurrent pi client connections over a Unix domain
+ * stream socket. Uses newline-delimited JSON framing (one WireMessage per
+ * line). Clients are anonymous until they send a control.register message.
+ *
+ * References:
+ * - net.createServer: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateserveroptions-connectionlistener
+ * - server.listen(path): https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
+ * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions
+ */
+
+import { createServer, type Server, type Socket } from "node:net";
+import { createInterface, type Interface } from "node:readline";
+import { unlinkSync } from "node:fs";
+import {
+ type WireMessage,
+ type WireControl,
+ isWireMessage,
+ DAEMON_SOCKET_PATH,
+ DAEMON_MAX_CLIENTS,
+ DAEMON_STALE_SOCKET_CLEANUP,
+} from "./protocol";
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+/** A connected pi client with its socket and readline interface. */
+export type ClientSocket = {
+ /** Client namespace (set after registration). */
+ id: string;
+ readonly socket: Socket;
+ readonly rl: Interface;
+ /** True once a valid control.register message has been received. */
+ registered: boolean;
+};
+
+/** Handler called for every valid WireMessage from any client. */
+export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void;
+
+/** Handler called when a client connects or disconnects. */
+export type ConnectionHandler = (client: ClientSocket) => void;
+
+export type IpcServer = {
+ /** Start listening on the Unix socket. Resolves when the server is ready. */
+ start(): Promise<void>;
+ /** Close the server and all client connections. Resolves when cleaned up. */
+ stop(): Promise<void>;
+ /** Register a handler for incoming WireMessages from any client. */
+ onMessage(handler: MessageHandler): void;
+ /** Register a handler for client connect events. */
+ onConnect(handler: ConnectionHandler): void;
+ /** Register a handler for client disconnect events. */
+ onDisconnect(handler: ConnectionHandler): void;
+ /** Send a WireMessage to a specific client by clientId. No-op if client not found. */
+ send(clientId: string, msg: WireMessage): void;
+ /** Send a WireMessage to all connected (registered) clients. */
+ broadcast(msg: WireMessage): void;
+ /** Force-disconnect a client by clientId. */
+ disconnectClient(clientId: string): void;
+ /** All currently connected clients (by clientId). */
+ clients(): ReadonlyMap<string, ClientSocket>;
+ /** Number of connected clients. */
+ clientCount(): number;
+};
+
+// ── Implementation ─────────────────────────────────────────────────────────────
+
+export const createIpcServer = (): IpcServer => {
+ let server: Server | null = null;
+ // Anonymous temp-id → ClientSocket. Temp ids are assigned before registration.
+ const sockets = new Map<string, ClientSocket>();
+ // clientId → ClientSocket (only registered clients).
+ const clients = new Map<string, ClientSocket>();
+ let messageHandler: MessageHandler | null = null;
+ let connectHandler: ConnectionHandler | null = null;
+ let disconnectHandler: ConnectionHandler | null = null;
+ let nextTempId = 1;
+
+ const start = (): Promise<void> =>
+ new Promise((resolve, reject) => {
+ // Clean up stale socket file from a previous crashed daemon.
+ if (DAEMON_STALE_SOCKET_CLEANUP) {
+ try {
+ unlinkSync(DAEMON_SOCKET_PATH);
+ } catch {
+ // ENOENT is expected — no stale socket to clean.
+ }
+ }
+
+ server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
+ // Reject if at capacity (check registered clients only).
+ if (clients.size >= DAEMON_MAX_CLIENTS) {
+ socket.destroy();
+ return;
+ }
+
+ const tempId = `anon-${nextTempId++}`;
+ const rl = createInterface({ input: socket, crlfDelay: Infinity });
+ let currentClient: ClientSocket = { id: tempId, socket, rl, registered: false };
+
+ sockets.set(tempId, currentClient);
+
+ // Buffer for partial lines. readline handles line splitting, but
+ // we store a reference in case we need it for diagnostics.
+ let lineCount = 0;
+
+ rl.on("line", (line: string) => {
+ lineCount++;
+ let msg: WireMessage | null;
+ try {
+ const parsed: unknown = JSON.parse(line);
+ if (!isWireMessage(parsed)) return;
+ msg = parsed;
+ } catch {
+ // Malformed JSON — ignore silently, don't crash the daemon.
+ return;
+ }
+
+ // Handle registration before forwarding to the general handler.
+ if (msg.type === "control" && msg.action === "register" && !currentClient.registered) {
+ const clientId = msg.clientId;
+ if (!clientId) return;
+
+ // If a client with this id is already connected, reject.
+ if (clients.has(clientId)) {
+ const reply: WireControl = {
+ type: "control",
+ action: "shutdown",
+ reason: `clientId ${clientId} is already connected`,
+ };
+ socket.write(JSON.stringify(reply) + "\n");
+ socket.destroy();
+ return;
+ }
+
+ // Promote from anonymous to registered — mutate in place so the
+ // closure captures the updated client reference.
+ sockets.delete(tempId);
+ currentClient.id = clientId;
+ currentClient.registered = true;
+ clients.set(clientId, currentClient);
+
+ // Acknowledge registration.
+ const ack: WireControl = { type: "control", action: "registered", clientId };
+ socket.write(JSON.stringify(ack) + "\n");
+
+ connectHandler?.(currentClient);
+ return;
+ }
+
+ // Require registration for non-control messages.
+ if (!currentClient.registered) return;
+
+ // Forward to the general handler.
+ messageHandler?.(msg, currentClient);
+ });
+
+ socket.on("error", () => {
+ // Socket errors (ECONNRESET, etc.) are handled by the 'close' event.
+ });
+
+ socket.on("close", () => {
+ // Clean up whichever map the client is in.
+ const found = clients.get(currentClient.id) ?? sockets.get(tempId);
+ if (found) {
+ clients.delete(found.id);
+ sockets.delete(tempId);
+ // Notify if this was a registered client.
+ if (found.registered) {
+ disconnectHandler?.(found);
+ }
+ }
+ // Clean up readline to prevent memory leaks.
+ rl.close();
+ });
+ });
+
+ server.on("error", (err: NodeJS.ErrnoException) => {
+ reject(err);
+ });
+
+ server.listen(DAEMON_SOCKET_PATH, () => {
+ resolve();
+ });
+ });
+
+ const stop = (): Promise<void> =>
+ new Promise((resolve) => {
+ // Close all client sockets.
+ for (const [, client] of clients) {
+ try { client.socket.destroy(); } catch { /* best-effort */ }
+ }
+ for (const [, client] of sockets) {
+ try { client.socket.destroy(); } catch { /* best-effort */ }
+ }
+ clients.clear();
+ sockets.clear();
+
+ if (server) {
+ server.close(() => {
+ // Clean up the socket file.
+ try { unlinkSync(DAEMON_SOCKET_PATH); } catch { /* ENOENT ok */ }
+ resolve();
+ });
+ } else {
+ resolve();
+ }
+ });
+
+ return {
+ start,
+ stop,
+ onMessage(handler) {
+ messageHandler = handler;
+ },
+ onConnect(handler) {
+ connectHandler = handler;
+ },
+ onDisconnect(handler) {
+ disconnectHandler = handler;
+ },
+ send(clientId, msg) {
+ const client = clients.get(clientId);
+ if (!client) return;
+ try {
+ client.socket.write(JSON.stringify(msg) + "\n");
+ } catch {
+ // Socket write error — the close handler will clean up.
+ }
+ },
+ broadcast(msg) {
+ for (const [clientId] of clients) {
+ this.send(clientId, msg);
+ }
+ },
+ disconnectClient(clientId) {
+ const client = clients.get(clientId);
+ if (!client) return;
+ client.socket.destroy();
+ },
+ clients: (): ReadonlyMap<string, ClientSocket> => clients,
+ clientCount: (): number => clients.size,
+ };
+};