pi-browser-harness 0.1.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +134 -112
  3. package/package.json +4 -1
  4. package/skills/pi-browser-harness/SKILL.md +17 -4
  5. package/src/cdp/discovery.ts +95 -0
  6. package/src/cdp/errors.ts +19 -0
  7. package/src/cdp/session.ts +139 -0
  8. package/src/cdp/transport.ts +221 -0
  9. package/src/cdp/types.ts +72 -0
  10. package/src/client.ts +232 -0
  11. package/src/domains/click.ts +73 -0
  12. package/src/domains/dialog.ts +31 -0
  13. package/src/domains/drag.ts +66 -0
  14. package/src/domains/files.ts +194 -0
  15. package/src/domains/history.ts +76 -0
  16. package/src/domains/js.ts +197 -0
  17. package/src/domains/keyboard.ts +127 -0
  18. package/src/domains/navigate.ts +146 -0
  19. package/src/domains/network.ts +80 -0
  20. package/src/domains/page.ts +88 -0
  21. package/src/domains/screenshot.ts +114 -0
  22. package/src/domains/scroll.ts +53 -0
  23. package/src/domains/tabs.ts +121 -0
  24. package/src/domains/viewport.ts +40 -0
  25. package/src/index.ts +65 -142
  26. package/src/prompt.ts +16 -3
  27. package/src/registry.ts +52 -0
  28. package/src/schemas/common.ts +11 -0
  29. package/src/setup.ts +20 -21
  30. package/src/state.ts +22 -54
  31. package/src/util/js-template.ts +27 -0
  32. package/src/util/keycodes.ts +10 -0
  33. package/src/util/paths.ts +21 -0
  34. package/src/util/result.ts +15 -0
  35. package/src/util/sharp-shim.ts +49 -0
  36. package/src/util/time.ts +29 -0
  37. package/src/util/tool.ts +169 -0
  38. package/src/util/truncate.ts +35 -0
  39. package/tsconfig.json +10 -1
  40. package/src/daemon.ts +0 -767
  41. package/src/protocol.ts +0 -77
  42. package/src/renderers.ts +0 -21
  43. package/src/tools.ts +0 -1358
@@ -0,0 +1,127 @@
1
+ import { Type } from "typebox";
2
+ import { safeJs } from "../util/js-template";
3
+ import { virtualKeyCode } from "../util/keycodes";
4
+ import { type Result, err, ok } from "../util/result";
5
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
6
+
7
+ const TypeArgs = Type.Object({
8
+ text: Type.String({ description: "Text to type" }),
9
+ });
10
+
11
+ export const typeTool = defineBrowserTool({
12
+ name: "browser_type",
13
+ label: "Browser Type",
14
+ description: "Type text into the currently focused element. Use browser_click first to focus an input field.",
15
+ promptSnippet: "Type text into the focused element",
16
+ promptGuidelines: [
17
+ "Use browser_type to enter text. Click on an input field with browser_click first to focus it.",
18
+ "For special keys (Enter, Tab, Escape, arrows), use browser_press_key instead.",
19
+ ],
20
+ parameters: TypeArgs,
21
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
22
+ const r = await client.session().call("Input.insertText", { text: args.text });
23
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
24
+ return ok({ text: `Typed: "${args.text}"` });
25
+ },
26
+ });
27
+
28
+ const PressKeyArgs = Type.Object({
29
+ key: Type.String({
30
+ description:
31
+ 'Key to press. Special keys: Enter, Tab, Backspace, Escape, Delete, ArrowLeft/Up/Right/Down, Home, End, PageUp, PageDown. Space as " "',
32
+ }),
33
+ modifiers: Type.Optional(
34
+ Type.Integer({
35
+ minimum: 0,
36
+ maximum: 15,
37
+ description: "Modifier bitfield: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift. Combine with OR.",
38
+ }),
39
+ ),
40
+ });
41
+
42
+ export const pressKeyTool = defineBrowserTool({
43
+ name: "browser_press_key",
44
+ label: "Browser Press Key",
45
+ description:
46
+ "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.",
47
+ promptSnippet: "Press a key (Enter, Tab, Escape, arrows, or any character)",
48
+ promptGuidelines: [
49
+ "Use browser_press_key for keyboard shortcuts and navigation keys.",
50
+ "Special key names: Enter, Tab, Backspace, Escape, Delete, ArrowLeft, ArrowUp, ArrowRight, ArrowDown, Home, End, PageUp, PageDown.",
51
+ "Use Space as ' ' (a single space character).",
52
+ "Modifiers: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift. Combine with bitwise OR: Ctrl+Shift = 2|8 = 10.",
53
+ ],
54
+ parameters: PressKeyArgs,
55
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
56
+ const k = args.key;
57
+ const code = virtualKeyCode(k);
58
+ const modifiers = args.modifiers ?? 0;
59
+ const isChar = k.length === 1;
60
+ const downParams: Record<string, unknown> = {
61
+ type: "keyDown",
62
+ key: k,
63
+ code: k,
64
+ modifiers,
65
+ ...(code ? { windowsVirtualKeyCode: code, nativeVirtualKeyCode: code } : {}),
66
+ ...(isChar ? { text: k, unmodifiedText: k } : {}),
67
+ };
68
+ const down = await client.session().call("Input.dispatchKeyEvent", downParams);
69
+ if (!down.success) return err({ kind: "cdp_error", message: down.error.message });
70
+ const up = await client.session().call("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, modifiers });
71
+ if (!up.success) return err({ kind: "cdp_error", message: up.error.message });
72
+ return ok({ text: `Pressed: ${k}${modifiers ? ` (modifiers=${modifiers})` : ""}` });
73
+ },
74
+ });
75
+
76
+ const DispatchKeyArgs = Type.Object({
77
+ selector: Type.String({ description: "CSS selector of the target element" }),
78
+ key: Type.String({ description: "Key value (e.g., 'Enter', 'a')" }),
79
+ eventType: Type.Optional(
80
+ Type.Union(
81
+ [Type.Literal("keydown"), Type.Literal("keyup"), Type.Literal("keypress")],
82
+ { default: "keydown", description: "Event type to dispatch. Default: keydown" },
83
+ ),
84
+ ),
85
+ });
86
+
87
+ export const dispatchKeyTool = defineBrowserTool({
88
+ name: "browser_dispatch_key",
89
+ label: "Browser Dispatch Key",
90
+ description:
91
+ "Dispatch a DOM KeyboardEvent on a specific element via JS injection. Use for React/Vue components that listen to synthetic events more reliably than CDP input.",
92
+ promptSnippet: "Dispatch a DOM KeyboardEvent on a specific element",
93
+ promptGuidelines: [
94
+ "Dispatches a synthetic DOM KeyboardEvent — for React/Vue synthetic event listeners. Does NOT insert text into inputs (use browser_type or browser_press_key for actual typing).",
95
+ "Try browser_press_key first; only use browser_dispatch_key when the page ignores raw CDP key events.",
96
+ "The selector must match exactly one or more elements; zero matches is reported as an error.",
97
+ "eventType defaults to 'keydown'.",
98
+ ],
99
+ parameters: DispatchKeyArgs,
100
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
101
+ const eventType = args.eventType ?? "keydown";
102
+ const expr = safeJs`
103
+ (() => {
104
+ const els = document.querySelectorAll(${args.selector});
105
+ if (els.length === 0) return 0;
106
+ for (const el of els) {
107
+ el.dispatchEvent(new KeyboardEvent(${eventType}, { key: ${args.key}, bubbles: true, cancelable: true }));
108
+ }
109
+ return els.length;
110
+ })()
111
+ `;
112
+ const r = await client.evaluateJs(expr);
113
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
114
+ const matched = Number(r.data ?? 0);
115
+ if (matched === 0) {
116
+ return err({
117
+ kind: "invalid_state",
118
+ message: `Selector matched 0 elements: ${args.selector}`,
119
+ details: { matched: 0, selector: args.selector },
120
+ });
121
+ }
122
+ return ok({
123
+ text: `Dispatched ${eventType}('${args.key}') on ${matched} element(s)`,
124
+ details: { matched, selector: args.selector },
125
+ });
126
+ },
127
+ });
@@ -0,0 +1,146 @@
1
+ import { Type } from "typebox";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import { type Result, err, ok } from "../util/result";
4
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
5
+ import { applyTruncation } from "../util/truncate";
6
+
7
+ const NavigateArgs = Type.Object({
8
+ url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }),
9
+ });
10
+
11
+ export type NavOutcome =
12
+ | { readonly kind: "in_place"; readonly targetId: string }
13
+ | { readonly kind: "new_tab_created"; readonly targetId: string; readonly reason: "no_tabs" };
14
+
15
+ export const navigateTool = defineBrowserTool({
16
+ name: "browser_navigate",
17
+ label: "Browser Navigate",
18
+ description:
19
+ "Navigate to a URL. Creates a new tab on first call (when no real tabs exist). Otherwise navigates the current tab in place.",
20
+ promptSnippet: "Navigate to a URL",
21
+ promptGuidelines: [
22
+ "Use browser_navigate to go to URLs.",
23
+ "Use browser_wait_for_load after browser_navigate to wait for the page to finish loading.",
24
+ "For extracting data from a page you already navigated to, use browser_execute_js or browser_http_get (faster for APIs).",
25
+ "Note: Google and some sites with strict anti-bot detection may reject CDP navigation. Use browser_http_get for search results.",
26
+ ],
27
+ parameters: NavigateArgs,
28
+ renderCall: (a) => new Text(`🌐 Navigate to ${a.url}`, 0, 0),
29
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
30
+ const tabs = await client.listTabs(false);
31
+ if (!tabs.success) return err({ kind: "cdp_error", message: tabs.error.message });
32
+ let outcome: NavOutcome;
33
+ if (tabs.data.length === 0) {
34
+ const created = await client.newTab(args.url);
35
+ if (!created.success) return err({ kind: "cdp_error", message: created.error.message });
36
+ outcome = { kind: "new_tab_created", targetId: created.data, reason: "no_tabs" };
37
+ } else {
38
+ const nav = await client.session().call("Page.navigate", { url: args.url });
39
+ if (!nav.success) return err({ kind: "cdp_error", message: nav.error.message });
40
+ const cur = client.current();
41
+ if (!cur) return err({ kind: "internal", message: "navigate succeeded but no current target tracked" });
42
+ outcome = { kind: "in_place", targetId: cur.targetId };
43
+ }
44
+ const info = await client.pageInfo();
45
+ if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
46
+ if ("dialog" in info.data) {
47
+ return ok({
48
+ text: `Navigated to ${args.url}\nTarget: ${outcome.targetId}\n⚠️ Dialog open: ${info.data.dialog.type} — "${info.data.dialog.message}"\nUse browser_handle_dialog.`,
49
+ details: { outcome, dialog: info.data.dialog },
50
+ });
51
+ }
52
+ const prefix = outcome.kind === "new_tab_created" ? "New tab: " : "";
53
+ return ok({
54
+ text: `${prefix}Navigated to: ${info.data.url}\nTitle: ${info.data.title}\nViewport: ${info.data.width}x${info.data.height}\nTarget: ${outcome.targetId}`,
55
+ details: { outcome, page: info.data },
56
+ });
57
+ },
58
+ });
59
+
60
+ const OpenUrlsArgs = Type.Object({
61
+ urls: Type.Array(Type.String(), { description: "Array of URLs to open in new tabs" }),
62
+ });
63
+
64
+ type TabResult = {
65
+ readonly url: string;
66
+ readonly targetId: string;
67
+ readonly ok: boolean;
68
+ readonly error?: string;
69
+ };
70
+
71
+ export const openUrlsTool = defineBrowserTool({
72
+ name: "browser_open_urls",
73
+ label: "Browser Open URLs",
74
+ description: "Open multiple URLs in parallel new tabs. Returns per-URL outcomes.",
75
+ promptSnippet: "Open multiple URLs in new tabs (parallel)",
76
+ promptGuidelines: [
77
+ "Use after web_search to open citations in parallel.",
78
+ "After opening, use browser_list_tabs / browser_switch_tab / browser_screenshot to interact.",
79
+ "Use browser_wait_for_load on a tab before extracting data from SPAs.",
80
+ ],
81
+ parameters: OpenUrlsArgs,
82
+ renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
83
+ async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
84
+ const total = args.urls.length;
85
+ // Phase 1: create tabs in parallel.
86
+ const created = await Promise.all(args.urls.map(async (url): Promise<TabResult> => {
87
+ const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank" });
88
+ if (!r.success) return { url, targetId: "", ok: false, error: r.error.message };
89
+ const c = r.data as { targetId: string }; // CDP boundary cast: Target.createTarget returns { targetId: string }
90
+ return { url, targetId: c.targetId, ok: true };
91
+ }));
92
+ // Phase 2: attach + navigate each created tab in parallel.
93
+ let completed = 0;
94
+ const settled = await Promise.all(
95
+ created.filter((t) => t.ok).map(async (tab): Promise<TabResult> => {
96
+ try {
97
+ const attached = await client.session().callBrowser("Target.attachToTarget", { targetId: tab.targetId, flatten: true });
98
+ if (!attached.success) return { ...tab, ok: false, error: attached.error.message };
99
+ const sid = (attached.data as { sessionId: string }).sessionId; // CDP boundary cast: Target.attachToTarget returns { sessionId: string }
100
+ const enabled = await client.session().callOnTarget("Page.enable", {}, sid);
101
+ if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message };
102
+ const nav = await client.session().callOnTarget("Page.navigate", { url: tab.url }, sid);
103
+ if (!nav.success) return { ...tab, ok: false, error: nav.error.message };
104
+ return tab;
105
+ } finally {
106
+ completed++;
107
+ // onUpdate must never escape: a renderer crash would otherwise
108
+ // tear down the tool call.
109
+ try {
110
+ onUpdate({ text: `Opening URLs… ${completed}/${created.filter((t) => t.ok).length} navigated` });
111
+ } catch { /* swallow */ }
112
+ }
113
+ }),
114
+ );
115
+ const failures = created.filter((t) => !t.ok);
116
+ const all: ReadonlyArray<TabResult> = [...settled, ...failures];
117
+ const okTabs = all.filter((r) => r.ok);
118
+ const failTabs = all.filter((r) => !r.ok);
119
+ // Phase 3: activate the last successfully-opened tab so the user sees something.
120
+ if (okTabs.length > 0) {
121
+ const last = okTabs[okTabs.length - 1];
122
+ if (last) {
123
+ await client.session().callBrowser("Target.activateTarget", { targetId: last.targetId });
124
+ }
125
+ }
126
+ const lines: string[] = [];
127
+ if (okTabs.length) {
128
+ lines.push(`✅ ${okTabs.length}/${total} tabs opened:`, ...okTabs.map((t, i) => ` [${i}] ${t.url} → ${t.targetId}`));
129
+ }
130
+ if (failTabs.length) {
131
+ lines.push(`❌ ${failTabs.length}/${total} failed:`, ...failTabs.map((t) => ` ${t.url}: ${t.error ?? "unknown"}`));
132
+ }
133
+ const truncated = await applyTruncation(lines.join("\n"), "urls");
134
+ if (okTabs.length === 0) {
135
+ return err({
136
+ kind: "cdp_error",
137
+ message: `All ${total} URLs failed`,
138
+ ...(truncated.fullOutputPath ? { details: { tabs: all, fullOutputPath: truncated.fullOutputPath } } : { details: { tabs: all } }),
139
+ });
140
+ }
141
+ return ok({
142
+ text: truncated.text,
143
+ ...(truncated.fullOutputPath ? { details: { tabs: all, fullOutputPath: truncated.fullOutputPath } } : { details: { tabs: all } }),
144
+ });
145
+ },
146
+ });
@@ -0,0 +1,80 @@
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 { applyTruncation } from "../util/truncate";
5
+
6
+ const HttpGetArgs = Type.Object({
7
+ url: Type.String({ description: "URL to GET" }),
8
+ headers: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Optional HTTP headers" })),
9
+ timeout: Type.Optional(
10
+ Type.Number({ default: 20, minimum: 1, maximum: 120, description: "Total seconds (covers headers AND body)" }),
11
+ ),
12
+ });
13
+
14
+ export const httpGetTool = defineBrowserTool({
15
+ name: "browser_http_get",
16
+ label: "Browser HTTP GET",
17
+ description:
18
+ "Fetch a URL outside the browser (faster than browser_navigate for APIs and static pages). Timeout covers headers AND body read.",
19
+ promptSnippet: "HTTP GET (outside browser; for APIs/static pages)",
20
+ promptGuidelines: [
21
+ "Faster than navigate+execute_js for JSON/HTML APIs.",
22
+ "No JS rendering — for SPAs use browser_navigate.",
23
+ ],
24
+ parameters: HttpGetArgs,
25
+ ensureAlive: false,
26
+ async handler(args): Promise<Result<ToolOk, ToolErr>> {
27
+ const ac = new AbortController();
28
+ const timer = setTimeout(() => ac.abort(), (args.timeout ?? 20) * 1000);
29
+ try {
30
+ const fetchInit: RequestInit = { signal: ac.signal };
31
+ if (args.headers !== undefined) fetchInit.headers = args.headers;
32
+ const res = await fetch(args.url, fetchInit);
33
+ const body = await res.text();
34
+ clearTimeout(timer);
35
+ const ct = res.headers.get("content-type") ?? "";
36
+ const truncated = await applyTruncation(body, "http");
37
+ return ok({
38
+ text: `HTTP ${res.status} ${ct}\n${truncated.text}`,
39
+ details: truncated.fullOutputPath !== undefined
40
+ ? { status: res.status, contentType: ct, length: body.length, fullOutputPath: truncated.fullOutputPath, wasTruncated: truncated.wasTruncated }
41
+ : { status: res.status, contentType: ct, length: body.length, wasTruncated: truncated.wasTruncated },
42
+ });
43
+ } catch (e) {
44
+ clearTimeout(timer);
45
+ const msg = e instanceof Error ? e.message : String(e);
46
+ const kind: ToolErr["kind"] = msg.toLowerCase().includes("abort") ? "timeout" : "io_error";
47
+ return err({ kind, message: msg });
48
+ }
49
+ },
50
+ });
51
+
52
+ const NetLogArgs = Type.Object({
53
+ eventTypes: Type.Optional(
54
+ Type.Array(Type.String(), { description: "CDP event names to filter (e.g., ['Network.requestWillBeSent'])" }),
55
+ ),
56
+ limit: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 500 })),
57
+ });
58
+
59
+ export const getNetworkLogTool = defineBrowserTool({
60
+ name: "browser_get_network_log",
61
+ label: "Browser Get Network Log",
62
+ description:
63
+ "Read buffered Network.* CDP events. NOTE: in v0.3 the event stream is consumed internally by the session manager; this tool is a placeholder. Use browser_execute_js with PerformanceObserver as a workaround.",
64
+ promptSnippet: "Get buffered network events (DEPRECATED in v0.3 — use browser_execute_js with PerformanceObserver instead)",
65
+ promptGuidelines: [
66
+ "v0.3 routes CDP events through an internal AsyncIterable; a synchronous drain API isn't exposed yet.",
67
+ "For now, use browser_execute_js with PerformanceObserver, performance.getEntries(), or fetch interceptors.",
68
+ ],
69
+ parameters: NetLogArgs,
70
+ async handler(args): Promise<Result<ToolOk, ToolErr>> {
71
+ return ok({
72
+ text: "browser_get_network_log: in v0.3 the CDP event stream is consumed by the session; use browser_execute_js with PerformanceObserver or performance.getEntries() to inspect network activity.",
73
+ details: {
74
+ deprecated: true,
75
+ eventTypes: args.eventTypes ?? null,
76
+ limit: args.limit ?? null,
77
+ },
78
+ });
79
+ },
80
+ });
@@ -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
+ });