pi-browser-harness 0.4.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
+ };
@@ -0,0 +1,173 @@
1
+ import { Type } from "typebox";
2
+ import { Markdown, Text } from "@mariozechner/pi-tui";
3
+ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
4
+ import { type Result, ok } from "../util/result";
5
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
6
+ import { applyTruncation } from "../util/truncate";
7
+ import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer";
8
+
9
+ const ConsoleArgs = Type.Object({
10
+ levels: Type.Optional(
11
+ Type.Array(
12
+ Type.Union(
13
+ [
14
+ Type.Literal("log"),
15
+ Type.Literal("info"),
16
+ Type.Literal("warn"),
17
+ Type.Literal("error"),
18
+ Type.Literal("debug"),
19
+ ],
20
+ ),
21
+ { description: 'Filter to these levels only, e.g. ["error","warn"]. Default: all levels.' },
22
+ ),
23
+ ),
24
+ textPattern: Type.Optional(
25
+ Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /TypeError/) for regex." }),
26
+ ),
27
+ sinceSeq: Type.Optional(
28
+ Type.Integer({ minimum: 0, description: "Only records with seq strictly greater than this. Use the previous call's nextCursor to see only what's new." }),
29
+ ),
30
+ sinceMs: Type.Optional(
31
+ Type.Integer({ minimum: 0, description: "Only records from the last N ms." }),
32
+ ),
33
+ limit: Type.Optional(
34
+ Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
35
+ ),
36
+ });
37
+
38
+ const levelTag = (lvl: ConsoleLevel): string => {
39
+ switch (lvl) {
40
+ case "error": return "[error]";
41
+ case "warn": return "[warn] ";
42
+ case "info": return "[info] ";
43
+ case "debug": return "[debug]";
44
+ default: return "[log] ";
45
+ }
46
+ };
47
+
48
+ const formatLocation = (r: ConsoleRecord): string => {
49
+ if (r.url === undefined || r.url === "") return "";
50
+ const short = r.url.length > 60 ? "…" + r.url.slice(-59) : r.url;
51
+ return r.lineNumber !== undefined ? `${short}:${r.lineNumber}` : short;
52
+ };
53
+
54
+ const renderConsoleMarkdown = (
55
+ records: ReadonlyArray<ConsoleRecord>,
56
+ opts: { total: number; bufferOverflowed: boolean; expanded: boolean; nextCursor: number | undefined },
57
+ ): string => {
58
+ if (records.length === 0) {
59
+ if (opts.bufferOverflowed) return "_No matching console records. (Buffer overflowed since last drain — some events were dropped.)_";
60
+ return "_No matching console records captured yet._";
61
+ }
62
+
63
+ const counts = records.reduce<Record<ConsoleLevel, number>>(
64
+ (acc, r) => { acc[r.level]++; return acc; },
65
+ { log: 0, info: 0, warn: 0, error: 0, debug: 0 },
66
+ );
67
+
68
+ const summaryParts: string[] = [];
69
+ if (counts.error > 0) summaryParts.push(`${counts.error} error${counts.error === 1 ? "" : "s"}`);
70
+ if (counts.warn > 0) summaryParts.push(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`);
71
+ if (counts.info > 0) summaryParts.push(`${counts.info} info`);
72
+ if (counts.log > 0) summaryParts.push(`${counts.log} log${counts.log === 1 ? "" : "s"}`);
73
+ if (counts.debug > 0) summaryParts.push(`${counts.debug} debug`);
74
+
75
+ const headerLines: string[] = [`**${summaryParts.join(" · ")}**`];
76
+ if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older entries dropped");
77
+ if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
78
+
79
+ const renderRow = (r: ConsoleRecord, compact: boolean): string => {
80
+ const tag = levelTag(r.level);
81
+ const loc = formatLocation(r);
82
+ const maxText = compact ? 80 : 200;
83
+ const text = r.text.length > maxText ? r.text.slice(0, maxText - 1) + "…" : r.text;
84
+ const locSuffix = loc !== "" ? ` _at ${loc}_` : "";
85
+ const main = `\`${tag}\` ${text}${locSuffix}`;
86
+ if (!compact && r.stackTrace !== undefined) return `${main}\n\`\`\`\n${r.stackTrace}\n\`\`\``;
87
+ return main;
88
+ };
89
+
90
+ const visible = !opts.expanded && records.length > 5 ? records.slice(-5) : records;
91
+ const rows = visible.map((r) => renderRow(r, !opts.expanded)).join(opts.expanded ? "\n\n" : "\n");
92
+
93
+ const more = !opts.expanded && records.length > 5 ? `\n … ${records.length - 5} earlier (expand to see all)` : "";
94
+ const cursor = opts.nextCursor !== undefined ? `\n\n_nextCursor=${opts.nextCursor}_` : "";
95
+
96
+ return `${headerLines.join("\n")}\n\n${rows}${more}${cursor}`;
97
+ };
98
+
99
+ type ConsoleDetails = {
100
+ total: number;
101
+ returned: number;
102
+ bufferOverflowed: boolean;
103
+ truncated: boolean;
104
+ fullOutputPath?: string;
105
+ records: ReadonlyArray<ConsoleRecord>;
106
+ nextCursor?: number;
107
+ };
108
+
109
+ export const consoleTool = defineBrowserTool({
110
+ name: "browser_console",
111
+ label: "Browser Console",
112
+ description:
113
+ "Read JS errors and console messages from the active tab. Diagnostic — use when an action looks broken or silent.",
114
+ promptSnippet: "Read JS errors / console output on the current tab",
115
+ promptGuidelines: [
116
+ "Use after a click, submit, or navigate produced no visible change — errors often explain it.",
117
+ "Pass `sinceSeq` from the previous call's `nextCursor` to see only new messages after an action.",
118
+ "Buffer is reset on tab switch (capacity 500 records). bufferOverflowed:true means older entries were dropped.",
119
+ "Don't use as a default observation tool — prefer browser_snapshot for page structure.",
120
+ ],
121
+ parameters: ConsoleArgs,
122
+
123
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
124
+ const session = client.session();
125
+ const drained = session.drainConsoleBuffer({
126
+ ...(args.levels !== undefined ? { levels: args.levels } : {}),
127
+ ...(args.textPattern !== undefined ? { textPattern: args.textPattern } : {}),
128
+ ...(args.sinceSeq !== undefined ? { sinceSeq: args.sinceSeq } : {}),
129
+ ...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
130
+ limit: args.limit ?? 50,
131
+ });
132
+
133
+ const last = drained.records.at(-1);
134
+ const nextCursor = last?.seq;
135
+
136
+ const text = renderConsoleMarkdown(drained.records, {
137
+ total: drained.total,
138
+ bufferOverflowed: drained.bufferOverflowed,
139
+ expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans
140
+ nextCursor,
141
+ });
142
+ const trunc = await applyTruncation(text, "console");
143
+
144
+ const details: ConsoleDetails = {
145
+ total: drained.total,
146
+ returned: drained.records.length,
147
+ bufferOverflowed: drained.bufferOverflowed,
148
+ truncated: trunc.wasTruncated,
149
+ records: drained.records,
150
+ ...(nextCursor !== undefined ? { nextCursor } : {}),
151
+ };
152
+ if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
153
+
154
+ return ok({ text: trunc.text, details });
155
+ },
156
+
157
+ renderResult(result, expanded, theme) {
158
+ const details = result.details as ConsoleDetails | undefined;
159
+ if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0);
160
+
161
+ const md = renderConsoleMarkdown(details.records, {
162
+ total: details.total,
163
+ bufferOverflowed: details.bufferOverflowed,
164
+ expanded,
165
+ nextCursor: details.nextCursor,
166
+ });
167
+ const tail = expanded
168
+ ? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
169
+ : `\n\n${keyHint("app.tools.expand", "to expand")}`;
170
+
171
+ return new Markdown(md + tail, 0, 0, getMarkdownTheme());
172
+ },
173
+ });
@@ -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