pi-browser-harness 0.5.0 → 0.8.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
+ };
@@ -1,13 +1,21 @@
1
- import { Type, type Static } from "typebox";
1
+ import { Type } from "typebox";
2
2
  import { Text } from "@mariozechner/pi-tui";
3
3
  import type { BrowserClient } from "../client";
4
4
  import { Coords, MouseButton } from "../schemas/common";
5
5
  import { type Result, err, ok } from "../util/result";
6
6
  import { defineBrowserTool, type ToolErr } from "../util/tool";
7
7
  import { captureWithCrosshair } from "./screenshot";
8
+ import { interactiveDiff, resolveRefToBox } from "./ref-resolve";
8
9
 
9
10
  const ClickArgs = Type.Object({
10
- ...Coords.properties,
11
+ ref: Type.Optional(
12
+ Type.String({
13
+ description:
14
+ "Stable element ref from browser_snapshot (e.g. 'e12'). PREFERRED over x/y — survives re-renders. When set, x/y are ignored.",
15
+ }),
16
+ ),
17
+ x: Type.Optional(Coords.properties.x),
18
+ y: Type.Optional(Coords.properties.y),
11
19
  button: Type.Optional(MouseButton),
12
20
  count: Type.Optional(
13
21
  Type.Integer({
@@ -18,20 +26,27 @@ const ClickArgs = Type.Object({
18
26
  }),
19
27
  ),
20
28
  });
21
- type ClickArgsT = Static<typeof ClickArgs>;
22
29
 
23
30
  const dispatchClick = async (
24
31
  client: BrowserClient,
25
- args: ClickArgsT,
32
+ x: number,
33
+ y: number,
34
+ button: "left" | "right" | "middle",
35
+ count: number,
26
36
  ): Promise<Result<void, ToolErr>> => {
27
- const button = args.button ?? "left";
28
- const count = args.count ?? 1;
37
+ // Move the pointer to the target first. Some focus/hover handlers (and React
38
+ // synthetic events) only fire when a mousemove precedes the press, so without
39
+ // it a click can land without focusing the element.
40
+ const moved = await client.session().call("Input.dispatchMouseEvent", {
41
+ type: "mouseMoved", x, y,
42
+ });
43
+ if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message });
29
44
  const pressed = await client.session().call("Input.dispatchMouseEvent", {
30
- type: "mousePressed", x: args.x, y: args.y, button, clickCount: count,
45
+ type: "mousePressed", x, y, button, clickCount: count,
31
46
  });
32
47
  if (!pressed.success) return err({ kind: "cdp_error", message: pressed.error.message });
33
48
  const released = await client.session().call("Input.dispatchMouseEvent", {
34
- type: "mouseReleased", x: args.x, y: args.y, button, clickCount: count,
49
+ type: "mouseReleased", x, y, button, clickCount: count,
35
50
  });
36
51
  if (!released.success) return err({ kind: "cdp_error", message: released.error.message });
37
52
  return ok(undefined);
@@ -41,34 +56,55 @@ export const clickTool = defineBrowserTool({
41
56
  name: "browser_click",
42
57
  label: "Browser Click",
43
58
  description:
44
- "Click at viewport CSS-pixel coordinates. Compositor-level click works through iframes, shadow DOM, and cross-origin content. Get coordinates from browser_snapshot's @(x,y) hints — do not screenshot to find click targets.",
45
- promptSnippet: "Click at pixel coordinates (x, y) on the page",
59
+ "Click an element. PREFERRED: pass `ref` (e.g. 'e12') from browser_snapshot — it re-resolves the element's position at click time, so it works even after the page re-renders and moves things. Fallback: pass viewport CSS-pixel `x`/`y`. Compositor-level click works through iframes, shadow DOM, and cross-origin content. After clicking, a compact diff of page changes is appended.",
60
+ promptSnippet: "Click an element by ref (preferred) or pixel coordinates",
46
61
  promptGuidelines: [
47
- "Get (x, y) from browser_snapshot — the outline shows '@(x,y)' for every interactive element. NO screenshot needed.",
48
- "If the target isn't an interactive role in the snapshot, fall back to browser_execute_js with `el.getBoundingClientRect()` — still NO screenshot.",
49
- "Coordinates are viewport CSS pixels (not device pixels).",
50
- "After clicking, verify with browser_snapshot (or browser_execute_js for a specific value). Only screenshot if you need to confirm visual rendering.",
51
- "Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content — no selector needed.",
52
- "If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots showing exact click positions (debug only).",
53
- "For React/Vue components that don't respond to clicks, try browser_dispatch_key to send DOM-level events.",
62
+ "PREFER `ref` from browser_snapshot (the outline shows '[eN]' for every interactive element) — it survives re-renders, unlike coordinates which go stale after a save/edit reflows the page.",
63
+ "Fallback: pass (x, y) from the snapshot's '@(x,y)' hint when there's no ref.",
64
+ "A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
65
+ "After clicking, read the appended page-changes diff to confirm the action landed before moving on (no separate snapshot needed for a quick check).",
66
+ "Coordinates are viewport CSS pixels (not device pixels). Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content.",
67
+ "If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots (debug only). For React/Vue components ignoring clicks, try browser_dispatch_key.",
54
68
  ],
55
69
  parameters: ClickArgs,
56
- renderCall: (a) => new Text(`🖱️ Click at (${a.x}, ${a.y})`, 0, 0),
70
+ renderCall: (a) => new Text(`🖱️ Click ${a.ref ? `[${a.ref}]` : `(${a.x}, ${a.y})`}`, 0, 0),
57
71
  async handler(args, { client }) {
58
- const clicked = await dispatchClick(client, args);
72
+ const button = args.button ?? "left";
73
+ const count = args.count ?? 1;
74
+
75
+ // Resolve the click point: ref re-resolves to the element's CURRENT box
76
+ // (so re-renders don't matter); otherwise use the literal x/y.
77
+ let x: number;
78
+ let y: number;
79
+ if (args.ref !== undefined) {
80
+ const box = await resolveRefToBox(client, args.ref);
81
+ if (!box.success) return box;
82
+ x = box.data.cx;
83
+ y = box.data.cy;
84
+ } else if (args.x !== undefined && args.y !== undefined) {
85
+ x = args.x;
86
+ y = args.y;
87
+ } else {
88
+ return err({ kind: "invalid_state", message: "Provide either `ref` or both `x` and `y`." });
89
+ }
90
+
91
+ const clicked = await dispatchClick(client, x, y, button, count);
59
92
  if (!clicked.success) return clicked;
93
+
94
+ const target = args.ref !== undefined ? `[${args.ref}] (${x}, ${y})` : `(${x}, ${y})`;
95
+ const diff = await interactiveDiff(client);
60
96
  if (process.env["BH_DEBUG_CLICKS"]) {
61
- const debug = await captureWithCrosshair(client, { x: args.x, y: args.y });
97
+ const debug = await captureWithCrosshair(client, { x, y });
62
98
  if (debug.success) {
63
99
  return ok({
64
- text: `Clicked at (${args.x}, ${args.y})\n[DEBUG] Overlay screenshot: ${debug.data.path}`,
65
- details: { debugScreenshotPath: debug.data.path, x: args.x, y: args.y },
100
+ text: `Clicked at ${target}\n[DEBUG] Overlay screenshot: ${debug.data.path}${diff}`,
101
+ details: { debugScreenshotPath: debug.data.path, x, y, ref: args.ref },
66
102
  });
67
103
  }
68
104
  }
69
105
  return ok({
70
- text: `Clicked at (${args.x}, ${args.y})`,
71
- details: { x: args.x, y: args.y },
106
+ text: `Clicked at ${target}${diff}`,
107
+ details: { x, y, ref: args.ref },
72
108
  });
73
109
  },
74
110
  });
@@ -6,12 +6,28 @@ import { type Result, err, ok } from "../util/result";
6
6
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
7
  import { safeJs } from "../util/js-template";
8
8
  import { pdfPath } from "../util/paths";
9
+ import { resolveRefToBackendId } from "./ref-resolve";
9
10
 
10
11
  const UploadArgs = Type.Object({
11
- selector: Type.String({ description: "CSS selector of the file <input>" }),
12
+ ref: Type.Optional(
13
+ Type.String({ description: "Stable ref of the file <input> from browser_snapshot. PREFERRED over selector." }),
14
+ ),
15
+ selector: Type.Optional(Type.String({ description: "CSS selector of the file <input> (fallback when no ref)" })),
12
16
  filePath: Type.String({ description: "Absolute path to the file to upload" }),
13
17
  });
14
18
 
19
+ // Ref path: set files directly on the resolved backendNodeId via CDP — simplest
20
+ // and most reliable, no selector needed.
21
+ const tryRefUpload = async (
22
+ client: BrowserClient,
23
+ backendId: number,
24
+ filePath: string,
25
+ ): Promise<Result<void, ToolErr>> => {
26
+ const set = await client.session().call("DOM.setFileInputFiles", { files: [filePath], backendNodeId: backendId });
27
+ if (!set.success) return err({ kind: "cdp_error", message: set.error.message });
28
+ return ok(undefined);
29
+ };
30
+
15
31
  const verifyReadable = async (filePath: string): Promise<Result<void, ToolErr>> => {
16
32
  try {
17
33
  await access(filePath, constants.R_OK);
@@ -89,16 +105,29 @@ const jsFallbackUpload = async (
89
105
  export const uploadFileTool = defineBrowserTool({
90
106
  name: "browser_upload_file",
91
107
  label: "Browser Upload File",
92
- description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages.",
93
- promptSnippet: "Upload a file to a file input",
108
+ description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.",
109
+ promptSnippet: "Upload a file to a file input by ref (preferred) or selector",
94
110
  promptGuidelines: [
111
+ "PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders. (File inputs are often hidden; snapshot still surfaces them.)",
95
112
  "File path must be absolute and readable.",
96
- "Selector must match a file input.",
97
113
  ],
98
114
  parameters: UploadArgs,
99
115
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
100
116
  const readable = await verifyReadable(args.filePath);
101
117
  if (!readable.success) return readable;
118
+
119
+ // Ref path: resolve to backendNodeId and set files directly via CDP.
120
+ if (args.ref !== undefined) {
121
+ const backendId = resolveRefToBackendId(client, args.ref);
122
+ if (!backendId.success) return backendId;
123
+ const up = await tryRefUpload(client, backendId.data, args.filePath);
124
+ if (!up.success) return up;
125
+ return ok({ text: `Uploaded ${args.filePath} via CDP (ref ${args.ref})`, details: { mode: "cdp", ref: args.ref, filePath: args.filePath } });
126
+ }
127
+
128
+ if (args.selector === undefined) {
129
+ return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
130
+ }
102
131
  const cdp = await tryCdpUpload(client, args.selector, args.filePath);
103
132
  if (cdp.success) {
104
133
  return ok({