aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/domains/page.ts88
-rw-r--r--src/registry.ts4
-rw-r--r--src/tools.ts115
3 files changed, 92 insertions, 115 deletions
diff --git a/src/domains/page.ts b/src/domains/page.ts
new file mode 100644
index 0000000..25b3c67
--- /dev/null
+++ b/src/domains/page.ts
@@ -0,0 +1,88 @@
+import { Type } from "typebox";
+import { type Result, err, ok } from "../util/result";
+import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
+import { sleep } from "../util/time";
+import { safeJs } from "../util/js-template";
+
+export const pageInfoTool = defineBrowserTool({
+ name: "browser_page_info",
+ label: "Browser Page Info",
+ description:
+ "Get current page state: URL, title, viewport size, scroll position, page dimensions. If a JS dialog is open, returns dialog info instead.",
+ promptSnippet: "Get current page URL, title, viewport, and scroll position",
+ promptGuidelines: [
+ "Use browser_page_info to quickly check what page you're on and whether a JS dialog is blocking interaction.",
+ "If browser_page_info returns a dialog, use browser_handle_dialog before any other browser actions.",
+ "JS dialogs freeze the page's JS thread, so no other interaction works until the dialog is handled.",
+ "browser_page_info auto-detects alert, confirm, prompt, and beforeunload dialogs.",
+ ],
+ parameters: Type.Object({}),
+ async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
+ const r = await client.pageInfo();
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
+ if ("dialog" in r.data) {
+ const d = r.data.dialog;
+ return ok({
+ text: `⚠️ DIALOG OPEN: ${d.type}\nMessage: ${d.message}${d.defaultPrompt ? `\nDefault: ${d.defaultPrompt}` : ""}\n\nUse browser_handle_dialog to accept or dismiss.`,
+ details: { dialog: d },
+ });
+ }
+ const i = r.data;
+ return ok({
+ 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}`,
+ details: { ...i },
+ });
+ },
+});
+
+const WaitArgs = Type.Object({
+ seconds: Type.Number({ minimum: 0, maximum: 60, description: "Seconds to wait" }),
+});
+
+export const waitTool = defineBrowserTool({
+ name: "browser_wait",
+ label: "Browser Wait",
+ description: "Wait N seconds before continuing.",
+ promptSnippet: "Wait N seconds",
+ promptGuidelines: ["Use sparingly — prefer browser_wait_for_load when waiting for a page load."],
+ parameters: WaitArgs,
+ async handler(args, { signal }): Promise<Result<ToolOk, ToolErr>> {
+ try {
+ await sleep(Math.round(args.seconds * 1000), signal);
+ return ok({ text: `Waited ${args.seconds}s` });
+ } catch (e) {
+ return err({ kind: "internal", message: e instanceof Error ? e.message : String(e) });
+ }
+ },
+});
+
+const WaitForLoadArgs = Type.Object({
+ timeout: Type.Optional(Type.Number({ default: 15, minimum: 1, maximum: 120, description: "Max seconds to wait. Default: 15." })),
+});
+
+export const waitForLoadTool = defineBrowserTool({
+ name: "browser_wait_for_load",
+ label: "Browser Wait For Load",
+ description:
+ "Poll until the current page reports document.readyState === 'complete'. Returns a typed timeout error if the deadline elapses.",
+ promptSnippet: "Wait for the page to finish loading",
+ promptGuidelines: [
+ "Call after browser_navigate / browser_open_urls before extracting data.",
+ "Returns when readyState becomes 'complete' OR the timeout elapses.",
+ ],
+ parameters: WaitForLoadArgs,
+ async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
+ const timeoutMs = (args.timeout ?? 15) * 1000;
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ if (signal?.aborted) return err({ kind: "internal", message: "aborted" });
+ const r = await client.evaluateJs(safeJs`document.readyState`);
+ if (r.success && r.data === "complete") {
+ const ms = Date.now() - start;
+ return ok({ text: `Page loaded in ${Math.round(ms / 100) / 10}s`, details: { ms } });
+ }
+ await new Promise((res) => setTimeout(res, 50));
+ }
+ return err({ kind: "timeout", message: `Page did not finish loading in ${args.timeout ?? 15}s` });
+ },
+});
diff --git a/src/registry.ts b/src/registry.ts
index a635042..c92f136 100644
--- a/src/registry.ts
+++ b/src/registry.ts
@@ -3,6 +3,7 @@ import type { BrowserClient } from "./client";
import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
import { clickTool } from "./domains/click";
import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
+import { pageInfoTool, waitTool, waitForLoadTool } from "./domains/page";
import { scrollTool } from "./domains/scroll";
const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
@@ -11,6 +12,9 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
pressKeyTool,
dispatchKeyTool,
scrollTool,
+ pageInfoTool,
+ waitTool,
+ waitForLoadTool,
];
export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => {
diff --git a/src/tools.ts b/src/tools.ts
index b1ef044..25e49d4 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -170,54 +170,6 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void {
});
// ═══════════════════════════════════════════════════════════════════════════
- // browser_page_info
- // ═══════════════════════════════════════════════════════════════════════════
- pi.registerTool({
- name: "browser_page_info",
- label: "Browser Page Info",
- description:
- "Get current page state: URL, title, viewport size, scroll position, page dimensions. If a JS dialog (alert/confirm/prompt) is open, returns dialog info instead.",
- promptSnippet: "Get current page URL, title, viewport, and scroll position",
- promptGuidelines: [
- "Use browser_page_info to quickly check what page you're on and whether a JS dialog is blocking interaction.",
- "If browser_page_info returns a dialog, use browser_handle_dialog before any other browser actions.",
- "JS dialogs freeze the page's JS thread, so no other interaction works until the dialog is handled.",
- "browser_page_info auto-detects alert, confirm, prompt, and beforeunload dialogs.",
- ],
- parameters: Type.Object({}),
- async execute() {
- try {
- await daemon.ensureAlive();
- const info = await daemon.getPageInfo();
-
- if ("dialog" in info) {
- return {
- content: [{
- type: "text" as const,
- text: `⚠️ DIALOG OPEN: ${info.dialog.type}\nMessage: ${info.dialog.message}${info.dialog.defaultPrompt ? `\nDefault: ${info.dialog.defaultPrompt}` : ""}\n\nUse browser_handle_dialog to accept or dismiss.`,
- }],
- details: undefined,
- };
- }
-
- return {
- content: [{
- type: "text" as const,
- text: `URL: ${info.url}\nTitle: ${info.title}\nViewport: ${info.width}x${info.height}\nScroll: (${info.scrollX}, ${info.scrollY})\nPage size: ${info.pageWidth}x${info.pageHeight}`,
- }],
- details: undefined,
- };
- } catch (err) {
- return {
- isError: true,
- content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }],
- details: undefined,
- };
- }
- },
- });
-
- // ═══════════════════════════════════════════════════════════════════════════
// browser_go_back
// ═══════════════════════════════════════════════════════════════════════════
pi.registerTool({
@@ -768,73 +720,6 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void {
});
// ═══════════════════════════════════════════════════════════════════════════
- // browser_wait
- // ═══════════════════════════════════════════════════════════════════════════
- pi.registerTool({
- name: "browser_wait",
- label: "Browser Wait",
- description: "Wait for a specified number of seconds.",
- promptSnippet: "Wait for N seconds (default 1s)",
- promptGuidelines: [
- "Use browser_wait for simple delays. Prefer browser_wait_for_load after navigation.",
- ],
- parameters: Type.Object({
- seconds: Type.Optional(Type.Number({ description: "Seconds to wait. Default: 1" })),
- }),
- async execute(_id, params) {
- const sec = params.seconds || 1;
- await new Promise((r) => setTimeout(r, sec * 1000));
- return { content: [{ type: "text" as const, text: `Waited ${sec}s.` }], details: undefined };
- },
- });
-
- // ═══════════════════════════════════════════════════════════════════════════
- // browser_wait_for_load
- // ═══════════════════════════════════════════════════════════════════════════
- pi.registerTool({
- name: "browser_wait_for_load",
- label: "Browser Wait For Load",
- description:
- "Wait for the page to finish loading (document.readyState === 'complete'). Returns false on timeout. Note: SPAs may need additional waiting after this.",
- promptSnippet: "Wait for document.readyState === 'complete'",
- promptGuidelines: [
- "Use browser_wait_for_load after browser_navigate or browser_new_tab.",
- "This checks document.readyState only. Single-page apps may still be loading data after this returns true.",
- "For SPAs, use browser_execute_js to check for specific elements after browser_wait_for_load.",
- ],
- parameters: Type.Object({
- timeout: Type.Optional(Type.Number({ description: "Max seconds to wait. Default: 15" })),
- }),
- async execute(_id, params) {
- try {
- await daemon.ensureAlive();
- const timeout = params.timeout || 15;
- const deadline = Date.now() + timeout * 1000;
-
- while (Date.now() < deadline) {
- const state = await daemon.evaluateJS("document.readyState");
- if (state === "complete") {
- return { content: [{ type: "text" as const, text: "Page loaded (readyState: complete)." }], details: undefined };
- }
- await new Promise((r) => setTimeout(r, 300));
- }
-
- return {
- isError: true,
- content: [{ type: "text" as const, text: `Page load timed out after ${timeout}s. Current state unknown.` }],
- details: undefined,
- };
- } catch (err) {
- return {
- isError: true,
- content: [{ type: "text" as const, text: `Wait failed: ${err instanceof Error ? err.message : String(err)}` }],
- details: undefined,
- };
- }
- },
- });
-
- // ═══════════════════════════════════════════════════════════════════════════
// browser_handle_dialog
// ═══════════════════════════════════════════════════════════════════════════
pi.registerTool({