feat(browser): add stable tab handles and automation skill

This commit is contained in:
Peter Steinberger
2026-04-25 00:23:29 +01:00
parent 86856b88e3
commit 45e2a15e29
18 changed files with 384 additions and 42 deletions

View File

@@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import {
@@ -82,6 +84,16 @@ describe("browser plugin", () => {
expect(browserSecurityAuditCollectors).toHaveLength(1);
});
it("bundles the browser automation skill with the plugin", () => {
const manifest = JSON.parse(
fs.readFileSync(path.join(__dirname, "openclaw.plugin.json"), "utf8"),
) as { skills?: string[] };
const skillPath = path.join(__dirname, "skills", "browser-automation", "SKILL.md");
expect(manifest.skills).toEqual(["./skills"]);
expect(fs.readFileSync(skillPath, "utf8")).toContain("name: browser-automation");
});
it("forwards per-session browser options into the tool factory", async () => {
const { api, registerTool } = createApi();
registerBrowserPlugin(api);

View File

@@ -1,6 +1,7 @@
{
"id": "browser",
"enabledByDefault": true,
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -0,0 +1,83 @@
---
name: browser-automation
description: Use when controlling web pages with the OpenClaw browser tool, especially multi-step flows, login checks, tab management, or recovery from stale refs/timeouts.
user-invocable: false
---
# Browser Automation
Use this skill when you need the `browser` tool for anything beyond a single page check.
## Operating Loop
1. Check browser state before acting:
- `action="status"` for availability.
- `action="profiles"` if login state or profile choice matters.
- `action="tabs"` before opening a new tab if retries/timeouts may have left windows behind.
2. Prefer stable tab handles:
- Open important tabs with `label`, for example `label="meet"`.
- Use `tabId` handles like `t1` or labels like `meet` as `targetId` in later calls.
- Avoid relying on raw DevTools `targetId` unless the tool just returned it.
3. Read before you click:
- Use `action="snapshot"` on the intended `targetId`.
- Use the same `targetId` for follow-up actions so refs stay on the same tab.
- For durable Playwright refs, request `refs="aria"` when supported.
4. Act narrowly:
- Prefer `action="act"` with a ref from the latest snapshot.
- After navigation, modal changes, or form submission, snapshot again before the next action.
- Avoid blind waits. Wait for visible UI state when possible.
5. Report real blockers:
- If the page needs login, permission, captcha, 2FA, camera/microphone approval, or another manual step, stop and tell the user exactly what is needed.
- Do not claim the browser is not logged in just because the current page shows a permission or onboarding dialog. Inspect the visible UI first.
## Tab Hygiene
Before creating a tab for a named task, list tabs and reuse an existing matching label or URL when it is still usable.
Example:
```json
{ "action": "tabs" }
```
If no suitable tab exists:
```json
{ "action": "open", "url": "https://example.com", "label": "task" }
```
Then target it by label:
```json
{ "action": "snapshot", "targetId": "task", "refs": "aria" }
```
If a retry creates duplicates, close the extras by `tabId`:
```json
{ "action": "close", "targetId": "t3" }
```
## Stale Ref Recovery
If an action fails with a missing or stale ref:
1. Snapshot the same `targetId` again.
2. Find the current visible control.
3. Retry once with the new ref.
4. If the UI moved to a blocker state, report the blocker instead of looping.
## Existing User Browser
Use `profile="user"` only when existing cookies/login matter. This attaches to the user's running Chromium-based browser.
For `profile="user"` and other existing-session profiles, omit `timeoutMs` on `act:type`, `evaluate`, `hover`, `scrollIntoView`, `drag`, `select`, and `fill`; that driver rejects per-call timeout overrides for those actions.
## Google Meet Notes
When creating or joining a Meet:
- Treat camera/microphone permission screens as progress, not login failure.
- If asked whether people can hear you, click the microphone option when voice is required.
- If Google asks for sign-in, 2FA, account chooser confirmation, or permission that needs user approval, report the exact manual action.
- Use one labeled tab per meeting flow, for example `label="meet"`, and reuse it during retries.

View File

@@ -93,6 +93,7 @@ export const BrowserToolSchema = Type.Object({
targetUrl: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
label: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
maxChars: Type.Optional(Type.Number()),
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES),

View File

@@ -308,6 +308,7 @@ describe("browser tool description", () => {
expect(tool.description).toContain('profile="user"');
expect(tool.description).toContain("omit timeoutMs on act:type");
expect(tool.description).toContain("existing-session profiles");
expect(tool.description).toContain("browser-automation skill");
});
});

View File

@@ -384,7 +384,8 @@ export function createBrowserTool(opts?: {
'For the logged-in user browser, use profile="user". A supported Chromium-based browser (v144+) must be running on the selected host or browser node. Use only when existing logins/cookies matter and the user is present.',
'For profile="user" or other existing-session profiles, omit timeoutMs on act:type, evaluate, hover, scrollIntoView, drag, select, and fill; that driver rejects per-call timeout overrides for those actions.',
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc). For tab operations, targetId also accepts tabId handles (t1) and labels from action=tabs.",
"For multi-step browser work, login checks, stale refs, duplicate tabs, or Google Meet flows, use the bundled browser-automation skill when it is available.",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
`target selects browser location (sandbox|host|node). Default: ${targetDefault}.`,
@@ -523,16 +524,20 @@ export function createBrowserTool(opts?: {
return await executeTabsAction({ baseUrl, profile, proxyRequest });
case "open": {
const targetUrl = readTargetUrlParam(params);
const label = normalizeOptionalString(params.label);
if (proxyRequest) {
const result = await proxyRequest({
method: "POST",
path: "/tabs/open",
profile,
body: { url: targetUrl },
body: { url: targetUrl, ...(label ? { label } : {}) },
});
return jsonResult(result);
}
const opened = await browserToolDeps.browserOpenTab(baseUrl, targetUrl, { profile });
const opened = await browserToolDeps.browserOpenTab(baseUrl, targetUrl, {
profile,
label,
});
browserToolDeps.trackSessionBrowserTab({
sessionKey: opts?.agentSessionKey,
targetId: opened.targetId,

View File

@@ -37,6 +37,21 @@ describe("browser target id resolution", () => {
expect(res).toEqual({ ok: true, targetId: "FULL" });
});
it("resolves exact tab ids and labels", () => {
expect(
resolveTargetIdFromTabs("t2", [
{ targetId: "AAA", tabId: "t1" },
{ targetId: "BBB", tabId: "t2", label: "docs" },
]),
).toEqual({ ok: true, targetId: "BBB" });
expect(
resolveTargetIdFromTabs("docs", [
{ targetId: "AAA", tabId: "t1" },
{ targetId: "BBB", tabId: "t2", label: "docs" },
]),
).toEqual({ ok: true, targetId: "BBB" });
});
it("resolves unique prefixes (case-insensitive)", () => {
const res = resolveTargetIdFromTabs("57a01309", [
{ targetId: "57A01309E14B5DEE0FB41F908515A2FC" },

View File

@@ -200,13 +200,13 @@ export async function browserTabs(
export async function browserOpenTab(
baseUrl: string | undefined,
url: string,
opts?: { profile?: string },
opts?: { profile?: string; label?: string },
): Promise<BrowserTab> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserTab>(withBaseUrl(baseUrl, `/tabs/open${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
body: JSON.stringify({ url, ...(opts?.label ? { label: opts.label } : {}) }),
timeoutMs: 15000,
});
}

View File

@@ -2,6 +2,10 @@ export type BrowserTransport = "cdp" | "chrome-mcp";
export type BrowserTab = {
targetId: string;
/** Stable, human-friendly tab handle for this profile runtime (for example t1). */
tabId?: string;
/** Optional user-assigned tab label. */
label?: string;
title: string;
url: string;
wsUrl?: string;

View File

@@ -72,6 +72,7 @@ describe("browser tab routes attachOnly loopback profiles", () => {
tabs: [
{
targetId: "PAGE-1",
tabId: "t1",
title: "WordPress",
url: "https://example.com/wp-login.php",
wsUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",

View File

@@ -93,6 +93,14 @@ function baseProfileContext() {
url: "https://example.com",
type: "page",
})),
labelTab: vi.fn(async (_targetId: string, label: string) => ({
targetId: "T1",
tabId: "t1",
label,
title: "Tab 1",
url: "https://example.com",
type: "page",
})),
focusTab: vi.fn(async () => {}),
closeTab: vi.fn(async () => {}),
stopRunningBrowser: vi.fn(async () => ({ stopped: false })),
@@ -118,6 +126,7 @@ function createRouteContext(profileCtx: ProfileContext, options?: { ssrfPolicy?:
isReachable: profileCtx.isReachable,
listTabs: profileCtx.listTabs,
openTab: profileCtx.openTab,
labelTab: profileCtx.labelTab,
focusTab: profileCtx.focusTab,
closeTab: profileCtx.closeTab,
stopRunningBrowser: profileCtx.stopRunningBrowser,
@@ -325,6 +334,29 @@ describe("browser tab routes", () => {
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
});
it("labels tabs by friendly target handles", async () => {
const profileCtx = createProfileContext();
const response = await callTabsAction({
body: { action: "label", targetId: "t1", label: "meet" },
profileCtx,
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
ok: true,
tab: {
targetId: "T1",
tabId: "t1",
label: "meet",
title: "Tab 1",
url: "https://example.com",
type: "page",
},
});
expect(profileCtx.labelTab).toHaveBeenCalledWith("t1", "meet");
});
it("redacts blocked tab URLs for /tabs/action list", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation(
async (opts?: { url: string }) => {

View File

@@ -121,6 +121,11 @@ function parseRequiredTargetId(res: BrowserResponse, rawTargetId: unknown): stri
return targetId;
}
function readOptionalTabLabel(body: unknown): string | undefined {
const label = toStringOrEmpty((body as { label?: unknown })?.label);
return label || undefined;
}
async function runTabTargetMutation(params: {
req: BrowserRequest;
res: BrowserResponse;
@@ -170,6 +175,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
"/tabs/open",
asyncBrowserRoute(async (req, res) => {
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
const label = readOptionalTabLabel(req.body);
if (!url) {
return jsonError(res, 400, "url is required");
}
@@ -185,7 +191,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
});
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab(url);
const tab = await profileCtx.openTab(url, { label });
res.json(tab);
},
});
@@ -275,7 +281,28 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
if (action === "new") {
await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab("about:blank");
const tab = await profileCtx.openTab("about:blank", {
label: readOptionalTabLabel(req.body),
});
return res.json({ ok: true, tab });
}
if (action === "label") {
if (!(await ensureBrowserRunning(profileCtx, res))) {
return;
}
const targetId = parseRequiredTargetId(
res,
(req.body as { targetId?: unknown })?.targetId,
);
if (!targetId) {
return;
}
const label = readOptionalTabLabel(req.body);
if (!label) {
return jsonError(res, 400, "label is required");
}
const tab = await profileCtx.labelTab(targetId, label);
return res.json({ ok: true, tab });
}

View File

@@ -59,6 +59,35 @@ describe("browser remote profile tab ops via Playwright", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("assigns stable tab ids and resolves labels", async () => {
const listPagesViaPlaywright = vi.fn(async () => [
page("A", "https://example.com"),
page("B", "https://docs.example.com"),
]);
const focusPageByTargetIdViaPlaywright = vi.fn(async () => {});
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue({
listPagesViaPlaywright,
focusPageByTargetIdViaPlaywright,
} as unknown as Awaited<ReturnType<typeof deps.pwAiModule.getPwAiModule>>);
const { remote } = deps.createRemoteRouteHarness();
const tabs = await remote.listTabs();
expect(tabs.map((tab) => [tab.targetId, tab.tabId])).toEqual([
["A", "t1"],
["B", "t2"],
]);
const labeled = await remote.labelTab("t2", "docs");
expect(labeled).toMatchObject({ targetId: "B", tabId: "t2", label: "docs" });
await remote.focusTab("docs");
expect(focusPageByTargetIdViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({ targetId: "B" }),
);
});
it("prefers lastTargetId for remote profiles when targetId is omitted", async () => {
const responses = [
[

View File

@@ -9,6 +9,7 @@ import {
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import { getChromeMcpModule } from "./chrome-mcp.runtime.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
@@ -29,6 +30,7 @@ import type {
BrowserTab,
ProfileRuntimeState,
} from "./server-context.types.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
type TabOpsDeps = {
profile: ResolvedBrowserProfile;
@@ -38,7 +40,8 @@ type TabOpsDeps = {
type ProfileTabOps = {
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
openTab: (url: string, opts?: { label?: string }) => Promise<BrowserTab>;
labelTab: (targetId: string, label: string) => Promise<BrowserTab>;
};
/**
@@ -63,6 +66,58 @@ type CdpTarget = {
type?: string;
};
const TAB_LABEL_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
function normalizeTabLabel(label: string): string {
const trimmed = label.trim();
if (!TAB_LABEL_PATTERN.test(trimmed)) {
throw new Error("tab label must be 1-64 chars and use only letters, numbers, _, ., :, or -");
}
return trimmed;
}
function getTabAliasState(
profileState: ProfileRuntimeState,
): NonNullable<ProfileRuntimeState["tabAliases"]> {
profileState.tabAliases ??= { nextTabNumber: 1, byTargetId: {} };
return profileState.tabAliases;
}
function assignTabAlias(params: {
profileState: ProfileRuntimeState;
tab: BrowserTab;
label?: string;
}): BrowserTab {
const aliases = getTabAliasState(params.profileState);
let entry = aliases.byTargetId[params.tab.targetId];
if (!entry) {
entry = { tabId: `t${aliases.nextTabNumber}` };
aliases.nextTabNumber += 1;
aliases.byTargetId[params.tab.targetId] = entry;
}
if (params.label) {
const label = normalizeTabLabel(params.label);
for (const [targetId, current] of Object.entries(aliases.byTargetId)) {
if (targetId !== params.tab.targetId && current.label === label) {
delete current.label;
}
}
entry.label = label;
}
return { ...params.tab, tabId: entry.tabId, ...(entry.label ? { label: entry.label } : {}) };
}
function assignTabAliases(profileState: ProfileRuntimeState, tabs: BrowserTab[]): BrowserTab[] {
const aliases = getTabAliasState(profileState);
const liveTargetIds = new Set(tabs.map((tab) => tab.targetId));
for (const targetId of Object.keys(aliases.byTargetId)) {
if (!liveTargetIds.has(targetId)) {
delete aliases.byTargetId[targetId];
}
}
return tabs.map((tab) => assignTabAlias({ profileState, tab }));
}
export function createProfileTabOps({
profile,
state,
@@ -72,7 +127,7 @@ export function createProfileTabOps({
const capabilities = getBrowserProfileCapabilities(profile);
const getCdpControlPolicy = () => resolveCdpControlPolicy(profile, state().resolved.ssrfPolicy);
const listTabs = async (): Promise<BrowserTab[]> => {
const readTabs = async (): Promise<BrowserTab[]> => {
if (capabilities.usesChromeMcp) {
const { listChromeMcpTabs } = await getChromeMcpModule();
return await listChromeMcpTabs(profile.name, profile.userDataDir);
@@ -114,6 +169,11 @@ export function createProfileTabOps({
.filter((t) => Boolean(t.targetId));
};
const listTabs = async (): Promise<BrowserTab[]> => {
const tabs = await readTabs();
return assignTabAliases(getProfileState(), tabs);
};
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
const profileState = getProfileState();
if (
@@ -151,7 +211,7 @@ export function createProfileTabOps({
});
};
const openTab = async (url: string): Promise<BrowserTab> => {
const openTab = async (url: string, opts?: { label?: string }): Promise<BrowserTab> => {
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
if (capabilities.usesChromeMcp) {
@@ -161,7 +221,7 @@ export function createProfileTabOps({
const profileState = getProfileState();
profileState.lastTargetId = page.targetId;
await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });
return page;
return assignTabAlias({ profileState, tab: page, label: opts?.label });
}
if (capabilities.usesPersistentPlaywright) {
@@ -176,12 +236,16 @@ export function createProfileTabOps({
const profileState = getProfileState();
profileState.lastTargetId = page.targetId;
triggerManagedTabLimit(page.targetId);
return {
targetId: page.targetId,
title: page.title,
url: page.url,
type: page.type,
};
return assignTabAlias({
profileState,
label: opts?.label,
tab: {
targetId: page.targetId,
title: page.title,
url: page.url,
type: page.type,
},
});
}
}
@@ -209,12 +273,16 @@ export function createProfileTabOps({
if (found) {
await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts });
triggerManagedTabLimit(found.targetId);
return found;
return assignTabAlias({ profileState, tab: found, label: opts?.label });
}
await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));
}
triggerManagedTabLimit(createdViaCdp);
return { targetId: createdViaCdp, title: "", url, type: "page" };
return assignTabAlias({
profileState,
tab: { targetId: createdViaCdp, title: "", url, type: "page" },
label: opts?.label,
});
}
const encoded = encodeURIComponent(url);
@@ -253,17 +321,39 @@ export function createProfileTabOps({
const resolvedUrl = created.url ?? url;
await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts });
triggerManagedTabLimit(created.id);
return {
targetId: created.id,
title: created.title ?? "",
url: resolvedUrl,
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
type: created.type,
};
return assignTabAlias({
profileState,
label: opts?.label,
tab: {
targetId: created.id,
title: created.title ?? "",
url: resolvedUrl,
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
type: created.type,
},
});
};
const labelTab = async (targetId: string, label: string): Promise<BrowserTab> => {
const normalizedLabel = normalizeTabLabel(label);
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new BrowserTargetAmbiguousError();
}
throw new BrowserTabNotFoundError();
}
const tab = tabs.find((candidate) => candidate.targetId === resolved.targetId);
if (!tab) {
throw new BrowserTabNotFoundError();
}
return assignTabAlias({ profileState: getProfileState(), tab, label: normalizedLabel });
};
return {
listTabs,
openTab,
labelTab,
};
}

View File

@@ -73,7 +73,7 @@ function createProfileContext(
profileState.running = running;
};
const { listTabs, openTab } = createProfileTabOps({
const { listTabs, openTab, labelTab } = createProfileTabOps({
profile,
state,
getProfileState,
@@ -113,6 +113,7 @@ function createProfileContext(
isReachable,
listTabs,
openTab,
labelTab,
focusTab,
closeTab,
stopRunningBrowser,
@@ -252,7 +253,8 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs),
isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs),
listTabs: () => getDefaultContext().listTabs(),
openTab: (url) => getDefaultContext().openTab(url),
openTab: (url, opts) => getDefaultContext().openTab(url, opts),
labelTab: (targetId, label) => getDefaultContext().labelTab(targetId, label),
focusTab: (targetId) => getDefaultContext().focusTab(targetId),
closeTab: (targetId) => getDefaultContext().closeTab(targetId),
stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(),

View File

@@ -13,6 +13,11 @@ export type ProfileRuntimeState = {
running: RunningChrome | null;
/** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */
lastTargetId?: string | null;
/** Stable, user-facing tab aliases scoped to this profile runtime. */
tabAliases?: {
nextTabNumber: number;
byTargetId: Record<string, { tabId: string; label?: string }>;
};
reconcile?: {
previousProfile: ResolvedBrowserProfile;
reason: string;
@@ -32,7 +37,8 @@ type BrowserProfileActions = {
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
openTab: (url: string, opts?: { label?: string }) => Promise<BrowserTab>;
labelTab: (targetId: string, label: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;

View File

@@ -6,14 +6,14 @@ export type TargetIdResolution =
export function resolveTargetIdFromTabs(
input: string,
tabs: Array<{ targetId: string }>,
tabs: Array<{ targetId: string; tabId?: string; label?: string }>,
): TargetIdResolution {
const needle = input.trim();
if (!needle) {
return { ok: false, reason: "not_found" };
}
const exact = tabs.find((t) => t.targetId === needle);
const exact = tabs.find((t) => t.targetId === needle || t.tabId === needle || t.label === needle);
if (exact) {
return { ok: true, targetId: exact.targetId };
}

View File

@@ -33,7 +33,10 @@ function printJsonResult(parent: BrowserParentOpts, payload: unknown): boolean {
async function callTabAction(
parent: BrowserParentOpts,
profile: string | undefined,
body: { action: "new" | "select" | "close"; index?: number },
body:
| { action: "new"; label?: string }
| { action: "select" | "close"; index?: number }
| { action: "label"; targetId: string; label: string },
) {
return callBrowserRequest(
parent,
@@ -99,7 +102,10 @@ function logBrowserTabs(tabs: BrowserTab[], json?: boolean) {
}
defaultRuntime.log(
tabs
.map((t, i) => `${i + 1}. ${t.title || "(untitled)"}\n ${t.url}\n id: ${t.targetId}`)
.map((t, i) => {
const alias = [t.tabId, t.label ? `label:${t.label}` : undefined].filter(Boolean).join(" ");
return `${i + 1}. ${t.title || "(untitled)"}${alias ? ` [${alias}]` : ""}\n ${t.url}\n id: ${t.targetId}`;
})
.join("\n"),
);
}
@@ -271,15 +277,39 @@ export function registerBrowserManageCommands(
tab
.command("new")
.description("Open a new tab (about:blank)")
.action(async (_opts, cmd) => {
.option("--label <label>", "Assign a friendly tab label")
.action(async (opts: { label?: string }, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callTabAction(parent, profile, { action: "new" });
const result = await callTabAction(parent, profile, { action: "new", label: opts.label });
if (printJsonResult(parent, result)) {
return;
}
defaultRuntime.log("opened new tab");
const opened = (result as { tab?: BrowserTab }).tab;
defaultRuntime.log(
opened?.tabId
? `opened new tab ${opened.tabId}${opened.label ? ` (${opened.label})` : ""}`
: "opened new tab",
);
});
});
tab
.command("label")
.description("Assign a friendly label to a tab")
.argument("<targetId>", "Target id, tab id, label, or unique target id prefix")
.argument("<label>", "Friendly label")
.action(async (targetId: string, label: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
const result = await callTabAction(parent, profile, { action: "label", targetId, label });
if (printJsonResult(parent, result)) {
return;
}
const tab = (result as { tab?: BrowserTab }).tab;
defaultRuntime.log(`labeled tab ${tab?.tabId ?? targetId} as ${tab?.label ?? label}`);
});
});
@@ -334,7 +364,8 @@ export function registerBrowserManageCommands(
.command("open")
.description("Open a URL in a new tab")
.argument("<url>", "URL to open")
.action(async (url: string, _opts, cmd) => {
.option("--label <label>", "Assign a friendly tab label")
.action(async (url: string, opts: { label?: string }, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
@@ -344,21 +375,23 @@ export function registerBrowserManageCommands(
method: "POST",
path: "/tabs/open",
query: resolveProfileQuery(profile),
body: { url },
body: { url, ...(opts.label ? { label: opts.label } : {}) },
},
{ timeoutMs: BROWSER_MANAGE_REQUEST_TIMEOUT_MS },
);
if (printJsonResult(parent, tab)) {
return;
}
defaultRuntime.log(`opened: ${tab.url}\nid: ${tab.targetId}`);
defaultRuntime.log(
`opened: ${tab.url}\n${tab.tabId ? `tab: ${tab.tabId}\n` : ""}${tab.label ? `label: ${tab.label}\n` : ""}id: ${tab.targetId}`,
);
});
});
browser
.command("focus")
.description("Focus a tab by target id (or unique prefix)")
.argument("<targetId>", "Target id or unique prefix")
.description("Focus a tab by target id, tab id, label, or unique target id prefix")
.argument("<targetId>", "Target id, tab id, label, or unique target id prefix")
.action(async (targetId: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
@@ -383,7 +416,7 @@ export function registerBrowserManageCommands(
browser
.command("close")
.description("Close a tab (target id optional)")
.argument("[targetId]", "Target id or unique prefix (optional)")
.argument("[targetId]", "Target id, tab id, label, or unique target id prefix (optional)")
.action(async (targetId: string | undefined, _opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;