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.
- package/CHANGELOG.md +12 -1
- package/package.json +2 -1
- package/skills/pi-browser-harness/SKILL.md +17 -1
- package/src/cdp/daemon-transport.ts +211 -0
- package/src/cdp/event-queue.ts +128 -0
- package/src/cdp/session.ts +31 -0
- package/src/cdp/transport.ts +4 -69
- package/src/client.ts +16 -2
- package/src/daemon/bridge.ts +411 -0
- package/src/daemon/index.ts +114 -0
- package/src/daemon/protocol.ts +162 -0
- package/src/daemon/server.ts +245 -0
- package/src/daemon/spawn.ts +85 -0
- package/src/domains/click.ts +60 -24
- package/src/domains/files.ts +33 -4
- package/src/domains/form.ts +270 -0
- package/src/domains/keyboard.ts +61 -5
- package/src/domains/navigate.ts +17 -3
- package/src/domains/page.ts +45 -0
- package/src/domains/ref-resolve.ts +152 -0
- package/src/domains/setup.ts +41 -0
- package/src/domains/snapshot.ts +87 -11
- package/src/index.ts +46 -25
- package/src/prompt.ts +26 -7
- package/src/registry.ts +9 -1
- package/src/setup.ts +45 -39
- package/src/util/tool.ts +19 -0
|
@@ -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
|
+
};
|
package/src/domains/click.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import { Type
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
x: number,
|
|
33
|
+
y: number,
|
|
34
|
+
button: "left" | "right" | "middle",
|
|
35
|
+
count: number,
|
|
26
36
|
): Promise<Result<void, ToolErr>> => {
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
45
|
-
promptSnippet: "Click
|
|
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
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"After clicking,
|
|
51
|
-
"Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content
|
|
52
|
-
"If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots
|
|
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
|
|
70
|
+
renderCall: (a) => new Text(`🖱️ Click ${a.ref ? `[${a.ref}]` : `(${a.x}, ${a.y})`}`, 0, 0),
|
|
57
71
|
async handler(args, { client }) {
|
|
58
|
-
const
|
|
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
|
|
97
|
+
const debug = await captureWithCrosshair(client, { x, y });
|
|
62
98
|
if (debug.success) {
|
|
63
99
|
return ok({
|
|
64
|
-
text: `Clicked at
|
|
65
|
-
details: { debugScreenshotPath: debug.data.path, x
|
|
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
|
|
71
|
-
details: { x
|
|
106
|
+
text: `Clicked at ${target}${diff}`,
|
|
107
|
+
details: { x, y, ref: args.ref },
|
|
72
108
|
});
|
|
73
109
|
},
|
|
74
110
|
});
|
package/src/domains/files.ts
CHANGED
|
@@ -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
|
-
|
|
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({
|