aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-05 23:23:59 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-05 23:23:59 +0530
commit20ceaa1c6ade4950ed7d16caa5269ad2f4805db4 (patch)
treedf4459a67b08533704f0dd2a1c85010578471917 /src
parente42d9538ad5dafd4e11cf8f5a3912ebf0763d770 (diff)
feat: tab ownership — isolate harness tabs from the user's browsing
Track which page targets this harness session opened so the agent can never attach to, switch to, or close a tab the user opened themselves. New tabs are spawned in a dedicated Chrome window (newWindow:true on first open, openerId chaining after) so harness activity stays visually separate. - new OwnershipRegistry (src/cdp/ownership.ts) tracks owned targetIds and the harness window seed; mutations notify a single listener that persists to BrowserState (ownedTargetIds, harnessWindowTargetId) - attachFirstPage prefers an already-owned live target; otherwise opens a fresh harness-owned tab in its own window instead of grabbing whatever page the user is on - session subscribes Target.setDiscoverTargets and reaps targetDestroyed events so the registry stays in sync with reality - listTabs returns owned:boolean per tab and reconciles dead ids - browser_list_tabs defaults to scope:"owned"; pass scope:"all" to inspect the user's other tabs (read-only — they cannot be switched to or closed) - browser_switch_tab refuses non-owned tabs, with a clear remediation hint - browser_new_tab opens inside the harness window via openerId, falling back to a fresh window when the seed has been closed - new browser_close_tab tool — owned tabs only, supports unique hex prefix Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/cdp/ownership.ts72
-rw-r--r--src/cdp/session.ts59
-rw-r--r--src/cdp/types.ts1
-rw-r--r--src/client.ts79
-rw-r--r--src/domains/tabs.ts152
-rw-r--r--src/index.ts18
-rw-r--r--src/registry.ts3
-rw-r--r--src/state.ts7
8 files changed, 332 insertions, 59 deletions
diff --git a/src/cdp/ownership.ts b/src/cdp/ownership.ts
new file mode 100644
index 0000000..f221d6a
--- /dev/null
+++ b/src/cdp/ownership.ts
@@ -0,0 +1,72 @@
+/**
+ * Tracks which tab targetIds belong to this harness session.
+ *
+ * The harness attaches to the user's running Chrome, which is also full of
+ * the user's own tabs. Without an ownership boundary the agent could attach
+ * to (or close) personal tabs by accident. The registry is the single source
+ * of truth for "did this session open that tab?".
+ *
+ * Mutations notify a single change listener so the orchestrator can persist
+ * to disk via the existing BrowserState flow without the registry needing to
+ * know about the pi storage API.
+ */
+
+export type OwnershipRegistry = {
+ add(targetId: string): void;
+ remove(targetId: string): void;
+ has(targetId: string): boolean;
+ list(): ReadonlyArray<string>;
+ /** Replace the full set — used on restoreState to re-hydrate from disk. */
+ replaceAll(ids: ReadonlyArray<string>): void;
+ setHarnessWindow(targetId: string | undefined): void;
+ harnessWindow(): string | undefined;
+ onChange(cb: () => void): void;
+};
+
+export const createOwnershipRegistry = (
+ initial?: { readonly ownedTargetIds?: ReadonlyArray<string>; readonly harnessWindowTargetId?: string },
+): OwnershipRegistry => {
+ const owned = new Set<string>(initial?.ownedTargetIds ?? []);
+ let harnessWindow: string | undefined = initial?.harnessWindowTargetId;
+ let listener: (() => void) | null = null;
+
+ const notify = (): void => {
+ if (listener) listener();
+ };
+
+ return {
+ add(targetId) {
+ if (owned.has(targetId)) return;
+ owned.add(targetId);
+ notify();
+ },
+ remove(targetId) {
+ if (!owned.has(targetId) && harnessWindow !== targetId) return;
+ owned.delete(targetId);
+ if (harnessWindow === targetId) harnessWindow = undefined;
+ notify();
+ },
+ has(targetId) {
+ return owned.has(targetId);
+ },
+ list() {
+ return [...owned];
+ },
+ replaceAll(ids) {
+ owned.clear();
+ for (const id of ids) owned.add(id);
+ notify();
+ },
+ setHarnessWindow(targetId) {
+ if (harnessWindow === targetId) return;
+ harnessWindow = targetId;
+ notify();
+ },
+ harnessWindow() {
+ return harnessWindow;
+ },
+ onChange(cb) {
+ listener = cb;
+ },
+ };
+};
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index b1ae71c..42f6107 100644
--- a/src/cdp/session.ts
+++ b/src/cdp/session.ts
@@ -1,7 +1,7 @@
-import { type Result, err, ok } from "../util/result";
-import { type CdpError, cdpError } from "./errors";
+import { type Result, ok } from "../util/result";
+import type { CdpError } from "./errors";
import type { DialogInfo } from "./types";
-import { isInternalUrl } from "./types";
+import type { OwnershipRegistry } from "./ownership";
import type { CdpTransport } from "./transport";
export type CdpSession = {
@@ -15,7 +15,10 @@ export type CdpSession = {
drainPageInfoInvalidations(): boolean;
};
-export const createCdpSession = (transport: CdpTransport): CdpSession => {
+export const createCdpSession = (
+ transport: CdpTransport,
+ ownership?: OwnershipRegistry,
+): CdpSession => {
let sessionId: string | null = null;
let targetId: string | null = null;
let dialog: DialogInfo | null = null;
@@ -41,6 +44,10 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
pageInfoDirty = true;
}
+ if (ev.method === "Target.targetDestroyed" && ownership) {
+ const params = ev.params as { targetId?: string } | undefined;
+ if (params?.targetId) ownership.remove(params.targetId);
+ }
}
};
@@ -81,25 +88,51 @@ export const createCdpSession = (transport: CdpTransport): CdpSession => {
// as a clear Error in normal use.
return {
async attachFirstPage() {
+ // Subscribe to Target.* events so we can react to targetDestroyed.
+ // Best-effort: failing to enable discovery is not fatal for attach.
+ await transport.request("Target.setDiscoverTargets", { discover: true }, { sessionId: null });
+
const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
if (!targets.success) return targets;
const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
- let pages = data.targetInfos.filter((t) => t.type === "page" && !isInternalUrl(t.url));
- if (pages.length === 0) {
- const created = await transport.request("Target.createTarget", { url: "about:blank" }, { sessionId: null });
+ const allPages = data.targetInfos.filter((t) => t.type === "page");
+ // Reconcile the persisted ownership set against live targets — drop dead IDs.
+ if (ownership) {
+ const live = new Set(allPages.map((p) => p.targetId));
+ const survivors = ownership.list().filter((id) => live.has(id));
+ if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
+ const hw = ownership.harnessWindow();
+ if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
+ }
+
+ // Prefer attaching to a tab this session already owns. Falls back to
+ // creating a fresh harness-owned tab in a dedicated window — never
+ // grabs the user's foreground tab.
+ let pickTargetId: string | undefined;
+ if (ownership) {
+ const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
+ pickTargetId = ownedLive[0];
+ }
+ if (!pickTargetId) {
+ const createParams: Record<string, unknown> = { url: "about:blank" };
+ if (ownership) createParams["newWindow"] = true;
+ const created = await transport.request("Target.createTarget", createParams, { sessionId: null });
if (!created.success) return created;
const c = created.data as { targetId: string };
- pages = [{ targetId: c.targetId, type: "page", url: "about:blank" }];
+ pickTargetId = c.targetId;
+ if (ownership) {
+ ownership.setHarnessWindow(c.targetId);
+ ownership.add(c.targetId);
+ }
}
- const first = pages[0];
- if (!first) return err(cdpError("invalid_response", "no page targets after creation"));
- const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null });
+
+ const attached = await transport.request("Target.attachToTarget", { targetId: pickTargetId, flatten: true }, { sessionId: null });
if (!attached.success) return attached;
const a = attached.data as { sessionId: string };
sessionId = a.sessionId;
- targetId = first.targetId;
+ targetId = pickTargetId;
await enableDomains(a.sessionId);
- return ok({ targetId: first.targetId, sessionId: a.sessionId });
+ return ok({ targetId: pickTargetId, sessionId: a.sessionId });
},
async switchTo(tid) {
const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
diff --git a/src/cdp/types.ts b/src/cdp/types.ts
index 2bcc352..d57255f 100644
--- a/src/cdp/types.ts
+++ b/src/cdp/types.ts
@@ -23,6 +23,7 @@ export type TabInfo = {
readonly targetId: string;
readonly title: string;
readonly url: string;
+ readonly owned?: boolean;
};
export type PageInfo = {
diff --git a/src/client.ts b/src/client.ts
index 94b6ac0..2e451e8 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -6,11 +6,20 @@ import { type CdpError, cdpError } from "./cdp/errors";
import type { CdpTransport } from "./cdp/transport";
import { createCdpTransport } from "./cdp/transport";
import { type CdpSession, createCdpSession } from "./cdp/session";
+import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
export type BrowserClientOptions = {
readonly namespace: string;
readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
+ readonly initialOwnership?: {
+ readonly ownedTargetIds?: ReadonlyArray<string>;
+ readonly harnessWindowTargetId?: string;
+ };
+ readonly onOwnershipChange?: (snapshot: {
+ readonly ownedTargetIds: ReadonlyArray<string>;
+ readonly harnessWindowTargetId: string | undefined;
+ }) => void;
};
export type BrowserClient = {
@@ -25,6 +34,9 @@ export type BrowserClient = {
listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
switchTab(targetId: string): Promise<Result<void, CdpError>>;
newTab(url?: string): Promise<Result<string, CdpError>>;
+ closeTab(targetId: string): Promise<Result<void, CdpError>>;
+ owns(targetId: string): boolean;
+ ownership(): OwnershipRegistry;
current(): { readonly sessionId: string; readonly targetId: string } | null;
session(): CdpSession;
transport(): CdpTransport;
@@ -66,7 +78,24 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
const transport = createCdpTransport();
- const session = createCdpSession(transport);
+ const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
+ if (opts.initialOwnership?.ownedTargetIds !== undefined) {
+ ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
+ }
+ if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
+ ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
+ }
+ const ownership = createOwnershipRegistry(ownershipInit);
+ if (opts.onOwnershipChange) {
+ const cb = opts.onOwnershipChange;
+ ownership.onChange(() => {
+ cb({
+ ownedTargetIds: ownership.list(),
+ harnessWindowTargetId: ownership.harnessWindow(),
+ });
+ });
+ }
+ const session = createCdpSession(transport, ownership);
const mutationMutex = createMutex();
let lastHealth = 0;
let pageCache: { readonly info: PageInfo; readonly at: number } | null = null;
@@ -186,7 +215,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
const tabs = data.targetInfos
.filter((t) => t.type === "page")
.filter((t) => includeInternal || !t.url.startsWith("chrome://"))
- .map((t): TabInfo => ({ targetId: t.targetId, title: t.title, url: t.url }));
+ .map((t): TabInfo => ({
+ targetId: t.targetId,
+ title: t.title,
+ url: t.url,
+ owned: ownership.has(t.targetId),
+ }));
+ // Reconcile any persisted owned ids that no longer exist as live page targets.
+ const live = new Set(tabs.map((t) => t.targetId));
+ const owned = ownership.list();
+ const survivors = owned.filter((id) => live.has(id));
+ if (survivors.length !== owned.length) ownership.replaceAll(survivors);
+ const hw = ownership.harnessWindow();
+ if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
return ok(tabs);
};
@@ -204,9 +245,30 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
};
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
- const created = await session.callBrowser("Target.createTarget", { url: "about:blank" });
+ // Verify the recorded harness window still exists. Querying the live
+ // target list also gives listTabs's reconciliation a chance to run.
+ const tabsResult = await listTabs(true);
+ if (!tabsResult.success) return tabsResult;
+ const live = new Set(tabsResult.data.map((t) => t.targetId));
+ const hw = ownership.harnessWindow();
+
+ const params: Record<string, unknown> = { url: "about:blank" };
+ if (hw && live.has(hw)) {
+ // Open as a child of the harness window's seed tab — Chrome places
+ // it in the same window, giving us visual grouping for free.
+ params["openerId"] = hw;
+ } else {
+ // Either fresh session or the harness window was closed by the user.
+ // Spawn a new dedicated window.
+ params["newWindow"] = true;
+ }
+ const created = await session.callBrowser("Target.createTarget", params);
if (!created.success) return created;
const c = created.data as { targetId: string };
+
+ if (params["newWindow"]) ownership.setHarnessWindow(c.targetId);
+ ownership.add(c.targetId);
+
const switched = await switchTab(c.targetId);
if (!switched.success) return switched;
if (url && url !== "about:blank") {
@@ -217,6 +279,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
return ok(c.targetId);
};
+ const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
+ const r = await session.callBrowser("Target.closeTarget", { targetId });
+ if (!r.success) return r;
+ ownership.remove(targetId);
+ return ok(undefined);
+ };
+
const status = (): DaemonStatus => ({
alive: transport.state() === "open" && session.current() !== null,
sessionId: session.current()?.sessionId ?? null,
@@ -229,7 +298,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
ensureAlive, status, start, stop,
evaluateJs, pageInfo,
takeDialog: () => session.takeDialog(),
- listTabs, switchTab, newTab,
+ listTabs, switchTab, newTab, closeTab,
+ owns: (id: string) => ownership.has(id),
+ ownership: () => ownership,
current: () => session.current(),
session: () => session,
transport: () => transport,
diff --git a/src/domains/tabs.ts b/src/domains/tabs.ts
index d2cd646..f706100 100644
--- a/src/domains/tabs.ts
+++ b/src/domains/tabs.ts
@@ -4,26 +4,41 @@ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
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 all open browser tabs (page targets).",
+ 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: [
- "Use to find a full targetId for browser_switch_tab.",
- "Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.",
+ "Defaults to scope:'owned' — only tabs this session created. Pass scope:'all' to inspect the user's other tabs.",
+ "browser_switch_tab and browser_close_tab refuse non-owned tabs; use browser_new_tab to open one this session controls.",
"Each tab shows its full 32-char hex targetId — use the exact value with browser_switch_tab.",
+ "Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.",
],
parameters: ListTabsArgs,
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 lines = r.data.map((t, i) => ` [${i}] ${t.targetId} ${t.url}\n ${t.title}`);
+ 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: `Tabs (${r.data.length}):\n${lines.join("\n")}`,
- details: { tabs: r.data },
+ text: `${header}\n${lines.join("\n")}`,
+ details: { tabs: filtered, scope },
});
},
});
@@ -33,7 +48,7 @@ export const currentTabTool = defineBrowserTool({
label: "Browser Current Tab",
description: "Get info about the currently attached tab.",
promptSnippet: "Get current tab info",
- promptGuidelines: ["Returns targetId, url, title."],
+ promptGuidelines: ["Returns targetId, url, title, and whether the tab is owned by this harness session."],
parameters: Type.Object({}),
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
const cur = client.current();
@@ -41,9 +56,10 @@ export const currentTabTool = defineBrowserTool({
const ti = await client.session().callBrowser("Target.getTargetInfo", { targetId: cur.targetId });
if (!ti.success) return err({ kind: "cdp_error", message: ti.error.message });
const info = (ti.data as { targetInfo: { targetId: string; url: string; title: string } }).targetInfo; // CDP boundary cast: Target.getTargetInfo returns { targetInfo: { targetId, url, title } }
+ const owned = client.owns(info.targetId);
return ok({
- text: `Current tab:\n ${info.targetId}\n ${info.url}\n ${info.title}`,
- details: { targetId: info.targetId, url: info.url, title: info.title },
+ 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 },
});
},
});
@@ -52,47 +68,59 @@ 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.",
+ 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.",
+ "Refuses tabs not owned by this session — use browser_new_tab to open a controllable tab instead.",
],
parameters: SwitchTabArgs,
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- // Try exact match first
- let r = await client.switchTab(args.targetId);
- if (r.success) {
- return ok({ text: `Switched to ${args.targetId}`, details: { targetId: args.targetId } });
- }
- // If exact match failed and input looks like a short prefix (≥8 hex chars), try prefix resolution
- const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId);
- if (!isHexPrefix) {
- return err({ kind: "cdp_error", message: r.error.message });
- }
- const tabs = await client.listTabs(true);
- if (!tabs.success) return err({ kind: "cdp_error", message: r.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}"` });
+ // Resolve to a concrete targetId (exact, or unique hex prefix). We resolve
+ // first so the ownership check sees the canonical id, not the prefix.
+ 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 })) },
+ });
+ }
+ resolved = matches[0]!.targetId;
+ }
+ if (!client.owns(resolved)) {
+ return err({
+ kind: "invalid_state",
+ message: ownershipDeniedMessage(resolved, client.namespace),
+ details: { targetId: resolved, namespace: client.namespace },
+ });
+ }
}
- 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 resolved = matches[0]!.targetId;
- r = await client.switchTab(resolved);
+
+ 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} (resolved from prefix "${args.targetId}")`,
- details: { targetId: resolved, resolvedPrefix: args.targetId },
+ text: `Switched to ${resolved}${note}`,
+ details: { targetId: resolved },
});
},
});
@@ -104,9 +132,12 @@ const NewTabArgs = Type.Object({
export const newTabTool = defineBrowserTool({
name: "browser_new_tab",
label: "Browser New Tab",
- description: "Open a new tab and switch to it. Optionally navigate to a URL.",
+ 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: ["Pass url to navigate immediately."],
+ promptGuidelines: [
+ "All tabs opened by this tool live in a single dedicated Chrome window separate from the user's main browsing.",
+ "Pass url to navigate immediately.",
+ ],
parameters: NewTabArgs,
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.newTab(args.url);
@@ -119,3 +150,46 @@ export const newTabTool = defineBrowserTool({
});
},
});
+
+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: [
+ "Only closes tabs this session opened (visible in browser_list_tabs default scope).",
+ "Accepts exact targetId or a unique hex prefix (>=8 chars).",
+ ],
+ parameters: CloseTabArgs,
+ 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));
+ if (matches.length === 1) resolved = matches[0]!;
+ 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 } });
+ },
+});
diff --git a/src/index.ts b/src/index.ts
index ee59f10..c2aa2d0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -98,7 +98,23 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
pi.on("session_start", async (_event, ctx) => {
state = restoreState(ctx, state.namespace);
- client = createBrowserClient({ namespace: state.namespace });
+ const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
+ if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds;
+ if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId;
+ client = createBrowserClient({
+ namespace: state.namespace,
+ ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}),
+ onOwnershipChange: (snap) => {
+ state = {
+ ...state,
+ ownedTargetIds: snap.ownedTargetIds,
+ ...(snap.harnessWindowTargetId !== undefined
+ ? { harnessWindowTargetId: snap.harnessWindowTargetId }
+ : {}),
+ };
+ persistState(pi, state);
+ },
+ });
// failure is fine — surfaced via tool errors when the agent first tries to use the browser
await client.start();
if (!toolsRegistered) {
diff --git a/src/registry.ts b/src/registry.ts
index d9b7cea..d205bf6 100644
--- a/src/registry.ts
+++ b/src/registry.ts
@@ -9,7 +9,7 @@ import { handleDialogTool } from "./domains/dialog";
import { screenshotTool } from "./domains/screenshot";
import { navigateTool, openUrlsTool } from "./domains/navigate";
import { goBackTool, goForwardTool, reloadTool } from "./domains/history";
-import { listTabsTool, currentTabTool, switchTabTool, newTabTool } from "./domains/tabs";
+import { listTabsTool, currentTabTool, switchTabTool, newTabTool, closeTabTool } from "./domains/tabs";
import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files";
import { viewportResizeTool } from "./domains/viewport";
import { dragAndDropTool } from "./domains/drag";
@@ -39,6 +39,7 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
currentTabTool,
{ ...switchTabTool, serialized: true },
{ ...newTabTool, serialized: true },
+ { ...closeTabTool, serialized: true },
{ ...uploadFileTool, serialized: true },
{ ...downloadTool, serialized: true },
{ ...printToPdfTool, serialized: true },
diff --git a/src/state.ts b/src/state.ts
index 9dcef69..0629f40 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -10,9 +10,14 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
export type BrowserState = {
readonly namespace: string;
readonly remoteBrowserId?: string;
+ readonly ownedTargetIds?: ReadonlyArray<string>;
+ readonly harnessWindowTargetId?: string;
};
-export const defaultState = (namespace = "default"): BrowserState => ({ namespace });
+export const defaultState = (namespace = "default"): BrowserState => ({
+ namespace,
+ ownedTargetIds: [],
+});
export const persistState = (pi: ExtensionAPI, state: BrowserState): void => {
pi.appendEntry<BrowserState>("browser-harness-state", state);