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
package/src/domains/snapshot.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
4
4
|
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import type { CdpSession } from "../cdp/session";
|
|
5
6
|
import { type Result, err, ok } from "../util/result";
|
|
6
7
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
7
8
|
import { applyTruncation } from "../util/truncate";
|
|
@@ -27,7 +28,7 @@ const SnapshotArgs = Type.Object({
|
|
|
27
28
|
|
|
28
29
|
// Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only.
|
|
29
30
|
type AxValue = { value?: unknown };
|
|
30
|
-
type RawAxNode = {
|
|
31
|
+
export type RawAxNode = {
|
|
31
32
|
nodeId: string;
|
|
32
33
|
parentId?: string;
|
|
33
34
|
childIds?: ReadonlyArray<string>;
|
|
@@ -42,7 +43,7 @@ type RawAxNode = {
|
|
|
42
43
|
|
|
43
44
|
type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
|
|
44
45
|
|
|
45
|
-
type SlimNode = {
|
|
46
|
+
export type SlimNode = {
|
|
46
47
|
role: string;
|
|
47
48
|
name?: string;
|
|
48
49
|
value?: string;
|
|
@@ -51,6 +52,8 @@ type SlimNode = {
|
|
|
51
52
|
children: SlimNode[];
|
|
52
53
|
/** Internal: kept for the post-build box fetch. Stripped before returning to the LLM. */
|
|
53
54
|
_backendId?: number;
|
|
55
|
+
/** Stable handle (e.g. "e12") for interaction tools. Survives re-renders via backendNodeId. */
|
|
56
|
+
ref?: string;
|
|
54
57
|
/** Click target — center of the element's bounding box, in viewport CSS pixels. */
|
|
55
58
|
box?: Box;
|
|
56
59
|
};
|
|
@@ -70,6 +73,20 @@ const INTERACTIVE_ROLES = new Set([
|
|
|
70
73
|
"spinbutton",
|
|
71
74
|
]);
|
|
72
75
|
|
|
76
|
+
// Roles whose live DOM value/checked state should override the (often stale)
|
|
77
|
+
// AX-tree value. The AX `value` is unreliable for freshly-typed or controlled
|
|
78
|
+
// inputs, so for these we read the real DOM property at snapshot time.
|
|
79
|
+
const VALUE_ROLES = new Set([
|
|
80
|
+
"textbox",
|
|
81
|
+
"searchbox",
|
|
82
|
+
"spinbutton",
|
|
83
|
+
"combobox",
|
|
84
|
+
"checkbox",
|
|
85
|
+
"radio",
|
|
86
|
+
"switch",
|
|
87
|
+
"slider",
|
|
88
|
+
]);
|
|
89
|
+
|
|
73
90
|
const stringOf = (v: AxValue | undefined): string | undefined => {
|
|
74
91
|
const x = v?.value;
|
|
75
92
|
if (typeof x === "string" && x !== "") return x;
|
|
@@ -93,7 +110,7 @@ const collectState = (props: RawAxNode["properties"]): string | undefined => {
|
|
|
93
110
|
return flags.length > 0 ? flags.join(", ") : undefined;
|
|
94
111
|
};
|
|
95
112
|
|
|
96
|
-
const buildTree = (
|
|
113
|
+
export const buildTree = (
|
|
97
114
|
rawNodes: ReadonlyArray<RawAxNode>,
|
|
98
115
|
opts: { interestingOnly: boolean; maxNodes: number },
|
|
99
116
|
): SlimNode[] => {
|
|
@@ -204,8 +221,9 @@ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => {
|
|
|
204
221
|
if (n.name) line += ` "${n.name}"`;
|
|
205
222
|
if (n.value && n.value !== n.name) line += ` = ${JSON.stringify(n.value)}`;
|
|
206
223
|
if (n.state) line += ` (${n.state})`;
|
|
207
|
-
// Surface click coordinates for interactive nodes
|
|
208
|
-
//
|
|
224
|
+
// Surface the stable ref + click coordinates for interactive nodes. Agents
|
|
225
|
+
// pass `ref` to interaction tools (survives re-renders); @(x,y) is a fallback.
|
|
226
|
+
if (n.ref) line += ` [${n.ref}]`;
|
|
209
227
|
if (n.box && INTERACTIVE_ROLES.has(n.role)) {
|
|
210
228
|
line += ` @(${n.box.cx},${n.box.cy})`;
|
|
211
229
|
}
|
|
@@ -229,7 +247,7 @@ const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
|
|
|
229
247
|
* roles. Caller fetches DOM.getBoxModel for each and writes box back into the
|
|
230
248
|
* slim node by reference.
|
|
231
249
|
*/
|
|
232
|
-
const collectInteractiveTargets = (
|
|
250
|
+
export const collectInteractiveTargets = (
|
|
233
251
|
nodes: ReadonlyArray<SlimNode>,
|
|
234
252
|
): Array<{ node: SlimNode; backendId: number }> => {
|
|
235
253
|
const out: Array<{ node: SlimNode; backendId: number }> = [];
|
|
@@ -245,6 +263,33 @@ const collectInteractiveTargets = (
|
|
|
245
263
|
return out;
|
|
246
264
|
};
|
|
247
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Read the live DOM value for a form control by backend node id. Resolves the
|
|
268
|
+
* node to a JS object then reads the property that matters for its type:
|
|
269
|
+
* `.value` for inputs/selects/textareas, `.checked` (→ "checked"/"unchecked")
|
|
270
|
+
* for checkboxes/radios. Returns undefined on any failure so the caller falls
|
|
271
|
+
* back to the AX value. Mirrors the box-fetch error handling: never throws.
|
|
272
|
+
*/
|
|
273
|
+
const liveValue = async (session: CdpSession, backendId: number): Promise<string | undefined> => {
|
|
274
|
+
const resolved = await session.call("DOM.resolveNode", { backendNodeId: backendId });
|
|
275
|
+
if (!resolved.success) return undefined;
|
|
276
|
+
const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
|
|
277
|
+
if (!objectId) return undefined;
|
|
278
|
+
const fn = `function () {
|
|
279
|
+
if (this.type === "checkbox" || this.type === "radio") return this.checked ? "checked" : "unchecked";
|
|
280
|
+
if (typeof this.value === "string") return this.value;
|
|
281
|
+
return null;
|
|
282
|
+
}`;
|
|
283
|
+
const r = await session.call("Runtime.callFunctionOn", {
|
|
284
|
+
objectId,
|
|
285
|
+
functionDeclaration: fn,
|
|
286
|
+
returnByValue: true,
|
|
287
|
+
});
|
|
288
|
+
if (!r.success) return undefined;
|
|
289
|
+
const value = (r.data as { result?: { value?: unknown } }).result?.value;
|
|
290
|
+
return typeof value === "string" ? value : undefined;
|
|
291
|
+
};
|
|
292
|
+
|
|
248
293
|
type SnapshotDetails = {
|
|
249
294
|
nodeCount: number;
|
|
250
295
|
truncated: boolean;
|
|
@@ -258,14 +303,15 @@ export const snapshotTool = defineBrowserTool({
|
|
|
258
303
|
name: "browser_snapshot",
|
|
259
304
|
label: "Browser Snapshot",
|
|
260
305
|
description:
|
|
261
|
-
"DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy)
|
|
262
|
-
promptSnippet: "Get accessibility-tree snapshot
|
|
306
|
+
"DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy). Every interactive element gets a stable ref shown as [eN] plus click coordinates @(x,y). Pass the ref to browser_click/browser_fill/etc. — refs survive re-renders, coordinates don't. Use this BEFORE deciding whether you need a screenshot. Pair with browser_execute_js for surgical reads of specific element values.",
|
|
307
|
+
promptSnippet: "Get accessibility-tree snapshot with stable element refs (default for page inspection)",
|
|
263
308
|
promptGuidelines: [
|
|
264
309
|
"DEFAULT — use this whenever you need to know what's on a page, what's clickable, or how the page is structured.",
|
|
265
|
-
"
|
|
266
|
-
"DO NOT call browser_screenshot just to understand the page. This tool already gives you structure, labels, states, and click targets.",
|
|
310
|
+
"Refs come for free: every interactive element shows '[eN]' in the outline. Pass eN as `ref` to browser_click/browser_fill/browser_select_option/browser_focus/browser_upload_file — refs survive re-renders, so prefer them over the '@(x,y)' coordinates (a fallback).",
|
|
311
|
+
"DO NOT call browser_screenshot just to understand the page. This tool already gives you structure, labels, states, refs, and click targets.",
|
|
312
|
+
"Re-run after a navigation or a major re-render to get fresh refs; a 'ref is stale' error from an interaction tool means you need a new snapshot.",
|
|
267
313
|
"Pass includeScreenshot:true ONLY if you also need to verify visual rendering (rare).",
|
|
268
|
-
"format:'json' returns the raw slim structure (with `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
|
|
314
|
+
"format:'json' returns the raw slim structure (with `ref` and `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
|
|
269
315
|
],
|
|
270
316
|
parameters: SnapshotArgs,
|
|
271
317
|
|
|
@@ -293,6 +339,19 @@ export const snapshotTool = defineBrowserTool({
|
|
|
293
339
|
// without a screenshot round-trip. Capped budget so a slow page can't
|
|
294
340
|
// wedge the call.
|
|
295
341
|
const targets = collectInteractiveTargets(slim);
|
|
342
|
+
|
|
343
|
+
// Assign stable refs (e1, e2, …) to every interactive target. Interaction
|
|
344
|
+
// tools resolve these refs to backendNodeIds at call time, so they stay
|
|
345
|
+
// valid across re-renders. The ref → backendNodeId map (and the per-ref
|
|
346
|
+
// signature baseline for the post-mutation diff) is published below, after
|
|
347
|
+
// the box/live-value fetch so the signature reflects the live DOM value.
|
|
348
|
+
const refMap = new Map<string, number>();
|
|
349
|
+
targets.forEach(({ node, backendId }, i) => {
|
|
350
|
+
const ref = `e${i + 1}`;
|
|
351
|
+
node.ref = ref;
|
|
352
|
+
refMap.set(ref, backendId);
|
|
353
|
+
});
|
|
354
|
+
|
|
296
355
|
if (targets.length > 0) {
|
|
297
356
|
const ac = new AbortController();
|
|
298
357
|
const timer = setTimeout(() => ac.abort(), 1_500);
|
|
@@ -320,11 +379,28 @@ export const snapshotTool = defineBrowserTool({
|
|
|
320
379
|
cx: Math.round(x + width / 2),
|
|
321
380
|
cy: Math.round(y + height / 2),
|
|
322
381
|
};
|
|
382
|
+
// Override the AX value with the live DOM property for form controls.
|
|
383
|
+
// The AX `value` is stale for freshly-typed / controlled inputs, so the
|
|
384
|
+
// agent can't trust it as a read-back signal. Reading .value/.checked
|
|
385
|
+
// from the actual element fixes that.
|
|
386
|
+
if (ac.signal.aborted || !VALUE_ROLES.has(node.role)) return;
|
|
387
|
+
const live = await liveValue(session, backendId);
|
|
388
|
+
if (live !== undefined) node.value = live;
|
|
323
389
|
}),
|
|
324
390
|
);
|
|
325
391
|
clearTimeout(timer);
|
|
326
392
|
}
|
|
327
393
|
|
|
394
|
+
// Publish refs to the active tab. The signature (role|name|value|state) is
|
|
395
|
+
// the baseline the post-mutation auto-diff compares against, so it must use
|
|
396
|
+
// the live values resolved above.
|
|
397
|
+
const refSig = new Map<string, string>();
|
|
398
|
+
for (const { node } of targets) {
|
|
399
|
+
if (node.ref === undefined) continue;
|
|
400
|
+
refSig.set(node.ref, `${node.role}|${node.name ?? ""}|${node.value ?? ""}|${node.state ?? ""}`);
|
|
401
|
+
}
|
|
402
|
+
session.setRefMap(refMap, refSig);
|
|
403
|
+
|
|
328
404
|
const stripped = stripInternals(slim);
|
|
329
405
|
const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripped, null, 2);
|
|
330
406
|
const trunc = await applyTruncation(text, "snapshot");
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/prompt.ts
CHANGED
|
@@ -21,9 +21,13 @@ to interact with pages visually, and other tools alongside them.
|
|
|
21
21
|
|
|
22
22
|
| Tool | Purpose |
|
|
23
23
|
|------|---------|
|
|
24
|
+
| browser_snapshot | Accessibility-tree snapshot. Assigns every interactive element a stable **ref** (\`[eN]\`) plus \`@(x,y)\`. Refs are the primary handle for interaction tools — they survive re-renders, coordinates don't. |
|
|
24
25
|
| browser_screenshot | Capture a PNG or JPEG screenshot of the current page (rendered inline in the TUI; the LLM reads the image via the saved file path) |
|
|
25
|
-
| browser_click | Click
|
|
26
|
-
| browser_type | Type text into the focused element |
|
|
26
|
+
| browser_click | Click an element by **ref** (preferred — re-resolves position, survives re-renders) or viewport x/y. Appends a compact diff of page changes. |
|
|
27
|
+
| browser_type | Type text into the focused element (real keystrokes; requires a focused field) |
|
|
28
|
+
| browser_fill | Fill an input/textarea by **ref** (preferred) or selector — updates React/Vue controlled components, verifies the value, appends a page-changes diff |
|
|
29
|
+
| browser_select_option | Select an option in a native <select> by **ref** (preferred) or selector, choosing by value/label/index (fires change) |
|
|
30
|
+
| browser_focus | Focus an element by **ref** (preferred) or selector (deterministic; use before browser_type) |
|
|
27
31
|
| browser_press_key | Press a keyboard key (Enter, Tab, Escape, arrows, etc.) |
|
|
28
32
|
| browser_scroll | Scroll the page at coordinates |
|
|
29
33
|
| browser_navigate | Navigate to a URL (result details.outcome.kind is "in_place" or "new_tab_created") |
|
|
@@ -38,6 +42,7 @@ to interact with pages visually, and other tools alongside them.
|
|
|
38
42
|
| browser_http_get | Direct HTTP GET (outside browser, for APIs) |
|
|
39
43
|
| browser_console | Read JS errors / console output on the current tab (diagnostic — use when an action looks broken; pass sinceSeq from the previous nextCursor to see only new messages) |
|
|
40
44
|
| browser_wait | Wait N seconds |
|
|
45
|
+
| browser_wait_for | Wait until a selector/text appears (or a selector disappears) — use for SPA content before interacting |
|
|
41
46
|
| browser_wait_for_load | Wait for document.readyState === 'complete' (returns a typed timeout error if the page doesn't reach readyState=complete in N seconds, default 15) |
|
|
42
47
|
| browser_handle_dialog | Accept or dismiss a JS dialog |
|
|
43
48
|
| browser_run_script | Execute a temporary script file with daemon access (write script to disk, then run) |
|
|
@@ -83,11 +88,25 @@ browser_http_get("https://api.example.com/data") + browser_click(x, y)
|
|
|
83
88
|
browser_new_tab("https://example.com") → browser_wait_for_load() → browser_screenshot()
|
|
84
89
|
\`\`\`
|
|
85
90
|
|
|
86
|
-
**Form filling:**
|
|
91
|
+
**Form filling (ref-first — this is the reliable path on React/SPA forms):**
|
|
87
92
|
\`\`\`
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
browser_wait_for({ selector: "#email" }) // ensure SPA field is rendered
|
|
94
|
+
browser_snapshot() // each interactive element gets a [eN] ref
|
|
95
|
+
→ browser_fill({ ref: "e7", value: "a@b.com" }) // target by ref, not a guessed selector
|
|
96
|
+
→ browser_select_option({ ref: "e9", label: "India" }) // native <select> by ref
|
|
97
|
+
→ browser_click({ ref: "e12" }) // e.g. Save — re-resolves position even if the form reflowed
|
|
98
|
+
// each mutating call appends a compact "Page changes" diff — read it to confirm
|
|
99
|
+
// the change landed (e.g. a save closed the form, a new field appeared as *[eN]).
|
|
90
100
|
\`\`\`
|
|
101
|
+
**Why ref over selector/coordinates:** refs are keyed to the element's identity, so
|
|
102
|
+
they survive the re-renders that React/Vue forms trigger on every edit and save —
|
|
103
|
+
coordinates go stale and selectors you guess often don't match. If a call returns
|
|
104
|
+
"ref is stale", the page changed: re-run browser_snapshot for fresh refs.
|
|
105
|
+
|
|
106
|
+
Prefer browser_fill for text inputs/textareas — it fires input/change so controlled
|
|
107
|
+
components update, and returns the field's value for verification. Use browser_click +
|
|
108
|
+
browser_type only for keystroke-sensitive widgets (autocomplete, masked inputs);
|
|
109
|
+
browser_type requires an already-focused field and errors if none is focused.
|
|
91
110
|
|
|
92
111
|
**Data extraction:**
|
|
93
112
|
\`\`\`
|
|
@@ -121,8 +140,8 @@ write("/tmp/extract.js", "...script with daemon access...") → browser_run_scri
|
|
|
121
140
|
|
|
122
141
|
| Tool | Purpose |
|
|
123
142
|
|------|---------|
|
|
124
|
-
| browser_upload_file | Upload a file to a file input (bypasses file picker) |
|
|
125
|
-
| browser_dispatch_key | Dispatch a DOM KeyboardEvent on
|
|
143
|
+
| browser_upload_file | Upload a file to a file input by **ref** (preferred) or selector (bypasses file picker) |
|
|
144
|
+
| browser_dispatch_key | Dispatch a DOM KeyboardEvent on an element by **ref** (preferred) or selector (for React/Vue inputs); returns details.matched |
|
|
126
145
|
| browser_download | Configure download directory and disable save-as prompts |
|
|
127
146
|
| browser_viewport_resize | Resize the viewport for responsive testing |
|
|
128
147
|
| browser_drag_and_drop | Perform drag-and-drop from one coordinate to another |
|
package/src/registry.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { BrowserClient } from "./client";
|
|
|
3
3
|
import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
|
|
4
4
|
import { clickTool } from "./domains/click";
|
|
5
5
|
import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
|
|
6
|
-
import {
|
|
6
|
+
import { fillTool, selectOptionTool, focusTool } from "./domains/form";
|
|
7
|
+
import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
|
|
7
8
|
import { scrollTool } from "./domains/scroll";
|
|
8
9
|
import { handleDialogTool } from "./domains/dialog";
|
|
9
10
|
import { screenshotTool } from "./domains/screenshot";
|
|
@@ -17,18 +18,25 @@ import { httpGetTool, networkRequestsTool } from "./domains/network";
|
|
|
17
18
|
import { consoleTool } from "./domains/console";
|
|
18
19
|
import { snapshotTool } from "./domains/snapshot";
|
|
19
20
|
import { executeJsTool, runScriptTool } from "./domains/js";
|
|
21
|
+
import { setupTool } from "./domains/setup";
|
|
20
22
|
|
|
21
23
|
// Mutation tools that modify page / browser state are marked `serialized: true`
|
|
22
24
|
// so they run through a shared async mutex. Observation / read-only tools are
|
|
23
25
|
// left unmarked and can execute in parallel.
|
|
24
26
|
const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
|
|
27
|
+
// setup comes first — the agent calls this to self-recover from not-initialized
|
|
28
|
+
setupTool,
|
|
25
29
|
{ ...clickTool, serialized: true },
|
|
26
30
|
{ ...typeTool, serialized: true },
|
|
31
|
+
{ ...fillTool, serialized: true },
|
|
32
|
+
{ ...selectOptionTool, serialized: true },
|
|
33
|
+
{ ...focusTool, serialized: true },
|
|
27
34
|
{ ...pressKeyTool, serialized: true },
|
|
28
35
|
{ ...dispatchKeyTool, serialized: true },
|
|
29
36
|
{ ...scrollTool, serialized: true },
|
|
30
37
|
pageInfoTool,
|
|
31
38
|
waitTool,
|
|
39
|
+
waitForTool,
|
|
32
40
|
{ ...waitForLoadTool, serialized: true },
|
|
33
41
|
{ ...handleDialogTool, serialized: true },
|
|
34
42
|
screenshotTool,
|
package/src/setup.ts
CHANGED
|
@@ -1,37 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Setup
|
|
3
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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:
|
|
34
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
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
|
-
|
|
52
|
-
);
|
|
53
|
-
return;
|
|
66
|
+
};
|
|
54
67
|
}
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
return;
|
|
69
|
+
return { success: false, error: `Connection failed: ${msg}` };
|
|
58
70
|
}
|
|
59
|
-
ctx.ui.notify("Connected to Chrome ✓", "info");
|
|
60
71
|
|
|
61
|
-
// Step
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
85
|
-
//
|
|
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
|
|
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"];
|
package/src/util/tool.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
ToolRenderResultOptions,
|
|
10
10
|
} from "@mariozechner/pi-coding-agent";
|
|
11
11
|
import type { Component } from "@mariozechner/pi-tui";
|
|
12
|
+
import { access } from "node:fs/promises";
|
|
13
|
+
import { DAEMON_SOCKET_PATH } from "../daemon/protocol";
|
|
12
14
|
import type { BrowserClient } from "../client";
|
|
13
15
|
import type { Result } from "./result";
|
|
14
16
|
|
|
@@ -142,6 +144,23 @@ export const registerBrowserTool = (
|
|
|
142
144
|
: {}),
|
|
143
145
|
async execute(_toolCallId, args, signal, onUpdate, extensionCtx) {
|
|
144
146
|
if (def.ensureAlive !== false) {
|
|
147
|
+
// Check daemon socket exists before attempting browser control.
|
|
148
|
+
// If missing, the user hasn't run /browser-setup yet. Return a clear
|
|
149
|
+
// error so the agent knows to direct the user to initialize.
|
|
150
|
+
try {
|
|
151
|
+
await access(DAEMON_SOCKET_PATH);
|
|
152
|
+
} catch {
|
|
153
|
+
return toToolResult(
|
|
154
|
+
{
|
|
155
|
+
success: false,
|
|
156
|
+
error: {
|
|
157
|
+
kind: "not_connected",
|
|
158
|
+
message: "Browser harness not initialized. Run /browser-setup first",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
def.name,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
145
164
|
const alive = await client.ensureAlive();
|
|
146
165
|
if (!alive.success) {
|
|
147
166
|
return toToolResult(
|