src/domains/tabs.ts (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
import { Type } from "typebox";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { cdpCallBrowser } from "./cdp-call";
const ListTabsArgs = Type.Object({
includeInternal: Type.Optional(Type.Boolean({ default: true, description: "Include chrome:// pages" })),
scope: Type.Optional(
Type.Union([Type.Literal("owned"), Type.Literal("all")], {
default: "owned",
description: "'owned' (default) shows only tabs this harness session opened; 'all' shows every tab in the browser.",
}),
),
});
export const listTabsTool = defineBrowserTool({
name: "browser_list_tabs",
label: "Browser List Tabs",
description: "List browser tabs. Defaults to tabs this harness session opened (scope:'owned'). Pass scope:'all' to see the user's other tabs too.",
promptSnippet: "List browser tabs",
promptGuidelines: [
"Each tab shows its full 32-char hex targetId — use the exact value with browser_switch_tab.",
],
parameters: ListTabsArgs,
concurrency: "parallel",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.listTabs(args.includeInternal ?? true);
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
const scope = args.scope ?? "owned";
const filtered = scope === "owned" ? r.data.filter((t) => t.owned) : r.data;
const lines = filtered.map((t, i) => {
const flag = t.owned ? "★" : " ";
return ` ${flag} [${i}] ${t.targetId} ${t.url}\n ${t.title}`;
});
const header = scope === "owned"
? `Owned tabs (${filtered.length}):`
: `All tabs (${filtered.length}, ★ = owned by this session):`;
return ok({
text: `${header}\n${lines.join("\n")}`,
details: { tabs: filtered, scope },
});
},
});
export const currentTabTool = defineBrowserTool({
name: "browser_current_tab",
label: "Browser Current Tab",
description: "Get info about the currently attached tab.",
promptSnippet: "Get current tab info",
promptGuidelines: ["Returns targetId, url, title, and whether the tab is owned by this harness session."],
parameters: Type.Object({}),
concurrency: "parallel",
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
const cur = client.current();
if (!cur) return err({ kind: "invalid_state", message: "No tab attached." });
const ti = await cdpCallBrowser(client, "Target.getTargetInfo", { targetId: cur.targetId });
if (!ti.success) return ti;
const info = ti.data.targetInfo;
const owned = client.owns(info.targetId);
return ok({
text: `Current tab${owned ? " (owned)" : " (NOT owned by this session)"}:\n ${info.targetId}\n ${info.url}\n ${info.title}`,
details: { targetId: info.targetId, url: info.url, title: info.title, owned },
});
},
});
const SwitchTabArgs = Type.Object({
targetId: Type.String({ description: "Target ID from browser_list_tabs" }),
});
const ownershipDeniedMessage = (id: string, namespace: string): string =>
`Tab ${id} is not owned by this harness session (namespace: ${namespace}). ` +
`Use browser_list_tabs with scope:'all' to see it, or browser_new_tab to open one this session can control.`;
export const switchTabTool = defineBrowserTool({
name: "browser_switch_tab",
label: "Browser Switch Tab",
description: "Switch to and attach to a different tab by targetId. Only tabs this harness session opened are accepted.",
promptSnippet: "Switch tabs",
promptGuidelines: [
"Get a targetId via browser_list_tabs first.",
"Accepts exact targetId or a unique prefix of at least 8 hex characters.",
],
parameters: SwitchTabArgs,
concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
let resolved = args.targetId;
if (!client.owns(resolved)) {
const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
if (isHexPrefix) {
const tabs = await client.listTabs(true);
if (!tabs.success) return err({ kind: "cdp_error", message: tabs.error.message });
const matches = tabs.data.filter((t) => t.targetId.startsWith(args.targetId));
if (matches.length === 0) {
return err({ kind: "cdp_error", message: `No tab found with prefix "${args.targetId}"` });
}
if (matches.length > 1) {
const ids = matches.map((t) => t.targetId).join(", ");
return err({
kind: "invalid_state",
message: `Ambiguous prefix "${args.targetId}" matches ${matches.length} tabs: ${ids}`,
details: { matches: matches.map((t) => ({ targetId: t.targetId, url: t.url })) },
});
}
const first = matches[0];
if (first === undefined) return err({ kind: "invalid_state", message: "no matching tab" });
resolved = first.targetId;
}
if (!client.owns(resolved)) {
return err({
kind: "invalid_state",
message: ownershipDeniedMessage(resolved, client.namespace),
details: { targetId: resolved, namespace: client.namespace },
});
}
}
const r = await client.switchTab(resolved);
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
const note = resolved === args.targetId ? "" : ` (resolved from prefix "${args.targetId}")`;
return ok({
text: `Switched to ${resolved}${note}`,
details: { targetId: resolved },
});
},
});
const NewTabArgs = Type.Object({
url: Type.Optional(Type.String({ description: "Optional URL to navigate to" })),
});
export const newTabTool = defineBrowserTool({
name: "browser_new_tab",
label: "Browser New Tab",
description: "Open a new tab in the harness's dedicated Chrome window and switch to it. Optionally navigate to a URL.",
promptSnippet: "Open a new tab",
promptGuidelines: [],
parameters: NewTabArgs,
concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.newTab(args.url);
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
return ok({
text: `New tab: ${r.data}${args.url ? ` (${args.url})` : ""}`,
details: args.url !== undefined
? { targetId: r.data, url: args.url }
: { targetId: r.data },
});
},
});
const CloseTabArgs = Type.Object({
targetId: Type.String({ description: "Target ID from browser_list_tabs" }),
});
export const closeTabTool = defineBrowserTool({
name: "browser_close_tab",
label: "Browser Close Tab",
description: "Close a tab the harness owns. Refuses tabs not opened by this session.",
promptSnippet: "Close an owned tab",
promptGuidelines: [
"Accepts exact targetId or a unique hex prefix (>=8 chars).",
],
parameters: CloseTabArgs,
concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
let resolved = args.targetId;
if (!client.owns(resolved)) {
const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
if (isHexPrefix) {
const owned = client.ownership().list();
const matches = owned.filter((id) => id.startsWith(args.targetId));
const first = matches[0];
if (matches.length === 1 && first !== undefined) resolved = first;
else if (matches.length > 1) {
return err({
kind: "invalid_state",
message: `Ambiguous prefix "${args.targetId}" matches ${matches.length} owned tabs: ${matches.join(", ")}`,
});
}
}
if (!client.owns(resolved)) {
return err({
kind: "invalid_state",
message: ownershipDeniedMessage(resolved, client.namespace),
details: { targetId: resolved, namespace: client.namespace },
});
}
}
const r = await client.closeTab(resolved);
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
return ok({ text: `Closed tab ${resolved}`, details: { targetId: resolved } });
},
});
|