pi-browser-harness 0.2.0 → 0.3.1
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 +70 -0
- package/package.json +4 -1
- package/skills/pi-browser-harness/SKILL.md +17 -4
- package/src/cdp/discovery.ts +95 -0
- package/src/cdp/errors.ts +19 -0
- package/src/cdp/session.ts +139 -0
- package/src/cdp/transport.ts +221 -0
- package/src/cdp/types.ts +72 -0
- package/src/client.ts +232 -0
- package/src/domains/click.ts +73 -0
- package/src/domains/dialog.ts +31 -0
- package/src/domains/drag.ts +66 -0
- package/src/domains/files.ts +194 -0
- package/src/domains/history.ts +76 -0
- package/src/domains/js.ts +197 -0
- package/src/domains/keyboard.ts +127 -0
- package/src/domains/navigate.ts +146 -0
- package/src/domains/network.ts +80 -0
- package/src/domains/page.ts +88 -0
- package/src/domains/screenshot.ts +114 -0
- package/src/domains/scroll.ts +53 -0
- package/src/domains/tabs.ts +121 -0
- package/src/domains/viewport.ts +40 -0
- package/src/index.ts +65 -142
- package/src/prompt.ts +16 -3
- package/src/registry.ts +52 -0
- package/src/schemas/common.ts +11 -0
- package/src/setup.ts +20 -21
- package/src/state.ts +22 -54
- package/src/util/js-template.ts +27 -0
- package/src/util/keycodes.ts +10 -0
- package/src/util/paths.ts +21 -0
- package/src/util/result.ts +15 -0
- package/src/util/sharp-shim.ts +49 -0
- package/src/util/time.ts +29 -0
- package/src/util/tool.ts +169 -0
- package/src/util/truncate.ts +35 -0
- package/tsconfig.json +10 -1
- package/src/daemon.ts +0 -881
- package/src/protocol.ts +0 -77
- package/src/renderers.ts +0 -21
- package/src/tools.ts +0 -1363
package/src/tools.ts
DELETED
|
@@ -1,1363 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool registrations for pi-browser-harness.
|
|
3
|
-
*
|
|
4
|
-
* Maps browser-harness daemon methods to pi tools with TypeBox schemas,
|
|
5
|
-
* prompt snippets, guidelines, and TUI renderers.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
DEFAULT_MAX_BYTES,
|
|
10
|
-
DEFAULT_MAX_LINES,
|
|
11
|
-
formatSize,
|
|
12
|
-
truncateHead,
|
|
13
|
-
withFileMutationQueue,
|
|
14
|
-
} from "@mariozechner/pi-coding-agent";
|
|
15
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
16
|
-
import { Text } from "@mariozechner/pi-tui";
|
|
17
|
-
import { Type } from "typebox";
|
|
18
|
-
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
19
|
-
import { tmpdir } from "node:os";
|
|
20
|
-
import { join } from "node:path";
|
|
21
|
-
import type { BrowserDaemon } from "./daemon";
|
|
22
|
-
import type { TabInfo } from "./protocol";
|
|
23
|
-
|
|
24
|
-
// ── AsyncFunction constructor ────────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new (...args: string[]) => (...args: unknown[]) => Promise<unknown>;
|
|
27
|
-
|
|
28
|
-
// ── Key code map ─────────────────────────────────────────────────────────────
|
|
29
|
-
|
|
30
|
-
const SPECIAL_KEYS = [
|
|
31
|
-
"Enter", "Tab", "Backspace", "Escape", "Delete",
|
|
32
|
-
"ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown",
|
|
33
|
-
"Home", "End", "PageUp", "PageDown", " ",
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
// ── Screenshot path ──────────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
let screenshotCounter = 0;
|
|
39
|
-
function nextScreenshotPath(): string {
|
|
40
|
-
screenshotCounter++;
|
|
41
|
-
return join(tmpdir(), `pi-browser-screenshot-${Date.now()}-${screenshotCounter}.png`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ── Output truncation ────────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
/** Track temp directories for cleanup on session shutdown. */
|
|
47
|
-
const tempDirs: string[] = [];
|
|
48
|
-
|
|
49
|
-
/** Best-effort cleanup of all tracked temp directories. */
|
|
50
|
-
export async function cleanupTempDirs(): Promise<void> {
|
|
51
|
-
const dirs = tempDirs.splice(0);
|
|
52
|
-
await Promise.all(
|
|
53
|
-
dirs.map((d) => rm(d, { recursive: true, force: true }).catch(() => {})),
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Truncate output to pi's built-in limits (50KB / 2000 lines).
|
|
59
|
-
* When truncated, writes the full output to a temp file so the LLM
|
|
60
|
-
* can read it via the read tool and so CTRL+O expand can show it.
|
|
61
|
-
*/
|
|
62
|
-
async function applyTruncation(
|
|
63
|
-
output: string,
|
|
64
|
-
prefix: string,
|
|
65
|
-
): Promise<{
|
|
66
|
-
text: string;
|
|
67
|
-
fullOutputPath?: string;
|
|
68
|
-
wasTruncated: boolean;
|
|
69
|
-
}> {
|
|
70
|
-
const truncation = truncateHead(output, {
|
|
71
|
-
maxLines: DEFAULT_MAX_LINES,
|
|
72
|
-
maxBytes: DEFAULT_MAX_BYTES,
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
if (!truncation.truncated) {
|
|
76
|
-
return { text: truncation.content, wasTruncated: false };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const tempDir = await mkdtemp(join(tmpdir(), `pi-bh-${prefix}-`));
|
|
80
|
-
tempDirs.push(tempDir);
|
|
81
|
-
|
|
82
|
-
const tempFile = join(tempDir, "output.txt");
|
|
83
|
-
await withFileMutationQueue(tempFile, async () => {
|
|
84
|
-
await writeFile(tempFile, output, "utf8");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const omittedBytes = truncation.totalBytes - truncation.outputBytes;
|
|
88
|
-
let text = truncation.content;
|
|
89
|
-
text += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
90
|
-
text += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
91
|
-
text += ` ${formatSize(omittedBytes)} omitted. Full output: ${tempFile}]`;
|
|
92
|
-
|
|
93
|
-
return { text, fullOutputPath: tempFile, wasTruncated: true };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── Registration ─────────────────────────────────────────────────────────────
|
|
97
|
-
|
|
98
|
-
export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void {
|
|
99
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
100
|
-
// browser_navigate
|
|
101
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
102
|
-
pi.registerTool({
|
|
103
|
-
name: "browser_navigate",
|
|
104
|
-
label: "Browser Navigate",
|
|
105
|
-
description:
|
|
106
|
-
"Navigate to a URL. Creates a new tab on first call (safe), navigates current tab on subsequent calls.",
|
|
107
|
-
promptSnippet: "Navigate to a URL (creates new tab on first call)",
|
|
108
|
-
promptGuidelines: [
|
|
109
|
-
"Use browser_navigate to go to URLs. The first call creates a new tab so it doesn't clobber the user's active tab.",
|
|
110
|
-
"Use browser_wait_for_load after browser_navigate to wait for the page to finish loading.",
|
|
111
|
-
"For extracting data from a page you already navigated to, use browser_execute_js or browser_http_get (faster for APIs).",
|
|
112
|
-
],
|
|
113
|
-
parameters: Type.Object({
|
|
114
|
-
url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
|
|
115
|
-
}),
|
|
116
|
-
async execute(_id, params) {
|
|
117
|
-
try {
|
|
118
|
-
await daemon.ensureAlive();
|
|
119
|
-
|
|
120
|
-
// Determine if we need a new tab: create one on first-ever call,
|
|
121
|
-
// or if the current tab is an internal Chrome page. Otherwise
|
|
122
|
-
// navigate the current tab in-place.
|
|
123
|
-
let targetId: string;
|
|
124
|
-
let isNewTab = false;
|
|
125
|
-
try {
|
|
126
|
-
const tabs = await daemon.listTabs(false);
|
|
127
|
-
if (tabs.length === 0) {
|
|
128
|
-
// No real tabs exist — create one
|
|
129
|
-
targetId = await daemon.newTab(params.url);
|
|
130
|
-
isNewTab = true;
|
|
131
|
-
} else {
|
|
132
|
-
// Navigate the current tab in-place
|
|
133
|
-
await daemon.cdp("Page.navigate", { url: params.url });
|
|
134
|
-
const tab = await daemon.currentTab();
|
|
135
|
-
targetId = tab.targetId;
|
|
136
|
-
}
|
|
137
|
-
} catch {
|
|
138
|
-
// Fallback: create a new tab
|
|
139
|
-
targetId = await daemon.newTab(params.url);
|
|
140
|
-
isNewTab = true;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const info = await daemon.getPageInfo();
|
|
144
|
-
if ("dialog" in info) {
|
|
145
|
-
return {
|
|
146
|
-
content: [{ type: "text" as const, text: `Navigated to ${params.url}\nTarget: ${targetId}\n⚠️ A dialog is open: ${info.dialog.type} — "${info.dialog.message}"\nUse browser_handle_dialog to dismiss or accept it.` }],
|
|
147
|
-
details: undefined,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const prefix = isNewTab ? "New tab: " : "";
|
|
152
|
-
return {
|
|
153
|
-
content: [{
|
|
154
|
-
type: "text" as const,
|
|
155
|
-
text: `${prefix}Navigated to: ${info.url}\nTitle: ${info.title}\nViewport: ${info.width}x${info.height}\nTarget: ${targetId}`,
|
|
156
|
-
}],
|
|
157
|
-
details: undefined,
|
|
158
|
-
};
|
|
159
|
-
} catch (err) {
|
|
160
|
-
return {
|
|
161
|
-
isError: true,
|
|
162
|
-
content: [{ type: "text" as const, text: `Navigation failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
163
|
-
details: undefined,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
renderCall(args, theme) {
|
|
168
|
-
return new Text(`🌐 Navigate to ${(args as { url: string }).url}`, 0, 0);
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
173
|
-
// browser_page_info
|
|
174
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
175
|
-
pi.registerTool({
|
|
176
|
-
name: "browser_page_info",
|
|
177
|
-
label: "Browser Page Info",
|
|
178
|
-
description:
|
|
179
|
-
"Get current page state: URL, title, viewport size, scroll position, page dimensions. If a JS dialog (alert/confirm/prompt) is open, returns dialog info instead.",
|
|
180
|
-
promptSnippet: "Get current page URL, title, viewport, and scroll position",
|
|
181
|
-
promptGuidelines: [
|
|
182
|
-
"Use browser_page_info to quickly check what page you're on and whether a JS dialog is blocking interaction.",
|
|
183
|
-
"If browser_page_info returns a dialog, use browser_handle_dialog before any other browser actions.",
|
|
184
|
-
],
|
|
185
|
-
parameters: Type.Object({}),
|
|
186
|
-
async execute() {
|
|
187
|
-
try {
|
|
188
|
-
await daemon.ensureAlive();
|
|
189
|
-
const info = await daemon.getPageInfo();
|
|
190
|
-
|
|
191
|
-
if ("dialog" in info) {
|
|
192
|
-
return {
|
|
193
|
-
content: [{
|
|
194
|
-
type: "text" as const,
|
|
195
|
-
text: `⚠️ DIALOG OPEN: ${info.dialog.type}\nMessage: ${info.dialog.message}${info.dialog.defaultPrompt ? `\nDefault: ${info.dialog.defaultPrompt}` : ""}\n\nUse browser_handle_dialog to accept or dismiss.`,
|
|
196
|
-
}],
|
|
197
|
-
details: undefined,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return {
|
|
202
|
-
content: [{
|
|
203
|
-
type: "text" as const,
|
|
204
|
-
text: `URL: ${info.url}\nTitle: ${info.title}\nViewport: ${info.width}x${info.height}\nScroll: (${info.scrollX}, ${info.scrollY})\nPage size: ${info.pageWidth}x${info.pageHeight}`,
|
|
205
|
-
}],
|
|
206
|
-
details: undefined,
|
|
207
|
-
};
|
|
208
|
-
} catch (err) {
|
|
209
|
-
return {
|
|
210
|
-
isError: true,
|
|
211
|
-
content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
212
|
-
details: undefined,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
},
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
219
|
-
// browser_go_back
|
|
220
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
221
|
-
pi.registerTool({
|
|
222
|
-
name: "browser_go_back",
|
|
223
|
-
label: "Browser Go Back",
|
|
224
|
-
description: "Navigate back one page in history.",
|
|
225
|
-
promptSnippet: "Go back one page in browser history",
|
|
226
|
-
promptGuidelines: [
|
|
227
|
-
"Use browser_go_back to return to the previous page after navigating.",
|
|
228
|
-
],
|
|
229
|
-
parameters: Type.Object({}),
|
|
230
|
-
async execute() {
|
|
231
|
-
try {
|
|
232
|
-
await daemon.ensureAlive();
|
|
233
|
-
const history = (await daemon.cdp("Page.getNavigationHistory")) as {
|
|
234
|
-
entries: Array<{ id: number; url: string; title: string }>;
|
|
235
|
-
currentIndex: number;
|
|
236
|
-
};
|
|
237
|
-
if (history.currentIndex <= 0) {
|
|
238
|
-
return { content: [{ type: "text" as const, text: "Already at the beginning of history." }], details: undefined };
|
|
239
|
-
}
|
|
240
|
-
const targetEntry = history.entries[history.currentIndex - 1];
|
|
241
|
-
await daemon.cdp("Page.navigateToHistoryEntry", { entryId: targetEntry.id });
|
|
242
|
-
return { content: [{ type: "text" as const, text: `Navigated back to: ${targetEntry.url}` }], details: undefined };
|
|
243
|
-
} catch (err) {
|
|
244
|
-
return {
|
|
245
|
-
isError: true,
|
|
246
|
-
content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
247
|
-
details: undefined,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
},
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
254
|
-
// browser_go_forward
|
|
255
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
256
|
-
pi.registerTool({
|
|
257
|
-
name: "browser_go_forward",
|
|
258
|
-
label: "Browser Go Forward",
|
|
259
|
-
description: "Navigate forward one page in history.",
|
|
260
|
-
promptSnippet: "Go forward one page in browser history",
|
|
261
|
-
promptGuidelines: [
|
|
262
|
-
"Use browser_go_forward to go forward after navigating back.",
|
|
263
|
-
],
|
|
264
|
-
parameters: Type.Object({}),
|
|
265
|
-
async execute() {
|
|
266
|
-
try {
|
|
267
|
-
await daemon.ensureAlive();
|
|
268
|
-
const history = (await daemon.cdp("Page.getNavigationHistory")) as {
|
|
269
|
-
entries: Array<{ id: number; url: string; title: string }>;
|
|
270
|
-
currentIndex: number;
|
|
271
|
-
};
|
|
272
|
-
if (history.currentIndex >= history.entries.length - 1) {
|
|
273
|
-
return { content: [{ type: "text" as const, text: "Already at the end of history." }], details: undefined };
|
|
274
|
-
}
|
|
275
|
-
const targetEntry = history.entries[history.currentIndex + 1];
|
|
276
|
-
await daemon.cdp("Page.navigateToHistoryEntry", { entryId: targetEntry.id });
|
|
277
|
-
return { content: [{ type: "text" as const, text: `Navigated forward to: ${targetEntry.url}` }], details: undefined };
|
|
278
|
-
} catch (err) {
|
|
279
|
-
return {
|
|
280
|
-
isError: true,
|
|
281
|
-
content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
282
|
-
details: undefined,
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
289
|
-
// browser_reload
|
|
290
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
291
|
-
pi.registerTool({
|
|
292
|
-
name: "browser_reload",
|
|
293
|
-
label: "Browser Reload",
|
|
294
|
-
description: "Reload the current page.",
|
|
295
|
-
promptSnippet: "Reload the current page",
|
|
296
|
-
promptGuidelines: [
|
|
297
|
-
"Use browser_reload to refresh the current page, e.g. after making changes that should be reflected.",
|
|
298
|
-
],
|
|
299
|
-
parameters: Type.Object({}),
|
|
300
|
-
async execute() {
|
|
301
|
-
try {
|
|
302
|
-
await daemon.ensureAlive();
|
|
303
|
-
await daemon.cdp("Page.reload");
|
|
304
|
-
const info = await daemon.getPageInfo();
|
|
305
|
-
if ("dialog" in info) {
|
|
306
|
-
return { content: [{ type: "text" as const, text: `Reloaded. ⚠️ Dialog open: ${info.dialog.type}` }], details: undefined };
|
|
307
|
-
}
|
|
308
|
-
return { content: [{ type: "text" as const, text: `Reloaded: ${info.title} (${info.url})` }], details: undefined };
|
|
309
|
-
} catch (err) {
|
|
310
|
-
return {
|
|
311
|
-
isError: true,
|
|
312
|
-
content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
313
|
-
details: undefined,
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
},
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
320
|
-
// browser_click
|
|
321
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
322
|
-
pi.registerTool({
|
|
323
|
-
name: "browser_click",
|
|
324
|
-
label: "Browser Click",
|
|
325
|
-
description:
|
|
326
|
-
"Click at viewport CSS-pixel coordinates. Compositor-level click works through iframes, shadow DOM, and cross-origin content. Use browser_screenshot first to find coordinates.",
|
|
327
|
-
promptSnippet: "Click at pixel coordinates (x, y) on the page",
|
|
328
|
-
promptGuidelines: [
|
|
329
|
-
"Use browser_click for all clicks. Coordinates are viewport CSS pixels (not device pixels).",
|
|
330
|
-
"Capture a browser_screenshot BEFORE clicking to find the right coordinates.",
|
|
331
|
-
"Capture another browser_screenshot AFTER clicking to verify the action worked.",
|
|
332
|
-
"Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content — no selector needed.",
|
|
333
|
-
],
|
|
334
|
-
parameters: Type.Object({
|
|
335
|
-
x: Type.Number({ description: "X coordinate in CSS pixels from left edge of viewport" }),
|
|
336
|
-
y: Type.Number({ description: "Y coordinate in CSS pixels from top edge of viewport" }),
|
|
337
|
-
button: Type.Optional(Type.String({ description: 'Mouse button: "left", "right", or "middle". Default: "left"' })),
|
|
338
|
-
clicks: Type.Optional(Type.Number({ description: "Number of clicks (1 = single, 2 = double). Default: 1" })),
|
|
339
|
-
}),
|
|
340
|
-
async execute(_id, params) {
|
|
341
|
-
try {
|
|
342
|
-
await daemon.ensureAlive();
|
|
343
|
-
await daemon.cdp("Input.dispatchMouseEvent", {
|
|
344
|
-
type: "mousePressed",
|
|
345
|
-
x: params.x,
|
|
346
|
-
y: params.y,
|
|
347
|
-
button: params.button || "left",
|
|
348
|
-
clickCount: params.clicks || 1,
|
|
349
|
-
});
|
|
350
|
-
await daemon.cdp("Input.dispatchMouseEvent", {
|
|
351
|
-
type: "mouseReleased",
|
|
352
|
-
x: params.x,
|
|
353
|
-
y: params.y,
|
|
354
|
-
button: params.button || "left",
|
|
355
|
-
clickCount: params.clicks || 1,
|
|
356
|
-
});
|
|
357
|
-
return { content: [{ type: "text" as const, text: `Clicked at (${params.x}, ${params.y})` }], details: undefined };
|
|
358
|
-
} catch (err) {
|
|
359
|
-
return {
|
|
360
|
-
isError: true,
|
|
361
|
-
content: [{ type: "text" as const, text: `Click failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
362
|
-
details: undefined,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
},
|
|
366
|
-
renderCall(args, theme) {
|
|
367
|
-
const { x, y } = args as { x: number; y: number };
|
|
368
|
-
return new Text(`🖱️ Click at (${x}, ${y})`, 0, 0);
|
|
369
|
-
},
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
373
|
-
// browser_type
|
|
374
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
375
|
-
pi.registerTool({
|
|
376
|
-
name: "browser_type",
|
|
377
|
-
label: "Browser Type",
|
|
378
|
-
description: "Type text into the currently focused element. Use browser_click first to focus an input field.",
|
|
379
|
-
promptSnippet: "Type text into the focused element",
|
|
380
|
-
promptGuidelines: [
|
|
381
|
-
"Use browser_type to enter text. Click on an input field with browser_click first to focus it.",
|
|
382
|
-
"For special keys (Enter, Tab, Escape, arrows), use browser_press_key instead.",
|
|
383
|
-
],
|
|
384
|
-
parameters: Type.Object({
|
|
385
|
-
text: Type.String({ description: "Text to type" }),
|
|
386
|
-
}),
|
|
387
|
-
async execute(_id, params) {
|
|
388
|
-
try {
|
|
389
|
-
await daemon.ensureAlive();
|
|
390
|
-
await daemon.cdp("Input.insertText", { text: params.text });
|
|
391
|
-
return { content: [{ type: "text" as const, text: `Typed: "${params.text}"` }], details: undefined };
|
|
392
|
-
} catch (err) {
|
|
393
|
-
return {
|
|
394
|
-
isError: true,
|
|
395
|
-
content: [{ type: "text" as const, text: `Type failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
396
|
-
details: undefined,
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
},
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
403
|
-
// browser_press_key
|
|
404
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
405
|
-
pi.registerTool({
|
|
406
|
-
name: "browser_press_key",
|
|
407
|
-
label: "Browser Press Key",
|
|
408
|
-
description:
|
|
409
|
-
"Press a keyboard key. Supports special keys (Enter, Tab, Backspace, Escape, Delete, arrows, Home, End, PageUp, PageDown, Space as ' ') and regular characters. Optional modifier bitfield: 1=Alt, 2=Ctrl, 4=Meta/Cmd, 8=Shift.",
|
|
410
|
-
promptSnippet: "Press a key (Enter, Tab, Escape, arrows, or any character)",
|
|
411
|
-
promptGuidelines: [
|
|
412
|
-
"Use browser_press_key for keyboard shortcuts and navigation keys.",
|
|
413
|
-
"Special key names: Enter, Tab, Backspace, Escape, Delete, ArrowLeft, ArrowUp, ArrowRight, ArrowDown, Home, End, PageUp, PageDown.",
|
|
414
|
-
"Use Space as ' ' (a single space character).",
|
|
415
|
-
"Modifiers: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift. Combine with bitwise OR: Ctrl+Shift = 2|8 = 10.",
|
|
416
|
-
],
|
|
417
|
-
parameters: Type.Object({
|
|
418
|
-
key: Type.String({ description: 'Key to press. Special keys: Enter, Tab, Backspace, Escape, Delete, ArrowLeft/Up/Right/Down, Home, End, PageUp, PageDown. Space as " "' }),
|
|
419
|
-
modifiers: Type.Optional(Type.Number({ description: "Modifier bitfield: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift. Combine with OR." })),
|
|
420
|
-
}),
|
|
421
|
-
async execute(_id, params) {
|
|
422
|
-
try {
|
|
423
|
-
await daemon.ensureAlive();
|
|
424
|
-
const k = params.key;
|
|
425
|
-
const mod = params.modifiers || 0;
|
|
426
|
-
const vk = daemon.getVirtualKeyCode(k);
|
|
427
|
-
const code = daemon.getKeyCode(k);
|
|
428
|
-
const isSpecial = SPECIAL_KEYS.includes(k);
|
|
429
|
-
const text = k.length === 1 && !isSpecial ? k : "";
|
|
430
|
-
|
|
431
|
-
// keyDown
|
|
432
|
-
const base = {
|
|
433
|
-
type: "keyDown" as const,
|
|
434
|
-
key: k,
|
|
435
|
-
code,
|
|
436
|
-
modifiers: mod,
|
|
437
|
-
windowsVirtualKeyCode: vk,
|
|
438
|
-
nativeVirtualKeyCode: vk,
|
|
439
|
-
...(text ? { text } : {}),
|
|
440
|
-
};
|
|
441
|
-
await daemon.cdp("Input.dispatchKeyEvent", base);
|
|
442
|
-
|
|
443
|
-
// char event for printable single characters
|
|
444
|
-
if (text.length === 1) {
|
|
445
|
-
await daemon.cdp("Input.dispatchKeyEvent", {
|
|
446
|
-
type: "char",
|
|
447
|
-
text,
|
|
448
|
-
key: k,
|
|
449
|
-
code,
|
|
450
|
-
modifiers: mod,
|
|
451
|
-
windowsVirtualKeyCode: vk,
|
|
452
|
-
nativeVirtualKeyCode: vk,
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// keyUp
|
|
457
|
-
await daemon.cdp("Input.dispatchKeyEvent", {
|
|
458
|
-
type: "keyUp",
|
|
459
|
-
key: k,
|
|
460
|
-
code,
|
|
461
|
-
modifiers: mod,
|
|
462
|
-
windowsVirtualKeyCode: vk,
|
|
463
|
-
nativeVirtualKeyCode: vk,
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
return { content: [{ type: "text" as const, text: `Pressed: "${k}"${mod ? ` (modifiers: ${mod})` : ""}` }], details: undefined };
|
|
467
|
-
} catch (err) {
|
|
468
|
-
return {
|
|
469
|
-
isError: true,
|
|
470
|
-
content: [{ type: "text" as const, text: `Key press failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
471
|
-
details: undefined,
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
},
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
478
|
-
// browser_scroll
|
|
479
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
480
|
-
pi.registerTool({
|
|
481
|
-
name: "browser_scroll",
|
|
482
|
-
label: "Browser Scroll",
|
|
483
|
-
description:
|
|
484
|
-
"Scroll the page at given coordinates (or center of viewport if not specified). Default scrolls down 300px.",
|
|
485
|
-
promptSnippet: "Scroll the page by deltaY/deltaX pixels at coordinates",
|
|
486
|
-
promptGuidelines: [
|
|
487
|
-
"Use browser_scroll to scroll pages. Default deltaY=-300 scrolls down.",
|
|
488
|
-
"Positive deltaY scrolls up, negative scrolls down.",
|
|
489
|
-
"Capture browser_screenshot after scrolling to see the new viewport content.",
|
|
490
|
-
],
|
|
491
|
-
parameters: Type.Object({
|
|
492
|
-
deltaY: Type.Optional(Type.Number({ description: "Vertical scroll delta in pixels. Negative = down, positive = up. Default: -300" })),
|
|
493
|
-
deltaX: Type.Optional(Type.Number({ description: "Horizontal scroll delta in pixels. Default: 0" })),
|
|
494
|
-
x: Type.Optional(Type.Number({ description: "X position to scroll from. Default: viewport center" })),
|
|
495
|
-
y: Type.Optional(Type.Number({ description: "Y position to scroll from. Default: viewport center" })),
|
|
496
|
-
}),
|
|
497
|
-
async execute(_id, params) {
|
|
498
|
-
try {
|
|
499
|
-
await daemon.ensureAlive();
|
|
500
|
-
const dy = params.deltaY ?? -300;
|
|
501
|
-
const dx = params.deltaX ?? 0;
|
|
502
|
-
|
|
503
|
-
// Default to center of viewport if not specified
|
|
504
|
-
let cx = params.x;
|
|
505
|
-
let cy = params.y;
|
|
506
|
-
if (cx === undefined || cy === undefined) {
|
|
507
|
-
try {
|
|
508
|
-
const info = await daemon.getPageInfo();
|
|
509
|
-
if (!("dialog" in info)) {
|
|
510
|
-
if (cx === undefined) cx = Math.round(info.width / 2);
|
|
511
|
-
if (cy === undefined) cy = Math.round(info.height / 2);
|
|
512
|
-
}
|
|
513
|
-
} catch {
|
|
514
|
-
cx = cx ?? 500;
|
|
515
|
-
cy = cy ?? 400;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
await daemon.cdp("Input.dispatchMouseEvent", {
|
|
520
|
-
type: "mouseWheel",
|
|
521
|
-
x: cx,
|
|
522
|
-
y: cy,
|
|
523
|
-
deltaX: dx,
|
|
524
|
-
deltaY: dy,
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
return { content: [{ type: "text" as const, text: `Scrolled by (${dx}, ${dy}) at (${cx}, ${cy})` }], details: undefined };
|
|
528
|
-
} catch (err) {
|
|
529
|
-
return {
|
|
530
|
-
isError: true,
|
|
531
|
-
content: [{ type: "text" as const, text: `Scroll failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
532
|
-
details: undefined,
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
},
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
539
|
-
// browser_screenshot
|
|
540
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
541
|
-
pi.registerTool({
|
|
542
|
-
name: "browser_screenshot",
|
|
543
|
-
label: "Browser Screenshot",
|
|
544
|
-
description:
|
|
545
|
-
"Capture a PNG or JPEG screenshot of the current page. Use this BEFORE interactions to find targets, and AFTER to verify results. JPEG (quality 80) is 2-5x smaller for complex pages — faster transfer and lower LLM context cost.",
|
|
546
|
-
promptSnippet: "Capture a screenshot of the current page (PNG or JPEG)",
|
|
547
|
-
promptGuidelines: [
|
|
548
|
-
"Use browser_screenshot as your FIRST step when exploring a page — it shows you what's visible.",
|
|
549
|
-
"After every browser_click, browser_type, browser_scroll, or browser_navigate, capture a screenshot to VERIFY the action worked.",
|
|
550
|
-
"Screenshots are the fastest way to find click targets, notice hidden blockers, and decide next steps.",
|
|
551
|
-
"Never assume an action worked. Always re-screenshot to confirm.",
|
|
552
|
-
],
|
|
553
|
-
parameters: Type.Object({
|
|
554
|
-
fullPage: Type.Optional(Type.Boolean({ description: "Capture the full scrollable page (not just viewport). Default: false" })),
|
|
555
|
-
format: Type.Optional(Type.String({ description: "Image format: 'png' or 'jpeg'. Default: 'png'" })),
|
|
556
|
-
quality: Type.Optional(Type.Number({ description: "JPEG quality 1-100. Default: 80. Ignored for PNG." })),
|
|
557
|
-
}),
|
|
558
|
-
async execute(_id, params) {
|
|
559
|
-
try {
|
|
560
|
-
await daemon.ensureAlive();
|
|
561
|
-
const path = nextScreenshotPath();
|
|
562
|
-
const format = (params.format === "jpeg" ? "jpeg" : "png") as "png" | "jpeg";
|
|
563
|
-
const quality = typeof params.quality === "number" ? params.quality : 80;
|
|
564
|
-
await daemon.captureScreenshot(path, params.fullPage === true, format, quality);
|
|
565
|
-
const fmtLabel = format === "jpeg" ? ` (JPEG q${quality})` : "";
|
|
566
|
-
return {
|
|
567
|
-
content: [{ type: "text" as const, text: `Screenshot saved: ${path}${params.fullPage ? " (full page)" : ""}${fmtLabel}` }],
|
|
568
|
-
details: { path, format, quality },
|
|
569
|
-
};
|
|
570
|
-
} catch (err) {
|
|
571
|
-
return {
|
|
572
|
-
isError: true,
|
|
573
|
-
content: [{ type: "text" as const, text: `Screenshot failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
574
|
-
details: undefined,
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
},
|
|
578
|
-
renderCall(_, theme) {
|
|
579
|
-
return new Text(`📸 Capturing screenshot...`, 0, 0);
|
|
580
|
-
},
|
|
581
|
-
renderResult(result, _options, theme) {
|
|
582
|
-
const path = (result.details as { path?: string })?.path;
|
|
583
|
-
return new Text(path ? `📸 Screenshot: ${path}` : `📸 Screenshot captured`, 0, 0);
|
|
584
|
-
},
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
588
|
-
// browser_list_tabs
|
|
589
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
590
|
-
pi.registerTool({
|
|
591
|
-
name: "browser_list_tabs",
|
|
592
|
-
label: "Browser List Tabs",
|
|
593
|
-
description: "List all open browser tabs.",
|
|
594
|
-
promptSnippet: "List all open browser tabs with URLs and titles",
|
|
595
|
-
promptGuidelines: [
|
|
596
|
-
"Use browser_list_tabs to see what tabs are open before switching.",
|
|
597
|
-
"Use browser_switch_tab to switch to a specific tab by targetId.",
|
|
598
|
-
"Set includeChrome: true to also see Chrome internal pages (devtools, settings, etc).",
|
|
599
|
-
],
|
|
600
|
-
parameters: Type.Object({
|
|
601
|
-
includeChrome: Type.Optional(Type.Boolean({ description: "Include Chrome internal pages (chrome://, devtools://, etc). Default: true" })),
|
|
602
|
-
}),
|
|
603
|
-
async execute(_id, params) {
|
|
604
|
-
try {
|
|
605
|
-
await daemon.ensureAlive();
|
|
606
|
-
const tabs = await daemon.listTabs(params.includeChrome !== false);
|
|
607
|
-
const listing = tabs
|
|
608
|
-
.map((t, i) => `[${i}] ${t.title || "(no title)"}\n URL: ${t.url}\n ID: ${t.targetId}`)
|
|
609
|
-
.join("\n\n");
|
|
610
|
-
return {
|
|
611
|
-
content: [{ type: "text" as const, text: `${tabs.length} tab(s):\n\n${listing}` }],
|
|
612
|
-
details: { tabs },
|
|
613
|
-
};
|
|
614
|
-
} catch (err) {
|
|
615
|
-
return {
|
|
616
|
-
isError: true,
|
|
617
|
-
content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
618
|
-
details: undefined,
|
|
619
|
-
};
|
|
620
|
-
}
|
|
621
|
-
},
|
|
622
|
-
renderResult(result, { expanded }, theme) {
|
|
623
|
-
const details = result.details as {
|
|
624
|
-
tabs?: TabInfo[];
|
|
625
|
-
fullOutputPath?: string;
|
|
626
|
-
} | undefined;
|
|
627
|
-
|
|
628
|
-
const tabCount = details?.tabs?.length ?? 0;
|
|
629
|
-
|
|
630
|
-
if (expanded) {
|
|
631
|
-
// Expanded (CTRL+O): show full tab listing
|
|
632
|
-
const content = result.content[0];
|
|
633
|
-
const raw = content?.type === "text" ? content.text : "";
|
|
634
|
-
return new Text(raw || "(no tabs)", 0, 0);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// Collapsed (default): compact count
|
|
638
|
-
return new Text(
|
|
639
|
-
theme ? theme.fg("dim", `📋 ${tabCount} tab${tabCount !== 1 ? "s" : ""}`) : `📋 ${tabCount} tab${tabCount !== 1 ? "s" : ""}`,
|
|
640
|
-
0,
|
|
641
|
-
0,
|
|
642
|
-
);
|
|
643
|
-
},
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
647
|
-
// browser_current_tab
|
|
648
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
649
|
-
pi.registerTool({
|
|
650
|
-
name: "browser_current_tab",
|
|
651
|
-
label: "Browser Current Tab",
|
|
652
|
-
description: "Get info about the currently active tab.",
|
|
653
|
-
promptSnippet: "Get the current active tab's URL, title, and ID",
|
|
654
|
-
promptGuidelines: [
|
|
655
|
-
"Use browser_current_tab to confirm which tab you're controlling.",
|
|
656
|
-
],
|
|
657
|
-
parameters: Type.Object({}),
|
|
658
|
-
async execute() {
|
|
659
|
-
try {
|
|
660
|
-
await daemon.ensureAlive();
|
|
661
|
-
const tab = await daemon.currentTab();
|
|
662
|
-
return {
|
|
663
|
-
content: [{ type: "text" as const, text: `Current tab: ${tab.title || "(no title)"}\nURL: ${tab.url}\nID: ${tab.targetId}` }],
|
|
664
|
-
details: undefined,
|
|
665
|
-
};
|
|
666
|
-
} catch (err) {
|
|
667
|
-
return {
|
|
668
|
-
isError: true,
|
|
669
|
-
content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
670
|
-
details: undefined,
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
},
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
677
|
-
// browser_switch_tab
|
|
678
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
679
|
-
pi.registerTool({
|
|
680
|
-
name: "browser_switch_tab",
|
|
681
|
-
label: "Browser Switch Tab",
|
|
682
|
-
description: "Switch to a different browser tab by targetId (from browser_list_tabs).",
|
|
683
|
-
promptSnippet: "Switch to a browser tab by its targetId",
|
|
684
|
-
promptGuidelines: [
|
|
685
|
-
"Use browser_list_tabs first to find the targetId, then browser_switch_tab to switch.",
|
|
686
|
-
],
|
|
687
|
-
parameters: Type.Object({
|
|
688
|
-
targetId: Type.String({ description: "Target ID of the tab to switch to (from browser_list_tabs)" }),
|
|
689
|
-
}),
|
|
690
|
-
async execute(_id, params) {
|
|
691
|
-
try {
|
|
692
|
-
await daemon.ensureAlive();
|
|
693
|
-
await daemon.switchTab(params.targetId);
|
|
694
|
-
const tab = await daemon.currentTab();
|
|
695
|
-
return {
|
|
696
|
-
content: [{ type: "text" as const, text: `Switched to: ${tab.title || "(no title)"}\nURL: ${tab.url}` }],
|
|
697
|
-
details: undefined,
|
|
698
|
-
};
|
|
699
|
-
} catch (err) {
|
|
700
|
-
return {
|
|
701
|
-
isError: true,
|
|
702
|
-
content: [{ type: "text" as const, text: `Switch failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
703
|
-
details: undefined,
|
|
704
|
-
};
|
|
705
|
-
}
|
|
706
|
-
},
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
710
|
-
// browser_new_tab
|
|
711
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
712
|
-
pi.registerTool({
|
|
713
|
-
name: "browser_new_tab",
|
|
714
|
-
label: "Browser New Tab",
|
|
715
|
-
description: "Open a new browser tab and switch to it. Optionally navigate to a URL.",
|
|
716
|
-
promptSnippet: "Open a new browser tab, optionally navigate to a URL",
|
|
717
|
-
promptGuidelines: [
|
|
718
|
-
"Use browser_new_tab for the first navigation in a task — it preserves the user's active tab.",
|
|
719
|
-
"Use browser_navigate for subsequent navigation in the same tab.",
|
|
720
|
-
],
|
|
721
|
-
parameters: Type.Object({
|
|
722
|
-
url: Type.Optional(Type.String({ description: "URL to navigate to in the new tab. Default: about:blank" })),
|
|
723
|
-
}),
|
|
724
|
-
async execute(_id, params) {
|
|
725
|
-
try {
|
|
726
|
-
await daemon.ensureAlive();
|
|
727
|
-
const targetId = await daemon.newTab(params.url);
|
|
728
|
-
const msg = params.url
|
|
729
|
-
? `New tab created and navigated to: ${params.url}\nTarget: ${targetId}`
|
|
730
|
-
: `New tab created: about:blank\nTarget: ${targetId}`;
|
|
731
|
-
return { content: [{ type: "text" as const, text: msg }], details: undefined };
|
|
732
|
-
} catch (err) {
|
|
733
|
-
return {
|
|
734
|
-
isError: true,
|
|
735
|
-
content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
736
|
-
details: undefined,
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
},
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
743
|
-
// browser_execute_js
|
|
744
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
745
|
-
pi.registerTool({
|
|
746
|
-
name: "browser_execute_js",
|
|
747
|
-
label: "Browser Execute JS",
|
|
748
|
-
description:
|
|
749
|
-
'Execute JavaScript in the current page and return the result. Use for data extraction, DOM inspection, and page manipulation. Expressions with "return" are automatically wrapped in an IIFE.',
|
|
750
|
-
promptSnippet: "Execute JavaScript in the page and return the result",
|
|
751
|
-
promptGuidelines: [
|
|
752
|
-
"Use browser_execute_js for extracting structured data from the page (text, attributes, JSON from embedded scripts).",
|
|
753
|
-
"Use browser_execute_js for DOM inspection when coordinates from screenshots aren't sufficient.",
|
|
754
|
-
"For bulk data extraction, prefer browser_http_get for API calls — it's 10-50x faster than browser DOM scraping.",
|
|
755
|
-
],
|
|
756
|
-
parameters: Type.Object({
|
|
757
|
-
expression: Type.String({ description: "JavaScript expression to evaluate. 'return' statements are auto-wrapped in IIFE." }),
|
|
758
|
-
targetId: Type.Optional(Type.String({ description: "Target ID of an iframe to evaluate in (optional)" })),
|
|
759
|
-
}),
|
|
760
|
-
async execute(_id, params) {
|
|
761
|
-
try {
|
|
762
|
-
await daemon.ensureAlive();
|
|
763
|
-
const value = await daemon.evaluateJS(params.expression, params.targetId);
|
|
764
|
-
const raw = value === undefined ? "undefined" : JSON.stringify(value, null, 2);
|
|
765
|
-
const { text, fullOutputPath } = await applyTruncation(raw, "js");
|
|
766
|
-
return {
|
|
767
|
-
content: [{ type: "text" as const, text }],
|
|
768
|
-
details: { valueLength: raw.length, fullOutputPath },
|
|
769
|
-
};
|
|
770
|
-
} catch (err) {
|
|
771
|
-
return {
|
|
772
|
-
isError: true,
|
|
773
|
-
content: [{ type: "text" as const, text: `JS execution failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
774
|
-
details: undefined,
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
},
|
|
778
|
-
renderResult(result, { expanded }, theme) {
|
|
779
|
-
const details = result.details as {
|
|
780
|
-
valueLength?: number;
|
|
781
|
-
fullOutputPath?: string;
|
|
782
|
-
} | undefined;
|
|
783
|
-
|
|
784
|
-
const len = details?.valueLength ?? 0;
|
|
785
|
-
|
|
786
|
-
if (expanded) {
|
|
787
|
-
// Expanded (CTRL+O): show the full JS result
|
|
788
|
-
const content = result.content[0];
|
|
789
|
-
const raw = content?.type === "text" ? content.text : "";
|
|
790
|
-
return new Text(raw || "undefined", 0, 0);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Collapsed (default): size + first-line preview
|
|
794
|
-
const content = result.content[0];
|
|
795
|
-
const raw = content?.type === "text" ? content.text : "";
|
|
796
|
-
const firstLine = raw.split("\n")[0] || "";
|
|
797
|
-
const preview =
|
|
798
|
-
firstLine.length > 120 ? firstLine.slice(0, 117) + "..." : firstLine;
|
|
799
|
-
|
|
800
|
-
const summary = `📜 JS result (${formatSize(len)})`;
|
|
801
|
-
return new Text(
|
|
802
|
-
theme
|
|
803
|
-
? theme.fg("dim", summary) + "\n" + theme.fg("muted", preview)
|
|
804
|
-
: summary + "\n" + preview,
|
|
805
|
-
0,
|
|
806
|
-
0,
|
|
807
|
-
);
|
|
808
|
-
},
|
|
809
|
-
});
|
|
810
|
-
|
|
811
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
812
|
-
// browser_http_get
|
|
813
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
814
|
-
pi.registerTool({
|
|
815
|
-
name: "browser_http_get",
|
|
816
|
-
label: "Browser HTTP GET",
|
|
817
|
-
description:
|
|
818
|
-
"Make a direct HTTP GET request (no browser). Use for APIs and static pages — much faster than browser navigation for data retrieval.",
|
|
819
|
-
promptSnippet: "Make a direct HTTP GET request (outside browser, for APIs)",
|
|
820
|
-
promptGuidelines: [
|
|
821
|
-
"Use browser_http_get for API calls and static page retrieval — it bypasses the browser entirely and is 10-50x faster.",
|
|
822
|
-
"Use browser_http_get with ThreadPoolExecutor for bulk data retrieval from multiple URLs.",
|
|
823
|
-
"Do NOT use browser_http_get for pages that require JavaScript rendering — use browser_navigate + browser_execute_js instead.",
|
|
824
|
-
],
|
|
825
|
-
parameters: Type.Object({
|
|
826
|
-
url: Type.String({ description: "URL to fetch" }),
|
|
827
|
-
headers: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Optional HTTP headers" })),
|
|
828
|
-
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds. Default: 20" })),
|
|
829
|
-
}),
|
|
830
|
-
async execute(_id, params) {
|
|
831
|
-
try {
|
|
832
|
-
const controller = new AbortController();
|
|
833
|
-
const timer = setTimeout(() => controller.abort(), (params.timeout || 20) * 1000);
|
|
834
|
-
|
|
835
|
-
const response = await fetch(params.url, {
|
|
836
|
-
headers: {
|
|
837
|
-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
838
|
-
Accept: "text/html,application/json,*/*",
|
|
839
|
-
...params.headers,
|
|
840
|
-
},
|
|
841
|
-
signal: controller.signal,
|
|
842
|
-
});
|
|
843
|
-
|
|
844
|
-
clearTimeout(timer);
|
|
845
|
-
|
|
846
|
-
const body = await response.text();
|
|
847
|
-
const contentType = response.headers.get("content-type") || "unknown";
|
|
848
|
-
|
|
849
|
-
// Build header (always shown) and truncate body separately
|
|
850
|
-
const headerText = `HTTP ${response.status} ${response.statusText}\nContent-Type: ${contentType}\nLength: ${body.length} chars\n\n`;
|
|
851
|
-
const { text: truncatedBody, fullOutputPath, wasTruncated } = await applyTruncation(
|
|
852
|
-
body,
|
|
853
|
-
"http",
|
|
854
|
-
);
|
|
855
|
-
|
|
856
|
-
return {
|
|
857
|
-
content: [{
|
|
858
|
-
type: "text" as const,
|
|
859
|
-
text: headerText + truncatedBody,
|
|
860
|
-
}],
|
|
861
|
-
details: {
|
|
862
|
-
status: response.status,
|
|
863
|
-
contentType,
|
|
864
|
-
length: body.length,
|
|
865
|
-
fullOutputPath,
|
|
866
|
-
wasTruncated,
|
|
867
|
-
},
|
|
868
|
-
};
|
|
869
|
-
} catch (err) {
|
|
870
|
-
return {
|
|
871
|
-
isError: true,
|
|
872
|
-
content: [{ type: "text" as const, text: `HTTP request failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
873
|
-
details: undefined,
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
|
-
},
|
|
877
|
-
renderResult(result, { expanded }, theme) {
|
|
878
|
-
const details = result.details as {
|
|
879
|
-
status?: number;
|
|
880
|
-
contentType?: string;
|
|
881
|
-
length?: number;
|
|
882
|
-
wasTruncated?: boolean;
|
|
883
|
-
fullOutputPath?: string;
|
|
884
|
-
} | undefined;
|
|
885
|
-
|
|
886
|
-
const status = details?.status ?? 0;
|
|
887
|
-
const ct = details?.contentType ?? "unknown";
|
|
888
|
-
const len = details?.length ?? 0;
|
|
889
|
-
const wasTruncated = details?.wasTruncated ?? false;
|
|
890
|
-
|
|
891
|
-
if (expanded) {
|
|
892
|
-
// Expanded (CTRL+O): show full response body
|
|
893
|
-
const content = result.content[0];
|
|
894
|
-
const raw = content?.type === "text" ? content.text : "";
|
|
895
|
-
return new Text(raw || "(empty response)", 0, 0);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// Collapsed (default): compact HTTP response summary
|
|
899
|
-
const shortCt = ct.split(";")[0] || ct; // drop charset etc.
|
|
900
|
-
const truncNote = wasTruncated ? " (truncated)" : "";
|
|
901
|
-
const summary = `🌐 HTTP ${status}, ${shortCt}, ${formatSize(len)}${truncNote}`;
|
|
902
|
-
|
|
903
|
-
return new Text(
|
|
904
|
-
theme
|
|
905
|
-
? theme.fg(status >= 200 && status < 300 ? "success" : status >= 400 ? "error" : "warning", summary)
|
|
906
|
-
: summary,
|
|
907
|
-
0,
|
|
908
|
-
0,
|
|
909
|
-
);
|
|
910
|
-
},
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
914
|
-
// browser_wait
|
|
915
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
916
|
-
pi.registerTool({
|
|
917
|
-
name: "browser_wait",
|
|
918
|
-
label: "Browser Wait",
|
|
919
|
-
description: "Wait for a specified number of seconds.",
|
|
920
|
-
promptSnippet: "Wait for N seconds (default 1s)",
|
|
921
|
-
promptGuidelines: [
|
|
922
|
-
"Use browser_wait for simple delays. Prefer browser_wait_for_load after navigation.",
|
|
923
|
-
],
|
|
924
|
-
parameters: Type.Object({
|
|
925
|
-
seconds: Type.Optional(Type.Number({ description: "Seconds to wait. Default: 1" })),
|
|
926
|
-
}),
|
|
927
|
-
async execute(_id, params) {
|
|
928
|
-
const sec = params.seconds || 1;
|
|
929
|
-
await new Promise((r) => setTimeout(r, sec * 1000));
|
|
930
|
-
return { content: [{ type: "text" as const, text: `Waited ${sec}s.` }], details: undefined };
|
|
931
|
-
},
|
|
932
|
-
});
|
|
933
|
-
|
|
934
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
935
|
-
// browser_wait_for_load
|
|
936
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
937
|
-
pi.registerTool({
|
|
938
|
-
name: "browser_wait_for_load",
|
|
939
|
-
label: "Browser Wait For Load",
|
|
940
|
-
description:
|
|
941
|
-
"Wait for the page to finish loading (document.readyState === 'complete'). Returns false on timeout. Note: SPAs may need additional waiting after this.",
|
|
942
|
-
promptSnippet: "Wait for document.readyState === 'complete'",
|
|
943
|
-
promptGuidelines: [
|
|
944
|
-
"Use browser_wait_for_load after browser_navigate or browser_new_tab.",
|
|
945
|
-
"This checks document.readyState only. Single-page apps may still be loading data after this returns true.",
|
|
946
|
-
"For SPAs, use browser_execute_js to check for specific elements after browser_wait_for_load.",
|
|
947
|
-
],
|
|
948
|
-
parameters: Type.Object({
|
|
949
|
-
timeout: Type.Optional(Type.Number({ description: "Max seconds to wait. Default: 15" })),
|
|
950
|
-
}),
|
|
951
|
-
async execute(_id, params) {
|
|
952
|
-
try {
|
|
953
|
-
await daemon.ensureAlive();
|
|
954
|
-
const timeout = params.timeout || 15;
|
|
955
|
-
const deadline = Date.now() + timeout * 1000;
|
|
956
|
-
|
|
957
|
-
while (Date.now() < deadline) {
|
|
958
|
-
const state = await daemon.evaluateJS("document.readyState");
|
|
959
|
-
if (state === "complete") {
|
|
960
|
-
return { content: [{ type: "text" as const, text: "Page loaded (readyState: complete)." }], details: undefined };
|
|
961
|
-
}
|
|
962
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
return {
|
|
966
|
-
isError: true,
|
|
967
|
-
content: [{ type: "text" as const, text: `Page load timed out after ${timeout}s. Current state unknown.` }],
|
|
968
|
-
details: undefined,
|
|
969
|
-
};
|
|
970
|
-
} catch (err) {
|
|
971
|
-
return {
|
|
972
|
-
isError: true,
|
|
973
|
-
content: [{ type: "text" as const, text: `Wait failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
974
|
-
details: undefined,
|
|
975
|
-
};
|
|
976
|
-
}
|
|
977
|
-
},
|
|
978
|
-
});
|
|
979
|
-
|
|
980
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
981
|
-
// browser_handle_dialog
|
|
982
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
983
|
-
pi.registerTool({
|
|
984
|
-
name: "browser_handle_dialog",
|
|
985
|
-
label: "Browser Handle Dialog",
|
|
986
|
-
description:
|
|
987
|
-
"Handle a JavaScript dialog (alert, confirm, prompt, beforeunload). Use when browser_page_info reports an open dialog.",
|
|
988
|
-
promptSnippet: "Accept or dismiss a JavaScript dialog (alert/confirm/prompt)",
|
|
989
|
-
promptGuidelines: [
|
|
990
|
-
"Check browser_page_info first — if it returns a dialog, use browser_handle_dialog before any other browser actions.",
|
|
991
|
-
"JS dialogs freeze the page's JS thread, so no other interaction works until the dialog is handled.",
|
|
992
|
-
'Use action: "accept" for alerts and confirms, "dismiss" to cancel. For prompts, provide promptText.',
|
|
993
|
-
],
|
|
994
|
-
parameters: Type.Object({
|
|
995
|
-
action: Type.String({ description: '"accept" or "dismiss"' }),
|
|
996
|
-
promptText: Type.Optional(Type.String({ description: 'Text to enter for prompt dialogs (only used with "accept")' })),
|
|
997
|
-
}),
|
|
998
|
-
async execute(_id, params) {
|
|
999
|
-
try {
|
|
1000
|
-
await daemon.ensureAlive();
|
|
1001
|
-
|
|
1002
|
-
if (params.action === "accept") {
|
|
1003
|
-
await daemon.cdp("Page.handleJavaScriptDialog", {
|
|
1004
|
-
accept: true,
|
|
1005
|
-
...(params.promptText !== undefined ? { promptText: params.promptText } : {}),
|
|
1006
|
-
});
|
|
1007
|
-
} else {
|
|
1008
|
-
await daemon.cdp("Page.handleJavaScriptDialog", { accept: false });
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
return { content: [{ type: "text" as const, text: `Dialog ${params.action}ed.` }], details: undefined };
|
|
1012
|
-
} catch (err) {
|
|
1013
|
-
return {
|
|
1014
|
-
isError: true,
|
|
1015
|
-
content: [{ type: "text" as const, text: `Dialog handling failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
1016
|
-
details: undefined,
|
|
1017
|
-
};
|
|
1018
|
-
}
|
|
1019
|
-
},
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
1023
|
-
// browser_open_urls
|
|
1024
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
1025
|
-
pi.registerTool({
|
|
1026
|
-
name: "browser_open_urls",
|
|
1027
|
-
label: "Browser Open URLs",
|
|
1028
|
-
description:
|
|
1029
|
-
"Open multiple URLs in new browser tabs in parallel. Useful after web_search to open citation links for visual inspection. Creates real Chrome tabs with full JS rendering support — identical to browser_new_tab but for many URLs at once.",
|
|
1030
|
-
promptSnippet: "Open multiple URLs in new tabs (parallel, full JS rendering)",
|
|
1031
|
-
promptGuidelines: [
|
|
1032
|
-
"Use browser_open_urls to open multiple URLs in parallel — much faster than calling browser_new_tab repeatedly.",
|
|
1033
|
-
"Use browser_open_urls after web_search to open citation links from the synthesized answer.",
|
|
1034
|
-
"Each URL opens in a real Chrome tab with full JS rendering — same as browser_new_tab.",
|
|
1035
|
-
"After opening, use browser_list_tabs to see all tabs, browser_switch_tab to switch, and browser_screenshot / browser_execute_js to interact.",
|
|
1036
|
-
"Use browser_wait_for_load on a tab before extracting data from SPAs.",
|
|
1037
|
-
],
|
|
1038
|
-
parameters: Type.Object({
|
|
1039
|
-
urls: Type.Array(Type.String(), { description: "Array of URLs to open in new tabs" }),
|
|
1040
|
-
}),
|
|
1041
|
-
async execute(_id, params, _signal, onUpdate) {
|
|
1042
|
-
await daemon.ensureAlive();
|
|
1043
|
-
|
|
1044
|
-
const total = params.urls.length;
|
|
1045
|
-
const results: Array<{
|
|
1046
|
-
url: string;
|
|
1047
|
-
targetId: string;
|
|
1048
|
-
ok: boolean;
|
|
1049
|
-
error?: string;
|
|
1050
|
-
}> = [];
|
|
1051
|
-
|
|
1052
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1053
|
-
// Phase 1: Create all tabs in parallel.
|
|
1054
|
-
// Target.createTarget is a browser-level CDP call — no session
|
|
1055
|
-
// needed, no shared state mutated. Safe to parallelize.
|
|
1056
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1057
|
-
const createPromises = params.urls.map(async (url: string) => {
|
|
1058
|
-
try {
|
|
1059
|
-
const result = (await daemon.cdp("Target.createTarget", {
|
|
1060
|
-
url: "about:blank",
|
|
1061
|
-
})) as { targetId: string };
|
|
1062
|
-
return { url, targetId: result.targetId, ok: true as const };
|
|
1063
|
-
} catch (err) {
|
|
1064
|
-
return {
|
|
1065
|
-
url,
|
|
1066
|
-
targetId: "",
|
|
1067
|
-
ok: false as const,
|
|
1068
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1069
|
-
details: undefined,
|
|
1070
|
-
};
|
|
1071
|
-
}
|
|
1072
|
-
});
|
|
1073
|
-
|
|
1074
|
-
const created = await Promise.all(createPromises);
|
|
1075
|
-
|
|
1076
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1077
|
-
// Phase 2: Attach a CDP session to each tab and navigate.
|
|
1078
|
-
// Each tab gets its own sessionId passed explicitly to cdp(),
|
|
1079
|
-
// avoiding the shared daemon._sessionId. Safe to parallelize.
|
|
1080
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1081
|
-
let completed = 0;
|
|
1082
|
-
const attachAndNav = created
|
|
1083
|
-
.filter((t) => t.ok)
|
|
1084
|
-
.map(async (tab) => {
|
|
1085
|
-
try {
|
|
1086
|
-
// Attach to target to get a session for this tab
|
|
1087
|
-
const attachResult = (await daemon.cdp(
|
|
1088
|
-
"Target.attachToTarget",
|
|
1089
|
-
{ targetId: tab.targetId, flatten: true },
|
|
1090
|
-
)) as { sessionId: string };
|
|
1091
|
-
const sid = attachResult.sessionId;
|
|
1092
|
-
|
|
1093
|
-
// Enable the Page domain so navigation events fire
|
|
1094
|
-
await daemon.cdp("Page.enable", {}, sid);
|
|
1095
|
-
|
|
1096
|
-
// Navigate to the URL using the tab's own session
|
|
1097
|
-
await daemon.cdp("Page.navigate", { url: tab.url }, sid);
|
|
1098
|
-
|
|
1099
|
-
return { ...tab, ok: true as const, targetId: tab.targetId };
|
|
1100
|
-
} catch (err) {
|
|
1101
|
-
return {
|
|
1102
|
-
...tab,
|
|
1103
|
-
ok: false as const,
|
|
1104
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1105
|
-
details: undefined,
|
|
1106
|
-
};
|
|
1107
|
-
} finally {
|
|
1108
|
-
completed++;
|
|
1109
|
-
// Stream progress so the TUI shows live per-URL status
|
|
1110
|
-
const okCount = created.filter((t) => t.ok).length;
|
|
1111
|
-
onUpdate?.({
|
|
1112
|
-
content: [
|
|
1113
|
-
{
|
|
1114
|
-
type: "text" as const,
|
|
1115
|
-
text: `Opening URLs… ${completed}/${okCount} navigated`,
|
|
1116
|
-
},
|
|
1117
|
-
],
|
|
1118
|
-
details: undefined,
|
|
1119
|
-
});
|
|
1120
|
-
}
|
|
1121
|
-
});
|
|
1122
|
-
|
|
1123
|
-
// Also collect failures from Phase 1
|
|
1124
|
-
const failures = created.filter((t) => !t.ok);
|
|
1125
|
-
|
|
1126
|
-
const settled = await Promise.all(attachAndNav);
|
|
1127
|
-
results.push(...settled, ...failures);
|
|
1128
|
-
|
|
1129
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1130
|
-
// Phase 3: Activate the last successfully opened tab so the user
|
|
1131
|
-
// sees something meaningful in the foreground.
|
|
1132
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1133
|
-
const lastOk = [...settled].reverse().find((r) => r.ok);
|
|
1134
|
-
if (lastOk) {
|
|
1135
|
-
try {
|
|
1136
|
-
await daemon.cdp("Target.activateTarget", {
|
|
1137
|
-
targetId: lastOk.targetId,
|
|
1138
|
-
});
|
|
1139
|
-
} catch {
|
|
1140
|
-
// best-effort — the tabs are still open
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
// Build result text
|
|
1145
|
-
const okTabs = results.filter((r) => r.ok);
|
|
1146
|
-
const failedTabs = results.filter((r) => !r.ok);
|
|
1147
|
-
|
|
1148
|
-
const lines: string[] = [];
|
|
1149
|
-
if (okTabs.length > 0) {
|
|
1150
|
-
lines.push(
|
|
1151
|
-
`✅ ${okTabs.length}/${total} tabs opened:`,
|
|
1152
|
-
...okTabs.map(
|
|
1153
|
-
(t, i) => ` [${i}] ${t.url} → ${t.targetId}`,
|
|
1154
|
-
),
|
|
1155
|
-
);
|
|
1156
|
-
}
|
|
1157
|
-
if (failedTabs.length > 0) {
|
|
1158
|
-
lines.push(
|
|
1159
|
-
`❌ ${failedTabs.length}/${total} failed:`,
|
|
1160
|
-
...failedTabs.map(
|
|
1161
|
-
(t) => ` ${t.url}: ${t.error || "unknown error"}`,
|
|
1162
|
-
),
|
|
1163
|
-
);
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
const isTotalFailure = okTabs.length === 0;
|
|
1167
|
-
const fullOutput = lines.join("\n");
|
|
1168
|
-
const { text, fullOutputPath } = await applyTruncation(fullOutput, "urls");
|
|
1169
|
-
|
|
1170
|
-
return {
|
|
1171
|
-
isError: isTotalFailure,
|
|
1172
|
-
content: [{ type: "text" as const, text }],
|
|
1173
|
-
details: { tabs: results, fullOutputPath },
|
|
1174
|
-
};
|
|
1175
|
-
},
|
|
1176
|
-
renderCall(args, _theme) {
|
|
1177
|
-
const { urls } = args as { urls: string[] };
|
|
1178
|
-
return new Text(
|
|
1179
|
-
`🌐 Opening ${urls.length} URL${urls.length !== 1 ? "s" : ""}…`,
|
|
1180
|
-
0,
|
|
1181
|
-
0,
|
|
1182
|
-
);
|
|
1183
|
-
},
|
|
1184
|
-
renderResult(result, { expanded }, theme) {
|
|
1185
|
-
const details = result.details as {
|
|
1186
|
-
tabs?: Array<{
|
|
1187
|
-
url: string;
|
|
1188
|
-
targetId: string;
|
|
1189
|
-
ok: boolean;
|
|
1190
|
-
error?: string;
|
|
1191
|
-
}>;
|
|
1192
|
-
fullOutputPath?: string;
|
|
1193
|
-
} | undefined;
|
|
1194
|
-
|
|
1195
|
-
const tabs = details?.tabs || [];
|
|
1196
|
-
const ok = tabs.filter((t) => t.ok).length;
|
|
1197
|
-
const fail = tabs.filter((t) => !t.ok).length;
|
|
1198
|
-
const total = tabs.length;
|
|
1199
|
-
|
|
1200
|
-
if (expanded) {
|
|
1201
|
-
// Expanded (CTRL+O): show the full URL→targetId mapping
|
|
1202
|
-
const content = result.content[0];
|
|
1203
|
-
const raw = content?.type === "text" ? content.text : "";
|
|
1204
|
-
// Strip the truncation footer for cleaner display, show it separately
|
|
1205
|
-
const truncIdx = raw.indexOf("\n\n[Output truncated:");
|
|
1206
|
-
const displayText = truncIdx >= 0 ? raw.slice(0, truncIdx) : raw;
|
|
1207
|
-
|
|
1208
|
-
let expandedText = displayText || "(no output)";
|
|
1209
|
-
if (details?.fullOutputPath && truncIdx < 0) {
|
|
1210
|
-
// Truncated but we stripped the footer — add a compact note
|
|
1211
|
-
expandedText += `\n\n${theme ? theme.fg("dim", `Full output: ${details.fullOutputPath}`) : `Full output: ${details.fullOutputPath}`}`;
|
|
1212
|
-
} else if (details?.fullOutputPath) {
|
|
1213
|
-
expandedText += `\n${theme ? theme.fg("dim", `Full output: ${details.fullOutputPath}`) : `Full output: ${details.fullOutputPath}`}`;
|
|
1214
|
-
}
|
|
1215
|
-
return new Text(expandedText, 0, 0);
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
// Collapsed (default): compact summary
|
|
1219
|
-
let text: string;
|
|
1220
|
-
if (total === 0) {
|
|
1221
|
-
text = "No URLs provided.";
|
|
1222
|
-
} else if (fail === 0) {
|
|
1223
|
-
text = `✅ ${ok} tab${ok !== 1 ? "s" : ""} opened`;
|
|
1224
|
-
} else if (ok === 0) {
|
|
1225
|
-
text = `❌ All ${fail} URL${fail !== 1 ? "s" : ""} failed`;
|
|
1226
|
-
} else {
|
|
1227
|
-
text = `⚠️ ${ok} opened, ${fail} failed`;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
return new Text(
|
|
1231
|
-
theme ? theme.fg(fail === 0 ? "success" : ok === 0 ? "error" : "warning", text) : text,
|
|
1232
|
-
0,
|
|
1233
|
-
0,
|
|
1234
|
-
);
|
|
1235
|
-
},
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
1239
|
-
// browser_run_script
|
|
1240
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
1241
|
-
pi.registerTool({
|
|
1242
|
-
name: "browser_run_script",
|
|
1243
|
-
label: "Browser Run Script",
|
|
1244
|
-
description:
|
|
1245
|
-
"Execute a temporary JavaScript script file with access to the browser daemon " +
|
|
1246
|
-
"and Node.js APIs. Use this when the built-in browser_* tools are insufficient " +
|
|
1247
|
-
"for a multi-step workflow — write a script to disk, then run it with this tool. " +
|
|
1248
|
-
"The script receives these bindings in scope:\n" +
|
|
1249
|
-
" params — the arguments passed to this tool\n" +
|
|
1250
|
-
" daemon — the browser daemon (daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc.)\n" +
|
|
1251
|
-
" require — Node.js require() for builtins and installed packages\n" +
|
|
1252
|
-
" signal — AbortSignal for cancellation\n" +
|
|
1253
|
-
" onUpdate — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] })\n" +
|
|
1254
|
-
" ctx — ExtensionContext with cwd, sessionManager, ui, signal, etc.\n" +
|
|
1255
|
-
" console, fetch, JSON, Buffer, setTimeout, clearTimeout\n\n" +
|
|
1256
|
-
"The script MUST return { content: [{ type: 'text', text: '...' }], details?: {...} }.\n" +
|
|
1257
|
-
"For errors, throw: throw new Error('something went wrong').",
|
|
1258
|
-
promptSnippet: "Run a temporary script with browser daemon access (write script to disk first)",
|
|
1259
|
-
promptGuidelines: [
|
|
1260
|
-
"Use write to create a temporary script file, then browser_run_script to execute it — no dynamic tool registration needed.",
|
|
1261
|
-
"Scripts are written to disk, making them auditable and re-runnable. The user can inspect them.",
|
|
1262
|
-
"The script has access to the browser daemon via `daemon` — use daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc.",
|
|
1263
|
-
"For pure data processing or file operations, use require() to access Node.js modules like fs, path, crypto.",
|
|
1264
|
-
"If the script only needs built-in browser_* tools, break those into separate tool calls before or after — do not try to invoke them from within the script.",
|
|
1265
|
-
],
|
|
1266
|
-
parameters: Type.Object({
|
|
1267
|
-
path: Type.String({ description: "Path to the temporary script file (.js or .mjs) to execute" }),
|
|
1268
|
-
params: Type.Optional(Type.Object({}, { additionalProperties: true, description: "Optional parameters to pass to the script as `params`" })),
|
|
1269
|
-
}),
|
|
1270
|
-
async execute(_id, p, signal, onUpdate, ctx) {
|
|
1271
|
-
const { path: scriptPath, params: scriptParams } = p as {
|
|
1272
|
-
path: string;
|
|
1273
|
-
params?: Record<string, unknown>;
|
|
1274
|
-
};
|
|
1275
|
-
|
|
1276
|
-
// ── Read the script from disk ────────────────────────────────────
|
|
1277
|
-
let source: string;
|
|
1278
|
-
try {
|
|
1279
|
-
source = await readFile(scriptPath, "utf8");
|
|
1280
|
-
} catch (err) {
|
|
1281
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1282
|
-
return {
|
|
1283
|
-
isError: true,
|
|
1284
|
-
content: [{
|
|
1285
|
-
type: "text" as const,
|
|
1286
|
-
text: `Failed to read script: ${msg}\n\nPath: ${scriptPath}\nCheck that the file exists and is readable.`,
|
|
1287
|
-
}],
|
|
1288
|
-
details: { error: "read_failed", message: msg },
|
|
1289
|
-
};
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
if (source.trim().length === 0) {
|
|
1293
|
-
return {
|
|
1294
|
-
isError: true,
|
|
1295
|
-
content: [{ type: "text" as const, text: `Script is empty: ${scriptPath}` }],
|
|
1296
|
-
details: { error: "empty_script" },
|
|
1297
|
-
};
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
// ── Compile ─────────────────────────────────────────────────────
|
|
1301
|
-
let executeFn: (...args: unknown[]) => Promise<unknown>;
|
|
1302
|
-
try {
|
|
1303
|
-
const wrapped = `"use strict";\n${source}`;
|
|
1304
|
-
executeFn = new AsyncFunction(
|
|
1305
|
-
"params", "daemon", "require", "signal", "onUpdate", "ctx",
|
|
1306
|
-
"console", "fetch", "JSON", "Buffer", "setTimeout", "clearTimeout",
|
|
1307
|
-
wrapped,
|
|
1308
|
-
);
|
|
1309
|
-
} catch (err) {
|
|
1310
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1311
|
-
return {
|
|
1312
|
-
isError: true,
|
|
1313
|
-
content: [{
|
|
1314
|
-
type: "text" as const,
|
|
1315
|
-
text: `Syntax error in script: ${msg}\n\nFile: ${scriptPath}\nFix the JavaScript syntax and try again.`,
|
|
1316
|
-
}],
|
|
1317
|
-
details: { error: "syntax_error", message: msg },
|
|
1318
|
-
};
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
// ── Execute ─────────────────────────────────────────────────────
|
|
1322
|
-
try {
|
|
1323
|
-
const result = await executeFn(
|
|
1324
|
-
scriptParams ?? {},
|
|
1325
|
-
daemon,
|
|
1326
|
-
require,
|
|
1327
|
-
signal,
|
|
1328
|
-
onUpdate ?? ((_update: unknown) => {}),
|
|
1329
|
-
ctx ?? { cwd: process.cwd() },
|
|
1330
|
-
console,
|
|
1331
|
-
fetch,
|
|
1332
|
-
JSON,
|
|
1333
|
-
Buffer,
|
|
1334
|
-
setTimeout,
|
|
1335
|
-
clearTimeout,
|
|
1336
|
-
);
|
|
1337
|
-
|
|
1338
|
-
if (!result || typeof result !== "object" || !Array.isArray((result as any).content)) {
|
|
1339
|
-
return {
|
|
1340
|
-
isError: true,
|
|
1341
|
-
content: [{
|
|
1342
|
-
type: "text" as const,
|
|
1343
|
-
text: `Script must return { content: [{ type: 'text', text: '...' }], details?: {...} }.\nGot: ${JSON.stringify(result)}\n\nFile: ${scriptPath}`,
|
|
1344
|
-
}],
|
|
1345
|
-
details: { error: "invalid_return" },
|
|
1346
|
-
};
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
return result as { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> | undefined };
|
|
1350
|
-
} catch (err) {
|
|
1351
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1352
|
-
return {
|
|
1353
|
-
isError: true,
|
|
1354
|
-
content: [{
|
|
1355
|
-
type: "text" as const,
|
|
1356
|
-
text: `Script execution failed: ${msg}\n\nFile: ${scriptPath}\nCheck for runtime errors, undefined variables, or broken require() calls.`,
|
|
1357
|
-
}],
|
|
1358
|
-
details: { error: "execution_failed", message: msg },
|
|
1359
|
-
};
|
|
1360
|
-
}
|
|
1361
|
-
},
|
|
1362
|
-
});
|
|
1363
|
-
}
|