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/spawn.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/spawn.ts')
| -rw-r--r-- | src/daemon/spawn.ts | 85 |
1 files changed, 85 insertions, 0 deletions
diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts new file mode 100644 index 0000000..e9f4d51 --- /dev/null +++ b/src/daemon/spawn.ts @@ -0,0 +1,85 @@ +/** + * Daemon lifecycle helpers — spawn detection and lazy launch. + * + * The daemon is a standalone long-lived process that owns the single WebSocket + * to Chrome. pi instances connect to it via a Unix domain socket. This module + * provides the plumbing to detect a running daemon and spawn one if needed. + * + * Reference: + * - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { access } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { DAEMON_SOCKET_PATH } from "./protocol"; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); + +/** + * Check whether the daemon is running by testing if its Unix socket exists. + * The socket file is created by the daemon on startup and removed on shutdown. + */ +export const isDaemonRunning = async (): Promise<boolean> => { + try { + await access(DAEMON_SOCKET_PATH); + return true; + } catch { + return false; + } +}; + +/** + * Spawn the daemon as a detached child process. The daemon will continue + * running after the parent pi process exits. + * + * The daemon entry point is resolved relative to this module. When running + * from source (tsx), the entry is src/daemon/index.ts. When running compiled, + * it's dist/daemon/index.js. + * + * Returns the child process handle (caller should call child.unref()). + */ +export const spawnDaemon = (): ChildProcess | null => { + // Resolve the daemon entry point and tsx binary. + const daemonScript = join(moduleDir, "index.ts"); + const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx"); + + // Use tsx to run the daemon TypeScript source directly. + // The spawed process inherits the parent's env so module resolution works. + // Fall back to `npx tsx` if the local binary isn't available. + const cmd = existsSync(tsxBin) ? tsxBin : "npx"; + const args = existsSync(tsxBin) ? [daemonScript] : ["tsx", daemonScript]; + + const child = spawn(cmd, args, { + detached: true, + stdio: "ignore", + env: { ...process.env }, + }); + + child.unref(); + return child; +}; + +/** + * Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs) + * for the socket file to appear. + * + * Returns true if the daemon is running (or was successfully spawned). + */ +export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => { + if (await isDaemonRunning()) return true; + + const child = spawnDaemon(); + if (!child) return false; + + // Poll for the socket file to appear + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isDaemonRunning()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + + return false; +}; |
