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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { type Result, err, ok } from "../util/result";
|
|
3
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
|
+
import { sleep } from "../util/time";
|
|
5
|
+
import { safeJs } from "../util/js-template";
|
|
6
|
+
|
|
7
|
+
export const pageInfoTool = defineBrowserTool({
|
|
8
|
+
name: "browser_page_info",
|
|
9
|
+
label: "Browser Page Info",
|
|
10
|
+
description:
|
|
11
|
+
"Get current page state: URL, title, viewport size, scroll position, page dimensions. If a JS dialog is open, returns dialog info instead.",
|
|
12
|
+
promptSnippet: "Get current page URL, title, viewport, and scroll position",
|
|
13
|
+
promptGuidelines: [
|
|
14
|
+
"Use browser_page_info to quickly check what page you're on and whether a JS dialog is blocking interaction.",
|
|
15
|
+
"If browser_page_info returns a dialog, use browser_handle_dialog before any other browser actions.",
|
|
16
|
+
"JS dialogs freeze the page's JS thread, so no other interaction works until the dialog is handled.",
|
|
17
|
+
"browser_page_info auto-detects alert, confirm, prompt, and beforeunload dialogs.",
|
|
18
|
+
],
|
|
19
|
+
parameters: Type.Object({}),
|
|
20
|
+
async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
21
|
+
const r = await client.pageInfo();
|
|
22
|
+
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
23
|
+
if ("dialog" in r.data) {
|
|
24
|
+
const d = r.data.dialog;
|
|
25
|
+
return ok({
|
|
26
|
+
text: `⚠️ DIALOG OPEN: ${d.type}\nMessage: ${d.message}${d.defaultPrompt ? `\nDefault: ${d.defaultPrompt}` : ""}\n\nUse browser_handle_dialog to accept or dismiss.`,
|
|
27
|
+
details: { dialog: d },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const i = r.data;
|
|
31
|
+
return ok({
|
|
32
|
+
text: `URL: ${i.url}\nTitle: ${i.title}\nViewport: ${i.width}x${i.height}\nScroll: (${i.scrollX}, ${i.scrollY})\nPage size: ${i.pageWidth}x${i.pageHeight}`,
|
|
33
|
+
details: { ...i },
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const WaitArgs = Type.Object({
|
|
39
|
+
seconds: Type.Number({ minimum: 0, maximum: 60, description: "Seconds to wait" }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const waitTool = defineBrowserTool({
|
|
43
|
+
name: "browser_wait",
|
|
44
|
+
label: "Browser Wait",
|
|
45
|
+
description: "Wait N seconds before continuing.",
|
|
46
|
+
promptSnippet: "Wait N seconds",
|
|
47
|
+
promptGuidelines: ["Use sparingly — prefer browser_wait_for_load when waiting for a page load."],
|
|
48
|
+
parameters: WaitArgs,
|
|
49
|
+
async handler(args, { signal }): Promise<Result<ToolOk, ToolErr>> {
|
|
50
|
+
try {
|
|
51
|
+
await sleep(Math.round(args.seconds * 1000), signal);
|
|
52
|
+
return ok({ text: `Waited ${args.seconds}s` });
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return err({ kind: "internal", message: e instanceof Error ? e.message : String(e) });
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const WaitForLoadArgs = Type.Object({
|
|
60
|
+
timeout: Type.Optional(Type.Number({ default: 15, minimum: 1, maximum: 120, description: "Max seconds to wait. Default: 15." })),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const waitForLoadTool = defineBrowserTool({
|
|
64
|
+
name: "browser_wait_for_load",
|
|
65
|
+
label: "Browser Wait For Load",
|
|
66
|
+
description:
|
|
67
|
+
"Poll until the current page reports document.readyState === 'complete'. Returns a typed timeout error if the deadline elapses.",
|
|
68
|
+
promptSnippet: "Wait for the page to finish loading",
|
|
69
|
+
promptGuidelines: [
|
|
70
|
+
"Call after browser_navigate / browser_open_urls before extracting data.",
|
|
71
|
+
"Returns when readyState becomes 'complete' OR the timeout elapses.",
|
|
72
|
+
],
|
|
73
|
+
parameters: WaitForLoadArgs,
|
|
74
|
+
async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
|
|
75
|
+
const timeoutMs = (args.timeout ?? 15) * 1000;
|
|
76
|
+
const start = Date.now();
|
|
77
|
+
while (Date.now() - start < timeoutMs) {
|
|
78
|
+
if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
|
|
79
|
+
const r = await client.evaluateJs(safeJs`document.readyState`);
|
|
80
|
+
if (r.success && r.data === "complete") {
|
|
81
|
+
const ms = Date.now() - start;
|
|
82
|
+
return ok({ text: `Page loaded in ${Math.round(ms / 100) / 10}s`, details: { ms } });
|
|
83
|
+
}
|
|
84
|
+
await new Promise((res) => setTimeout(res, 50));
|
|
85
|
+
}
|
|
86
|
+
return err({ kind: "timeout", message: `Page did not finish loading in ${args.timeout ?? 15}s` });
|
|
87
|
+
},
|
|
88
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { writeFile, rename } from "node:fs/promises";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { BrowserClient } from "../client";
|
|
4
|
+
import { type Result, err, ok } from "../util/result";
|
|
5
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
6
|
+
import { screenshotPath } from "../util/paths";
|
|
7
|
+
import { loadSharp } from "../util/sharp-shim";
|
|
8
|
+
import { safeJs } from "../util/js-template";
|
|
9
|
+
|
|
10
|
+
const ScreenshotArgs = Type.Object({
|
|
11
|
+
fullPage: Type.Optional(Type.Boolean({ default: false, description: "Capture beyond viewport" })),
|
|
12
|
+
format: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpeg")], { default: "png" })),
|
|
13
|
+
quality: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 80, description: "JPEG quality 1-100" })),
|
|
14
|
+
maxDim: Type.Optional(Type.Integer({ minimum: 100, maximum: 8000, description: "If max(w,h) exceeds this, resize via sharp." })),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
type CaptureArgs = {
|
|
18
|
+
readonly fullPage?: boolean;
|
|
19
|
+
readonly format?: "png" | "jpeg";
|
|
20
|
+
readonly quality?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const captureBase = async (
|
|
24
|
+
client: BrowserClient,
|
|
25
|
+
args: CaptureArgs,
|
|
26
|
+
): Promise<Result<{ readonly path: string; readonly format: "png" | "jpeg" }, ToolErr>> => {
|
|
27
|
+
const format = args.format ?? "png";
|
|
28
|
+
const quality = args.quality ?? 80;
|
|
29
|
+
const path = screenshotPath(client.namespace, format);
|
|
30
|
+
const params: Record<string, unknown> = { format, captureBeyondViewport: args.fullPage ?? false };
|
|
31
|
+
if (format === "jpeg") params["quality"] = quality;
|
|
32
|
+
const r = await client.session().call("Page.captureScreenshot", params);
|
|
33
|
+
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
34
|
+
// Page.captureScreenshot returns { data: base64 }
|
|
35
|
+
const data = (r.data as { data: string }).data;
|
|
36
|
+
await writeFile(path, Buffer.from(data, "base64"));
|
|
37
|
+
return ok({ path, format });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const resizeIfNeeded = async (
|
|
41
|
+
path: string,
|
|
42
|
+
maxDim: number,
|
|
43
|
+
): Promise<{ readonly note: string }> => {
|
|
44
|
+
const load = await loadSharp();
|
|
45
|
+
if (load.kind === "missing") return { note: " (maxDim ignored: install sharp for auto-resize)" };
|
|
46
|
+
if (load.kind === "error") return { note: ` (maxDim ignored: sharp failed to load: ${load.message})` };
|
|
47
|
+
try {
|
|
48
|
+
const meta = await load.sharp(path).metadata();
|
|
49
|
+
const w = meta.width ?? 0;
|
|
50
|
+
const h = meta.height ?? 0;
|
|
51
|
+
if (Math.max(w, h) <= maxDim) return { note: "" };
|
|
52
|
+
const tmp = `${path}.resized`;
|
|
53
|
+
await load.sharp(path).resize(maxDim, maxDim, { fit: "inside" }).toFile(tmp);
|
|
54
|
+
await rename(tmp, path);
|
|
55
|
+
return { note: ` (resized to fit ${maxDim}px)` };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return { note: ` (maxDim ignored: sharp threw: ${e instanceof Error ? e.message : String(e)})` };
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const screenshotTool = defineBrowserTool({
|
|
62
|
+
name: "browser_screenshot",
|
|
63
|
+
label: "Browser Screenshot",
|
|
64
|
+
description: "Capture the current page as PNG or JPEG. JPEG is 2-5x smaller — prefer it for photo-heavy pages.",
|
|
65
|
+
promptSnippet: "Capture a screenshot of the current page",
|
|
66
|
+
promptGuidelines: [
|
|
67
|
+
"Use BEFORE clicks/scrolls to find coordinates; AFTER to verify the action.",
|
|
68
|
+
"Pass format='jpeg' with a quality (60-90) for smaller files on photo-heavy pages.",
|
|
69
|
+
"Set maxDim if the page is huge and you want to fit under LLM image-size limits.",
|
|
70
|
+
],
|
|
71
|
+
parameters: ScreenshotArgs,
|
|
72
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
73
|
+
const cap = await captureBase(client, args);
|
|
74
|
+
if (!cap.success) return cap;
|
|
75
|
+
let note = "";
|
|
76
|
+
if (args.maxDim !== undefined) {
|
|
77
|
+
const resized = await resizeIfNeeded(cap.data.path, args.maxDim);
|
|
78
|
+
note = resized.note;
|
|
79
|
+
}
|
|
80
|
+
return ok({
|
|
81
|
+
text: `Screenshot saved: ${cap.data.path}${note}`,
|
|
82
|
+
details: { path: cap.data.path, format: cap.data.format, attached: false },
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const captureWithCrosshair = async (
|
|
88
|
+
client: BrowserClient,
|
|
89
|
+
args: { readonly x: number; readonly y: number; readonly format?: "png" | "jpeg"; readonly quality?: number },
|
|
90
|
+
): Promise<Result<{ readonly path: string }, ToolErr>> => {
|
|
91
|
+
const cap = await captureBase(client, args);
|
|
92
|
+
if (!cap.success) return cap;
|
|
93
|
+
const load = await loadSharp();
|
|
94
|
+
if (load.kind !== "ok") return ok({ path: cap.data.path });
|
|
95
|
+
try {
|
|
96
|
+
const dprR = await client.evaluateJs(safeJs`window.devicePixelRatio`);
|
|
97
|
+
const dpr = dprR.success && typeof dprR.data === "number" ? dprR.data : 1;
|
|
98
|
+
const meta = await load.sharp(cap.data.path).metadata();
|
|
99
|
+
const w = meta.width ?? 0;
|
|
100
|
+
const h = meta.height ?? 0;
|
|
101
|
+
const px = Math.round(args.x * dpr);
|
|
102
|
+
const py = Math.round(args.y * dpr);
|
|
103
|
+
const r = Math.round(15 * dpr);
|
|
104
|
+
const stroke = Math.max(2, Math.round(3 * dpr));
|
|
105
|
+
const svg = `<svg width="${w}" height="${h}"><circle cx="${px}" cy="${py}" r="${r}" fill="none" stroke="red" stroke-width="${stroke}" opacity="0.8"/><line x1="${px - r - 5}" y1="${py}" x2="${px + r + 5}" y2="${py}" stroke="red" stroke-width="${Math.max(1, stroke - 1)}" opacity="0.8"/><line x1="${px}" y1="${py - r - 5}" x2="${px}" y2="${py + r + 5}" stroke="red" stroke-width="${Math.max(1, stroke - 1)}" opacity="0.8"/></svg>`;
|
|
106
|
+
const tmp = `${cap.data.path}.debug`;
|
|
107
|
+
await load.sharp(cap.data.path).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).toFile(tmp);
|
|
108
|
+
await rename(tmp, cap.data.path);
|
|
109
|
+
return ok({ path: cap.data.path });
|
|
110
|
+
} catch {
|
|
111
|
+
// Crosshair overlay is best-effort; on failure return the plain screenshot.
|
|
112
|
+
return ok({ path: cap.data.path });
|
|
113
|
+
}
|
|
114
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { type Result, err, ok } from "../util/result";
|
|
3
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
|
+
|
|
5
|
+
const ScrollArgs = Type.Object({
|
|
6
|
+
x: Type.Optional(Type.Number({ description: "X coordinate where to scroll. Default: viewport center" })),
|
|
7
|
+
y: Type.Optional(Type.Number({ description: "Y coordinate where to scroll. Default: viewport center" })),
|
|
8
|
+
deltaX: Type.Optional(Type.Number({ default: 0, description: "Horizontal scroll delta (CSS pixels). Positive = right." })),
|
|
9
|
+
deltaY: Type.Optional(Type.Number({
|
|
10
|
+
default: -300,
|
|
11
|
+
description: "Vertical scroll delta (CSS pixels). Positive = up (matches W3C wheel events). Default: -300 (scroll down).",
|
|
12
|
+
})),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const scrollTool = defineBrowserTool({
|
|
16
|
+
name: "browser_scroll",
|
|
17
|
+
label: "Browser Scroll",
|
|
18
|
+
description:
|
|
19
|
+
"Scroll the page at given coordinates. deltaY follows W3C wheel-event convention: positive = scroll up, negative = scroll down. Default scrolls down (deltaY = -300).",
|
|
20
|
+
promptSnippet: "Scroll the page (deltaY positive=up, negative=down)",
|
|
21
|
+
promptGuidelines: [
|
|
22
|
+
"Default behavior scrolls down 300px (deltaY=-300). Pass a positive deltaY to scroll up.",
|
|
23
|
+
"Pass x/y to target a specific scrollable region (e.g., a div with overflow); otherwise scrolls the page at viewport center.",
|
|
24
|
+
],
|
|
25
|
+
parameters: ScrollArgs,
|
|
26
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
27
|
+
const info = await client.pageInfo();
|
|
28
|
+
if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
|
|
29
|
+
if ("dialog" in info.data) {
|
|
30
|
+
return err({
|
|
31
|
+
kind: "invalid_state",
|
|
32
|
+
message: `Dialog open: ${info.data.dialog.type} — ${info.data.dialog.message}`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const cx = args.x ?? Math.round(info.data.width / 2);
|
|
36
|
+
const cy = args.y ?? Math.round(info.data.height / 2);
|
|
37
|
+
const dx = args.deltaX ?? 0;
|
|
38
|
+
const dy = args.deltaY ?? -300;
|
|
39
|
+
// Establish compositor mouse position before mouseWheel; without this,
|
|
40
|
+
// CDP sometimes drops the wheel event with a timeout. Fix preserved
|
|
41
|
+
// from v0.2.0.
|
|
42
|
+
const moved = await client.session().call("Input.dispatchMouseEvent", { type: "mouseMoved", x: cx, y: cy });
|
|
43
|
+
if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message });
|
|
44
|
+
const wheel = await client.session().call("Input.dispatchMouseEvent", {
|
|
45
|
+
type: "mouseWheel", x: cx, y: cy, deltaX: dx, deltaY: dy,
|
|
46
|
+
});
|
|
47
|
+
if (!wheel.success) return err({ kind: "cdp_error", message: wheel.error.message });
|
|
48
|
+
return ok({
|
|
49
|
+
text: `Scrolled at (${cx}, ${cy}) by (${dx}, ${dy})`,
|
|
50
|
+
details: { x: cx, y: cy, deltaX: dx, deltaY: dy },
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { type Result, err, ok } from "../util/result";
|
|
3
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
|
+
|
|
5
|
+
const ListTabsArgs = Type.Object({
|
|
6
|
+
includeInternal: Type.Optional(Type.Boolean({ default: true, description: "Include chrome:// pages" })),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const listTabsTool = defineBrowserTool({
|
|
10
|
+
name: "browser_list_tabs",
|
|
11
|
+
label: "Browser List Tabs",
|
|
12
|
+
description: "List all open browser tabs (page targets).",
|
|
13
|
+
promptSnippet: "List browser tabs",
|
|
14
|
+
promptGuidelines: [
|
|
15
|
+
"Use to find a full targetId for browser_switch_tab.",
|
|
16
|
+
"Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.",
|
|
17
|
+
"Each tab shows its full 32-char hex targetId — use the exact value with browser_switch_tab.",
|
|
18
|
+
],
|
|
19
|
+
parameters: ListTabsArgs,
|
|
20
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
21
|
+
const r = await client.listTabs(args.includeInternal ?? true);
|
|
22
|
+
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
23
|
+
const lines = r.data.map((t, i) => ` [${i}] ${t.targetId} ${t.url}\n ${t.title}`);
|
|
24
|
+
return ok({
|
|
25
|
+
text: `Tabs (${r.data.length}):\n${lines.join("\n")}`,
|
|
26
|
+
details: { tabs: r.data },
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const currentTabTool = defineBrowserTool({
|
|
32
|
+
name: "browser_current_tab",
|
|
33
|
+
label: "Browser Current Tab",
|
|
34
|
+
description: "Get info about the currently attached tab.",
|
|
35
|
+
promptSnippet: "Get current tab info",
|
|
36
|
+
promptGuidelines: ["Returns targetId, url, title."],
|
|
37
|
+
parameters: Type.Object({}),
|
|
38
|
+
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
39
|
+
const cur = client.current();
|
|
40
|
+
if (!cur) return err({ kind: "invalid_state", message: "No tab attached." });
|
|
41
|
+
const ti = await client.session().callBrowser("Target.getTargetInfo", { targetId: cur.targetId });
|
|
42
|
+
if (!ti.success) return err({ kind: "cdp_error", message: ti.error.message });
|
|
43
|
+
const info = (ti.data as { targetInfo: { targetId: string; url: string; title: string } }).targetInfo; // CDP boundary cast: Target.getTargetInfo returns { targetInfo: { targetId, url, title } }
|
|
44
|
+
return ok({
|
|
45
|
+
text: `Current tab:\n ${info.targetId}\n ${info.url}\n ${info.title}`,
|
|
46
|
+
details: { targetId: info.targetId, url: info.url, title: info.title },
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const SwitchTabArgs = Type.Object({
|
|
52
|
+
targetId: Type.String({ description: "Target ID from browser_list_tabs" }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const switchTabTool = defineBrowserTool({
|
|
56
|
+
name: "browser_switch_tab",
|
|
57
|
+
label: "Browser Switch Tab",
|
|
58
|
+
description: "Switch to and attach to a different tab by targetId.",
|
|
59
|
+
promptSnippet: "Switch tabs",
|
|
60
|
+
promptGuidelines: [
|
|
61
|
+
"Get a targetId via browser_list_tabs first.",
|
|
62
|
+
"Accepts exact targetId or a unique prefix of at least 8 hex characters.",
|
|
63
|
+
],
|
|
64
|
+
parameters: SwitchTabArgs,
|
|
65
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
66
|
+
// Try exact match first
|
|
67
|
+
let r = await client.switchTab(args.targetId);
|
|
68
|
+
if (r.success) {
|
|
69
|
+
return ok({ text: `Switched to ${args.targetId}`, details: { targetId: args.targetId } });
|
|
70
|
+
}
|
|
71
|
+
// If exact match failed and input looks like a short prefix (≥8 hex chars), try prefix resolution
|
|
72
|
+
const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
|
|
73
|
+
if (!isHexPrefix) {
|
|
74
|
+
return err({ kind: "cdp_error", message: r.error.message });
|
|
75
|
+
}
|
|
76
|
+
const tabs = await client.listTabs(true);
|
|
77
|
+
if (!tabs.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
78
|
+
const matches = tabs.data.filter((t) => t.targetId.startsWith(args.targetId));
|
|
79
|
+
if (matches.length === 0) {
|
|
80
|
+
return err({ kind: "cdp_error", message: `No tab found with prefix "${args.targetId}"` });
|
|
81
|
+
}
|
|
82
|
+
if (matches.length > 1) {
|
|
83
|
+
const ids = matches.map((t) => t.targetId).join(", ");
|
|
84
|
+
return err({
|
|
85
|
+
kind: "invalid_state",
|
|
86
|
+
message: `Ambiguous prefix "${args.targetId}" matches ${matches.length} tabs: ${ids}`,
|
|
87
|
+
details: { matches: matches.map((t) => ({ targetId: t.targetId, url: t.url })) },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const resolved = matches[0]!.targetId;
|
|
91
|
+
r = await client.switchTab(resolved);
|
|
92
|
+
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
93
|
+
return ok({
|
|
94
|
+
text: `Switched to ${resolved} (resolved from prefix "${args.targetId}")`,
|
|
95
|
+
details: { targetId: resolved, resolvedPrefix: args.targetId },
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const NewTabArgs = Type.Object({
|
|
101
|
+
url: Type.Optional(Type.String({ description: "Optional URL to navigate to" })),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const newTabTool = defineBrowserTool({
|
|
105
|
+
name: "browser_new_tab",
|
|
106
|
+
label: "Browser New Tab",
|
|
107
|
+
description: "Open a new tab and switch to it. Optionally navigate to a URL.",
|
|
108
|
+
promptSnippet: "Open a new tab",
|
|
109
|
+
promptGuidelines: ["Pass url to navigate immediately."],
|
|
110
|
+
parameters: NewTabArgs,
|
|
111
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
112
|
+
const r = await client.newTab(args.url);
|
|
113
|
+
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
114
|
+
return ok({
|
|
115
|
+
text: `New tab: ${r.data}${args.url ? ` (${args.url})` : ""}`,
|
|
116
|
+
details: args.url !== undefined
|
|
117
|
+
? { targetId: r.data, url: args.url }
|
|
118
|
+
: { targetId: r.data },
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { type Result, err, ok } from "../util/result";
|
|
3
|
+
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
|
+
|
|
5
|
+
const ViewportArgs = Type.Object({
|
|
6
|
+
width: Type.Integer({ minimum: 100, maximum: 8000, description: "Viewport CSS pixel width" }),
|
|
7
|
+
height: Type.Integer({ minimum: 100, maximum: 8000, description: "Viewport CSS pixel height" }),
|
|
8
|
+
deviceScaleFactor: Type.Optional(
|
|
9
|
+
Type.Number({ minimum: 0.5, maximum: 4, default: 1, description: "Device pixel ratio" }),
|
|
10
|
+
),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const viewportResizeTool = defineBrowserTool({
|
|
14
|
+
name: "browser_viewport_resize",
|
|
15
|
+
label: "Browser Viewport Resize",
|
|
16
|
+
description: "Override the viewport size and device pixel ratio for responsive testing.",
|
|
17
|
+
promptSnippet: "Resize the viewport (responsive testing)",
|
|
18
|
+
promptGuidelines: [
|
|
19
|
+
"Width/height in CSS pixels (e.g., 375x667 for iPhone SE).",
|
|
20
|
+
"Pass deviceScaleFactor=2 to simulate retina displays.",
|
|
21
|
+
],
|
|
22
|
+
parameters: ViewportArgs,
|
|
23
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
24
|
+
const r = await client.session().call("Emulation.setDeviceMetricsOverride", {
|
|
25
|
+
width: args.width,
|
|
26
|
+
height: args.height,
|
|
27
|
+
deviceScaleFactor: args.deviceScaleFactor ?? 1,
|
|
28
|
+
mobile: false,
|
|
29
|
+
});
|
|
30
|
+
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
|
|
31
|
+
return ok({
|
|
32
|
+
text: `Viewport set to ${args.width}x${args.height} @${args.deviceScaleFactor ?? 1}x`,
|
|
33
|
+
details: {
|
|
34
|
+
width: args.width,
|
|
35
|
+
height: args.height,
|
|
36
|
+
deviceScaleFactor: args.deviceScaleFactor ?? 1,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
});
|