pi-browser-harness 0.5.0 → 0.6.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.
@@ -0,0 +1,245 @@
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
+ import { createServer, type Server, type Socket } from "node:net";
15
+ import { createInterface, type Interface } from "node:readline";
16
+ import { unlinkSync } from "node:fs";
17
+ import {
18
+ type WireMessage,
19
+ type WireControl,
20
+ isWireMessage,
21
+ DAEMON_SOCKET_PATH,
22
+ DAEMON_MAX_CLIENTS,
23
+ DAEMON_STALE_SOCKET_CLEANUP,
24
+ } from "./protocol";
25
+
26
+ // ── Types ──────────────────────────────────────────────────────────────────────
27
+
28
+ /** A connected pi client with its socket and readline interface. */
29
+ export type ClientSocket = {
30
+ /** Client namespace (set after registration). */
31
+ id: string;
32
+ readonly socket: Socket;
33
+ readonly rl: Interface;
34
+ /** True once a valid control.register message has been received. */
35
+ registered: boolean;
36
+ };
37
+
38
+ /** Handler called for every valid WireMessage from any client. */
39
+ export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void;
40
+
41
+ /** Handler called when a client connects or disconnects. */
42
+ export type ConnectionHandler = (client: ClientSocket) => void;
43
+
44
+ export type IpcServer = {
45
+ /** Start listening on the Unix socket. Resolves when the server is ready. */
46
+ start(): Promise<void>;
47
+ /** Close the server and all client connections. Resolves when cleaned up. */
48
+ stop(): Promise<void>;
49
+ /** Register a handler for incoming WireMessages from any client. */
50
+ onMessage(handler: MessageHandler): void;
51
+ /** Register a handler for client connect events. */
52
+ onConnect(handler: ConnectionHandler): void;
53
+ /** Register a handler for client disconnect events. */
54
+ onDisconnect(handler: ConnectionHandler): void;
55
+ /** Send a WireMessage to a specific client by clientId. No-op if client not found. */
56
+ send(clientId: string, msg: WireMessage): void;
57
+ /** Send a WireMessage to all connected (registered) clients. */
58
+ 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
+ clientCount(): number;
65
+ };
66
+
67
+ // ── Implementation ─────────────────────────────────────────────────────────────
68
+
69
+ export const createIpcServer = (): IpcServer => {
70
+ let server: Server | null = null;
71
+ // Anonymous temp-id → ClientSocket. Temp ids are assigned before registration.
72
+ const sockets = new Map<string, ClientSocket>();
73
+ // clientId → ClientSocket (only registered clients).
74
+ const clients = new Map<string, ClientSocket>();
75
+ let messageHandler: MessageHandler | null = null;
76
+ let connectHandler: ConnectionHandler | null = null;
77
+ let disconnectHandler: ConnectionHandler | null = null;
78
+ let nextTempId = 1;
79
+
80
+ const start = (): Promise<void> =>
81
+ 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
+ }
90
+
91
+ server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
92
+ // Reject if at capacity (check registered clients only).
93
+ if (clients.size >= DAEMON_MAX_CLIENTS) {
94
+ socket.destroy();
95
+ return;
96
+ }
97
+
98
+ const tempId = `anon-${nextTempId++}`;
99
+ const rl = createInterface({ input: socket, crlfDelay: Infinity });
100
+ let currentClient: ClientSocket = { id: tempId, socket, rl, registered: false };
101
+
102
+ sockets.set(tempId, currentClient);
103
+
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
+ 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
+ }
119
+
120
+ // Handle registration before forwarding to the general handler.
121
+ if (msg.type === "control" && msg.action === "register" && !currentClient.registered) {
122
+ const clientId = msg.clientId;
123
+ if (!clientId) return;
124
+
125
+ // If a client with this id is already connected, reject.
126
+ if (clients.has(clientId)) {
127
+ const reply: WireControl = {
128
+ type: "control",
129
+ action: "shutdown",
130
+ reason: `clientId ${clientId} is already connected`,
131
+ };
132
+ socket.write(JSON.stringify(reply) + "\n");
133
+ socket.destroy();
134
+ return;
135
+ }
136
+
137
+ // Promote from anonymous to registered — mutate in place so the
138
+ // closure captures the updated client reference.
139
+ sockets.delete(tempId);
140
+ currentClient.id = clientId;
141
+ currentClient.registered = true;
142
+ clients.set(clientId, currentClient);
143
+
144
+ // Acknowledge registration.
145
+ const ack: WireControl = { type: "control", action: "registered", clientId };
146
+ socket.write(JSON.stringify(ack) + "\n");
147
+
148
+ connectHandler?.(currentClient);
149
+ return;
150
+ }
151
+
152
+ // Require registration for non-control messages.
153
+ if (!currentClient.registered) return;
154
+
155
+ // Forward to the general handler.
156
+ messageHandler?.(msg, currentClient);
157
+ });
158
+
159
+ socket.on("error", () => {
160
+ // Socket errors (ECONNRESET, etc.) are handled by the 'close' event.
161
+ });
162
+
163
+ socket.on("close", () => {
164
+ // Clean up whichever map the client is in.
165
+ const found = clients.get(currentClient.id) ?? sockets.get(tempId);
166
+ if (found) {
167
+ clients.delete(found.id);
168
+ sockets.delete(tempId);
169
+ // Notify if this was a registered client.
170
+ if (found.registered) {
171
+ disconnectHandler?.(found);
172
+ }
173
+ }
174
+ // Clean up readline to prevent memory leaks.
175
+ rl.close();
176
+ });
177
+ });
178
+
179
+ server.on("error", (err: NodeJS.ErrnoException) => {
180
+ reject(err);
181
+ });
182
+
183
+ server.listen(DAEMON_SOCKET_PATH, () => {
184
+ resolve();
185
+ });
186
+ });
187
+
188
+ const stop = (): Promise<void> =>
189
+ new Promise((resolve) => {
190
+ // Close all client sockets.
191
+ for (const [, client] of clients) {
192
+ try { client.socket.destroy(); } catch { /* best-effort */ }
193
+ }
194
+ for (const [, client] of sockets) {
195
+ try { client.socket.destroy(); } catch { /* best-effort */ }
196
+ }
197
+ clients.clear();
198
+ sockets.clear();
199
+
200
+ if (server) {
201
+ server.close(() => {
202
+ // Clean up the socket file.
203
+ try { unlinkSync(DAEMON_SOCKET_PATH); } catch { /* ENOENT ok */ }
204
+ resolve();
205
+ });
206
+ } else {
207
+ resolve();
208
+ }
209
+ });
210
+
211
+ return {
212
+ start,
213
+ stop,
214
+ onMessage(handler) {
215
+ messageHandler = handler;
216
+ },
217
+ onConnect(handler) {
218
+ connectHandler = handler;
219
+ },
220
+ onDisconnect(handler) {
221
+ disconnectHandler = handler;
222
+ },
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
+ },
232
+ 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();
241
+ },
242
+ clients: (): ReadonlyMap<string, ClientSocket> => clients,
243
+ clientCount: (): number => clients.size,
244
+ };
245
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Daemon lifecycle helpers — spawn detection and lazy launch.
3
+ *
4
+ * The daemon is a standalone long-lived process that owns the single WebSocket
5
+ * to Chrome. pi instances connect to it via a Unix domain socket. This module
6
+ * provides the plumbing to detect a running daemon and spawn one if needed.
7
+ *
8
+ * Reference:
9
+ * - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached
10
+ */
11
+
12
+ import { spawn, type ChildProcess } from "node:child_process";
13
+ import { access } from "node:fs/promises";
14
+ import { existsSync } from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, join } from "node:path";
17
+ import { DAEMON_SOCKET_PATH } from "./protocol";
18
+
19
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
20
+
21
+ /**
22
+ * Check whether the daemon is running by testing if its Unix socket exists.
23
+ * The socket file is created by the daemon on startup and removed on shutdown.
24
+ */
25
+ export const isDaemonRunning = async (): Promise<boolean> => {
26
+ try {
27
+ await access(DAEMON_SOCKET_PATH);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ };
33
+
34
+ /**
35
+ * Spawn the daemon as a detached child process. The daemon will continue
36
+ * running after the parent pi process exits.
37
+ *
38
+ * The daemon entry point is resolved relative to this module. When running
39
+ * from source (tsx), the entry is src/daemon/index.ts. When running compiled,
40
+ * it's dist/daemon/index.js.
41
+ *
42
+ * Returns the child process handle (caller should call child.unref()).
43
+ */
44
+ export const spawnDaemon = (): ChildProcess | null => {
45
+ // Resolve the daemon entry point and tsx binary.
46
+ const daemonScript = join(moduleDir, "index.ts");
47
+ const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
48
+
49
+ // Use tsx to run the daemon TypeScript source directly.
50
+ // The spawed process inherits the parent's env so module resolution works.
51
+ // Fall back to `npx tsx` if the local binary isn't available.
52
+ const cmd = existsSync(tsxBin) ? tsxBin : "npx";
53
+ const args = existsSync(tsxBin) ? [daemonScript] : ["tsx", daemonScript];
54
+
55
+ const child = spawn(cmd, args, {
56
+ detached: true,
57
+ stdio: "ignore",
58
+ env: { ...process.env },
59
+ });
60
+
61
+ child.unref();
62
+ return child;
63
+ };
64
+
65
+ /**
66
+ * Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs)
67
+ * for the socket file to appear.
68
+ *
69
+ * Returns true if the daemon is running (or was successfully spawned).
70
+ */
71
+ export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => {
72
+ if (await isDaemonRunning()) return true;
73
+
74
+ const child = spawnDaemon();
75
+ if (!child) return false;
76
+
77
+ // Poll for the socket file to appear
78
+ const deadline = Date.now() + timeoutMs;
79
+ while (Date.now() < deadline) {
80
+ if (await isDaemonRunning()) return true;
81
+ await new Promise((r) => setTimeout(r, 200));
82
+ }
83
+
84
+ return false;
85
+ };
@@ -3,6 +3,7 @@ import { Text } from "@mariozechner/pi-tui";
3
3
  import { type Result, err, ok } from "../util/result";
4
4
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
5
  import { applyTruncation } from "../util/truncate";
6
+ import { safeJs } from "../util/js-template";
6
7
 
7
8
  const NavigateArgs = Type.Object({
8
9
  url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
@@ -82,11 +83,18 @@ export const openUrlsTool = defineBrowserTool({
82
83
  renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
83
84
  async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
84
85
  const total = args.urls.length;
85
- // Phase 1: create tabs in parallel.
86
+ // Phase 1: create tabs in parallel. Own them so they appear in
87
+ // browser_list_tabs scope:'owned' and group in the harness window.
88
+ const hw = client.ownership().harnessWindow();
86
89
  const created = await Promise.all(args.urls.map(async (url): Promise<TabResult> => {
87
- const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank" });
90
+ const params: Record<string, unknown> = { url: "about:blank" };
91
+ if (hw) params["openerId"] = hw;
92
+ else params["newWindow"] = true;
93
+ const r = await client.session().callBrowser("Target.createTarget", params);
88
94
  if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
89
- const c = r.data as { targetId: string }; // CDP boundary cast: Target.createTarget returns { targetId: string }
95
+ const c = r.data as { targetId: string };
96
+ if (params["newWindow"]) client.ownership().setHarnessWindow(c.targetId);
97
+ client.ownership().add(c.targetId);
90
98
  return { url, targetId: c.targetId, ok: true };
91
99
  }));
92
100
  // Phase 2: attach + navigate each created tab in parallel.
@@ -101,6 +109,12 @@ export const openUrlsTool = defineBrowserTool({
101
109
  if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message };
102
110
  const nav = await client.session().callOnTarget("Page.navigate", { url: tab.url }, sid);
103
111
  if (!nav.success) return { ...tab, ok: false, error: nav.error.message };
112
+ // Mark the tab with a green circle so the user can see which
113
+ // tabs the agent opened. Best-effort; don't fail on CSP blocks.
114
+ client.evaluateJs(
115
+ safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
116
+ sid
117
+ ).catch(() => {});
104
118
  return tab;
105
119
  } finally {
106
120
  completed++;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * browser_setup tool — agent-callable browser initialization.
3
+ *
4
+ * The agent can call this directly when browser tools fail with
5
+ * "not initialized" instead of asking the user. Idempotent: safe
6
+ * to call even when already connected.
7
+ */
8
+
9
+ import { Type } from "typebox";
10
+ import { Text } from "@mariozechner/pi-tui";
11
+ import type { Result } from "../util/result";
12
+ import { err, ok } from "../util/result";
13
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
14
+ import { performSetup } from "../setup";
15
+
16
+ const SetupArgs = Type.Object({});
17
+
18
+ export const setupTool = defineBrowserTool({
19
+ name: "browser_setup",
20
+ label: "Browser Setup",
21
+ description:
22
+ "Initialize browser control. Spawns the daemon, connects to Chrome, opens a test tab. " +
23
+ "Call when browser tools fail with 'Browser harness not initialized'. Idempotent.",
24
+ promptSnippet: "Initialize browser connection",
25
+ promptGuidelines: [
26
+ "Call browser_setup when you get a 'Browser harness not initialized' error from any browser tool.",
27
+ "This tool is idempotent — calling it when already connected is harmless.",
28
+ "After browser_setup succeeds, retry the browser tool that failed.",
29
+ ],
30
+ parameters: SetupArgs,
31
+ // ensureAlive:false — this tool IS the setup; it must bypass the socket guard
32
+ ensureAlive: false,
33
+ renderCall: () => new Text("🔧 Initializing browser...", 0, 0),
34
+ async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
35
+ const result = await performSetup(client);
36
+ if (result.success) {
37
+ return ok({ text: result.data });
38
+ }
39
+ return err({ kind: "internal", message: result.error });
40
+ },
41
+ });
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import { registerSetupCommand } from "./setup";
27
27
  import { type BrowserState, defaultState, persistState, restoreState } from "./state";
28
28
  import { registerAllTools } from "./registry";
29
29
  import { cleanupTempDirs } from "./util/truncate";
30
+ import { createDaemonTransport } from "./cdp/daemon-transport";
30
31
 
31
32
  export default function browserHarnessExtension(pi: ExtensionAPI): void {
32
33
  const flagNs = pi.getFlag("browser-namespace") as string | undefined;
@@ -98,25 +99,43 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
98
99
 
99
100
  pi.on("session_start", async (_event, ctx) => {
100
101
  state = restoreState(ctx, state.namespace);
101
- const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
102
- if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds;
103
- if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId;
104
- client = createBrowserClient({
105
- namespace: state.namespace,
106
- ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}),
107
- onOwnershipChange: (snap) => {
108
- state = {
109
- ...state,
110
- ownedTargetIds: snap.ownedTargetIds,
111
- ...(snap.harnessWindowTargetId !== undefined
112
- ? { harnessWindowTargetId: snap.harnessWindowTargetId }
113
- : {}),
114
- };
115
- persistState(pi, state);
116
- },
117
- });
118
- // failure is fine — surfaced via tool errors when the agent first tries to use the browser
119
- await client.start();
102
+
103
+ // Create the client lazily on first session. The daemon transport
104
+ // is created but NOT connected — that only happens when the user
105
+ // runs /browser-setup or a browser tool call finds an existing socket.
106
+ // This means zero Chrome prompts for non-browser pi sessions.
107
+ if (!client) {
108
+ const transport = createDaemonTransport(state.namespace);
109
+
110
+ const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
111
+ if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds;
112
+ if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId;
113
+
114
+ client = createBrowserClient({
115
+ namespace: state.namespace,
116
+ transport,
117
+ ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}),
118
+ onOwnershipChange: (snap) => {
119
+ state = {
120
+ ...state,
121
+ ownedTargetIds: snap.ownedTargetIds,
122
+ ...(snap.harnessWindowTargetId !== undefined
123
+ ? { harnessWindowTargetId: snap.harnessWindowTargetId }
124
+ : {}),
125
+ };
126
+ // Ownership changes can fire from CDP events at any time,
127
+ // including after session replacement when pi's ctx is stale.
128
+ // Persistence is best-effort — failure must not crash the event consumer.
129
+ // ponytail: try/catch is the simplest guard against stale-ctx.
130
+ try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ }
131
+ },
132
+ });
133
+ }
134
+
135
+ // Do NOT call client.start() here — browser control is on-demand.
136
+ // The first browser tool call or /browser-setup triggers the actual
137
+ // daemon socket connection and Chrome prompt.
138
+
120
139
  if (!toolsRegistered) {
121
140
  registerAllTools(pi, client);
122
141
  toolsRegistered = true;
@@ -132,15 +151,17 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
132
151
  persistState(pi, state);
133
152
  if (client) {
134
153
  try {
135
- await client.stop();
154
+ // Detach from the page target (removes the "Chrome is being controlled"
155
+ // banner) but keep the transport alive for the next session.
156
+ await client.detach();
136
157
  } catch (e) {
137
- // Shutdown is best-effort, but a stuck stop() points at a transport
138
- // bug worth surfacing for debugging.
139
- console.warn("[pi-browser-harness] client.stop() failed during shutdown:", e);
158
+ console.warn("[pi-browser-harness] client.detach() failed during shutdown:", e);
140
159
  }
141
- client = null;
160
+ // ponytail: keep the transport alive across sessions.
161
+ // Do NOT call client.stop() or null out client — the daemon connection
162
+ // persists and eliminates the per-session "Allow Remote Debugging" prompt.
163
+ // Only Chrome restart or daemon death triggers a new prompt.
142
164
  }
143
- toolsRegistered = false;
144
165
  await cleanupTempDirs();
145
166
  });
146
167
 
package/src/registry.ts CHANGED
@@ -17,11 +17,14 @@ import { httpGetTool, networkRequestsTool } from "./domains/network";
17
17
  import { consoleTool } from "./domains/console";
18
18
  import { snapshotTool } from "./domains/snapshot";
19
19
  import { executeJsTool, runScriptTool } from "./domains/js";
20
+ import { setupTool } from "./domains/setup";
20
21
 
21
22
  // Mutation tools that modify page / browser state are marked `serialized: true`
22
23
  // so they run through a shared async mutex. Observation / read-only tools are
23
24
  // left unmarked and can execute in parallel.
24
25
  const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
26
+ // setup comes first — the agent calls this to self-recover from not-initialized
27
+ setupTool,
25
28
  { ...clickTool, serialized: true },
26
29
  { ...typeTool, serialized: true },
27
30
  { ...pressKeyTool, serialized: true },
package/src/setup.ts CHANGED
@@ -1,37 +1,50 @@
1
1
  /**
2
- * Setup command — verifies Chrome is running with remote debugging
3
- * and connects the pi agent to it.
2
+ * Setup — verifies Chrome is running with remote debugging and connects
3
+ * the pi agent to it. Exposed as both a slash command (/browser-setup) for
4
+ * users and a tool (browser_setup) for the agent to self-recover.
4
5
  */
5
6
 
6
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
7
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
8
  import { execSync } from "node:child_process";
8
9
  import type { BrowserClient } from "./client";
10
+ import { ensureDaemon } from "./daemon/spawn";
11
+
12
+ // ── Public: register the /browser-setup slash command ──────────────────────
9
13
 
10
14
  export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): void {
11
15
  pi.registerCommand("browser-setup", {
12
16
  description: "Connect pi to your Chrome browser",
13
17
  handler: async (_args, ctx) => {
14
- await runSetup(ctx, client);
18
+ const result = await performSetup(client);
19
+ if (result.success) {
20
+ ctx.ui.notify(result.data, "info");
21
+ } else {
22
+ ctx.ui.notify(result.error, "error");
23
+ }
15
24
  },
16
25
  });
17
26
  }
18
27
 
19
- async function runSetup(ctx: ExtensionContext, client: BrowserClient): Promise<void> {
20
- ctx.ui.notify("browser setup: checking Chrome...", "info");
28
+ // ── Shared setup result ────────────────────────────────────────────────────
29
+
30
+ export type SetupResult = { success: true; data: string } | { success: false; error: string };
31
+
32
+ // ── Shared setup logic (used by both the command and the tool) ─────────────
21
33
 
34
+ export async function performSetup(client: BrowserClient): Promise<SetupResult> {
22
35
  // Step 1: Check Chrome is running
23
36
  const chromeRunning = checkChromeRunning();
24
37
  if (!chromeRunning) {
25
- ctx.ui.notify(
26
- "No browser instance running. Please open your browser and then run /browser-setup.",
27
- "error",
28
- );
29
- return;
38
+ return { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." };
30
39
  }
31
- ctx.ui.notify("Chrome is running ✓", "info");
32
40
 
33
- // Step 2: Try to connect to Chrome DevTools
34
- ctx.ui.notify("Connecting to Chrome DevTools...", "info");
41
+ // Step 2: Start the browser daemon (spawns if not running, silently reuses if alive)
42
+ const daemonReady = await ensureDaemon();
43
+ if (!daemonReady) {
44
+ return { success: false, error: "Could not start the browser daemon. Check /tmp/pi-browser-daemon.sock." };
45
+ }
46
+
47
+ // Step 3: Connect to Chrome DevTools
35
48
  const startResult = await client.start();
36
49
  if (!startResult.success) {
37
50
  const msg = startResult.error.message;
@@ -43,30 +56,23 @@ async function runSetup(ctx: ExtensionContext, client: BrowserClient): Promise<v
43
56
  lower.includes("econnrefused") ||
44
57
  lower.includes("cannot reach chrome devtools")
45
58
  ) {
46
- ctx.ui.notify(
47
- "Chrome remote debugging needs to be enabled.\n\n" +
48
- "Open chrome://inspect/#remote-debugging in your browser, tick the\n" +
49
- "\"Discover network targets\" / Allow checkbox, then run /browser-setup again.\n\n" +
59
+ return {
60
+ success: false,
61
+ error:
62
+ "Chrome remote debugging needs to be enabled.\n\n" +
63
+ 'Open chrome://inspect/#remote-debugging in your browser, tick the\n' +
64
+ '"Discover network targets" / Allow checkbox, then retry.\n\n' +
50
65
  "Or set BU_CDP_WS to a remote browser WebSocket URL.",
51
- "warning",
52
- );
53
- return;
66
+ };
54
67
  }
55
68
 
56
- ctx.ui.notify(`Connection failed: ${msg}`, "error");
57
- return;
69
+ return { success: false, error: `Connection failed: ${msg}` };
58
70
  }
59
- ctx.ui.notify("Connected to Chrome ✓", "info");
60
71
 
61
- // Step 3: Verify with test navigation
62
- ctx.ui.notify("Testing browser control...", "info");
72
+ // Step 4: Verify with test navigation
63
73
  const tabResult = await client.newTab("https://github.com");
64
74
  if (!tabResult.success) {
65
- ctx.ui.notify(
66
- `Browser connected but test navigation failed: ${tabResult.error.message}`,
67
- "warning",
68
- );
69
- return;
75
+ return { success: false, error: `Browser connected but test navigation failed: ${tabResult.error.message}` };
70
76
  }
71
77
 
72
78
  const info = await client.pageInfo();
@@ -75,24 +81,24 @@ async function runSetup(ctx: ExtensionContext, client: BrowserClient): Promise<v
75
81
  }
76
82
 
77
83
  const pageUrl = info.success && !("dialog" in info.data) ? info.data.url : "github.com";
78
- ctx.ui.notify(`Browser control verified ✓\nNavigated to: ${pageUrl}`, "info");
84
+ return { success: true, data: `Browser connected ✓\nNavigated to: ${pageUrl}` };
79
85
  }
80
86
 
87
+ // ── Chrome process detection ───────────────────────────────────────────────
88
+
81
89
  function checkChromeRunning(): boolean {
82
90
  try {
83
91
  if (process.platform === "darwin") {
84
- // Exact line matching — excludes "Google Chrome Helper" etc.
85
- // that linger after the user quits the browser.
92
+ // ps -o comm= returns the full executable path on modern macOS.
93
+ // We check for "Google Chrome" anywhere in the path (case-insensitive),
94
+ // excluding helper/renderer subprocesses.
86
95
  const out = execSync("ps -A -o comm=", { timeout: 5000 }).toString().toLowerCase();
87
96
  const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
88
- const browserProcesses = ["google chrome", "chromium", "microsoft edge"];
89
97
  return lines.some((l) => {
90
- if (l.includes("helper") || l.includes("renderer")) return false;
91
- return browserProcesses.includes(l);
98
+ if (l.includes("helper") || l.includes("renderer") || l.includes("crashpad")) return false;
99
+ return l.includes("google chrome") || l.includes("chromium") || l.includes("microsoft edge");
92
100
  });
93
101
  } else if (process.platform === "linux") {
94
- // Use args to distinguish the main browser process from sub-processes
95
- // (GPU, renderer, utility, etc.) which carry --type= flags.
96
102
  const out = execSync("ps -A -o comm=,args=", { timeout: 5000 }).toString().toLowerCase();
97
103
  const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
98
104
  const browserComms = ["chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge", "google-chrome"];