aboutsummaryrefslogtreecommitdiff
path: root/src/daemon/protocol.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/protocol.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/protocol.ts')
-rw-r--r--src/daemon/protocol.ts162
1 files changed, 162 insertions, 0 deletions
diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts
new file mode 100644
index 0000000..9d777bc
--- /dev/null
+++ b/src/daemon/protocol.ts
@@ -0,0 +1,162 @@
+/**
+ * Unix socket daemon protocol — wire message types, type guards, and constants.
+ *
+ * The daemon speaks JSON-line protocol over a Unix domain socket.
+ * One JSON object per newline. Four message types: request, response, event, control.
+ *
+ * References:
+ * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals
+ * - net.createServer IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
+ * - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line
+ */
+
+// ── Message types ──────────────────────────────────────────────────────────────
+
+/** A CDP command sent from a pi client to the daemon. */
+export type WireRequest = {
+ readonly type: "request";
+ /** Client-local CDP id. The daemon remaps this to a globally-unique id for Chrome. */
+ readonly id: number;
+ readonly method: string;
+ readonly params?: Record<string, unknown>;
+ /** Optional CDP session id. Omitted for browser-level commands. */
+ readonly sessionId?: string;
+};
+
+/** A CDP response (result or error) sent from the daemon back to the pi client. */
+export type WireResponse = {
+ readonly type: "response";
+ /** Matches the id from the corresponding WireRequest. */
+ readonly id: number;
+ readonly result?: unknown;
+ readonly error?: {
+ readonly code: number;
+ readonly message: string;
+ readonly data?: string;
+ };
+};
+
+/** A CDP event forwarded from Chrome through the daemon to pi client(s). */
+export type WireEvent = {
+ readonly type: "event";
+ readonly method: string;
+ readonly params?: Record<string, unknown>;
+ /** Session-scoped events carry this; browser-level events omit it. */
+ readonly sessionId?: string;
+};
+
+/** Daemon lifecycle control messages. Bidirectional. */
+export type WireControl = {
+ readonly type: "control";
+ readonly action: "register" | "registered" | "deregister" | "shutdown";
+ /** pi client namespace (required for register/registered/deregister). */
+ readonly clientId?: string;
+ /** Human-readable reason for shutdown. */
+ readonly reason?: string;
+};
+
+/** All messages that may appear on the Unix socket. */
+export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl;
+
+// ── Type guard ─────────────────────────────────────────────────────────────────
+
+const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
+ typeof v === "object" && v !== null && !Array.isArray(v);
+
+/**
+ * Validates that an unknown parsed-JSON value is a well-formed WireMessage.
+ * Does NOT validate the full CDP semantics — only structural shape.
+ */
+export const isWireMessage = (v: unknown): v is WireMessage => {
+ if (!isObject(v)) return false;
+ const t = v["type"];
+ if (typeof t !== "string") return false;
+
+ switch (t) {
+ case "request": {
+ if (typeof v["id"] !== "number") return false;
+ if (typeof v["method"] !== "string") return false;
+ const sid = v["sessionId"];
+ if (sid !== undefined && typeof sid !== "string") return false;
+ return true;
+ }
+ case "response": {
+ if (typeof v["id"] !== "number") return false;
+ const errVal = v["error"];
+ if (errVal !== undefined) {
+ if (!isObject(errVal)) return false;
+ if (typeof errVal["message"] !== "string") return false;
+ if (typeof errVal["code"] !== "number") return false;
+ }
+ return true;
+ }
+ case "event": {
+ if (typeof v["method"] !== "string") return false;
+ const sid = v["sessionId"];
+ if (sid !== undefined && typeof sid !== "string") return false;
+ return true;
+ }
+ case "control": {
+ const action = v["action"];
+ if (typeof action !== "string") return false;
+ if (!["register", "registered", "deregister", "shutdown"].includes(action)) return false;
+ const cid = v["clientId"];
+ if (
+ (action === "register" || action === "registered" || action === "deregister") &&
+ typeof cid !== "string"
+ ) {
+ return false;
+ }
+ return true;
+ }
+ default:
+ return false;
+ }
+};
+
+// ── Wire message constructors ──────────────────────────────────────────────────
+
+/** Serialize a WireMessage to a JSON line (no trailing newline — caller adds \\n). */
+export const serialize = (msg: WireMessage): string => JSON.stringify(msg);
+
+/** Deserialize a single JSON-line string to a WireMessage. Returns null on parse failure. */
+export const deserialize = (line: string): WireMessage | null => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ return null;
+ }
+ if (!isWireMessage(parsed)) return null;
+ return parsed;
+};
+
+// ── Constants ──────────────────────────────────────────────────────────────────
+
+/** Filesystem path for the daemon's Unix domain socket. */
+export const DAEMON_SOCKET_PATH = "/tmp/pi-browser-daemon.sock";
+
+/**
+ * How long the daemon stays alive with zero connected clients before exiting
+ * (milliseconds). After this period, the daemon cleans up and shuts down.
+ */
+export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
+
+/**
+ * Hard cap on concurrent pi client connections. Extra connections are rejected
+ * immediately to prevent resource exhaustion.
+ */
+export const DAEMON_MAX_CLIENTS = 16;
+
+/**
+ * If true, the daemon unlinks (deletes) any stale socket file from a previous
+ * daemon crash before calling server.listen(). Without this, a crashed daemon
+ * leaves a socket inode that blocks the next listen() call with EADDRINUSE.
+ */
+export const DAEMON_STALE_SOCKET_CLEANUP = true;
+
+/** CDP WebSocket connection timeout (ms). */
+export const CDP_CONNECT_TIMEOUT_MS = 10_000;
+
+/** CDP command timeout when proxying through daemon (ms). */
+export const CDP_COMMAND_TIMEOUT_MS = 10_000;