pi-browser-harness 0.3.1 → 0.4.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,421 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { Type } from "typebox";
3
+ import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
+ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
5
+ import { type Result, err, ok } from "../util/result";
6
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
+ import { applyTruncation } from "../util/truncate";
8
+ import { screenshotPath } from "../util/paths";
9
+
10
+ const SnapshotArgs = Type.Object({
11
+ includeScreenshot: Type.Optional(
12
+ Type.Boolean({ default: false, description: "Also capture a JPEG screenshot of the current viewport." }),
13
+ ),
14
+ interestingOnly: Type.Optional(
15
+ Type.Boolean({ default: true, description: "Drop nodes the AX engine marked uninteresting (generic containers, inline text, etc.)." }),
16
+ ),
17
+ maxNodes: Type.Optional(
18
+ Type.Integer({ default: 1000, minimum: 1, maximum: 5000, description: "Cap on slim nodes returned." }),
19
+ ),
20
+ format: Type.Optional(
21
+ Type.Union(
22
+ [Type.Literal("outline"), Type.Literal("json")],
23
+ { default: "outline", description: "'outline' = indented markdown bullet tree; 'json' = raw slim structure." },
24
+ ),
25
+ ),
26
+ });
27
+
28
+ // Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only.
29
+ type AxValue = { value?: unknown };
30
+ type RawAxNode = {
31
+ nodeId: string;
32
+ parentId?: string;
33
+ childIds?: ReadonlyArray<string>;
34
+ ignored?: boolean;
35
+ role?: AxValue;
36
+ name?: AxValue;
37
+ value?: AxValue;
38
+ description?: AxValue;
39
+ properties?: ReadonlyArray<{ name?: string; value?: AxValue }>;
40
+ backendDOMNodeId?: number;
41
+ };
42
+
43
+ type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
44
+
45
+ type SlimNode = {
46
+ role: string;
47
+ name?: string;
48
+ value?: string;
49
+ description?: string;
50
+ state?: string;
51
+ children: SlimNode[];
52
+ /** Internal: kept for the post-build box fetch. Stripped before returning to the LLM. */
53
+ _backendId?: number;
54
+ /** Click target — center of the element's bounding box, in viewport CSS pixels. */
55
+ box?: Box;
56
+ };
57
+
58
+ const INTERACTIVE_ROLES = new Set([
59
+ "button",
60
+ "link",
61
+ "textbox",
62
+ "checkbox",
63
+ "radio",
64
+ "combobox",
65
+ "menuitem",
66
+ "tab",
67
+ "switch",
68
+ "slider",
69
+ "searchbox",
70
+ "spinbutton",
71
+ ]);
72
+
73
+ const stringOf = (v: AxValue | undefined): string | undefined => {
74
+ const x = v?.value;
75
+ if (typeof x === "string" && x !== "") return x;
76
+ if (typeof x === "number") return String(x);
77
+ return undefined;
78
+ };
79
+
80
+ const collectState = (props: RawAxNode["properties"]): string | undefined => {
81
+ if (!props || props.length === 0) return undefined;
82
+ const flags: string[] = [];
83
+ for (const p of props) {
84
+ const name = p.name;
85
+ if (!name) continue;
86
+ const raw = p.value?.value;
87
+ // Boolean flags worth surfacing in the outline.
88
+ if (raw === true && (name === "focused" || name === "required" || name === "disabled" || name === "checked" || name === "expanded" || name === "selected" || name === "pressed" || name === "modal")) {
89
+ flags.push(name);
90
+ }
91
+ if (typeof raw === "number" && name === "level") flags.push(`level ${raw}`);
92
+ }
93
+ return flags.length > 0 ? flags.join(", ") : undefined;
94
+ };
95
+
96
+ const buildTree = (
97
+ rawNodes: ReadonlyArray<RawAxNode>,
98
+ opts: { interestingOnly: boolean; maxNodes: number },
99
+ ): SlimNode[] => {
100
+ // Index for parent/child lookups.
101
+ const byId = new Map<string, RawAxNode>();
102
+ for (const n of rawNodes) byId.set(n.nodeId, n);
103
+
104
+ // CDP doesn't mark a single root explicitly; the root nodes are the ones
105
+ // whose parentId isn't in the index (typically just one — RootWebArea).
106
+ const rootIds: string[] = [];
107
+ for (const n of rawNodes) {
108
+ if (!n.parentId || !byId.has(n.parentId)) rootIds.push(n.nodeId);
109
+ }
110
+
111
+ let budget = opts.maxNodes;
112
+
113
+ const slim = (node: RawAxNode): SlimNode | undefined => {
114
+ if (budget <= 0) return undefined;
115
+ if (opts.interestingOnly && node.ignored) {
116
+ // Skip the ignored node but recurse through its children — Chrome often
117
+ // ignores wrapper divs but the meaningful descendants are still there.
118
+ const out: SlimNode[] = [];
119
+ for (const cid of node.childIds ?? []) {
120
+ const child = byId.get(cid);
121
+ if (!child) continue;
122
+ const slimChild = slim(child);
123
+ if (slimChild) out.push(slimChild);
124
+ }
125
+ // Hoist children into the parent. We return undefined; the parent's
126
+ // recursion will see them by re-walking — but that double-counts. Instead
127
+ // we handle hoisting at the parent level. So here just signal "skip me"
128
+ // by returning undefined; the parent will hoist by checking ignored flag.
129
+ return out.length > 0 ? { role: "_hoist", children: out } : undefined;
130
+ }
131
+ budget--;
132
+ const role = stringOf(node.role) ?? "unknown";
133
+ const name = stringOf(node.name);
134
+ const value = stringOf(node.value);
135
+ const description = stringOf(node.description);
136
+ const state = collectState(node.properties);
137
+ const children: SlimNode[] = [];
138
+ for (const cid of node.childIds ?? []) {
139
+ const child = byId.get(cid);
140
+ if (!child) continue;
141
+ const slimChild = slim(child);
142
+ if (!slimChild) continue;
143
+ // Hoist any synthetic _hoist nodes' children into this level.
144
+ if (slimChild.role === "_hoist") children.push(...slimChild.children);
145
+ else children.push(slimChild);
146
+ }
147
+ const out: SlimNode = { role, children };
148
+ if (name !== undefined) out.name = name;
149
+ if (value !== undefined) out.value = value;
150
+ if (description !== undefined) out.description = description;
151
+ if (state !== undefined) out.state = state;
152
+ if (node.backendDOMNodeId !== undefined) out._backendId = node.backendDOMNodeId;
153
+ return out;
154
+ };
155
+
156
+ const result: SlimNode[] = [];
157
+ for (const rid of rootIds) {
158
+ const root = byId.get(rid);
159
+ if (!root) continue;
160
+ const s = slim(root);
161
+ if (!s) continue;
162
+ if (s.role === "_hoist") result.push(...s.children);
163
+ else result.push(s);
164
+ }
165
+ return result;
166
+ };
167
+
168
+ const countNodes = (nodes: ReadonlyArray<SlimNode>): number => {
169
+ let n = 0;
170
+ for (const node of nodes) n += 1 + countNodes(node.children);
171
+ return n;
172
+ };
173
+
174
+ const summarize = (nodes: ReadonlyArray<SlimNode>): string => {
175
+ const counts = new Map<string, number>();
176
+ const walk = (ns: ReadonlyArray<SlimNode>): void => {
177
+ for (const n of ns) {
178
+ counts.set(n.role, (counts.get(n.role) ?? 0) + 1);
179
+ walk(n.children);
180
+ }
181
+ };
182
+ walk(nodes);
183
+ // Group landmarks (roles that are top-level structural).
184
+ const landmarkRoles = new Set(["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region"]);
185
+ let landmarks = 0;
186
+ for (const [role, c] of counts) if (landmarkRoles.has(role)) landmarks += c;
187
+ const buttons = counts.get("button") ?? 0;
188
+ const inputs = (counts.get("textbox") ?? 0) + (counts.get("combobox") ?? 0) + (counts.get("checkbox") ?? 0);
189
+ const links = counts.get("link") ?? 0;
190
+ const parts: string[] = [];
191
+ if (landmarks > 0) parts.push(`${landmarks} landmark${landmarks === 1 ? "" : "s"}`);
192
+ if (buttons > 0) parts.push(`${buttons} button${buttons === 1 ? "" : "s"}`);
193
+ if (inputs > 0) parts.push(`${inputs} input${inputs === 1 ? "" : "s"}`);
194
+ if (links > 0) parts.push(`${links} link${links === 1 ? "" : "s"}`);
195
+ return parts.join(" · ");
196
+ };
197
+
198
+ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => {
199
+ const lines: string[] = [];
200
+ const walk = (ns: ReadonlyArray<SlimNode>, depth: number): void => {
201
+ for (const n of ns) {
202
+ const indent = " ".repeat(depth);
203
+ let line = `${indent}- ${n.role}`;
204
+ if (n.name) line += ` "${n.name}"`;
205
+ if (n.value && n.value !== n.name) line += ` = ${JSON.stringify(n.value)}`;
206
+ if (n.state) line += ` (${n.state})`;
207
+ // Surface click coordinates for interactive nodes — agents can pass these
208
+ // straight to browser_click without a screenshot round-trip.
209
+ if (n.box && INTERACTIVE_ROLES.has(n.role)) {
210
+ line += ` @(${n.box.cx},${n.box.cy})`;
211
+ }
212
+ lines.push(line);
213
+ walk(n.children, depth + 1);
214
+ }
215
+ };
216
+ walk(nodes, 0);
217
+ return lines.join("\n");
218
+ };
219
+
220
+ /** Strip internal _backendId from a slim tree before returning to callers. */
221
+ const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
222
+ nodes.map((n) => {
223
+ const { _backendId: _bid, ...rest } = n;
224
+ return { ...rest, children: stripInternals(n.children) };
225
+ });
226
+
227
+ /**
228
+ * Walk all slim nodes and collect (slimNode, backendId) pairs for interactive
229
+ * roles. Caller fetches DOM.getBoxModel for each and writes box back into the
230
+ * slim node by reference.
231
+ */
232
+ const collectInteractiveTargets = (
233
+ nodes: ReadonlyArray<SlimNode>,
234
+ ): Array<{ node: SlimNode; backendId: number }> => {
235
+ const out: Array<{ node: SlimNode; backendId: number }> = [];
236
+ const walk = (ns: ReadonlyArray<SlimNode>): void => {
237
+ for (const n of ns) {
238
+ if (n._backendId !== undefined && INTERACTIVE_ROLES.has(n.role)) {
239
+ out.push({ node: n, backendId: n._backendId });
240
+ }
241
+ walk(n.children);
242
+ }
243
+ };
244
+ walk(nodes);
245
+ return out;
246
+ };
247
+
248
+ type SnapshotDetails = {
249
+ nodeCount: number;
250
+ truncated: boolean;
251
+ fullOutputPath?: string;
252
+ screenshotPath?: string;
253
+ url: string;
254
+ title: string;
255
+ };
256
+
257
+ export const snapshotTool = defineBrowserTool({
258
+ name: "browser_snapshot",
259
+ label: "Browser Snapshot",
260
+ description:
261
+ "DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy) plus click coordinates for every interactive element as @(x,y). Use this BEFORE deciding whether you need a screenshot — almost always sufficient on its own and far cheaper. Pair with browser_execute_js for surgical reads of specific element values.",
262
+ promptSnippet: "Get accessibility-tree snapshot of the current page (default for page inspection)",
263
+ promptGuidelines: [
264
+ "DEFAULT — use this whenever you need to know what's on a page, what's clickable, or how the page is structured.",
265
+ "Click coordinates come for free: every interactive element shows '@(x,y)' in the outline. Pass these straight to browser_click. NO screenshot round-trip needed.",
266
+ "DO NOT call browser_screenshot just to understand the page. This tool already gives you structure, labels, states, and click targets.",
267
+ "Pass includeScreenshot:true ONLY if you also need to verify visual rendering (rare).",
268
+ "format:'json' returns the raw slim structure (with `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
269
+ ],
270
+ parameters: SnapshotArgs,
271
+
272
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
273
+ const session = client.session();
274
+
275
+ // 1. AX tree
276
+ const axRes = await session.call("Accessibility.getFullAXTree", {});
277
+ if (!axRes.success) return err({ kind: "cdp_error", message: axRes.error.message });
278
+ const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
279
+
280
+ // 2. Page url/title
281
+ const piRes = await client.pageInfo();
282
+ if (!piRes.success) return err({ kind: "cdp_error", message: piRes.error.message });
283
+ const pageUrl = "dialog" in piRes.data ? "" : piRes.data.url;
284
+ const pageTitle = "dialog" in piRes.data ? "" : piRes.data.title;
285
+
286
+ // 3. Slim + format
287
+ const slim = buildTree(rawNodes, {
288
+ interestingOnly: args.interestingOnly ?? true,
289
+ maxNodes: args.maxNodes ?? 1000,
290
+ });
291
+
292
+ // 4. Fetch bounding boxes for interactive nodes so the agent can click
293
+ // without a screenshot round-trip. Capped budget so a slow page can't
294
+ // wedge the call.
295
+ const targets = collectInteractiveTargets(slim);
296
+ if (targets.length > 0) {
297
+ const ac = new AbortController();
298
+ const timer = setTimeout(() => ac.abort(), 1_500);
299
+ await Promise.allSettled(
300
+ targets.map(async ({ node, backendId }) => {
301
+ if (ac.signal.aborted) return;
302
+ const r = await session.call("DOM.getBoxModel", { backendNodeId: backendId });
303
+ if (!r.success) return;
304
+ // CDP boundary cast: DOM.getBoxModel
305
+ const data = r.data as { model?: { content?: ReadonlyArray<number>; width?: number; height?: number } };
306
+ const content = data.model?.content;
307
+ if (!content || content.length < 8) return;
308
+ // content is [x1,y1, x2,y1, x2,y2, x1,y2] in viewport CSS pixels.
309
+ const x = content[0]!;
310
+ const y = content[1]!;
311
+ const width = data.model!.width ?? 0;
312
+ const height = data.model!.height ?? 0;
313
+ // Skip zero-size and off-viewport boxes — they can't be clicked.
314
+ if (width <= 0 || height <= 0 || x < 0 || y < 0) return;
315
+ node.box = {
316
+ x: Math.round(x),
317
+ y: Math.round(y),
318
+ width: Math.round(width),
319
+ height: Math.round(height),
320
+ cx: Math.round(x + width / 2),
321
+ cy: Math.round(y + height / 2),
322
+ };
323
+ }),
324
+ );
325
+ clearTimeout(timer);
326
+ }
327
+
328
+ const stripped = stripInternals(slim);
329
+ const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripped, null, 2);
330
+ const trunc = await applyTruncation(text, "snapshot");
331
+
332
+ // 4. Optional screenshot — JPEG q=80 to match screenshotTool's default.
333
+ let shotPath: string | undefined;
334
+ if (args.includeScreenshot) {
335
+ const shot = await session.call("Page.captureScreenshot", { format: "jpeg", quality: 80 });
336
+ if (shot.success) {
337
+ const data = (shot.data as { data: string }).data;
338
+ shotPath = screenshotPath(client.namespace, "jpeg");
339
+ writeFileSync(shotPath, Buffer.from(data, "base64"));
340
+ }
341
+ }
342
+
343
+ const details: SnapshotDetails = {
344
+ nodeCount: countNodes(slim),
345
+ truncated: trunc.wasTruncated,
346
+ url: pageUrl,
347
+ title: pageTitle,
348
+ };
349
+ if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
350
+ if (shotPath !== undefined) details.screenshotPath = shotPath;
351
+
352
+ return ok({ text: trunc.text, details: { ...details, summary: summarize(slim) } });
353
+ },
354
+
355
+ renderResult(result, expanded, theme) {
356
+ const details = result.details as
357
+ | (SnapshotDetails & { summary?: string; ok?: boolean })
358
+ | undefined;
359
+ if (!details) return new Text(theme.fg("error", "snapshot: no details"), 0, 0);
360
+
361
+ const titleLine = details.title ? ` "${details.title}"` : "";
362
+ const summary = details.summary ?? "";
363
+ const screenshotNote = details.screenshotPath ? "screenshot attached" : "screenshot omitted";
364
+ const truncNote = details.truncated && details.fullOutputPath ? `\n\nFull tree at \`${details.fullOutputPath}\`` : "";
365
+
366
+ if (!expanded) {
367
+ const md = [
368
+ `**AX tree:** ${details.nodeCount} nodes · \`${details.url || "(no url)"}\``,
369
+ `${titleLine ? " " + titleLine : ""}`,
370
+ summary ? ` • ${summary}` : "",
371
+ ` ${keyHint("app.tools.expand", "to expand")} · ${screenshotNote}`,
372
+ ].filter((l) => l !== "").join("\n");
373
+ return new Markdown(md, 0, 0, getMarkdownTheme());
374
+ }
375
+
376
+ // Expanded view: read truncated text from result.content (the spilled file
377
+ // is too large to inline) — the same `text` we returned from handler is in
378
+ // result.content[0]. Fall back to a placeholder if shape is unexpected.
379
+ const content = result.content[0];
380
+ const treeText = content && content.type === "text" ? content.text : "";
381
+
382
+ const headerMd = [
383
+ `**AX tree:** ${details.nodeCount} nodes · \`${details.url || "(no url)"}\``,
384
+ titleLine ? ` ${titleLine}` : "",
385
+ "",
386
+ "```",
387
+ treeText,
388
+ "```",
389
+ truncNote,
390
+ "",
391
+ `${keyHint("app.tools.expand", "to collapse")}`,
392
+ ].filter((l) => l !== "").join("\n");
393
+
394
+ const treeBlock = new Markdown(headerMd, 0, 0, getMarkdownTheme());
395
+
396
+ if (!details.screenshotPath) return treeBlock;
397
+
398
+ // Mirror screenshotTool's width-clamp wrapper for the inline image.
399
+ try {
400
+ const buf = readFileSync(details.screenshotPath);
401
+ const b64 = buf.toString("base64");
402
+ const imageTheme: ImageTheme = { fallbackColor: (s: string) => theme.fg("dim", s) };
403
+ const image = new Image(b64, "image/jpeg", imageTheme, {
404
+ maxWidthCells: 80,
405
+ maxHeightCells: 24,
406
+ filename: details.screenshotPath,
407
+ });
408
+ const safeImage = {
409
+ invalidate: () => image.invalidate(),
410
+ render: (width: number) =>
411
+ image.render(width).map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width) : line)),
412
+ };
413
+ const container = new Container();
414
+ container.addChild(treeBlock);
415
+ container.addChild(safeImage);
416
+ return container;
417
+ } catch {
418
+ return treeBlock;
419
+ }
420
+ },
421
+ });
@@ -4,26 +4,41 @@ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
4
 
5
5
  const ListTabsArgs = Type.Object({
6
6
  includeInternal: Type.Optional(Type.Boolean({ default: true, description: "Include chrome:// pages" })),
7
+ scope: Type.Optional(
8
+ Type.Union([Type.Literal("owned"), Type.Literal("all")], {
9
+ default: "owned",
10
+ description: "'owned' (default) shows only tabs this harness session opened; 'all' shows every tab in the browser.",
11
+ }),
12
+ ),
7
13
  });
8
14
 
9
15
  export const listTabsTool = defineBrowserTool({
10
16
  name: "browser_list_tabs",
11
17
  label: "Browser List Tabs",
12
- description: "List all open browser tabs (page targets).",
18
+ description: "List browser tabs. Defaults to tabs this harness session opened (scope:'owned'). Pass scope:'all' to see the user's other tabs too.",
13
19
  promptSnippet: "List browser tabs",
14
20
  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.",
21
+ "Defaults to scope:'owned' — only tabs this session created. Pass scope:'all' to inspect the user's other tabs.",
22
+ "browser_switch_tab and browser_close_tab refuse non-owned tabs; use browser_new_tab to open one this session controls.",
17
23
  "Each tab shows its full 32-char hex targetId — use the exact value with browser_switch_tab.",
24
+ "Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.",
18
25
  ],
19
26
  parameters: ListTabsArgs,
20
27
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
21
28
  const r = await client.listTabs(args.includeInternal ?? true);
22
29
  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}`);
30
+ const scope = args.scope ?? "owned";
31
+ const filtered = scope === "owned" ? r.data.filter((t) => t.owned) : r.data;
32
+ const lines = filtered.map((t, i) => {
33
+ const flag = t.owned ? "★" : " ";
34
+ return ` ${flag} [${i}] ${t.targetId} ${t.url}\n ${t.title}`;
35
+ });
36
+ const header = scope === "owned"
37
+ ? `Owned tabs (${filtered.length}):`
38
+ : `All tabs (${filtered.length}, ★ = owned by this session):`;
24
39
  return ok({
25
- text: `Tabs (${r.data.length}):\n${lines.join("\n")}`,
26
- details: { tabs: r.data },
40
+ text: `${header}\n${lines.join("\n")}`,
41
+ details: { tabs: filtered, scope },
27
42
  });
28
43
  },
29
44
  });
@@ -33,7 +48,7 @@ export const currentTabTool = defineBrowserTool({
33
48
  label: "Browser Current Tab",
34
49
  description: "Get info about the currently attached tab.",
35
50
  promptSnippet: "Get current tab info",
36
- promptGuidelines: ["Returns targetId, url, title."],
51
+ promptGuidelines: ["Returns targetId, url, title, and whether the tab is owned by this harness session."],
37
52
  parameters: Type.Object({}),
38
53
  async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
39
54
  const cur = client.current();
@@ -41,9 +56,10 @@ export const currentTabTool = defineBrowserTool({
41
56
  const ti = await client.session().callBrowser("Target.getTargetInfo", { targetId: cur.targetId });
42
57
  if (!ti.success) return err({ kind: "cdp_error", message: ti.error.message });
43
58
  const info = (ti.data as { targetInfo: { targetId: string; url: string; title: string } }).targetInfo; // CDP boundary cast: Target.getTargetInfo returns { targetInfo: { targetId, url, title } }
59
+ const owned = client.owns(info.targetId);
44
60
  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 },
61
+ text: `Current tab${owned ? " (owned)" : " (NOT owned by this session)"}:\n ${info.targetId}\n ${info.url}\n ${info.title}`,
62
+ details: { targetId: info.targetId, url: info.url, title: info.title, owned },
47
63
  });
48
64
  },
49
65
  });
@@ -52,47 +68,59 @@ const SwitchTabArgs = Type.Object({
52
68
  targetId: Type.String({ description: "Target ID from browser_list_tabs" }),
53
69
  });
54
70
 
71
+ const ownershipDeniedMessage = (id: string, namespace: string): string =>
72
+ `Tab ${id} is not owned by this harness session (namespace: ${namespace}). ` +
73
+ `Use browser_list_tabs with scope:'all' to see it, or browser_new_tab to open one this session can control.`;
74
+
55
75
  export const switchTabTool = defineBrowserTool({
56
76
  name: "browser_switch_tab",
57
77
  label: "Browser Switch Tab",
58
- description: "Switch to and attach to a different tab by targetId.",
78
+ description: "Switch to and attach to a different tab by targetId. Only tabs this harness session opened are accepted.",
59
79
  promptSnippet: "Switch tabs",
60
80
  promptGuidelines: [
61
81
  "Get a targetId via browser_list_tabs first.",
62
82
  "Accepts exact targetId or a unique prefix of at least 8 hex characters.",
83
+ "Refuses tabs not owned by this session — use browser_new_tab to open a controllable tab instead.",
63
84
  ],
64
85
  parameters: SwitchTabArgs,
65
86
  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}"` });
87
+ // Resolve to a concrete targetId (exact, or unique hex prefix). We resolve
88
+ // first so the ownership check sees the canonical id, not the prefix.
89
+ let resolved = args.targetId;
90
+ if (!client.owns(resolved)) {
91
+ const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
92
+ if (isHexPrefix) {
93
+ const tabs = await client.listTabs(true);
94
+ if (!tabs.success) return err({ kind: "cdp_error", message: tabs.error.message });
95
+ const matches = tabs.data.filter((t) => t.targetId.startsWith(args.targetId));
96
+ if (matches.length === 0) {
97
+ return err({ kind: "cdp_error", message: `No tab found with prefix "${args.targetId}"` });
98
+ }
99
+ if (matches.length > 1) {
100
+ const ids = matches.map((t) => t.targetId).join(", ");
101
+ return err({
102
+ kind: "invalid_state",
103
+ message: `Ambiguous prefix "${args.targetId}" matches ${matches.length} tabs: ${ids}`,
104
+ details: { matches: matches.map((t) => ({ targetId: t.targetId, url: t.url })) },
105
+ });
106
+ }
107
+ resolved = matches[0]!.targetId;
108
+ }
109
+ if (!client.owns(resolved)) {
110
+ return err({
111
+ kind: "invalid_state",
112
+ message: ownershipDeniedMessage(resolved, client.namespace),
113
+ details: { targetId: resolved, namespace: client.namespace },
114
+ });
115
+ }
81
116
  }
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);
117
+
118
+ const r = await client.switchTab(resolved);
92
119
  if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
120
+ const note = resolved === args.targetId ? "" : ` (resolved from prefix "${args.targetId}")`;
93
121
  return ok({
94
- text: `Switched to ${resolved} (resolved from prefix "${args.targetId}")`,
95
- details: { targetId: resolved, resolvedPrefix: args.targetId },
122
+ text: `Switched to ${resolved}${note}`,
123
+ details: { targetId: resolved },
96
124
  });
97
125
  },
98
126
  });
@@ -104,9 +132,12 @@ const NewTabArgs = Type.Object({
104
132
  export const newTabTool = defineBrowserTool({
105
133
  name: "browser_new_tab",
106
134
  label: "Browser New Tab",
107
- description: "Open a new tab and switch to it. Optionally navigate to a URL.",
135
+ description: "Open a new tab in the harness's dedicated Chrome window and switch to it. Optionally navigate to a URL.",
108
136
  promptSnippet: "Open a new tab",
109
- promptGuidelines: ["Pass url to navigate immediately."],
137
+ promptGuidelines: [
138
+ "All tabs opened by this tool live in a single dedicated Chrome window separate from the user's main browsing.",
139
+ "Pass url to navigate immediately.",
140
+ ],
110
141
  parameters: NewTabArgs,
111
142
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
112
143
  const r = await client.newTab(args.url);
@@ -119,3 +150,46 @@ export const newTabTool = defineBrowserTool({
119
150
  });
120
151
  },
121
152
  });
153
+
154
+ const CloseTabArgs = Type.Object({
155
+ targetId: Type.String({ description: "Target ID from browser_list_tabs" }),
156
+ });
157
+
158
+ export const closeTabTool = defineBrowserTool({
159
+ name: "browser_close_tab",
160
+ label: "Browser Close Tab",
161
+ description: "Close a tab the harness owns. Refuses tabs not opened by this session.",
162
+ promptSnippet: "Close an owned tab",
163
+ promptGuidelines: [
164
+ "Only closes tabs this session opened (visible in browser_list_tabs default scope).",
165
+ "Accepts exact targetId or a unique hex prefix (>=8 chars).",
166
+ ],
167
+ parameters: CloseTabArgs,
168
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
169
+ let resolved = args.targetId;
170
+ if (!client.owns(resolved)) {
171
+ const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
172
+ if (isHexPrefix) {
173
+ const owned = client.ownership().list();
174
+ const matches = owned.filter((id) => id.startsWith(args.targetId));
175
+ if (matches.length === 1) resolved = matches[0]!;
176
+ else if (matches.length > 1) {
177
+ return err({
178
+ kind: "invalid_state",
179
+ message: `Ambiguous prefix "${args.targetId}" matches ${matches.length} owned tabs: ${matches.join(", ")}`,
180
+ });
181
+ }
182
+ }
183
+ if (!client.owns(resolved)) {
184
+ return err({
185
+ kind: "invalid_state",
186
+ message: ownershipDeniedMessage(resolved, client.namespace),
187
+ details: { targetId: resolved, namespace: client.namespace },
188
+ });
189
+ }
190
+ }
191
+ const r = await client.closeTab(resolved);
192
+ if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
193
+ return ok({ text: `Closed tab ${resolved}`, details: { targetId: resolved } });
194
+ },
195
+ });
package/src/index.ts CHANGED
@@ -98,7 +98,23 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
98
98
 
99
99
  pi.on("session_start", async (_event, ctx) => {
100
100
  state = restoreState(ctx, state.namespace);
101
- client = createBrowserClient({ namespace: state.namespace });
101
+ const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
102
+ if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds;
103
+ if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId;
104
+ client = createBrowserClient({
105
+ namespace: state.namespace,
106
+ ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}),
107
+ onOwnershipChange: (snap) => {
108
+ state = {
109
+ ...state,
110
+ ownedTargetIds: snap.ownedTargetIds,
111
+ ...(snap.harnessWindowTargetId !== undefined
112
+ ? { harnessWindowTargetId: snap.harnessWindowTargetId }
113
+ : {}),
114
+ };
115
+ persistState(pi, state);
116
+ },
117
+ });
102
118
  // failure is fine — surfaced via tool errors when the agent first tries to use the browser
103
119
  await client.start();
104
120
  if (!toolsRegistered) {