@underactive/pi-topping-web-tools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1033 @@
1
+ /**
2
+ * web_browser — headless browser automation for the pi coding agent (see ACTIONS for the full action list).
3
+ * Commands: /browser, /browser-close, /browser-screenshot.
4
+ */
5
+
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ DEFAULT_MAX_BYTES,
9
+ DEFAULT_MAX_LINES,
10
+ formatSize,
11
+ truncateHead,
12
+ withFileMutationQueue,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { StringEnum } from "@earendil-works/pi-ai";
15
+ import { Text } from "@earendil-works/pi-tui";
16
+ import { mkdtemp, writeFile } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { Type } from "typebox";
20
+ import {
21
+ closeBrowserManager,
22
+ getBrowserManager,
23
+ type BrowserManager,
24
+ type ConsoleEntry,
25
+ type DialogEntry,
26
+ type NavigateResult,
27
+ type NetworkEntry,
28
+ type ScreenshotResult,
29
+ type SetCookieParam,
30
+ type TabInfo,
31
+ } from "./browser-manager.ts";
32
+ import { checkUrlPermission, upgradeHttpToHttps, validateURL } from "./permissions.ts";
33
+
34
+ const ACTIONS = [
35
+ "navigate",
36
+ "screenshot",
37
+ "click",
38
+ "type",
39
+ "hover",
40
+ "press",
41
+ "select_option",
42
+ "set_viewport",
43
+ "evaluate",
44
+ "get_content",
45
+ "get_text",
46
+ "get_markdown",
47
+ "wait_for",
48
+ "get_accessibility_snapshot",
49
+ "get_console_logs",
50
+ "get_network_logs",
51
+ "get_cookies",
52
+ "set_cookies",
53
+ "go_back",
54
+ "go_forward",
55
+ "reload",
56
+ "scroll",
57
+ "drag",
58
+ "upload_file",
59
+ "set_dialog_behavior",
60
+ "get_dialog_logs",
61
+ "list_tabs",
62
+ "switch_tab",
63
+ "close",
64
+ ] as const;
65
+
66
+ type WebBrowserAction = (typeof ACTIONS)[number];
67
+
68
+ interface WebBrowserDetails {
69
+ action: WebBrowserAction;
70
+ url?: string;
71
+ title?: string;
72
+ statusCode?: number;
73
+ bytes?: number;
74
+ screenshotDims?: string;
75
+ consoleCount?: number;
76
+ networkCount?: number;
77
+ dialogCount?: number;
78
+ tabCount?: number;
79
+ fullOutputPath?: string;
80
+ error?: string;
81
+ }
82
+
83
+ function formatConsoleLogs(entries: ConsoleEntry[]): string {
84
+ if (entries.length === 0) {
85
+ return "No console messages captured.";
86
+ }
87
+ return entries
88
+ .map((e) => {
89
+ const ts = new Date(e.timestamp).toISOString();
90
+ return `[${ts}] ${e.type}: ${e.text}`;
91
+ })
92
+ .join("\n");
93
+ }
94
+
95
+ function formatNetworkLogs(entries: NetworkEntry[]): string {
96
+ if (entries.length === 0) {
97
+ return "No network issues captured.";
98
+ }
99
+ return entries
100
+ .map((e) => {
101
+ const ts = new Date(e.timestamp).toISOString();
102
+ const status = e.status > 0 ? `${e.status} ${e.statusText}`.trim() : "failed";
103
+ const failure = e.failure ? ` — ${e.failure}` : "";
104
+ return `[${ts}] ${e.type} ${e.method} ${status} ${e.url}${failure}`;
105
+ })
106
+ .join("\n");
107
+ }
108
+
109
+ function formatDialogLogs(entries: DialogEntry[]): string {
110
+ if (entries.length === 0) {
111
+ return "No dialogs captured.";
112
+ }
113
+ return entries
114
+ .map((e) => {
115
+ const ts = new Date(e.timestamp).toISOString();
116
+ const defaultNote = e.defaultValue ? ` (default: "${e.defaultValue}")` : "";
117
+ return `[${ts}] ${e.type}: "${e.message}"${defaultNote} — ${e.action}ed`;
118
+ })
119
+ .join("\n");
120
+ }
121
+
122
+ function formatTabs(tabs: TabInfo[]): string {
123
+ if (tabs.length === 0) {
124
+ return "No tabs open.";
125
+ }
126
+ return tabs
127
+ .map((t) => `[${t.index}]${t.active ? " (active)" : ""} ${t.title || "(untitled)"} — ${t.url}`)
128
+ .join("\n");
129
+ }
130
+
131
+ async function wrapUntrustedContent(mgr: BrowserManager, text: string): Promise<string> {
132
+ const info = await mgr.getPageInfoAsync();
133
+ return `<untrusted-content url="${info.url}">\n${text}\n</untrusted-content>`;
134
+ }
135
+
136
+ function isAbortError(err: unknown): boolean {
137
+ return err instanceof Error && (err.name === "AbortError" || err.message.includes("Aborted"));
138
+ }
139
+
140
+ async function truncateTextResult(
141
+ text: string,
142
+ details: WebBrowserDetails,
143
+ ): Promise<{ text: string; details: WebBrowserDetails }> {
144
+ const truncation = truncateHead(text, {
145
+ maxLines: DEFAULT_MAX_LINES,
146
+ maxBytes: DEFAULT_MAX_BYTES,
147
+ });
148
+
149
+ let resultText = truncation.content;
150
+ const outDetails = { ...details, bytes: truncation.totalBytes };
151
+
152
+ if (truncation.truncated) {
153
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-web-browser-"));
154
+ const tempFile = join(tempDir, "output.txt");
155
+ await withFileMutationQueue(tempFile, async () => {
156
+ await writeFile(tempFile, text, "utf-8");
157
+ });
158
+
159
+ outDetails.fullOutputPath = tempFile;
160
+
161
+ const truncatedLines = truncation.totalLines - truncation.outputLines;
162
+ const truncatedBytes = truncation.totalBytes - truncation.outputBytes;
163
+
164
+ resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`;
165
+ resultText += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
166
+ resultText += ` ${truncatedLines} lines (${formatSize(truncatedBytes)}) omitted.`;
167
+ resultText += ` Full output saved to: ${tempFile}]`;
168
+ }
169
+
170
+ return { text: resultText, details: outDetails };
171
+ }
172
+
173
+ async function saveScreenshotToTempFile(shot: ScreenshotResult): Promise<string> {
174
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-web-browser-"));
175
+ const tempFile = join(tempDir, "screenshot.png");
176
+ const buf = Buffer.from(shot.data, "base64");
177
+ await withFileMutationQueue(tempFile, async () => {
178
+ await writeFile(tempFile, buf);
179
+ });
180
+ return tempFile;
181
+ }
182
+
183
+ function browserStatus(ctx: ExtensionContext, text: string, tone: "success" | "muted"): string {
184
+ return ctx.ui.theme.fg(tone, `browser: ${text}`);
185
+ }
186
+
187
+ function updateBrowserStatus(ctx: ExtensionContext, info: { url: string; title?: string }): void {
188
+ try {
189
+ const parsed = new URL(info.url);
190
+ const label = parsed.protocol === "file:" ? "local file" : parsed.hostname;
191
+ ctx.ui.setStatus("browser", browserStatus(ctx, label, "success"));
192
+ } catch {
193
+ ctx.ui.setStatus("browser", browserStatus(ctx, "ready", "muted"));
194
+ }
195
+ }
196
+
197
+ export default function (pi: ExtensionAPI) {
198
+ const sessionPermissions = new Map<string, "allow" | "deny">();
199
+
200
+ pi.on("session_start", async (_event, ctx) => {
201
+ ctx.ui.setStatus("browser", undefined);
202
+ });
203
+
204
+ pi.on("session_shutdown", async () => {
205
+ await closeBrowserManager();
206
+ sessionPermissions.clear();
207
+ });
208
+
209
+ pi.on("tool_call", async (event, ctx) => {
210
+ if (event.toolName !== "web_browser") return undefined;
211
+
212
+ const input = event.input as { action?: string; url?: string };
213
+
214
+ if (input.action === "navigate" && input.url) {
215
+ const permission = await checkUrlPermission(input.url, ctx, sessionPermissions);
216
+ if (permission !== "allow") {
217
+ return { block: true, reason: permission.slice(6) };
218
+ }
219
+
220
+ return undefined;
221
+ }
222
+
223
+ if (input.action === "evaluate") {
224
+ const info = await getBrowserManager().getPageInfoAsync();
225
+ const permission = await checkUrlPermission(info.url, ctx, sessionPermissions);
226
+ if (permission !== "allow") {
227
+ return { block: true, reason: permission.slice(6) };
228
+ }
229
+ }
230
+
231
+ return undefined;
232
+ });
233
+
234
+ pi.registerCommand("browser", {
235
+ description:
236
+ "Show headless browser status (open/closed, URL, title, console and network counts, session-approved hosts)",
237
+ handler: async (_args, ctx) => {
238
+ const browser = getBrowserManager();
239
+ const info = await browser.getPageInfoAsync();
240
+ const lines = [
241
+ "Web Browser:",
242
+ ` Status: ${info.isOpen ? "open" : "closed"}`,
243
+ ` URL: ${info.url || "(none)"}`,
244
+ ` Title: ${info.title?.replace(/[\u0000-\u001f\u007f]/g, " ") || "(none)"}`,
245
+ ` Tabs open: ${info.tabCount}`,
246
+ ` Console messages: ${info.consoleCount}`,
247
+ ` Network issues: ${info.networkCount}`,
248
+ ` Dialogs captured: ${info.dialogCount}`,
249
+ ` Session-approved hosts: ${sessionPermissions.size}`,
250
+ ];
251
+ ctx.ui.notify(lines.join("\n"), "info");
252
+ },
253
+ });
254
+
255
+ pi.registerCommand("browser-close", {
256
+ description: "Force close the headless browser",
257
+ handler: async (_args, ctx) => {
258
+ await closeBrowserManager();
259
+ ctx.ui.setStatus("browser", undefined);
260
+ ctx.ui.notify("Browser closed", "info");
261
+ },
262
+ });
263
+
264
+ pi.registerCommand("browser-screenshot", {
265
+ description: "Save a full-page screenshot to a temp PNG file",
266
+ handler: async (_args, ctx) => {
267
+ const browser = getBrowserManager();
268
+ const info = await browser.getPageInfoAsync();
269
+ if (!info.isOpen) {
270
+ ctx.ui.notify("Browser is closed — navigate to a page first.", "warning");
271
+ return;
272
+ }
273
+
274
+ const shot = await browser.screenshot({ fullPage: true });
275
+ const tempFile = await saveScreenshotToTempFile(shot);
276
+ const dims = `${shot.width}×${shot.height}`;
277
+ ctx.ui.notify(`Screenshot saved to: ${tempFile}\nDimensions: ${dims} (full page)`, "info");
278
+ },
279
+ });
280
+
281
+ pi.registerTool({
282
+ name: "web_browser",
283
+ label: "Web Browser",
284
+ promptSnippet:
285
+ "Automate a headless browser to navigate sites, click/type, capture screenshots, evaluate JS, and read console logs.",
286
+ promptGuidelines: [
287
+ "Call web_browser with action navigate before interacting with a page.",
288
+ "Use get_text for visible page text (fastest, lowest tokens); use get_markdown for structured headings/lists.",
289
+ "Use get_accessibility_snapshot to find interactive elements with less noise than HTML.",
290
+ "Use get_content only when DOM structure or attributes are needed; it returns raw HTML.",
291
+ "On SPA sites, use wait_for after navigate (selector/state or networkidle) before click/type.",
292
+ "Use screenshot for visual verification (requires a vision-capable model); use toFile=true to save PNG to disk instead of base64.",
293
+ "Use get_console_logs when JavaScript errors or console output may explain page behavior.",
294
+ "Use get_network_logs when failed requests or 4xx/5xx responses may explain page behavior.",
295
+ "Use set_cookies to inject cookies for authenticated flows after manual login.",
296
+ "Use hover to trigger tooltips/hover-menus before click; press for keyboard key events (Enter, Escape, Tab) — distinct from type mode:press which types characters; select_option to choose <select> dropdown values; set_viewport to resize the page for responsive/mobile checks.",
297
+ "Selectors are CSS selectors, or Playwright's role= and text= selector engines (e.g. role=button[name='Submit'], text=Sign in). If click/type fails, read the returned diagnostics and page snippet and adjust the selector.",
298
+ "Use frame to target an element inside an iframe: pass a CSS selector for the iframe itself, separate from the element selector.",
299
+ "Call set_dialog_behavior before the action that triggers a JS dialog (alert/confirm/prompt) — dialogs are dismissed by default, which would silently cancel a confirm(). Use get_dialog_logs to see captured dialogs.",
300
+ "Opening a link with target=_blank or window.open creates a popup that automatically becomes the active tab. Use list_tabs and switch_tab to manage multiple open tabs.",
301
+ "Use scroll to bring an off-screen element into view (selector) or to scroll the page/container by pixel deltas; use drag for drag-and-drop between two selectors; use upload_file to set files on an <input type=file>.",
302
+ ],
303
+ description:
304
+ "Automate a headless Chromium browser. Actions: navigate, screenshot (toFile), click, type (mode fill|press), hover, press, select_option, set_viewport, evaluate, get_content, get_text, get_markdown, wait_for, get_accessibility_snapshot, get_console_logs, get_network_logs, get_cookies, set_cookies, go_back, go_forward, reload, scroll, drag, upload_file, set_dialog_behavior, get_dialog_logs, list_tabs, switch_tab, close. Returns page content, screenshots (PNG base64 or file path), console logs, network logs, dialog logs, tab lists, or cookie metadata (values redacted).",
305
+ parameters: Type.Object({
306
+ action: StringEnum(ACTIONS, {
307
+ description: "Browser action to perform",
308
+ }),
309
+ url: Type.Optional(
310
+ Type.String({
311
+ description:
312
+ "URL to navigate to (action: navigate). Accepts http(s), localhost/private-IP dev servers, and file:// paths.",
313
+ }),
314
+ ),
315
+ selector: Type.Optional(
316
+ Type.String({
317
+ description:
318
+ "CSS selector, or Playwright role=/text= selector (actions: click, type, hover, select_option, screenshot, get_text, wait_for, get_accessibility_snapshot, scroll, upload_file). For drag, this is the source selector.",
319
+ }),
320
+ ),
321
+ frame: Type.Optional(
322
+ Type.String({
323
+ description:
324
+ "CSS selector for an iframe to scope the element selector into (actions: click, type, hover, select_option, screenshot, get_text, wait_for, get_accessibility_snapshot, scroll, upload_file, drag)",
325
+ }),
326
+ ),
327
+ targetSelector: Type.Optional(
328
+ Type.String({ description: "Target CSS selector to drop onto (action: drag)" }),
329
+ ),
330
+ files: Type.Optional(
331
+ Type.Array(Type.String(), {
332
+ description: "Absolute file paths to upload (action: upload_file)",
333
+ }),
334
+ ),
335
+ deltaX: Type.Optional(
336
+ Type.Number({ description: "Horizontal scroll delta in pixels (action: scroll, default 0)" }),
337
+ ),
338
+ deltaY: Type.Optional(
339
+ Type.Number({
340
+ description: "Vertical scroll delta in pixels (action: scroll, default: viewport height)",
341
+ }),
342
+ ),
343
+ index: Type.Optional(
344
+ Type.Number({ description: "Tab index to switch to, from list_tabs (action: switch_tab)" }),
345
+ ),
346
+ dialogAction: Type.Optional(
347
+ StringEnum(["accept", "dismiss"], {
348
+ description:
349
+ "How to resolve future JS dialogs (alert/confirm/prompt) for the rest of the session (action: set_dialog_behavior, default dismiss)",
350
+ }),
351
+ ),
352
+ promptText: Type.Optional(
353
+ Type.String({
354
+ description: "Text to enter when accepting a prompt() dialog (action: set_dialog_behavior)",
355
+ }),
356
+ ),
357
+ text: Type.Optional(
358
+ Type.String({ description: "Text to type into selector (action: type)" }),
359
+ ),
360
+ key: Type.Optional(
361
+ Type.String({ description: "Keyboard key to press (action: press, e.g. Enter, Escape, Tab)" }),
362
+ ),
363
+ values: Type.Optional(
364
+ Type.String({
365
+ description:
366
+ "Comma-separated option values to select (action: select_option, e.g. option1,option2)",
367
+ }),
368
+ ),
369
+ width: Type.Optional(
370
+ Type.Number({ description: "Viewport width in pixels (action: set_viewport)" }),
371
+ ),
372
+ height: Type.Optional(
373
+ Type.Number({ description: "Viewport height in pixels (action: set_viewport)" }),
374
+ ),
375
+ mode: Type.Optional(
376
+ StringEnum(["fill", "press"], {
377
+ description:
378
+ "Typing mode (action: type, default fill). fill clears the field first; press types key-by-key and appends to existing input.",
379
+ }),
380
+ ),
381
+ state: Type.Optional(
382
+ StringEnum(["visible", "hidden", "attached", "detached"], {
383
+ description: "Wait state (action: wait_for, default visible)",
384
+ }),
385
+ ),
386
+ networkidle: Type.Optional(
387
+ Type.Boolean({ description: "Wait for network idle (action: wait_for)" }),
388
+ ),
389
+ script: Type.Optional(
390
+ Type.String({ description: "JavaScript expression to evaluate (action: evaluate)" }),
391
+ ),
392
+ fullPage: Type.Optional(
393
+ Type.Boolean({ description: "Capture full scrollable page (action: screenshot, default false)" }),
394
+ ),
395
+ toFile: Type.Optional(
396
+ Type.Boolean({
397
+ description:
398
+ "Write screenshot PNG to a temp file and return the path instead of a base64 image (action: screenshot, default false)",
399
+ }),
400
+ ),
401
+ cookies: Type.Optional(
402
+ Type.Array(
403
+ Type.Object({
404
+ name: Type.String(),
405
+ value: Type.String(),
406
+ url: Type.Optional(Type.String()),
407
+ domain: Type.Optional(Type.String()),
408
+ path: Type.Optional(Type.String()),
409
+ httpOnly: Type.Optional(Type.Boolean()),
410
+ secure: Type.Optional(Type.Boolean()),
411
+ sameSite: Type.Optional(StringEnum(["Strict", "Lax", "None"])),
412
+ }),
413
+ { description: "Cookies to set (action: set_cookies); each needs url or domain+path" },
414
+ ),
415
+ ),
416
+ timeout: Type.Optional(
417
+ Type.Number({ description: "Timeout in milliseconds for navigation/interaction (default 30000)" }),
418
+ ),
419
+ }),
420
+
421
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
422
+ const action = params.action as WebBrowserAction;
423
+ const timeout = params.timeout;
424
+
425
+ onUpdate?.({ content: [{ type: "text", text: `${action}…` }], details: { action } });
426
+
427
+ try {
428
+ if (action === "close") {
429
+ await closeBrowserManager();
430
+ ctx.ui.setStatus("browser", undefined);
431
+ return {
432
+ content: [{ type: "text", text: "Browser closed." }],
433
+ details: { action } satisfies WebBrowserDetails,
434
+ };
435
+ }
436
+
437
+ const mgr = getBrowserManager();
438
+
439
+ switch (action) {
440
+ case "navigate": {
441
+ if (!params.url) {
442
+ return {
443
+ content: [{ type: "text", text: "url is required for navigate." }],
444
+ details: { action, error: "missing url" },
445
+ isError: true,
446
+ };
447
+ }
448
+ if (!validateURL(params.url)) {
449
+ return {
450
+ content: [{ type: "text", text: "Invalid URL." }],
451
+ details: { action, error: "invalid url" },
452
+ isError: true,
453
+ };
454
+ }
455
+
456
+ const upgraded = upgradeHttpToHttps(params.url);
457
+ const result: NavigateResult = await mgr.navigate(upgraded, { timeout, signal });
458
+ updateBrowserStatus(ctx, result);
459
+
460
+ const text = `Navigated to ${result.url}\nTitle: ${result.title}${result.statusCode ? `\nStatus: ${result.statusCode}` : ""}`;
461
+ return {
462
+ content: [{ type: "text", text }],
463
+ details: {
464
+ action,
465
+ url: result.url,
466
+ title: result.title,
467
+ statusCode: result.statusCode,
468
+ bytes: Buffer.byteLength(text),
469
+ } satisfies WebBrowserDetails,
470
+ };
471
+ }
472
+
473
+ case "screenshot": {
474
+ const shot: ScreenshotResult = await mgr.screenshot({
475
+ fullPage: params.fullPage,
476
+ selector: params.selector,
477
+ frame: params.frame,
478
+ timeout,
479
+ signal,
480
+ });
481
+ const info = await mgr.getPageInfoAsync();
482
+ updateBrowserStatus(ctx, info);
483
+ const dims = `${shot.width}×${shot.height}`;
484
+ const dimsNote = `${dims}${shot.fullPage ? " (full page)" : ""}`;
485
+
486
+ if (params.toFile) {
487
+ const tempFile = await saveScreenshotToTempFile(shot);
488
+ const text = `Screenshot saved to: ${tempFile}\nDimensions: ${dimsNote}`;
489
+ return {
490
+ content: [{ type: "text", text }],
491
+ details: {
492
+ action,
493
+ url: info.url,
494
+ title: info.title,
495
+ screenshotDims: dims,
496
+ fullOutputPath: tempFile,
497
+ } satisfies WebBrowserDetails,
498
+ };
499
+ }
500
+
501
+ return {
502
+ content: [
503
+ {
504
+ type: "image",
505
+ data: shot.data,
506
+ mimeType: "image/png",
507
+ },
508
+ {
509
+ type: "text",
510
+ text: `Screenshot ${dimsNote}`,
511
+ },
512
+ ],
513
+ details: {
514
+ action,
515
+ url: info.url,
516
+ title: info.title,
517
+ screenshotDims: dims,
518
+ } satisfies WebBrowserDetails,
519
+ };
520
+ }
521
+
522
+ case "click": {
523
+ if (!params.selector) {
524
+ return {
525
+ content: [{ type: "text", text: "selector is required for click." }],
526
+ details: { action, error: "missing selector" },
527
+ isError: true,
528
+ };
529
+ }
530
+ const msg = await mgr.click(params.selector, { timeout, signal, frame: params.frame });
531
+ return {
532
+ content: [{ type: "text", text: msg }],
533
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
534
+ isError: msg.startsWith("No element"),
535
+ };
536
+ }
537
+
538
+ case "type": {
539
+ if (!params.selector || params.text === undefined) {
540
+ return {
541
+ content: [{ type: "text", text: "selector and text are required for type." }],
542
+ details: { action, error: "missing selector or text" },
543
+ isError: true,
544
+ };
545
+ }
546
+ const msg = await mgr.fill(params.selector, params.text, {
547
+ timeout,
548
+ signal,
549
+ mode: params.mode as "fill" | "press" | undefined,
550
+ frame: params.frame,
551
+ });
552
+ return {
553
+ content: [{ type: "text", text: msg }],
554
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
555
+ isError: msg.startsWith("No element"),
556
+ };
557
+ }
558
+
559
+ case "hover": {
560
+ if (!params.selector) {
561
+ return {
562
+ content: [{ type: "text", text: "selector is required for hover." }],
563
+ details: { action, error: "missing selector" },
564
+ isError: true,
565
+ };
566
+ }
567
+ const msg = await mgr.hover(params.selector, { timeout, signal, frame: params.frame });
568
+ return {
569
+ content: [{ type: "text", text: msg }],
570
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
571
+ isError: msg.startsWith("No element"),
572
+ };
573
+ }
574
+
575
+ case "press": {
576
+ if (!params.key) {
577
+ return {
578
+ content: [{ type: "text", text: "key is required for press." }],
579
+ details: { action, error: "missing key" },
580
+ isError: true,
581
+ };
582
+ }
583
+ const msg = await mgr.press(params.key, { timeout, signal });
584
+ return {
585
+ content: [{ type: "text", text: msg }],
586
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
587
+ };
588
+ }
589
+
590
+ case "select_option": {
591
+ if (!params.selector || !params.values) {
592
+ return {
593
+ content: [{ type: "text", text: "selector and values are required for select_option." }],
594
+ details: { action, error: "missing selector or values" },
595
+ isError: true,
596
+ };
597
+ }
598
+ const values = params.values.split(",").map((v) => v.trim()).filter(Boolean);
599
+ const msg = await mgr.selectOption(params.selector, values, { timeout, signal, frame: params.frame });
600
+ return {
601
+ content: [{ type: "text", text: msg }],
602
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
603
+ isError: msg.startsWith("No element"),
604
+ };
605
+ }
606
+
607
+ case "set_viewport": {
608
+ if (params.width === undefined || params.height === undefined) {
609
+ return {
610
+ content: [{ type: "text", text: "width and height are required for set_viewport." }],
611
+ details: { action, error: "missing width or height" },
612
+ isError: true,
613
+ };
614
+ }
615
+ const msg = await mgr.setViewport(params.width, params.height, { signal });
616
+ return {
617
+ content: [{ type: "text", text: msg }],
618
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
619
+ };
620
+ }
621
+
622
+ case "evaluate": {
623
+ if (!params.script) {
624
+ return {
625
+ content: [{ type: "text", text: "script is required for evaluate." }],
626
+ details: { action, error: "missing script" },
627
+ isError: true,
628
+ };
629
+ }
630
+ const value = await mgr.evaluate(params.script, { signal });
631
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? String(value);
632
+ const truncated = await truncateTextResult(text, { action });
633
+ return {
634
+ content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
635
+ details: truncated.details,
636
+ };
637
+ }
638
+
639
+ case "get_content": {
640
+ const html = await mgr.content({ signal });
641
+ const truncated = await truncateTextResult(html, { action });
642
+ return {
643
+ content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
644
+ details: truncated.details,
645
+ };
646
+ }
647
+
648
+ case "get_text": {
649
+ const text = await mgr.getText(params.selector, { timeout, signal, frame: params.frame });
650
+ const truncated = await truncateTextResult(text, { action });
651
+ return {
652
+ content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
653
+ details: truncated.details,
654
+ };
655
+ }
656
+
657
+ case "get_markdown": {
658
+ const md = await mgr.getMarkdown({ timeout, signal });
659
+ const truncated = await truncateTextResult(md, { action });
660
+ return {
661
+ content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
662
+ details: truncated.details,
663
+ };
664
+ }
665
+
666
+ case "wait_for": {
667
+ if (!params.selector && !params.networkidle) {
668
+ return {
669
+ content: [{ type: "text", text: "selector or networkidle is required for wait_for." }],
670
+ details: { action, error: "missing selector or networkidle" },
671
+ isError: true,
672
+ };
673
+ }
674
+ const msg = await mgr.waitFor({
675
+ selector: params.selector,
676
+ state: params.state as "visible" | "hidden" | "attached" | "detached" | undefined,
677
+ networkidle: params.networkidle,
678
+ timeout,
679
+ signal,
680
+ frame: params.frame,
681
+ });
682
+ return {
683
+ content: [{ type: "text", text: msg }],
684
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
685
+ isError: msg.startsWith("No element"),
686
+ };
687
+ }
688
+
689
+ case "get_accessibility_snapshot": {
690
+ const snapshot = await mgr.getAccessibilitySnapshot({
691
+ selector: params.selector,
692
+ timeout,
693
+ signal,
694
+ frame: params.frame,
695
+ });
696
+ const truncated = await truncateTextResult(snapshot, { action });
697
+ return {
698
+ content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
699
+ details: truncated.details,
700
+ };
701
+ }
702
+
703
+ case "get_console_logs": {
704
+ const entries = mgr.drainConsole();
705
+ const text = formatConsoleLogs(entries);
706
+ const truncated = await truncateTextResult(text, {
707
+ action,
708
+ consoleCount: entries.length,
709
+ });
710
+ return {
711
+ content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
712
+ details: truncated.details,
713
+ };
714
+ }
715
+
716
+ case "get_network_logs": {
717
+ const entries = mgr.drainNetwork();
718
+ const text = formatNetworkLogs(entries);
719
+ const truncated = await truncateTextResult(text, {
720
+ action,
721
+ networkCount: entries.length,
722
+ });
723
+ return {
724
+ content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
725
+ details: truncated.details,
726
+ };
727
+ }
728
+
729
+ case "get_cookies": {
730
+ const cookies = await mgr.getCookies({ signal });
731
+ const redacted = cookies.map(({ name, domain, path, expires, httpOnly, secure, sameSite }) => ({
732
+ name,
733
+ domain,
734
+ path,
735
+ expires,
736
+ httpOnly,
737
+ secure,
738
+ sameSite,
739
+ value: "[redacted]",
740
+ }));
741
+ const text = JSON.stringify(redacted, null, 2);
742
+ const truncated = await truncateTextResult(text, { action });
743
+ return {
744
+ content: [{ type: "text", text: truncated.text }],
745
+ details: truncated.details,
746
+ };
747
+ }
748
+
749
+ case "set_cookies": {
750
+ if (!params.cookies || params.cookies.length === 0) {
751
+ return {
752
+ content: [{ type: "text", text: "cookies is required for set_cookies." }],
753
+ details: { action, error: "missing cookies" },
754
+ isError: true,
755
+ };
756
+ }
757
+ for (const cookie of params.cookies) {
758
+ const cookieUrl = cookie.url ?? `https://${cookie.domain}${cookie.path ?? "/"}`;
759
+ const permission = await checkUrlPermission(cookieUrl, ctx, sessionPermissions);
760
+ if (permission !== "allow") {
761
+ return {
762
+ content: [{ type: "text", text: permission.slice(6) }],
763
+ details: { action, error: permission.slice(6) } satisfies WebBrowserDetails,
764
+ isError: true,
765
+ };
766
+ }
767
+ }
768
+ await mgr.setCookies(params.cookies as SetCookieParam[], { signal });
769
+ const text = `Set ${params.cookies.length} cookie(s).`;
770
+ return {
771
+ content: [{ type: "text", text }],
772
+ details: { action, bytes: Buffer.byteLength(text) } satisfies WebBrowserDetails,
773
+ };
774
+ }
775
+
776
+ case "go_back": {
777
+ const text = await mgr.goBack({ signal });
778
+ return {
779
+ content: [{ type: "text", text }],
780
+ details: { action, bytes: Buffer.byteLength(text) } satisfies WebBrowserDetails,
781
+ };
782
+ }
783
+
784
+ case "go_forward": {
785
+ const text = await mgr.goForward({ signal });
786
+ return {
787
+ content: [{ type: "text", text }],
788
+ details: { action, bytes: Buffer.byteLength(text) } satisfies WebBrowserDetails,
789
+ };
790
+ }
791
+
792
+ case "reload": {
793
+ const text = await mgr.reload({ signal });
794
+ return {
795
+ content: [{ type: "text", text }],
796
+ details: { action, bytes: Buffer.byteLength(text) } satisfies WebBrowserDetails,
797
+ };
798
+ }
799
+
800
+ case "scroll": {
801
+ const msg = await mgr.scroll({
802
+ selector: params.selector,
803
+ frame: params.frame,
804
+ deltaX: params.deltaX,
805
+ deltaY: params.deltaY,
806
+ timeout,
807
+ signal,
808
+ });
809
+ return {
810
+ content: [{ type: "text", text: msg }],
811
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
812
+ isError: msg.startsWith("No element"),
813
+ };
814
+ }
815
+
816
+ case "drag": {
817
+ if (!params.selector || !params.targetSelector) {
818
+ return {
819
+ content: [{ type: "text", text: "selector and targetSelector are required for drag." }],
820
+ details: { action, error: "missing selector or targetSelector" },
821
+ isError: true,
822
+ };
823
+ }
824
+ const msg = await mgr.drag(params.selector, params.targetSelector, {
825
+ frame: params.frame,
826
+ timeout,
827
+ signal,
828
+ });
829
+ return {
830
+ content: [{ type: "text", text: msg }],
831
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
832
+ isError: msg.startsWith("No element"),
833
+ };
834
+ }
835
+
836
+ case "upload_file": {
837
+ if (!params.selector || !params.files || params.files.length === 0) {
838
+ return {
839
+ content: [{ type: "text", text: "selector and files are required for upload_file." }],
840
+ details: { action, error: "missing selector or files" },
841
+ isError: true,
842
+ };
843
+ }
844
+ const msg = await mgr.uploadFile(params.selector, params.files, {
845
+ frame: params.frame,
846
+ timeout,
847
+ signal,
848
+ });
849
+ return {
850
+ content: [{ type: "text", text: msg }],
851
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
852
+ isError: msg.startsWith("No element"),
853
+ };
854
+ }
855
+
856
+ case "set_dialog_behavior": {
857
+ const dialogAction = (params.dialogAction as "accept" | "dismiss" | undefined) ?? "dismiss";
858
+ mgr.setDialogBehavior(dialogAction, params.promptText);
859
+ const text = `Dialogs will be ${dialogAction}ed${params.promptText ? ` (prompt text: "${params.promptText}")` : ""}.`;
860
+ return {
861
+ content: [{ type: "text", text }],
862
+ details: { action, bytes: Buffer.byteLength(text) } satisfies WebBrowserDetails,
863
+ };
864
+ }
865
+
866
+ case "get_dialog_logs": {
867
+ const entries = mgr.drainDialogs();
868
+ const text = formatDialogLogs(entries);
869
+ const truncated = await truncateTextResult(text, {
870
+ action,
871
+ dialogCount: entries.length,
872
+ });
873
+ return {
874
+ content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
875
+ details: truncated.details,
876
+ };
877
+ }
878
+
879
+ case "list_tabs": {
880
+ const tabs = await mgr.listTabs();
881
+ const text = formatTabs(tabs);
882
+ return {
883
+ content: [{ type: "text", text }],
884
+ details: {
885
+ action,
886
+ tabCount: tabs.length,
887
+ bytes: Buffer.byteLength(text),
888
+ } satisfies WebBrowserDetails,
889
+ };
890
+ }
891
+
892
+ case "switch_tab": {
893
+ if (params.index === undefined) {
894
+ return {
895
+ content: [{ type: "text", text: "index is required for switch_tab." }],
896
+ details: { action, error: "missing index" },
897
+ isError: true,
898
+ };
899
+ }
900
+ const msg = await mgr.switchTab(params.index);
901
+ const info = await mgr.getPageInfoAsync();
902
+ updateBrowserStatus(ctx, info);
903
+ return {
904
+ content: [{ type: "text", text: msg }],
905
+ details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
906
+ isError: msg.startsWith("Invalid tab index"),
907
+ };
908
+ }
909
+
910
+ default: {
911
+ return {
912
+ content: [{ type: "text", text: `Unknown action: ${action}` }],
913
+ details: { action, error: "unknown action" },
914
+ isError: true,
915
+ };
916
+ }
917
+ }
918
+ } catch (err: unknown) {
919
+ if (isAbortError(err)) {
920
+ return {
921
+ content: [{ type: "text", text: "Operation aborted." }],
922
+ details: { action, error: "aborted" } satisfies WebBrowserDetails,
923
+ isError: true,
924
+ };
925
+ }
926
+ const message = err instanceof Error ? err.message : String(err);
927
+ return {
928
+ content: [{ type: "text", text: `Browser error: ${message}` }],
929
+ details: { action, error: message } satisfies WebBrowserDetails,
930
+ isError: true,
931
+ };
932
+ }
933
+ },
934
+
935
+ renderCall(args, theme) {
936
+ const action = String(args.action ?? "?");
937
+ let text = theme.fg("toolTitle", theme.bold("web_browser "));
938
+ text += theme.fg("accent", action);
939
+
940
+ if (args.url) {
941
+ try {
942
+ const parsed = new URL(String(args.url));
943
+ text += theme.fg("muted", ` ${parsed.hostname}`);
944
+ } catch {
945
+ text += theme.fg("muted", ` ${String(args.url).slice(0, 40)}`);
946
+ }
947
+ } else if (args.selector) {
948
+ text += theme.fg("muted", ` ${String(args.selector)}`);
949
+ }
950
+
951
+ return new Text(text, 0, 0);
952
+ },
953
+
954
+ renderResult(result, { expanded, isPartial }, theme) {
955
+ const details = (result.details ?? {}) as WebBrowserDetails;
956
+
957
+ if (isPartial) {
958
+ return new Text(theme.fg("warning", `${details.action ?? "web_browser"}…`), 0, 0);
959
+ }
960
+
961
+ if (details.error) {
962
+ return new Text(theme.fg("error", details.error.slice(0, 120)), 0, 0);
963
+ }
964
+
965
+ const imageBlock = result.content.find((c) => c.type === "image");
966
+ if (imageBlock?.type === "image") {
967
+ const dims = details.screenshotDims ?? "screenshot";
968
+ return new Text(theme.fg("success", `Screenshot ${dims}`), 0, 0);
969
+ }
970
+
971
+ if (details.action === "screenshot" && details.fullOutputPath) {
972
+ let text = theme.fg("success", "Screenshot saved to disk");
973
+ if (expanded) {
974
+ text += `\n${theme.fg("dim", `Full: ${details.fullOutputPath}`)}`;
975
+ }
976
+ return new Text(text, 0, 0);
977
+ }
978
+
979
+ let text = "";
980
+ switch (details.action) {
981
+ case "navigate":
982
+ text = theme.fg(
983
+ "success",
984
+ `Navigated — ${details.title?.replace(/[\u0000-\u001f\u007f]/g, " ") ?? details.url ?? "ok"}`,
985
+ );
986
+ break;
987
+ case "get_content":
988
+ case "get_text":
989
+ case "get_markdown":
990
+ case "get_accessibility_snapshot":
991
+ text = theme.fg("success", details.bytes ? formatSize(details.bytes) : "Content");
992
+ break;
993
+ case "get_console_logs":
994
+ text = theme.fg("success", `${details.consoleCount ?? 0} console message(s)`);
995
+ break;
996
+ case "get_network_logs":
997
+ text = theme.fg("success", `${details.networkCount ?? 0} network issue(s)`);
998
+ break;
999
+ case "get_dialog_logs":
1000
+ text = theme.fg("success", `${details.dialogCount ?? 0} dialog(s)`);
1001
+ break;
1002
+ case "list_tabs":
1003
+ text = theme.fg("success", `${details.tabCount ?? 0} tab(s)`);
1004
+ break;
1005
+ default:
1006
+ text = theme.fg("success", details.action ?? "done");
1007
+ if (details.bytes) text += theme.fg("muted", ` (${formatSize(details.bytes)})`);
1008
+ }
1009
+
1010
+ if (details.fullOutputPath) {
1011
+ text += theme.fg("warning", " (truncated)");
1012
+ }
1013
+
1014
+ if (expanded) {
1015
+ const content = result.content.find((c) => c.type === "text");
1016
+ if (content?.type === "text") {
1017
+ const lines = content.text.split("\n", 16);
1018
+ for (const line of lines.slice(0, 15)) {
1019
+ text += `\n${theme.fg("dim", line)}`;
1020
+ }
1021
+ if (lines.length > 15) {
1022
+ text += `\n${theme.fg("muted", "…")}`;
1023
+ }
1024
+ }
1025
+ if (details.fullOutputPath) {
1026
+ text += `\n${theme.fg("dim", `Full: ${details.fullOutputPath}`)}`;
1027
+ }
1028
+ }
1029
+
1030
+ return new Text(text, 0, 0);
1031
+ },
1032
+ });
1033
+ }