diff options
| author | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-06-21 00:46:44 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-21 00:46:44 +0530 |
| commit | da5d31af999b728c31ab0169373531cab4543f0c (patch) | |
| tree | 5d350ad1428ff332372a9c724ac46c964d8ca477 /src/daemon/server.ts | |
| parent | 8db684d43366d335760e106eae39a74376bd7640 (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.ts | 245 |
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, + }; +}; |
