diff --git a/extensions/browser/index.test.ts b/extensions/browser/index.test.ts index 29181aa4f53..a20fa9b9928 100644 --- a/extensions/browser/index.test.ts +++ b/extensions/browser/index.test.ts @@ -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); diff --git a/extensions/browser/openclaw.plugin.json b/extensions/browser/openclaw.plugin.json index ec319d9fab4..629f589b36d 100644 --- a/extensions/browser/openclaw.plugin.json +++ b/extensions/browser/openclaw.plugin.json @@ -1,6 +1,7 @@ { "id": "browser", "enabledByDefault": true, + "skills": ["./skills"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/browser/skills/browser-automation/SKILL.md b/extensions/browser/skills/browser-automation/SKILL.md new file mode 100644 index 00000000000..b15f4367571 --- /dev/null +++ b/extensions/browser/skills/browser-automation/SKILL.md @@ -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. diff --git a/extensions/browser/src/browser-tool.schema.ts b/extensions/browser/src/browser-tool.schema.ts index f652ebb96a8..f1cebf5883b 100644 --- a/extensions/browser/src/browser-tool.schema.ts +++ b/extensions/browser/src/browser-tool.schema.ts @@ -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), diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index f5ea4dbac60..f526912ddcf 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -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"); }); }); diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index 340817f1d89..cc08742e1f3 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -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= 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, diff --git a/extensions/browser/src/browser/browser-utils.test.ts b/extensions/browser/src/browser/browser-utils.test.ts index 5bd45952321..3e05e3dc038 100644 --- a/extensions/browser/src/browser/browser-utils.test.ts +++ b/extensions/browser/src/browser/browser-utils.test.ts @@ -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" }, diff --git a/extensions/browser/src/browser/client.ts b/extensions/browser/src/browser/client.ts index a420b6077a7..091c88fed3f 100644 --- a/extensions/browser/src/browser/client.ts +++ b/extensions/browser/src/browser/client.ts @@ -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 { const q = buildProfileQuery(opts?.profile); return await fetchBrowserJson(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, }); } diff --git a/extensions/browser/src/browser/client.types.ts b/extensions/browser/src/browser/client.types.ts index 98ee7821afa..c017f6a6fb3 100644 --- a/extensions/browser/src/browser/client.types.ts +++ b/extensions/browser/src/browser/client.types.ts @@ -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; diff --git a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts index 73f7fb31a40..d13f4e1ebc5 100644 --- a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts +++ b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts @@ -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", diff --git a/extensions/browser/src/browser/routes/tabs.test.ts b/extensions/browser/src/browser/routes/tabs.test.ts index be3eaacd495..dccac4ebe58 100644 --- a/extensions/browser/src/browser/routes/tabs.test.ts +++ b/extensions/browser/src/browser/routes/tabs.test.ts @@ -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 }) => { diff --git a/extensions/browser/src/browser/routes/tabs.ts b/extensions/browser/src/browser/routes/tabs.ts index 2b4894b3633..ce80dc448f6 100644 --- a/extensions/browser/src/browser/routes/tabs.ts +++ b/extensions/browser/src/browser/routes/tabs.ts @@ -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 }); } diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts index e180ac6d3aa..76c580439ed 100644 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts +++ b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts @@ -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>); + + 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 = [ [ diff --git a/extensions/browser/src/browser/server-context.tab-ops.ts b/extensions/browser/src/browser/server-context.tab-ops.ts index 2909b82aee4..736764fdb09 100644 --- a/extensions/browser/src/browser/server-context.tab-ops.ts +++ b/extensions/browser/src/browser/server-context.tab-ops.ts @@ -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; - openTab: (url: string) => Promise; + openTab: (url: string, opts?: { label?: string }) => Promise; + labelTab: (targetId: string, label: string) => Promise; }; /** @@ -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 { + 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 => { + const readTabs = async (): Promise => { 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 => { + const tabs = await readTabs(); + return assignTabAliases(getProfileState(), tabs); + }; + const enforceManagedTabLimit = async (keepTargetId: string): Promise => { const profileState = getProfileState(); if ( @@ -151,7 +211,7 @@ export function createProfileTabOps({ }); }; - const openTab = async (url: string): Promise => { + const openTab = async (url: string, opts?: { label?: string }): Promise => { 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 => { + 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, }; } diff --git a/extensions/browser/src/browser/server-context.ts b/extensions/browser/src/browser/server-context.ts index 2dd46b3294a..166b0442e96 100644 --- a/extensions/browser/src/browser/server-context.ts +++ b/extensions/browser/src/browser/server-context.ts @@ -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(), diff --git a/extensions/browser/src/browser/server-context.types.ts b/extensions/browser/src/browser/server-context.types.ts index 00a813f28ac..3137218db74 100644 --- a/extensions/browser/src/browser/server-context.types.ts +++ b/extensions/browser/src/browser/server-context.types.ts @@ -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; + }; reconcile?: { previousProfile: ResolvedBrowserProfile; reason: string; @@ -32,7 +37,8 @@ type BrowserProfileActions = { isHttpReachable: (timeoutMs?: number) => Promise; isReachable: (timeoutMs?: number) => Promise; listTabs: () => Promise; - openTab: (url: string) => Promise; + openTab: (url: string, opts?: { label?: string }) => Promise; + labelTab: (targetId: string, label: string) => Promise; focusTab: (targetId: string) => Promise; closeTab: (targetId: string) => Promise; stopRunningBrowser: () => Promise<{ stopped: boolean }>; diff --git a/extensions/browser/src/browser/target-id.ts b/extensions/browser/src/browser/target-id.ts index eecd1e10342..a6188e8475f 100644 --- a/extensions/browser/src/browser/target-id.ts +++ b/extensions/browser/src/browser/target-id.ts @@ -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 }; } diff --git a/extensions/browser/src/cli/browser-cli-manage.ts b/extensions/browser/src/cli/browser-cli-manage.ts index 7bef0115a95..f58031dd78a 100644 --- a/extensions/browser/src/cli/browser-cli-manage.ts +++ b/extensions/browser/src/cli/browser-cli-manage.ts @@ -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