From adf64ac928057fe047cc2f6bde836d96331c1fbc Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 13 Oct 2026 22:56:09 +0100 Subject: [PATCH 1/2] feat(app): the agent's view shows its browser's tabs and stays on the one you pick On a box with browser.tabs, each agent can have a tab of its own in a worktree's browser. The agent's view lists them in a strip with a dot on the ones in use, opens on the tab used last, and then only moves when you pick another, or when the watched tab closes (with a short note). The address, size, screencast and Console drawer follow the watched tab. Co-Authored-By: Claude Opus 5.5 --- app/e2e/agent-browser-tabs.spec.ts | 89 +++++++++++++ app/package.json | 2 +- app/src/components/browser-pane.tsx | 198 ++++++++++++++++++++++++---- app/src/lib/agent-browser.ts | 22 +++- app/src/lib/browser-tabs.test.ts | 73 ++++++++++ app/src/lib/browser-tabs.ts | 99 ++++++++++++++ app/src/lib/devtools.ts | 11 +- app/src/lib/mock-browser.ts | 141 ++++++++++++++++---- app/src/lib/mock.ts | 7 +- 9 files changed, 584 insertions(+), 58 deletions(-) create mode 100644 app/e2e/agent-browser-tabs.spec.ts create mode 100644 app/src/lib/browser-tabs.test.ts create mode 100644 app/src/lib/browser-tabs.ts diff --git a/app/e2e/agent-browser-tabs.spec.ts b/app/e2e/agent-browser-tabs.spec.ts new file mode 100644 index 00000000..6ce828a6 --- /dev/null +++ b/app/e2e/agent-browser-tabs.spec.ts @@ -0,0 +1,89 @@ +import { type App, expect, mockOnly, test } from "./fixtures"; + +// A worktree's agent browser has tabs on a box that says "browser.tabs": +// each agent that asks gets a page of its own. The agent's view shows them +// in a strip, watches the one you pick, and never moves on its own while +// agents work in the others. The mock's checkout-fix worktree on devl has +// Main (the cart, used last), signup-a, signup-b and onboarding; +// window.__berthMockBrowser stands in for the agents. + +type MockBrowser = { __berthMockBrowser: { navigate(tab: string, path: string): void; openTab(name: string): void; closeTab(name: string): void } }; + +async function agentView(app: App) { + await app.open(); + const page = app.page; + await app.openWorktree("devl/checkout-fix"); + await page.getByRole("button", { name: "New tab" }).click(); + await page.getByRole("option", { name: /New browser tab/ }).click(); + const pane = page.locator("[data-testid=browser-pane]:visible"); + await pane.getByRole("button", { name: /Agent's view/ }).click(); + const strip = pane.getByRole("group", { name: "The agent's browser's tabs" }); + await expect(strip).toBeVisible(); + return { page, pane, strip, frame: pane.getByTestId("agent-frame"), tab: (name: string) => strip.getByRole("button", { name, exact: true }) }; +} + +test("the agent's view lists the browser's tabs, opens on the one used last, and watches the one you pick", async ({ app }) => { + mockOnly("casts the demo's agent browser and its tabs"); + const { pane, strip, frame, tab } = await agentView(app); + await expect(strip.getByRole("button")).toHaveText(["Main", "signup-a", "signup-b", "onboarding"]); + await expect(tab("Main")).toHaveAttribute("aria-pressed", "true"); + await expect(frame).toHaveAttribute("data-tab", ""); + await expect(pane.getByText(/\/cart$/)).toBeVisible(); + + await tab("signup-b").click(); + await expect(tab("signup-b")).toHaveAttribute("aria-pressed", "true"); + await expect(tab("Main")).toHaveAttribute("aria-pressed", "false"); + await expect(frame).toHaveAttribute("data-tab", "signup-b"); + await expect(pane.getByText(/\/signup\?plan=b$/)).toBeVisible(); + // The Console drawer has the watched tab's lines, not main's. + await pane.getByTestId("devtools-toggle").click(); + const rows = pane.getByTestId("devtools-drawer").getByTestId("console-row"); + await expect(rows).toHaveCount(1); + await expect(rows).toContainText("signup-b: Sign up: plan B is ready"); + await pane.getByTestId("devtools-toggle").click(); + + // Each tab has its own size, and the bar says the watched one's. + await tab("onboarding").click(); + await expect(frame).toHaveAttribute("data-size", "390×844 @3x"); + await expect(pane.getByTestId("agent-size")).toContainText("390×844 @3x"); + await expect(pane.getByText(/\/welcome$/)).toBeVisible(); +}); + +test("agents busy in other tabs never move the view off the one you watch", async ({ app }) => { + mockOnly("plays agents in the mock's browser tabs"); + const { page, pane, strip, frame, tab } = await agentView(app); + await tab("signup-a").click(); + await expect(frame).toHaveAttribute("data-tab", "signup-a"); + + // Another agent moves signup-b on and opens a tab of its own: the strip + // shows it, and the view stays where it was. + await page.evaluate(() => (window as unknown as MockBrowser).__berthMockBrowser.navigate("signup-b", "/signup/verify?plan=b")); + await expect(tab("signup-b")).toHaveAttribute("data-active", "true"); + await page.evaluate(() => (window as unknown as MockBrowser).__berthMockBrowser.openTab("checkout-c")); + await expect(tab("checkout-c")).toBeVisible(); + await expect(strip.getByRole("button", { pressed: true })).toHaveText("signup-a"); + await expect(frame).toHaveAttribute("data-tab", "signup-a"); + await expect(pane.getByText(/\/signup\?plan=a$/)).toBeVisible(); + + // And it keeps staying: another poll and more activity later, still signup-a. + await page.evaluate(() => (window as unknown as MockBrowser).__berthMockBrowser.navigate("checkout-c", "/checkout")); + await expect(tab("checkout-c")).toHaveAttribute("data-active", "true"); + await expect(strip.getByRole("button", { pressed: true })).toHaveText("signup-a"); + await expect(frame).toHaveAttribute("data-tab", "signup-a"); +}); + +test("when the watched tab closes, the view moves to the tab used last and says so", async ({ app }) => { + mockOnly("plays agents in the mock's browser tabs"); + const { page, pane, frame, tab } = await agentView(app); + await tab("signup-a").click(); + await expect(frame).toHaveAttribute("data-tab", "signup-a"); + await page.evaluate(() => (window as unknown as MockBrowser).__berthMockBrowser.navigate("signup-b", "/signup/verify?plan=b")); + await expect(tab("signup-b")).toHaveAttribute("data-active", "true"); + + await page.evaluate(() => (window as unknown as MockBrowser).__berthMockBrowser.closeTab("signup-a")); + await expect(pane.getByRole("status").filter({ hasText: "signup-a closed. Watching signup-b." })).toBeVisible(); + await expect(tab("signup-a")).toHaveCount(0); + await expect(tab("signup-b")).toHaveAttribute("aria-pressed", "true"); + await expect(frame).toHaveAttribute("data-tab", "signup-b"); + await expect(pane.getByText(/\/signup\/verify\?plan=b$/)).toBeVisible(); +}); diff --git a/app/package.json b/app/package.json index aea86474..e0921f89 100644 --- a/app/package.json +++ b/app/package.json @@ -23,7 +23,7 @@ "perf:guard": "node perf/guard.mjs", "perf:soak": "node perf/soak.mjs", "perf:terminals": "node perf/terminals.mjs", - "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts" + "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts src/lib/browser-tabs.test.ts" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/app/src/components/browser-pane.tsx b/app/src/components/browser-pane.tsx index 99a3b45e..6a85984f 100644 --- a/app/src/components/browser-pane.tsx +++ b/app/src/components/browser-pane.tsx @@ -11,7 +11,9 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { isTauri } from "@/lib/api"; import { openPreviewAt } from "@/lib/actions"; -import { agentBrowserStatus, boxHasBrowser, fitFrame, type Frame, sizeLabel, watchAgentBrowser } from "@/lib/agent-browser"; +import { type AgentBrowserTab, agentBrowserStatus, boxHasBrowser, fitFrame, type Frame, sizeLabel, watchAgentBrowser } from "@/lib/agent-browser"; +import { chooseTab, noteTabs, showTabStrip, tabActive, tabLabel, tabMovedNote, type TabSeen } from "@/lib/browser-tabs"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { agentOf } from "@/lib/derive"; import { useEventLog } from "@/lib/events"; import { DEVTOOLS_FRAME, proxiedHost, useAgentDevtoolsFeed, useDevtoolsFeed, useDrawerOpen, withDevtoolsFlag } from "@/lib/devtools"; @@ -22,7 +24,7 @@ import { berthUrlLabel, boxAliases, type BrowserContext, describeBerthUrl, hostS import { openUrl } from "@/lib/open-url"; import { initialPageLoads, type PageEvent, pageEvent, type PageLoads, reloadLoop, settle } from "@/lib/page-loads"; import { overlayOpen } from "@/lib/overlays"; -import { poll } from "@/lib/poll"; +import { poll, type Poller } from "@/lib/poll"; import { useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; import { demoDevServer } from "@/demo/dev-server"; @@ -123,8 +125,9 @@ export function BrowserPane({ id: paneId, url, visible, onNavigate, worktree, on // The pane is always your own browser, on this laptop, through its proxy. // While an agent drives its own browser on the box for this worktree, its // view can be watched here instead, live, and it says so. - const agentLive = useAgentBrowserLive(ctx.ref, visible); const [watching, setWatching] = useState(!!watch); + const tabsBox = useStore((s) => !!ctx.ref && !!s.boxes[ctx.ref.box]?.info?.capabilities?.includes("browser.tabs")); + const agentLive = useAgentBrowserLive(ctx.ref, visible, watching && tabsBox); useEffect(() => { if (watch) setWatching(true); }, [watch]); @@ -143,6 +146,53 @@ export function BrowserPane({ id: paneId, url, visible, onNavigate, worktree, on useEffect(() => { if (agentLive && !agentLive.running) setWatching(false); }, [agentLive]); + // On a box with tabs (lib/browser-tabs), the tab the view watches. It is + // chosen as the view opens, the one used last then, from a status asked + // for at that moment, and after that only you change it: agents working in + // other tabs never move the view, however busy they are. Only when the + // watched tab goes does it move, to the one used last, and say so. It is + // the pane's, for its worktree; one you picked is kept for when you come + // back to the agent's view, one it chose is chosen again. + const path = ctx.ref?.path; + const [tab, setTab] = useState<{ path: string; name: string; picked: boolean }>(); + const [tabMoved, setTabMoved] = useState(); + const openedAt = useRef(0); + useEffect(() => { + if (!agentView) { + openedAt.current = 0; + setTab((t) => (t?.picked ? t : undefined)); + setTabMoved(undefined); + return; + } + if (!openedAt.current) openedAt.current = Date.now(); + if (!tabsBox || !path || !agentLive) return; + const current = tab?.path === path ? tab.name : undefined; + // A status from well before the view opened may be half a minute old; + // the poll asks again as it opens. + if (current === undefined && agentLive.at < openedAt.current - FRESH_MS) return; + const next = chooseTab(agentLive.tabs, current) ?? { name: "" }; + if (next.name === current) return; + setTab({ path, name: next.name, picked: false }); + if (next.movedFrom !== undefined) setTabMoved(tabMovedNote(next.movedFrom, next.name)); + }, [agentView, tabsBox, path, agentLive, tab]); + useEffect(() => { + if (!tabMoved) return; + const t = window.setTimeout(() => setTabMoved(undefined), 8000); + return () => window.clearTimeout(t); + }, [tabMoved]); + // What the view streams: main on a box without tabs; nothing yet while a + // box with them is asked which. + const watchedTab = !tabsBox ? "" : tab && tab.path === path ? tab.name : undefined; + const watchedInfo = watchedTab === undefined ? undefined : agentLive?.tabs?.find((t) => t.name === watchedTab); + // Its page and size until its frames say them; the browser's own for main + // on a box that lists no tabs. + const watchedUrl = watchedInfo ? watchedInfo.url : watchedTab === "" ? agentLive?.url : undefined; + const watchedSize = watchedInfo ? watchedInfo.size : watchedTab === "" ? agentLive?.size : undefined; + const pickTab = (name: string) => { + if (!path) return; + setTab({ path, name, picked: true }); + setTabMoved(undefined); + }; // The agent's browser can't start on the box (Chromium's sandbox): a // marked chip, and a card in the agent's view with the ways out. const sandbox = useSandboxCardState(ctx.ref?.box ?? ""); @@ -163,7 +213,7 @@ export function BrowserPane({ id: paneId, url, visible, onNavigate, worktree, on const drawer = useDrawerOpen(id) && !!url; const agentKey = `${id}-agent`; const agentDrawer = useDrawerOpen(agentKey); - const agentDevtools = useAgentDevtoolsFeed(agentKey, ctx.ref, agentView && agentDrawer); + const agentDevtools = useAgentDevtoolsFeed(agentKey, ctx.ref, agentView && agentDrawer && watchedTab !== undefined, watchedTab); // A pick from the native webview comes back as an event. useEffect(() => { @@ -205,9 +255,9 @@ export function BrowserPane({ id: paneId, url, visible, onNavigate, worktree, on ) : agentView && ctx.ref ? ( setWatching(false)} onOpenHere={(u) => { @@ -309,8 +359,17 @@ export function BrowserPane({ id: paneId, url, visible, onNavigate, worktree, on void openUrl(loop.url)} onDismiss={() => setLoopDismissed(loop.url)} /> )} {failure && mode === "iframe" &&

The built-in browser could not open ({failure}); showing the page in a frame instead.

} + {agentView && ctx.ref && tabsBox && showTabStrip(agentLive?.tabs) && watchedTab !== undefined && } + {agentView && tabMoved && ( +
+ {tabMoved} + +
+ )} {/* Watching the agent keeps your page as it was, hidden underneath. */} - {agentView && ctx.ref && } + {agentView && ctx.ref && } {sandboxView && (
@@ -344,7 +403,7 @@ export function BrowserPane({ id: paneId, url, visible, onNavigate, worktree, on
)} {drawer && !agentView && !sandboxView && } - {agentView && agentDrawer && agentDevtools && } + {agentView && agentDrawer && agentDevtools && } ); } @@ -738,8 +797,21 @@ export interface AgentLive { url?: string; // Its page's size, as the box says it: 1920×1080. size?: string; + // Its tabs, on a box that has them. + tabs?: AgentBrowserTab[]; + // When the box said so (Date.now()). + at: number; } +// How old a status the agent's view may choose its tab from as it opens. +const FRESH_MS = 3000; + +// The box's events that a tab opened, closed, or was used: worth asking +// again at once rather than at the next poll. Navigations and shots only +// while the tabs show, where activity is drawn. +const TAB_EVENTS = new Set(["browser.tab.opened", "browser.tab.closed"]); +const TAB_ACTIVITY = new Set(["browser.navigated", "browser.shot"]); + // AgentSize is the agent's page as the view shows it: w×h CSS pixels at a // scale, shown at zoom (1 is its own size). interface AgentSize { @@ -752,21 +824,30 @@ interface AgentSize { // useAgentBrowserLive is whether an agent's browser runs on the box for the // pane's worktree, checked while the pane is on screen (undefined until // known, and always for a box without one), and again at once when the box -// says it opened or closed. -function useAgentBrowserLive(ref: BrowserContext["ref"], visible: boolean): AgentLive | undefined { +// says it opened or closed. tabs is set while the agent's view shows a box's +// tabs: it asks every 2s then, and at once when a tab opens, closes or is +// used, so the strip's activity is current and a closed tab is noticed. +function useAgentBrowserLive(ref: BrowserContext["ref"], visible: boolean, tabs: boolean): AgentLive | undefined { const box = ref?.box; const location = ref?.location; const worktree = ref?.worktree; const capable = !!box && boxHasBrowser(box); const nudge = useEventLog((s) => s.events.find((e) => e.box === box && (e.type === "browser.opened" || e.type === "browser.closed") && e.data?.path === ref?.path)?.time); + const tabNudge = useEventLog((s) => s.events.find((e) => e.box === box && (TAB_EVENTS.has(e.type) || (tabs && TAB_ACTIVITY.has(e.type))) && e.data?.path === ref?.path)?.time); const [live, setLive] = useState(); + const poller = useRef(null); + // Forgotten for another worktree, while the pane is away, and as the + // browser opens or closes (a pane opened to watch it must not see the old + // "not running" first); not when tabs only ask again, which would drop + // the view for a moment. + useEffect(() => setLive(undefined), [capable, visible, box, location, worktree, nudge]); useEffect(() => { - setLive(undefined); if (!capable || !visible || !box || !location || !worktree) return; let on = true; let seen = ""; - // Every 5s, backing off to 30s while nothing changes; never while the - // window is hidden (lib/poll). + // Every 5s, backing off to 30s while nothing changes, or every 2s up to + // 10s while the view shows tabs; never while the window is hidden + // (lib/poll). const p = poll( () => agentBrowserStatus(box, location, worktree).then( @@ -774,21 +855,27 @@ function useAgentBrowserLive(ref: BrowserContext["ref"], visible: boolean): Agen if (!on) return false; seedSandbox(box, s.health); const size = s.status?.size ?? s.size; - setLive((was) => (was?.running === s.running && was?.url === s.status?.url && was?.size === size ? was : { running: s.running, url: s.status?.url, size })); - const now = `${s.running}|${s.status?.url ?? ""}|${size ?? ""}`; + const list = s.tabs ?? s.status?.tabs; + setLive({ running: s.running, url: s.status?.url, size, tabs: list, at: Date.now() }); + const now = `${s.running}|${s.status?.url ?? ""}|${size ?? ""}|${JSON.stringify(list ?? [])}`; const changed = now !== seen; seen = now; return changed; }, - () => (on && setLive({ running: false }), false), + () => (on && setLive({ running: false, at: Date.now() }), false), ), - { every: 5000, max: 30_000 }, + tabs ? { every: 2000, max: 10_000 } : { every: 5000, max: 30_000 }, ); + poller.current = p; return () => { on = false; p.stop(); + if (poller.current === p) poller.current = null; }; - }, [capable, visible, box, location, worktree, nudge]); + }, [capable, visible, box, location, worktree, nudge, tabs]); + useEffect(() => { + if (tabNudge) poller.current?.kick(); + }, [tabNudge]); return live; } @@ -863,6 +950,68 @@ function AgentBar({ ); } +// AgentTabs is the strip of the agent's browser's tabs under its bar, one +// per agent that asked for its own: which one the view watches, which are +// being used now (a dot), and each one's page and size on hover. Choosing +// is the only thing it does; the agents' activity never chooses for you. +function AgentTabs({ tabs, watched, onPick }: { tabs: AgentBrowserTab[]; watched: string; onPick(name: string): void }) { + const [seen, setSeen] = useState({}); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const at = Date.now(); + setSeen((s) => noteTabs(s, tabs, at)); + setNow(at); + }, [tabs]); + const active = new Set(tabs.filter((t) => tabActive(t, seen, now)).map((t) => t.name)); + // A dot fades a few seconds after its tab was last used, between polls. + useEffect(() => { + if (!active.size) return; + const t = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(t); + }, [active.size > 0]); // eslint-disable-line react-hooks/exhaustive-deps + return ( +
+ { + const next = (v as string[])[0]; + if (next !== undefined) onPick(next.slice(4)); + }} + aria-label="The agent's browser's tabs" + className="min-w-0 overflow-x-auto [scrollbar-width:none]" + > + {tabs.map((t) => ( + + {t.title && {t.title}} + {t.url && {t.url}} + + {t.size} + {active.has(t.name) && · in use} + + + } + side="bottom" + className="max-w-sm" + > + + + {tabLabel(t.name)} + + + ))} + +
+ ); +} + // SandboxBar stands in for the address bar while the pane shows why the // agent's browser can't start. function SandboxBar({ box, fixed, onBack }: { box: string; fixed: boolean; onBack(): void }) { @@ -885,7 +1034,9 @@ function SandboxBar({ box, fixed, onBack }: { box: string; fixed: boolean; onBac // while this view is on screen. It only watches; the agent drives. The page // shows as large as the pane allows but never past its own size: a frame's // image is the page at its scale, so up to its CSS size it stays sharp. -function AgentView({ ctx, visible, onUrl, onSize }: { ctx: BrowserContext; visible: boolean; onUrl(url?: string): void; onSize(size?: AgentSize): void }) { +// tab is the one it shows (lib/browser-tabs); undefined while that is still +// being chosen. +function AgentView({ ctx, visible, tab, onUrl, onSize }: { ctx: BrowserContext; visible: boolean; tab?: string; onUrl(url?: string): void; onSize(size?: AgentSize): void }) { const ref = ctx.ref!; const [frame, setFrame] = useState(); const [error, setError] = useState(); @@ -911,14 +1062,16 @@ function AgentView({ ctx, visible, onUrl, onSize }: { ctx: BrowserContext; visib }, [fw, fh, frame?.scale, fit?.zoom]); useEffect(() => () => onSize(undefined), []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - if (!visible) return; + if (!visible || tab === undefined) return; const ac = new AbortController(); setError(undefined); + // Another tab's last frame must not stand in for this one's. + setFrame(undefined); const show = (f: Frame) => { setFrame(f); if (f.url) onUrl(f.url); }; - watchAgentBrowser(ref.box, ref.location, ref.worktree, show, ac.signal).catch((e) => { + watchAgentBrowser(ref.box, ref.location, ref.worktree, show, ac.signal, tab).catch((e) => { if (!ac.signal.aborted) setError(String(e)); }); return () => { @@ -927,13 +1080,14 @@ function AgentView({ ctx, visible, onUrl, onSize }: { ctx: BrowserContext; visib }; // onUrl is the pane's state setter, the same every render. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visible, ref.box, ref.location, ref.worktree]); + }, [visible, ref.box, ref.location, ref.worktree, tab]); return (
{frame ? ( The agent's browser void, signal: AbortSignal): Promise { +// the box casts only while someone watches, and only the tab asked for. +export function watchAgentBrowser(box: string, location: string, worktree: string, onFrame: (f: Frame) => void, signal: AbortSignal, tab?: string): Promise { const c = useStore.getState().client; if (!c) return Promise.reject(new Error("not connected")); - return c.stream("GET", `/v1/boxes/${encodeURIComponent(box)}/api/${base(location, worktree)}/screencast`, undefined, (v) => onFrame(v as Frame), signal); + return c.stream("GET", `/v1/boxes/${encodeURIComponent(box)}/api/${base(location, worktree)}/screencast${tabQuery(tab)}`, undefined, (v) => onFrame(v as Frame), signal); +} + +// agentBrowserDevtools is a tab's console lines and failed requests, as its +// box keeps them. +export function agentBrowserDevtools(box: string, location: string, worktree: string, tab?: string): Promise { + return client().box(box, "GET", `${base(location, worktree)}/devtools${tabQuery(tab)}`); } const shotCache = new Map>(); diff --git a/app/src/lib/browser-tabs.test.ts b/app/src/lib/browser-tabs.test.ts new file mode 100644 index 00000000..e1943062 --- /dev/null +++ b/app/src/lib/browser-tabs.test.ts @@ -0,0 +1,73 @@ +// node --experimental-strip-types --test src/lib/browser-tabs.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { type AgentBrowserTab, chooseTab, mostRecentTab, noteTabs, showTabStrip, tabActive, tabMovedNote, tabQuery } from "./browser-tabs.ts"; + +const T0 = Date.parse("2026-10-13T10:00:00Z"); +const tab = (name: string, secondsAfter: number, extra: Partial = {}): AgentBrowserTab => ({ + name, + last_used: new Date(T0 + secondsAfter * 1000).toISOString(), + watchers: 0, + viewport: { width: 1920, height: 1080, scale: 1 }, + size: "1920×1080", + ...extra, +}); + +const tabs = [tab("", 10), tab("signup-a", 30), tab("signup-b", 20), tab("onboarding", 5)]; + +test("the view opens on the most recently used tab", () => { + assert.deepEqual(chooseTab(tabs, undefined), { name: "signup-a" }); + assert.equal(mostRecentTab([tab("x", 1), tab("y", 1)])?.name, "x", "a tie goes to the earlier tab in the box's order"); +}); + +test("the watched tab stays while it exists, whatever the others do", () => { + assert.deepEqual(chooseTab(tabs, "onboarding"), { name: "onboarding" }); + const busier = tabs.map((t) => (t.name === "signup-b" ? { ...t, last_used: new Date(T0 + 99_000).toISOString(), busy: true } : t)); + assert.deepEqual(chooseTab(busier, "onboarding"), { name: "onboarding" }); + assert.deepEqual(chooseTab(tabs, ""), { name: "" }); +}); + +test("when the watched tab closes, the view moves to the most recent of the rest and says so", () => { + const rest = tabs.filter((t) => t.name !== "signup-a"); + assert.deepEqual(chooseTab(rest, "signup-a"), { name: "signup-b", movedFrom: "signup-a" }); + assert.equal(tabMovedNote("signup-a", "signup-b"), "signup-a closed. Watching signup-b."); + // Main drops out of the list while unused, which isn't closing. + assert.equal(tabMovedNote("", "signup-b"), "Watching signup-b, the tab in use."); +}); + +test("no list keeps what is watched, and an old box watches main", () => { + assert.deepEqual(chooseTab(undefined, "signup-a"), { name: "signup-a" }); + assert.equal(chooseTab(undefined, undefined), undefined); + assert.deepEqual(chooseTab([], undefined), undefined); +}); + +test("the strip shows only when there is a choice", () => { + assert.equal(showTabStrip(undefined), false); + assert.equal(showTabStrip([tab("", 0)]), false); + assert.equal(showTabStrip([tab("signup-a", 0)]), true); + assert.equal(showTabStrip(tabs), true); +}); + +test("a tab's query is only there for a named tab, encoded", () => { + assert.equal(tabQuery(""), ""); + assert.equal(tabQuery(undefined), ""); + assert.equal(tabQuery("sign up/a"), "?tab=sign%20up%2Fa"); +}); + +test("activity: busy, recently used by its clock, or seen to change when the clocks differ", () => { + const now = T0 + 31_000; + assert.equal(tabActive(tabs[1], {}, now), true, "used a second ago"); + assert.equal(tabActive(tabs[2], {}, now), false, "used eleven seconds ago"); + assert.equal(tabActive({ ...tabs[2], busy: true }, {}, now), true); + // A box whose clock is a minute behind: its times look old, but a change + // between polls still shows. + let seen = noteTabs({}, tabs, now); + assert.equal(seen["signup-b"].changedAt, 0, "the first poll has nothing to compare with"); + const moved = tabs.map((t) => (t.name === "signup-b" ? { ...t, last_used: new Date(T0 - 60_000).toISOString() } : t)); + seen = noteTabs(seen, moved, now + 2000); + assert.equal(tabActive(moved[2], seen, now + 2500), true); + assert.equal(tabActive(moved[2], seen, now + 2000 + 3000), false, "and it fades after a few seconds"); + // A clock ahead of this one doesn't make a tab look busy forever. + assert.equal(tabActive(tab("ahead", 120), {}, now), false); +}); diff --git a/app/src/lib/browser-tabs.ts b/app/src/lib/browser-tabs.ts new file mode 100644 index 00000000..881d28bf --- /dev/null +++ b/app/src/lib/browser-tabs.ts @@ -0,0 +1,99 @@ +// A worktree's agent browser has named tabs on a box that says +// "browser.tabs": each agent passes `berthd browser --tab NAME` and gets a +// page of its own, so a dozen teammates of one lead don't drive one page. +// The pane watches one tab at a time, and which one is the person's to +// choose: what agents do in the others never moves it. This file is the +// pure part of that, kept free of the app's store so tests run it in Node. + +import type { Viewport } from "@/lib/agent-browser"; + +export interface AgentBrowserTab { + // "" is the main tab, which agents that pass no --tab share. + name: string; + url?: string; + title?: string; + // RFC3339, by the box's clock. + last_used: string; + watchers: number; + viewport: Viewport; + // As the box says it: "1920×1080", "390×844 @3x". + size: string; + // An agent's command is running on it right now. + busy?: boolean; +} + +// How long after its last use a tab still shows as active. +export const ACTIVE_MS = 3000; + +export const tabLabel = (name: string) => (name === "" ? "Main" : name); + +// tabQuery is the query a per-worktree browser route takes for a tab: none +// for the main tab, so a box without tabs gets the same request as before. +export const tabQuery = (tab?: string) => (tab ? `?tab=${encodeURIComponent(tab)}` : ""); + +// showTabStrip is whether there is anything to choose between: more than +// one tab, or a named one (an agent on its own tab while main is unused). +export const showTabStrip = (tabs: AgentBrowserTab[] | undefined) => !!tabs && (tabs.length > 1 || tabs.some((t) => t.name !== "")); + +const usedAt = (t: AgentBrowserTab) => { + const at = Date.parse(t.last_used); + return Number.isNaN(at) ? 0 : at; +}; + +// mostRecentTab is the tab used last; on a tie, the earlier in the box's +// order (main first, then by when they opened). +export function mostRecentTab(tabs: AgentBrowserTab[]): AgentBrowserTab | undefined { + let best: AgentBrowserTab | undefined; + for (const t of tabs) if (!best || usedAt(t) > usedAt(best)) best = t; + return best; +} + +export interface TabChoice { + name: string; + // Set when the watched tab is gone and this is the one the view moved to. + movedFrom?: string; +} + +// chooseTab is the tab to watch, given the box's list and the one watched +// now (undefined when the view has just opened). It only ever changes an +// existing choice when that tab is gone: then to the most recently used of +// the rest. No list (an old box, or not known yet) keeps it as it is. +export function chooseTab(tabs: AgentBrowserTab[] | undefined, watched: string | undefined): TabChoice | undefined { + if (!tabs?.length) return watched === undefined ? undefined : { name: watched }; + if (watched !== undefined && tabs.some((t) => t.name === watched)) return { name: watched }; + const next = mostRecentTab(tabs)!; + return watched === undefined ? { name: next.name } : { name: next.name, movedFrom: watched }; +} + +// tabMovedNote is the line the view shows after it moved off a gone tab. +// The main tab isn't closed when it drops out of the list, only unused +// while agents work on named ones, so it isn't said to have closed. +export function tabMovedNote(from: string, to: string): string { + return from === "" ? `Watching ${tabLabel(to)}, the tab in use.` : `${tabLabel(from)} closed. Watching ${tabLabel(to)}.`; +} + +// TabSeen is what the pane remembers of each tab between status polls, to +// see a tab being used without trusting the box's clock to match this one. +export type TabSeen = Record; + +// noteTabs records, as of now, which tabs' last_used moved since the last +// poll. +export function noteTabs(seen: TabSeen, tabs: AgentBrowserTab[], now: number): TabSeen { + const next: TabSeen = {}; + for (const t of tabs) { + const was = seen[t.name]; + next[t.name] = was && was.last_used === t.last_used ? was : { last_used: t.last_used, changedAt: was ? now : 0 }; + } + return next; +} + +// tabActive is whether a tab shows activity: an agent's command runs on it, +// or it was used in the last few seconds, by its own time when the clocks +// agree and by when this app saw it change when they don't. +export function tabActive(t: AgentBrowserTab, seen: TabSeen, now: number): boolean { + if (t.busy) return true; + const changed = seen[t.name]?.changedAt ?? 0; + if (changed && now - changed < ACTIVE_MS) return true; + const age = now - usedAt(t); + return age >= 0 && age < ACTIVE_MS; +} diff --git a/app/src/lib/devtools.ts b/app/src/lib/devtools.ts index 35b31441..dac18778 100644 --- a/app/src/lib/devtools.ts +++ b/app/src/lib/devtools.ts @@ -2,6 +2,7 @@ import { listen } from "@tauri-apps/api/event"; import { useEffect, useRef } from "react"; import { create } from "zustand"; +import { agentBrowserDevtools } from "@/lib/agent-browser"; import { type AgentDevtools, appendConsole, appendNetwork, type ConsoleEntry, errorCount, fromAgent, type NetEntry, parseReport, type Report } from "@/lib/devtools-model"; import { poll, type Poller } from "@/lib/poll"; import { useStore } from "@/lib/store"; @@ -217,8 +218,9 @@ export function useDevtoolsFeed({ key, mode, url, visible, proxyPort }: FeedOpti // useAgentDevtoolsFeed keeps the agent's own browser's console lines and // failed requests (from its box, GET …/browser/devtools) under key, every -// two seconds while on: while its view shows and the drawer is open. -export function useAgentDevtoolsFeed(key: string, ref: { box: string; location: string; worktree: string } | undefined, on: boolean) { +// two seconds while on: while its view shows and the drawer is open. tab is +// the one the view watches (lib/browser-tabs), main when empty. +export function useAgentDevtoolsFeed(key: string, ref: { box: string; location: string; worktree: string } | undefined, on: boolean, tab = "") { const box = ref?.box; const location = ref?.location; const worktree = ref?.worktree; @@ -231,9 +233,8 @@ export function useAgentDevtoolsFeed(key: string, ref: { box: string; location: // the window is hidden. const p = poll( async () => { - const client = useStore.getState().client; try { - const d = await client?.box(box, "GET", `worktrees/${encodeURIComponent(location)}/${encodeURIComponent(worktree)}/browser/devtools`); + const d = await agentBrowserDevtools(box, location, worktree, tab); if (!live || !d) return false; const sig = JSON.stringify(d); if (sig === last) return false; @@ -251,7 +252,7 @@ export function useAgentDevtoolsFeed(key: string, ref: { box: string; location: live = false; p.stop(); }; - }, [key, on, capable, box, location, worktree]); + }, [key, on, capable, box, location, worktree, tab]); return capable; } diff --git a/app/src/lib/mock-browser.ts b/app/src/lib/mock-browser.ts index 43e8f4be..bbe0aa4f 100644 --- a/app/src/lib/mock-browser.ts +++ b/app/src/lib/mock-browser.ts @@ -1,11 +1,19 @@ +import type { AgentDevtools } from "@/lib/devtools-model"; import { mockAgentDevtools } from "@/lib/mock-devtools"; // The demo's agent browser: the checkout-fix worktree on devl has one open, // at the default 1920×1080, cast as a drawing of the shop's cart page, with -// two shots for Review. window.__berthMockBrowser.resize("390x844", 3) does -// what an agent's `berthd browser resize` would, and .opened() the event an -// agent's `berthd browser open` sends as its browser starts, for tests and -// screenshots. +// two shots for Review. Its box has tabs (browser.tabs): besides the main +// one, three teammates of the lead agent each have their own (signup-a, +// signup-b, onboarding), each cast as a page of its own. The main tab is the +// one used last, so the view opens on the cart as before. +// +// window.__berthMockBrowser stands in for agents, for tests and +// screenshots: .resize("390x844", 3, tab?) does what `berthd browser +// resize` would, .opened() the event `berthd browser open` sends as its +// browser starts, .navigate(tab, path) an agent moving its tab to another +// page, and .openTab(name) / .closeTab(name) an agent's first command on a +// tab and its closing. interface Size { width: number; @@ -13,50 +21,116 @@ interface Size { scale: number; } +interface Tab { + name: string; + path: string; + title: string; + // The page the drawing shows, and its colour. + heading: string; + accent: string; + // Its own size; the browser's default otherwise. + size?: Size; + used: number; + busyUntil: number; +} + +const ORIGIN = "http://checkout-fix.shop.devl.localhost:1377"; +const WT = { location: "shop", name: "checkout-fix", path: "/home/me/work/shop-checkout-fix" }; + let size: Size = { width: 1920, height: 1080, scale: 1 }; const label = (v: Size) => `${v.width}×${v.height}${v.scale !== 1 ? ` @${v.scale}x` : ""}`; +const t0 = Date.now(); +const tabs: Tab[] = [ + { name: "", path: "/cart", title: "Cart · acme shop", heading: "Cart", accent: "#6366f1", used: t0 - 1000, busyUntil: 0 }, + { name: "signup-a", path: "/signup?plan=a", title: "Sign up · acme shop", heading: "Sign up: plan A", accent: "#0ea5e9", used: t0 - 40_000, busyUntil: 0 }, + { name: "signup-b", path: "/signup?plan=b", title: "Sign up · acme shop", heading: "Sign up: plan B", accent: "#f59e0b", used: t0 - 25_000, busyUntil: 0 }, + { name: "onboarding", path: "/welcome", title: "Welcome · acme shop", heading: "Welcome aboard", accent: "#10b981", size: { width: 390, height: 844, scale: 3 }, used: t0 - 60_000, busyUntil: 0 }, +]; + +const tabSize = (t: Tab) => t.size ?? size; +const find = (name: string) => tabs.find((t) => t.name === name); + // The drawing is laid out at the page's own width, as the shop's page // would be (a column at most 1100 wide), and drawn at the page's scale. -const svg = (count: number, v: Size = size) => { +const svg = (count: number, v: Size = size, t: Tab = tabs[0]) => { const vw = v.width; const vh = v.height; const cw = Math.min(1100, vw - 48); const x = Math.round((vw - cw) / 2); const narrow = vw < 600; + const main = t.name === ""; + const body = main + ? ` +Ceramic mug × ${count} +Checkout` + : `you@acme.test +${t.path} +Continue`; + const where = main ? `The agent's browser on devl, ${label(v)}` : `The agent's browser on devl, tab ${t.name}, ${label(v)}`; return ` - + acme shop -Cart - -Ceramic mug × ${count} -Checkout -The agent's browser on devl, ${label(v)}`; +${t.heading} +${body} +${where}`; }; export const mockShotSvg = () => svg(2); const b64 = (s: string) => btoa(unescape(encodeURIComponent(s))); -export async function mockScreencast(onValue: (v: unknown) => void, signal?: AbortSignal) { +// The tab a route asks for: ?tab=NAME, none for main. +const tabOf = (path: string) => new URLSearchParams(path.split("?")[1] ?? "").get("tab") ?? ""; + +export async function mockScreencast(path: string, onValue: (v: unknown) => void, signal?: AbortSignal) { + const name = tabOf(path); + if (!find(name)) throw new Error(`no tab ${name} in this worktree's browser`); for (let i = 1; !signal?.aborted; i++) { - onValue({ data: b64(svg((i % 3) + 1)), mime: "image/svg+xml", w: size.width, h: size.height, scale: size.scale, url: "http://checkout-fix.shop.devl.localhost:1377/cart" }); - await new Promise((r) => setTimeout(r, 1200)); + const t = find(name); + // Its tab closed: the box ends the cast. + if (!t) return; + const v = tabSize(t); + onValue({ data: b64(svg((i % 3) + 1, v, t)), mime: "image/svg+xml", w: v.width, h: v.height, scale: v.scale, url: ORIGIN + t.path }); + await new Promise((r) => setTimeout(r, name ? 600 : 1200)); } } -export function browserCall(box: string, method: string, path: string): unknown { - if (box !== "devl" || !path.includes("/browser/")) return undefined; - if (method === "GET" && path === "worktrees/shop/checkout-fix/browser/status") +const tabStatus = (t: Tab, now: number) => ({ + name: t.name, + url: ORIGIN + t.path, + title: t.title, + last_used: new Date(t.used).toISOString(), + watchers: 0, + viewport: tabSize(t), + size: label(tabSize(t)), + ...(t.busyUntil > now ? { busy: true } : {}), +}); + +const tabDevtools = (t: Tab): AgentDevtools => + t.name === "" ? mockAgentDevtools : { running: true, url: ORIGIN + t.path, console: [{ seq: 1, level: "log", text: `${t.name}: ${t.heading} is ready`, count: 1 }], failures: [] }; + +export function browserCall(box: string, method: string, full: string): unknown { + if (box !== "devl" || !full.includes("/browser/")) return undefined; + const [path] = full.split("?"); + const now = Date.now(); + if (method === "GET" && path === "worktrees/shop/checkout-fix/browser/status") { + const main = tabs[0]; + const list = tabs.map((t) => tabStatus(t, now)); return { running: true, - text: `open: http://checkout-fix.shop.devl.localhost:1377/cart · ${label(size)} (312 MB)`, + text: `open: ${ORIGIN}${main.path} · ${label(size)} (312 MB) · ${tabs.length} tabs`, viewport: size, size: label(size), - status: { url: "http://checkout-fix.shop.devl.localhost:1377/cart", rss_bytes: 312 << 20, viewport: size, size: label(size) }, + tabs: list, + status: { url: ORIGIN + main.path, rss_bytes: 312 << 20, viewport: size, size: label(size), tabs: list }, }; + } if (method === "GET" && path.endsWith("/browser/status")) return { running: false, text: "no browser open; it opens at 1920×1080", viewport: { width: 1920, height: 1080, scale: 1 }, size: "1920×1080" }; - if (method === "GET" && path === "worktrees/shop/checkout-fix/browser/devtools") return mockAgentDevtools; + if (method === "GET" && path === "worktrees/shop/checkout-fix/browser/devtools") { + const t = find(tabOf(full)); + return t ? tabDevtools(t) : { running: false, console: [], failures: [] }; + } if (method === "GET" && path.endsWith("/browser/devtools")) return { running: false, console: [], failures: [] }; return undefined; } @@ -71,11 +145,32 @@ export function wireBrowserMock(e: typeof emit) { if (typeof window !== "undefined") { (window as unknown as { __berthMockBrowser: unknown }).__berthMockBrowser = { opened() { - emit?.({ type: "browser.opened", box: "devl", origin: "browser", data: { location: "shop", name: "checkout-fix", path: "/home/me/work/shop-checkout-fix" } }); + emit?.({ type: "browser.opened", box: "devl", origin: "browser", data: { ...WT } }); }, - resize(wxh: string, scale = 1) { + resize(wxh: string, scale = 1, tab?: string) { const [width, height] = wxh.split("x").map(Number); - size = { width, height, scale }; + const t = tab === undefined ? undefined : find(tab); + if (t) t.size = { width, height, scale }; + else size = { width, height, scale }; + }, + navigate(tab: string, path: string) { + const t = find(tab); + if (!t) return; + t.path = path; + t.used = Date.now(); + t.busyUntil = t.used + 2000; + emit?.({ type: "browser.navigated", box: "devl", origin: "browser", data: { ...WT, tab, url: ORIGIN + path } }); + }, + openTab(name: string, path = "/") { + if (find(name)) return; + tabs.push({ name, path, title: "acme shop", heading: name, accent: "#a855f7", used: Date.now(), busyUntil: 0 }); + emit?.({ type: "browser.tab.opened", box: "devl", origin: "browser", data: { ...WT, tab: name } }); + }, + closeTab(name: string) { + const i = tabs.findIndex((t) => t.name === name); + if (i <= 0) return; + tabs.splice(i, 1); + emit?.({ type: "browser.tab.closed", box: "devl", origin: "browser", data: { ...WT, tab: name } }); }, }; } diff --git a/app/src/lib/mock.ts b/app/src/lib/mock.ts index c7fb782c..b7775d84 100644 --- a/app/src/lib/mock.ts +++ b/app/src/lib/mock.ts @@ -747,8 +747,9 @@ function boxCall(box: string, method: string, path: string, body?: unknown): Pro build: mockBuilds[box] ?? SHIPPED_BUILD, tools: ["claude", "codex"], home: HOME, - // gpu runs an older berthd (mockBuilds): it can't keep worktree names. - capabilities: ["diff", "turns", "queue", "ask", "answer", "journal", "runs", "exec.detach", "browser", "browser.devtools", "titles", "sample", "service.terminal", "session.home", "agents.install", "artifacts", "processes", ...(box === "gpu" ? [] : ["worktree.titles", "agents.paths"])], + // gpu runs an older berthd (mockBuilds): it can't keep worktree names, + // and its agent browser has no tabs. + capabilities: ["diff", "turns", "queue", "ask", "answer", "journal", "runs", "exec.detach", "browser", "browser.devtools", "titles", "sample", "service.terminal", "session.home", "agents.install", "artifacts", "processes", ...(box === "gpu" ? [] : ["worktree.titles", "agents.paths", "browser.tabs"])], // Claude Code from npm under nvm, as the person's shell finds it; // Codex from Shipyard's own installer. agent_paths: [ @@ -1168,7 +1169,7 @@ function laptopBoxes(method: string, path: string, body: unknown): Promise void, signal?: AbortSignal) { if (method === "POST" && (await kitsStream(path, body, onValue, emit, signal))) return; - if (method === "GET" && path.endsWith("/browser/screencast")) return mockScreencast(onValue, signal); + if (method === "GET" && /\/browser\/screencast(\?|$)/.test(path)) return mockScreencast(path, onValue, signal); if (await localBoxStream(method, path, body, onValue, signal)) return; const wait = (ms: number) => new Promise((resolve, reject) => { From c77c03051eee12c508fbef4c9d31480468349ca2 Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 13 Oct 2026 23:03:08 +0100 Subject: [PATCH 2/2] feat(box): agents sharing a worktree each get a browser tab of their own berthd browser --tab NAME (or $BERTH_BROWSER_TAB) gives an agent its own page in the worktree's Chromium: its own refs, console, requests, size and cookies, in a browser context and window of its own, so it renders at full speed whether or not anyone watches. Without it, agents in one worktree shared a page and took it from each other, and the live view jumped between their pages. Every per-worktree browser route takes ?tab=; status and the new tabs route list the tabs (with idle_ms, by the box's clock); close --tab closes one; browser.tab.opened/closed are events, and navigated/shot name the tab. A worktree's browser has 2 to 8 tabs by the box's memory; at the cap a tab idle for a minute makes room, else open is refused. The skills tell agents working side by side to use a tab each. Co-Authored-By: Claude Opus 5.5 --- app/src/lib/browser-tabs.test.ts | 10 + app/src/lib/browser-tabs.ts | 14 +- docs/changelog.mdx | 10 + docs/guides/agent-browser.mdx | 60 +- docs/reference/berthd.mdx | 3 +- docs/reference/events.mdx | 9 +- internal/box/browser.go | 402 ++--------- internal/box/browser_test.go | 6 +- internal/box/browserapi.go | 138 +++- internal/box/browserdevtools.go | 6 +- internal/box/browserdevtools_test.go | 2 +- internal/box/browsertabs.go | 669 ++++++++++++++++++ internal/box/browsertabs_test.go | 268 +++++++ internal/box/browserviewport.go | 69 +- internal/box/browserviewport_test.go | 30 +- internal/box/upgrade.go | 2 +- internal/boxcmd/boxcmd.go | 7 +- internal/boxcmd/boxcmd_test.go | 35 + internal/boxcmd/browsercmd.go | 62 +- .../skills/berth-browser/SKILL.md | 28 + .../skills/berth-orchestrate/SKILL.md | 4 + 21 files changed, 1406 insertions(+), 428 deletions(-) create mode 100644 internal/box/browsertabs.go create mode 100644 internal/box/browsertabs_test.go diff --git a/app/src/lib/browser-tabs.test.ts b/app/src/lib/browser-tabs.test.ts index e1943062..cbf923b2 100644 --- a/app/src/lib/browser-tabs.test.ts +++ b/app/src/lib/browser-tabs.test.ts @@ -71,3 +71,13 @@ test("activity: busy, recently used by its clock, or seen to change when the clo // A clock ahead of this one doesn't make a tab look busy forever. assert.equal(tabActive(tab("ahead", 120), {}, now), false); }); + +test("a box that says how long a tab has been idle is believed over the clocks", () => { + // Its clock is an hour behind this one: last_used alone would say idle. + const behind = (name: string, idle: number) => tab(name, -3600, { idle_ms: idle }); + const list = [behind("", 60_000), behind("signup-a", 500), behind("signup-b", 9000)]; + assert.equal(tabActive(list[1], {}, Date.now()), true); + assert.equal(tabActive(list[2], {}, Date.now()), false); + assert.equal(mostRecentTab(list)?.name, "signup-a"); + assert.deepEqual(chooseTab(list, undefined), { name: "signup-a" }); +}); diff --git a/app/src/lib/browser-tabs.ts b/app/src/lib/browser-tabs.ts index 881d28bf..fd47ca9e 100644 --- a/app/src/lib/browser-tabs.ts +++ b/app/src/lib/browser-tabs.ts @@ -14,6 +14,9 @@ export interface AgentBrowserTab { title?: string; // RFC3339, by the box's clock. last_used: string; + // How long since it was used, as of the box's answer: unlike last_used, + // this doesn't need the laptop's clock to agree with the box's. + idle_ms?: number; watchers: number; viewport: Viewport; // As the box says it: "1920×1080", "390×844 @3x". @@ -35,9 +38,12 @@ export const tabQuery = (tab?: string) => (tab ? `?tab=${encodeURIComponent(tab) // one tab, or a named one (an agent on its own tab while main is unused). export const showTabStrip = (tabs: AgentBrowserTab[] | undefined) => !!tabs && (tabs.length > 1 || tabs.some((t) => t.name !== "")); +// usedAt orders tabs by use: by idle_ms where the box says it, else by +// last_used. const usedAt = (t: AgentBrowserTab) => { + if (t.idle_ms !== undefined) return -t.idle_ms; const at = Date.parse(t.last_used); - return Number.isNaN(at) ? 0 : at; + return Number.isNaN(at) ? Number.NEGATIVE_INFINITY : at; }; // mostRecentTab is the tab used last; on a tie, the earlier in the box's @@ -94,6 +100,8 @@ export function tabActive(t: AgentBrowserTab, seen: TabSeen, now: number): boole if (t.busy) return true; const changed = seen[t.name]?.changedAt ?? 0; if (changed && now - changed < ACTIVE_MS) return true; - const age = now - usedAt(t); - return age >= 0 && age < ACTIVE_MS; + if (t.idle_ms !== undefined) return t.idle_ms < ACTIVE_MS; + const at = Date.parse(t.last_used); + const age = now - at; + return !Number.isNaN(at) && age >= 0 && age < ACTIVE_MS; } diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 52d65388..75d67ee4 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -9,6 +9,16 @@ upgrades with `berth upgrade BOX`, or by running the [install command](/getting-started/add-a-box#with-the-install-command) again. +## Unreleased + +- **Agents sharing a worktree each get a browser tab of their own.** When + several agents check pages in one worktree at once, each passes + `--tab NAME` to `berthd browser` and gets its own page, cookies and size, + so they no longer take the page from each other. Every tab keeps rendering + at full speed while nobody watches it. The agent's view in a browser pane + shows the tabs, stays on the one you pick, and no longer jumps from one + agent's page to the next. Needs the box upgraded. + ## v0.3.17 - **Labs: OpenCode, Gemini CLI and Cursor Agent run structured too.** With diff --git a/docs/guides/agent-browser.mdx b/docs/guides/agent-browser.mdx index 244e6383..975e5a74 100644 --- a/docs/guides/agent-browser.mdx +++ b/docs/guides/agent-browser.mdx @@ -66,6 +66,8 @@ the error when it doesn't start ends with what Chromium itself said. Inside a Shipyard session, commands act on the session's worktree; elsewhere, name it as `LOC/WT` (and from a laptop, `berth browser open BOX/LOC/WT`). +Every command takes `--tab NAME` for a [tab](#tabs-several-agents-in-one-worktree) +of its own. | Command | Prints | | --- | --- | @@ -80,7 +82,8 @@ name it as `LOC/WT` (and from a laptop, `berth browser open BOX/LOC/WT`). | `berthd browser console [--all]` | Errors and warnings since the last look (`--all`: every level) | | `berthd browser network` | Failed requests since the last look | | `berthd browser eval JS` | Its value, at most 2 KB | -| `berthd browser status\|close`, `berthd browser list` | Whether it runs, its size, its memory | +| `berthd browser status\|close`, `berthd browser list` | Whether it runs, its size, its memory; `close --tab NAME` closes just that tab | +| `berthd browser tabs` | Its tabs, what each shows and its size, and which one a command runs on now | | `berthd browser allow ORIGIN` | Lets agents' browsers on the box load a public origin | | `berthd browser reap [--dry-run]` | Closes [agent-browser](#agent-browser) sessions left by Shipyard sessions that ended | @@ -126,6 +129,45 @@ by default (about 530 tokens), and `shot --native` is the page's own pixels, its size times its scale (a 390×844 page at 3x is 1170×2532). The live view is cast at the page's pixels, at most 2560 on a side. +## Tabs: several agents in one worktree + +Without tabs, every agent in a worktree drives the same page. When a lead +agent has several subagents or teammates checking pages at once, each +one's `open` takes the page from the others: their refs stop working, a +shot can be of another agent's page, and the live view jumps from one +agent's page to the next. A tab gives each agent a page of its own: + +```sh +berthd browser open /signup --tab signup-a # opens the tab on first use +berthd browser click @e3 --tab signup-a +berthd browser tabs # every tab and what it shows +berthd browser close --tab signup-a +``` + +`--tab` goes on any command, anywhere on the line, and `$BERTH_BROWSER_TAB` +sets it for a shell. A name is letters, digits, dots, dashes and +underscores, up to 48. Without one, a command uses the main tab, the page +the browser has always had. + +Each tab has its own page, refs, console, failed requests and +[size](#size) (a tab without a size of its own opens at the worktree's), and +its own cookies and storage: logging in with `--as` in one tab doesn't log +the others in or out. Every tab is a window of its own, so its page counts as +visible and renders at full speed whether or not anyone watches it: its +timers, animations and shots are real. Only a tab someone watches is cast to +the app. + +A named tab closes after 5 idle minutes, and the browser closes when all of +its tabs are idle. A worktree's browser has at most 2 tabs on a box under +4 GB of memory, 4 up to 8 GB, 6 up to 16 GB and 8 above, counting the main +tab once an agent uses it. At the cap, a tab nobody has used for a minute +makes room. If every tab is in use, `open` is refused, so no agent loses a +page it is on. Each tab costs about 100 MB with a small page. + +The `berth-browser` skill tells an agent working beside others to use a tab +named after itself, and an agent that hands browser work to others to give +each one its tab's name. + ## What it costs an agent Everything Shipyard prints is short on purpose, because an agent reads every @@ -279,6 +321,14 @@ page is fitted to the pane but never enlarged past its own size, so it stays sharp. When the agent's browser closes, the pane goes back to your view by itself. The phone does not show it. +When agents use [tabs](#tabs-several-agents-in-one-worktree), a strip under +the bar lists them, the main tab as **Main**, with a green dot on each tab an +agent is using now. Hover over a tab to see its title, URL and size. The view +opens on the tab used last and stays on the tab you pick: agents opening +pages in other tabs never move it. The address, the size, and the Console and +Network drawer are the watched tab's. If the tab you're watching closes, the +view moves to the tab used last and says so. + You don't have to open it yourself. When an agent opens its browser in the worktree you're looking at, its live view opens to the right of the agent's pane, so you see the chat and the page side by side, and the keyboard stays @@ -318,14 +368,16 @@ none). It also keeps a browser to two renderer processes, whatever a page opens. At most 1 runs on a box under 2 GB of memory, 2 up to 4 GB, 3 up to 8 GB and 4 above (`max_browsers` in `~/.berth/runs.json`); one more closes the least recently -used. When the box runs low on memory, the [resource guard](/guides/resource-guard) +used. Each [tab](#tabs-several-agents-in-one-worktree) adds a renderer of its +own. When the box runs low on memory, the [resource guard](/guides/resource-guard) closes agents' browsers before it stops services or pauses agents. ## Events and gates `browser.opened`, `browser.navigated`, `browser.shot`, `browser.closed` (with a reason: idle, the guard, another worktree needing one, its worktree -removed) and `browser.failed` (with a reason: `sandbox` or `error`) are -[events](/reference/events). The gates `before:browser.open` +removed), `browser.tab.opened`, `browser.tab.closed` and `browser.failed` +(with a reason: `sandbox` or `error`) are [events](/reference/events); +`browser.navigated` and `browser.shot` name the tab. The gates `before:browser.open` (with the URL), `before:browser.eval`, `before:browser.watch` and `before:browser.allow` decide who may open, script, watch or widen it. diff --git a/docs/reference/berthd.mdx b/docs/reference/berthd.mdx index 6fa80c3e..9dbc7784 100644 --- a/docs/reference/berthd.mdx +++ b/docs/reference/berthd.mdx @@ -80,7 +80,8 @@ asks the app to open the page as a browser tab beside the agent; the | `berthd exec LOC[/WORKTREE] [--timeout 10m] [--detach [--no-notify]] -- COMMAND...` | Run a command there and print its output; `--detach` runs it as a run, which reports back to an agent that started it | | `berthd loop SESSION --check CMD [--prompt TEXT] [--max 5] [--turn-timeout 30m] [--cancel-on-exit] [--detach]` | Prompt, wait, check, and feed failures back, as a durable run | | `berthd runs`, `berthd run start\|get\|logs\|approve\|reject\|cancel\|templates …` | [Runs](/guides/runs), as `berth run` without the box | -| `berthd browser open\|snapshot\|click\|fill\|press\|select\|hover\|check\|wait\|shot\|console\|network\|eval\|status\|close [LOC/WT] …` | The worktree's [agent browser](/guides/agent-browser); inside a session, its own worktree | +| `berthd browser open\|snapshot\|click\|fill\|press\|select\|hover\|check\|wait\|shot\|console\|network\|eval\|status\|close [LOC/WT] … [--tab NAME]` | The worktree's [agent browser](/guides/agent-browser); inside a session, its own worktree. `--tab` (or `$BERTH_BROWSER_TAB`) gives an agent a [tab](/guides/agent-browser#tabs-several-agents-in-one-worktree) of its own | +| `berthd browser tabs [LOC/WT]` | The browser's tabs, what each shows, and which is busy | | `berthd browser open [LOC/WT] --as EMAIL [--path /x]` | Log the agent browser in as one of the project's [dev users](/guides/kits#personas-logging-in-as-a-dev-user) (the worktree's login script runs, its cookies go in for the worktree's address), then open the page: `--path`, else where the login says | | `berthd browser list`, `berthd browser allow ORIGIN`, `berthd browser install` | Browsers running; allow a public origin; download a Chromium | | `berthd browser reap [--dry-run] [--json]` | Close the [agent-browser](/guides/agent-browser#agent-browser) sessions (Vercel's CLI) that Shipyard sessions which have ended left running; `--dry-run` only lists them. `berthd doctor` reports them too | diff --git a/docs/reference/events.mdx b/docs/reference/events.mdx index 423a6478..5d612ab8 100644 --- a/docs/reference/events.mdx +++ b/docs/reference/events.mdx @@ -114,8 +114,9 @@ events. | `browser.opened`, `browser.closed` | `location`, `name`, `path`; `closed` has `reason` (`idle`, `closed`, the guard, another worktree needing one, its worktree removed) | | `artifact.added`, `artifact.updated`, `artifact.removed` | `id`, `title`, `kind`, `location`, `name`, `path`, `version`, and `session` when an agent made it; `problem` when a rewrite of its file wasn't taken. A rewrite the box noticed has origin `watch`; a visual diff from `berthd shots compare` has origin `shots` and its `summary`. See [artifacts](/guides/artifacts) | | `browser.failed` | `location`, `name`, `path`, `reason`: `sandbox` (Chromium's sandbox can't start; see [agent browser](/guides/agent-browser#when-ubuntu-blocks-it)) or `error` | -| `browser.navigated` | `location`, `name`, `path`, `url`: the [agent browser](/guides/agent-browser) loaded a page | -| `browser.shot` | `location`, `name`, `path`, `file`, `url` | +| `browser.navigated` | `location`, `name`, `path`, `tab`, `url`: the [agent browser](/guides/agent-browser) loaded a page (`tab` is `""` for the main tab) | +| `browser.shot` | `location`, `name`, `path`, `tab`, `file`, `url` | +| `browser.tab.opened`, `browser.tab.closed` | `location`, `name`, `path`, `tab`: an agent opened a [tab](/guides/agent-browser#tabs-several-agents-in-one-worktree) of its own, or it closed; `closed` has `reason` (`idle`, `closed`, another tab needing room) | | `worktree.login` | `location`, `worktree`, `email`, `ok`: a worktree was logged in as a [dev user](/guides/kits#personas-logging-in-as-a-dev-user), from the laptop's `/__berth/login`, `berthd browser open --as` (origin is the caller's) or `berthd shots compare --as` (origin `shots`). `email` is empty when it wasn't a valid address. Never a cookie | | `notify` | `title`, `body`, `flow`, `path`, `location`: a flow's or the guard's notification | | `guard.acted` | `action`, `location`, `name`, `path`, `services`, `sessions`, `memory_percent`, `reason` | @@ -201,8 +202,8 @@ exits non-zero refuses it with what it printed. Gates in a laptop's | `before:run.pr` | `run`, `path`, `branch`: a run about to push and open a pull request | | `before:artifact.add` | `location`, `name`, `path`, `title`, `kind`, `id` (when updating one) | | `before:artifact.remove` | `location`, `name`, `path`, `id` | -| `before:browser.open` | `location`, `name`, `path`, `url`: an agent browser opening a page | -| `before:browser.eval`, `before:browser.watch` | `location`, `name`, `path`: running JavaScript in it, watching it live | +| `before:browser.open` | `location`, `name`, `path`, `tab`, `url`: an agent browser opening a page | +| `before:browser.eval`, `before:browser.watch` | `location`, `name`, `path`, `tab`: running JavaScript in it, watching it live | | `before:browser.allow` | `origin`: letting agents' browsers load a public origin | | `before:worktree.login` | `location`, `worktree`, `email`: logging a worktree in as a dev user from the laptop (`/__berth/login`) | | `before:process.stop` | `id`: stopping a browser or session from the box's browsers list | diff --git a/internal/box/browser.go b/internal/box/browser.go index ca72c1da..04e9eec5 100644 --- a/internal/box/browser.go +++ b/internal/box/browser.go @@ -18,6 +18,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/cosscom/shipyard/internal/events" @@ -59,10 +60,14 @@ type Browsers struct { // Idle closes a browser unused this long (default BrowserIdle). Idle time.Duration + // MaxTabs caps the tabs one worktree's browser has (0: by the box's + // memory). + MaxTabs int + mu sync.Mutex vpMu sync.Mutex // viewports.json b *Box - open map[string]*browser // worktree path → + open map[string]*chromium // worktree path → stop chan struct{} failure *startFailure // the last start that failed, until one starts @@ -93,19 +98,20 @@ type frame struct { URL string `json:"url,omitempty"` } +// browser is one tab of a worktree's Chromium: the page an agent drives, +// with its own refs, console, requests, size and watchers. The main tab +// (tab "") is the one agents get without --tab; a named tab has a browser +// context of its own, so its cookies and storage are its own too. type browser struct { - m *Browsers - path string - location string - worktree string - cmd *exec.Cmd - cdp *cdpConn - session string - profile string - started time.Time - done chan struct{} - - run sync.Mutex // one command at a time + *chromium + tab string + target string + session string + bctx string // its browser context; "" for the main tab's, the default + opened time.Time + + run sync.Mutex // one command at a time + busy atomic.Int32 mu sync.Mutex url, title string @@ -134,7 +140,7 @@ type browser struct { // NewBrowsers makes the box's browser manager. func (b *Box) NewBrowsers(dir string, max int) *Browsers { - m := &Browsers{Dir: dir, Max: max, b: b, open: map[string]*browser{}, stop: make(chan struct{})} + m := &Browsers{Dir: dir, Max: max, b: b, open: map[string]*chromium{}, stop: make(chan struct{})} b.Browsers = m return m } @@ -166,18 +172,23 @@ func (m *Browsers) Run(ctx context.Context) { } } case <-t.C: + // A named tab goes when it is idle; the browser when all of + // its tabs are. m.mu.Lock() - var stale []*browser - for _, br := range m.open { - br.mu.Lock() - if time.Since(br.lastUsed) > idle && len(br.watchers) == 0 { - stale = append(stale, br) - } - br.mu.Unlock() + all := make([]*chromium, 0, len(m.open)) + for _, c := range m.open { + all = append(all, c) } m.mu.Unlock() - for _, br := range stale { - m.close(br, "idle") + for _, c := range all { + for _, br := range c.list() { + if br.tab != "" && br.idleFor() > idle { + c.closeTab(br, "idle") + } + } + if c.idleFor() > idle { + m.close(c, "idle") + } } } } @@ -255,175 +266,10 @@ func FindChromium() (string, error) { var errNoChromium = errors.New("no Chromium on this box; install one with `berthd browser install` (Playwright's headless shell) or set BERTH_CHROMIUM") -// get returns the worktree's browser, starting one if need be. -func (m *Browsers) get(ctx context.Context, loc Location, wt Worktree) (*browser, error) { - m.mu.Lock() - if br := m.open[wt.Path]; br != nil { - select { - case <-br.done: - delete(m.open, wt.Path) - default: - m.mu.Unlock() - br.touch() - return br, nil - } - } - // At the cap, the least recently used goes. - var lru *browser - if len(m.open) >= m.limit() { - for _, br := range m.open { - br.mu.Lock() - if lru == nil || br.lastUsed.Before(lru.lastUsed) { - lru = br - } - br.mu.Unlock() - } - } - m.mu.Unlock() - if lru != nil { - m.close(lru, "another worktree needed a browser") - } - br, err := m.launch(ctx, loc, wt) - m.noteStart(err) - if err != nil { - reason := "error" - if errors.Is(err, ErrBrowserSandbox) { - reason = "sandbox" - } - m.b.Events.Publish(events.Event{Type: "browser.failed", Box: m.b.Name, Origin: "browser", Data: map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path, "reason": reason}}) - return nil, err - } - m.mu.Lock() - m.open[wt.Path] = br - m.mu.Unlock() - m.b.Events.Publish(events.Event{Type: "browser.opened", Box: m.b.Name, Origin: "browser", Data: map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path}}) - return br, nil -} - -// Lookup returns the worktree's browser if it runs. -func (m *Browsers) Lookup(path string) *browser { - m.mu.Lock() - defer m.mu.Unlock() - if br := m.open[filepath.Clean(path)]; br != nil { - select { - case <-br.done: - return nil - default: - return br - } - } - return nil -} - -func (m *Browsers) close(br *browser, why string) { - m.mu.Lock() - if m.open[br.path] == br { - delete(m.open, br.path) - } - m.mu.Unlock() - br.shutdown() - m.b.Events.Publish(events.Event{Type: "browser.closed", Box: m.b.Name, Origin: "browser", Data: map[string]any{"location": br.location, "name": br.worktree, "path": br.path, "reason": why}}) -} - -// Close closes a worktree's browser. -func (m *Browsers) Close(path, why string) bool { - if br := m.Lookup(path); br != nil { - m.close(br, why) - return true - } - return false -} - -// CloseAll closes every browser. -func (m *Browsers) CloseAll(why string) { - m.mu.Lock() - all := make([]*browser, 0, len(m.open)) - for _, br := range m.open { - all = append(all, br) - } - m.mu.Unlock() - for _, br := range all { - m.close(br, why) - } -} - -// CloseLRU closes the least recently used browser nobody watches, for the -// memory guard, which closes browsers before it pauses agents. -func (m *Browsers) CloseLRU(why string) (*BrowserStatus, bool) { - m.mu.Lock() - var lru *browser - for _, br := range m.open { - br.mu.Lock() - if len(br.watchers) == 0 && (lru == nil || br.lastUsed.Before(lru.lastUsed)) { - lru = br - } - br.mu.Unlock() - } - m.mu.Unlock() - if lru == nil { - return nil, false - } - st := lru.status() - m.close(lru, why) - return &st, true -} - -// mains is every running browser's main process and worktree, without -// the cost of status (its memory, its proxy). -func (m *Browsers) mains() []BrowserStatus { - m.mu.Lock() - defer m.mu.Unlock() - var out []BrowserStatus - for _, br := range m.open { - if br.cmd.Process != nil { - out = append(out, BrowserStatus{Location: br.location, Worktree: br.worktree, Path: br.path, PID: br.cmd.Process.Pid}) - } - } - return out -} - -// List is every running browser's status. -func (m *Browsers) List() []BrowserStatus { - m.mu.Lock() - all := make([]*browser, 0, len(m.open)) - for _, br := range m.open { - all = append(all, br) - } - m.mu.Unlock() - out := []BrowserStatus{} - for _, br := range all { - out = append(out, br.status()) - } - return out -} - func (m *Browsers) shotDir(location, worktree string) string { return filepath.Join(m.Dir, "shots", location, worktree) } -func (m *Browsers) launch(ctx context.Context, loc Location, wt Worktree) (*browser, error) { - if err := m.simulatedSandbox(); err != nil { - return nil, err - } - bin := m.Chromium - if bin == "" { - var err error - if bin, err = FindChromium(); err != nil { - return nil, err - } - } - px, err := m.b.BrowserProxies.For(m.b, wt.Path) - if err != nil { - return nil, err - } - br, err := m.start(ctx, bin, px.Addr(), wt.Path, m.Viewport(wt.Path)) - if err != nil { - return nil, err - } - br.location, br.worktree = loc.Name, wt.Name - return br, nil -} - // Check starts Chromium once, on a blank page with nowhere to go, and // closes it: whether a browser starts now, after a fix. func (m *Browsers) Check(ctx context.Context) error { @@ -434,10 +280,10 @@ func (m *Browsers) Check(ctx context.Context) error { bin, err = FindChromium() } if err == nil { - var br *browser + var c *chromium // A proxy that refuses everything: the page is about:blank. - if br, err = m.start(ctx, bin, "127.0.0.1:9", "", DefaultViewport); err == nil { - br.shutdown() + if c, err = m.start(ctx, bin, "127.0.0.1:9", "", DefaultViewport); err == nil { + c.shutdown() } } } @@ -457,8 +303,8 @@ func (m *Browsers) simulatedSandbox() error { } // start runs Chromium with its only way out through proxy, for the -// worktree at path (where downloads go), at the size v. -func (m *Browsers) start(ctx context.Context, bin, proxy, path string, v Viewport) (*browser, error) { +// worktree at path (where downloads go), its main tab at the size v. +func (m *Browsers) start(ctx context.Context, bin, proxy, path string, v Viewport) (*chromium, error) { profile, err := os.MkdirTemp(filepath.Join(m.Dir, "profiles"), "p-") if err != nil { os.MkdirAll(filepath.Join(m.Dir, "profiles"), 0o700) @@ -489,19 +335,18 @@ func (m *Browsers) start(ctx context.Context, bin, proxy, path string, v Viewpor } toChrome.Close() fromChrome.Close() - br := &browser{m: m, cmd: cmd, path: path, viewport: v, profile: profile, started: time.Now(), lastUsed: time.Now(), - done: make(chan struct{}), refs: map[int64]string{}, reqs: map[string]string{}, inflight: map[string]bool{}, watchers: map[chan frame]struct{}{}} - br.cdp = newCDP(ours, theirs, br.onEvent) + c := &chromium{m: m, cmd: cmd, path: path, profile: profile, started: time.Now(), done: make(chan struct{}), tabs: map[string]*browser{}, sessions: map[string]*browser{}} + c.cdp = newCDP(ours, theirs, c.onEvent) go func() { cmd.Wait() ours.Close() theirs.Close() - close(br.done) + close(c.done) }() cctx, cancel := context.WithTimeout(ctx, browserStartTimeout()) defer cancel() - if err := br.attach(cctx); err != nil { - br.shutdown() + if err := c.attachMain(cctx, v); err != nil { + c.shutdown() if out := strings.ToLower(stderr.String()); strings.Contains(out, "sandbox") || strings.Contains(out, "namespace") { return nil, sandboxError(readUserns()) } @@ -510,7 +355,7 @@ func (m *Browsers) start(ctx context.Context, bin, proxy, path string, v Viewpor } return nil, fmt.Errorf("starting Chromium: %w", err) } - return br, nil + return c, nil } // chromiumArgs is how berthd runs Chromium, the agents' browser and the @@ -592,90 +437,9 @@ func (t *stderrTail) String() string { return string(t.b) } -func (br *browser) attach(ctx context.Context) error { - var targets struct { - TargetInfos []struct { - TargetID string `json:"targetId"` - Type string `json:"type"` - } `json:"targetInfos"` - } - if err := br.cdp.call(ctx, "", "Target.getTargets", nil, &targets); err != nil { - return err - } - target := "" - for _, t := range targets.TargetInfos { - if t.Type == "page" { - target = t.TargetID - break - } - } - if target == "" { - var created struct { - TargetID string `json:"targetId"` - } - if err := br.cdp.call(ctx, "", "Target.createTarget", map[string]any{"url": "about:blank"}, &created); err != nil { - return err - } - target = created.TargetID - } - var att struct { - SessionID string `json:"sessionId"` - } - if err := br.cdp.call(ctx, "", "Target.attachToTarget", map[string]any{"targetId": target, "flatten": true}, &att); err != nil { - return err - } - br.session = att.SessionID - for _, m := range []string{"Page.enable", "Runtime.enable", "Network.enable", "Log.enable", "DOM.enable", "Accessibility.enable"} { - if err := br.cdp.call(ctx, br.session, m, nil, nil); err != nil && !strings.HasPrefix(m, "Log") { - return err - } - } - if err := br.applyViewport(ctx, br.viewport); err != nil { - return err - } - // Downloads land in the worktree, where the agent can read them. - if br.path != "" { - dl := filepath.Join(br.path, ".berth", "browser") - os.MkdirAll(dl, 0o755) - br.cdp.call(ctx, "", "Browser.setDownloadBehavior", map[string]any{"behavior": "allow", "downloadPath": dl}, nil) - } - return nil -} - -func (br *browser) shutdown() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - br.cdp.call(ctx, "", "Browser.close", nil, nil) - cancel() - select { - case <-br.done: - case <-time.After(3 * time.Second): - if br.cmd.Process != nil { - br.cmd.Process.Kill() - } - <-br.done - } - br.cdp.close() - os.RemoveAll(br.profile) - br.mu.Lock() - for ch := range br.watchers { - close(ch) - delete(br.watchers, ch) - } - br.mu.Unlock() -} - -func (br *browser) touch() { - br.mu.Lock() - br.lastUsed = time.Now() - br.mu.Unlock() -} - // onEvent keeps what the page says: navigations, console errors, failed // requests, in-flight requests, screencast frames. Buffers are bounded. -func (br *browser) onEvent(method, session string, params json.RawMessage) { - if session != br.session && session != "" { - return - } +func (br *browser) onEvent(method string, params json.RawMessage) { switch method { case "Page.frameNavigated": var p struct { @@ -692,10 +456,10 @@ func (br *browser) onEvent(method, session string, params json.RawMessage) { br.mu.Lock() br.url = p.Frame.URL // A new document: new elements, new refs. - br.refs, br.nextRef, br.lastSnap = map[int64]string{}, 0, nil + br.refs, br.nextRef, br.lastSnap, br.title = map[int64]string{}, 0, nil, "" br.mu.Unlock() if !strings.HasPrefix(p.Frame.URL, "about:") { - br.m.b.Events.Publish(events.Event{Type: "browser.navigated", Box: br.m.b.Name, Origin: "browser", Data: map[string]any{"location": br.location, "name": br.worktree, "path": br.path, "url": p.Frame.URL}}) + br.m.b.Events.Publish(events.Event{Type: "browser.navigated", Box: br.m.b.Name, Origin: "browser", Data: map[string]any{"location": br.location, "name": br.worktree, "path": br.path, "tab": br.tab, "url": p.Frame.URL}}) } case "Page.navigatedWithinDocument": var p struct { @@ -956,41 +720,6 @@ func (br *browser) settle(ctx context.Context, max time.Duration) { br.mu.Unlock() } -// BrowserStatus is a browser as status and lists show it. -type BrowserStatus struct { - Location string `json:"location"` - Worktree string `json:"worktree"` - Path string `json:"path"` - URL string `json:"url,omitempty"` - PID int `json:"pid"` - RSS uint64 `json:"rss_bytes,omitempty"` - Started time.Time `json:"started"` - LastUsed time.Time `json:"last_used"` - Watchers int `json:"watchers"` - Refused int `json:"refused,omitempty"` - // Viewport is the page's size, and Size it as text (1920×1080). - Viewport Viewport `json:"viewport"` - Size string `json:"size"` -} - -func (br *browser) status() BrowserStatus { - br.mu.Lock() - st := BrowserStatus{Location: br.location, Worktree: br.worktree, Path: br.path, URL: br.url, Started: br.started, LastUsed: br.lastUsed, Watchers: len(br.watchers), Viewport: br.viewport} - br.mu.Unlock() - if st.Viewport.Width == 0 { - st.Viewport = DefaultViewport - } - st.Size = st.Viewport.String() - if br.cmd.Process != nil { - st.PID = br.cmd.Process.Pid - st.RSS = treeRSS(st.PID) - } - if px, err := br.m.b.BrowserProxies.For(br.m.b, br.path); err == nil { - st.Refused, _ = px.Refused() - } - return st -} - // treeRSS is the resident memory of pid and its descendants (Chromium's // renderers and GPU process), in bytes. func treeRSS(pid int) uint64 { @@ -1076,9 +805,7 @@ func (br *browser) pageTitle(ctx context.Context) string { // Open navigates and prints a first look: title, URL and a compact // interactive snapshot, with any errors. func (br *browser) Open(ctx context.Context, url string) (BrowserResult, error) { - br.run.Lock() - defer br.run.Unlock() - br.touch() + defer br.begin()() loaded := make(chan struct{}) br.mu.Lock() br.loaded = loaded @@ -1108,7 +835,11 @@ func (br *browser) Open(ctx context.Context, url string) (BrowserResult, error) br.mu.Lock() br.lastSnap, br.lastSnapURL = full, cur br.mu.Unlock() - fmt.Fprintf(&b, "url: %s\ntitle: %s\nsize: %s\n", cur, clip(br.pageTitle(ctx), 120), br.currentViewport()) + title := clip(br.pageTitle(ctx), 120) + br.mu.Lock() + br.title = title + br.mu.Unlock() + fmt.Fprintf(&b, "url: %s\ntitle: %s\nsize: %s\n", cur, title, br.currentViewport()) text, file := br.capLines(lines, firstLookCap) b.WriteString(strings.TrimRight(text, "\n")) br.appendLogs(&b, consoleAfter, netAfter) @@ -1212,9 +943,7 @@ func (br *browser) capLines(lines []string, limit int) (string, string) { // Snapshot prints the page, or with delta what changed since the last // snapshot of this page. func (br *browser) Snapshot(ctx context.Context, full, delta bool, selector string, depth int) (BrowserResult, error) { - br.run.Lock() - defer br.run.Unlock() - br.touch() + defer br.begin()() o := snapOptions{full: full, depth: depth, maxLines: 2000} if selector != "" { id, err := br.resolve(ctx, selector) @@ -1346,9 +1075,7 @@ var keyCodes = map[string]int{"Enter": 13, "Tab": 9, "Escape": 27, "Backspace": // Act clicks, fills, presses, selects or hovers, then prints what changed: // a delta of the snapshot, new console errors and failed requests. func (br *browser) Act(ctx context.Context, action, target, value string) (BrowserResult, error) { - br.run.Lock() - defer br.run.Unlock() - br.touch() + defer br.begin()() before := br.currentURL() var id int64 if action != "press" || target != "" { @@ -1457,9 +1184,7 @@ func (br *browser) Act(ctx context.Context, action, target, value string) (Brows // Wait waits for text on the page, a URL containing url, or the network // to go quiet. func (br *browser) Wait(ctx context.Context, text, url string, idle bool, timeout time.Duration) (BrowserResult, error) { - br.run.Lock() - defer br.run.Unlock() - br.touch() + defer br.begin()() if timeout <= 0 || timeout > time.Minute { timeout = 10 * time.Second } @@ -1507,9 +1232,7 @@ func jsString(s string) string { // width pixels wide (800 by default, which an agent reads for about 530 // tokens), or with native the page's own pixels, its size at its scale. func (br *browser) Shot(ctx context.Context, el string, full bool, width int, native bool) (BrowserResult, error) { - br.run.Lock() - defer br.run.Unlock() - br.touch() + defer br.begin()() if width <= 0 { width = 800 } @@ -1580,7 +1303,7 @@ func (br *browser) Shot(ctx context.Context, el string, full bool, width int, na } pruneShots(dir, keepShots) cur := br.currentURL() - br.m.b.Events.Publish(events.Event{Type: "browser.shot", Box: br.m.b.Name, Origin: "browser", Data: map[string]any{"location": br.location, "name": br.worktree, "path": br.path, "file": name, "url": cur}}) + br.m.b.Events.Publish(events.Event{Type: "browser.shot", Box: br.m.b.Name, Origin: "browser", Data: map[string]any{"location": br.location, "name": br.worktree, "path": br.path, "tab": br.tab, "file": name, "url": cur}}) pw, ph := pngSize(raw) if pw == 0 { pw, ph = int(math.Round(w*scale*dpr)), int(math.Round(h*scale*dpr)) @@ -1632,9 +1355,7 @@ func (br *browser) Network() BrowserResult { // Eval runs JavaScript in the page and prints its value, at most 2 KB. func (br *browser) Eval(ctx context.Context, js string) (BrowserResult, error) { - br.run.Lock() - defer br.run.Unlock() - br.touch() + defer br.begin()() raw, err := br.evaluate(ctx, js) if err != nil { return BrowserResult{Text: "error: " + err.Error()}, nil @@ -1721,7 +1442,8 @@ func (m *Browsers) Artifacts(location, worktree, path string) *BrowserArtifacts } a.Shots = append(a.Shots, filepath.Base(s)) } - if br := m.Lookup(path); br != nil { + if c := m.lookupChromium(path); c != nil && c.recent() != nil { + br := c.recent() br.mu.Lock() a.URL = br.url for i := len(br.console) - 1; i >= 0 && len(a.Errors) < 5; i-- { diff --git a/internal/box/browser_test.go b/internal/box/browser_test.go index 9c201588..f10e30c4 100644 --- a/internal/box/browser_test.go +++ b/internal/box/browser_test.go @@ -260,7 +260,7 @@ func TestAgentBrowserDrivesARealChromium(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() loc, _ := b.Locations.Get(ctx, "cal") - br, err := m.get(ctx, loc, wt) + br, err := m.get(ctx, loc, wt, "") if errors.Is(err, ErrBrowserSandbox) { t.Skip(err) // CI sets BERTH_BROWSER_NO_SANDBOX=1 instead } @@ -351,12 +351,12 @@ func TestAgentBrowserDrivesARealChromium(t *testing.T) { done := make(chan struct{}) go func() { m.Run(rctx); close(done) }() deadline := time.Now().Add(10 * time.Second) - for m.Lookup(wt.Path) != nil && time.Now().Before(deadline) { + for m.Lookup(wt.Path, "") != nil && time.Now().Before(deadline) { time.Sleep(100 * time.Millisecond) } stop() <-done - if m.Lookup(wt.Path) != nil || treeRSS(pid) != 0 { + if m.Lookup(wt.Path, "") != nil || treeRSS(pid) != 0 { t.Fatal("an idle browser was not closed") } // Its goroutines (the pipe reader, the waiter, tunnels) went with it, diff --git a/internal/box/browserapi.go b/internal/box/browserapi.go index 20fb3894..233b5ccf 100644 --- a/internal/box/browserapi.go +++ b/internal/box/browserapi.go @@ -13,7 +13,8 @@ import ( "time" ) -// The browser API, per worktree: +// The browser API, per worktree. Every route takes ?tab=NAME, a tab of the +// worktree's browser of its own (browsertabs.go); without it, the main tab: // // POST /v1/worktrees/{loc}/{wt}/browser/open {url, size, scale, as}: a path, a URL, or "" for the // worktree's own; size (1280x800, phone) and scale resize it first; as @@ -28,7 +29,8 @@ import ( // GET /v1/worktrees/{loc}/{wt}/browser/console?all=1 // GET /v1/worktrees/{loc}/{wt}/browser/network // GET /v1/worktrees/{loc}/{wt}/browser/status -// POST /v1/worktrees/{loc}/{wt}/browser/close +// GET /v1/worktrees/{loc}/{wt}/browser/tabs +// POST /v1/worktrees/{loc}/{wt}/browser/close the browser, or with ?tab= that tab // GET /v1/worktrees/{loc}/{wt}/browser/screencast NDJSON frames while you watch // GET /v1/worktrees/{loc}/{wt}/browser/shots/{name} // GET /v1/browsers, POST /v1/browser/allow {origin} @@ -54,21 +56,38 @@ func (b *Box) browserTarget(r *http.Request) (Location, Worktree, error) { return Location{}, Worktree{}, ErrUnknownWorktree } -// browserFor returns the worktree's running browser, or starts one when -// start (open does; reading commands don't). +// browserTab is the tab a request is for: ?tab=NAME, or "" for the main +// tab. +func browserTab(r *http.Request) (string, error) { + tab := r.URL.Query().Get("tab") + if err := CheckTabName(tab); err != nil { + return "", badRequest("%v", err) + } + return tab, nil +} + +// browserFor returns the worktree's open tab, or opens it (and the browser) +// when start (open does; reading commands don't). func (b *Box) browserFor(r *http.Request, start bool) (*browser, Location, Worktree, error) { loc, wt, err := b.browserTarget(r) if err != nil { return nil, loc, wt, err } - if br := b.Browsers.Lookup(wt.Path); br != nil { + tab, err := browserTab(r) + if err != nil { + return nil, loc, wt, err + } + if br := b.Browsers.Lookup(wt.Path, tab); br != nil { br.touch() return br, loc, wt, nil } if !start { + if tab != "" { + return nil, loc, wt, httpError{http.StatusConflict, "no tab " + tab + " is open for " + loc.Name + "/" + wt.Name + "; open it first (browser open --tab " + tab + ")"} + } return nil, loc, wt, httpError{http.StatusConflict, "no browser is open for " + loc.Name + "/" + wt.Name + "; open one first (browser open)"} } - br, err := b.Browsers.get(r.Context(), loc, wt) + br, err := b.Browsers.get(r.Context(), loc, wt, tab) return br, loc, wt, err } @@ -84,9 +103,13 @@ func (b *Box) browserOpen(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } + tab, err := browserTab(r) + if err != nil { + return err + } var size *Viewport if req.Size != "" || req.Scale != "" { - v, err := b.Browsers.Viewport(wt.Path).Resolve(req.Size, req.Scale) + v, err := b.Browsers.Viewport(wt.Path, tab).Resolve(req.Size, req.Scale) if err != nil { return badRequest("%v", err) } @@ -108,12 +131,12 @@ func (b *Box) browserOpen(w http.ResponseWriter, r *http.Request) error { if _, err := url.Parse(target); err != nil { return badRequest("%q is not a URL", target) } - if err := b.before(r, "browser.open", map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path, "url": target}); err != nil { + if err := b.before(r, "browser.open", map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path, "tab": tab, "url": target}); err != nil { return err } // The size first: a browser that starts, starts at it. if size != nil { - if _, err := b.Browsers.Resize(r.Context(), wt.Path, *size); err != nil { + if _, err := b.Browsers.Resize(r.Context(), wt.Path, tab, *size); err != nil { return err } } @@ -159,11 +182,15 @@ func (b *Box) browserResize(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - v, err := b.Browsers.Viewport(wt.Path).Resolve(req.Size, req.Scale) + tab, err := browserTab(r) + if err != nil { + return err + } + v, err := b.Browsers.Viewport(wt.Path, tab).Resolve(req.Size, req.Scale) if err != nil { return badRequest("%v", err) } - br, err := b.Browsers.Resize(r.Context(), wt.Path, v) + br, err := b.Browsers.Resize(r.Context(), wt.Path, tab, v) if err != nil { return err } @@ -268,7 +295,7 @@ func (b *Box) browserEval(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - if err := b.before(r, "browser.eval", map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path}); err != nil { + if err := b.before(r, "browser.eval", map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path, "tab": br.tab}); err != nil { return err } res, err := br.Eval(r.Context(), req.JS) @@ -302,26 +329,80 @@ func (b *Box) browserStatus(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - br := b.Browsers.Lookup(wt.Path) + tab, err := browserTab(r) + if err != nil { + return err + } + c := b.Browsers.lookupChromium(wt.Path) + var br *browser + if c != nil { + br = c.lookup(tab) + } if br == nil { // Why one can't start, if it can't: cheap, without starting it. - v := b.Browsers.Viewport(wt.Path) - res := map[string]any{"running": false, "viewport": v, "size": v.String(), "text": "no browser open; it opens at " + v.String()} - if h := b.Browsers.Health(); h.State != "ok" { + v := b.Browsers.Viewport(wt.Path, tab) + what := "no browser open" + if c != nil { + what = "no tab " + tab + " open" + } + res := map[string]any{"running": false, "viewport": v, "size": v.String(), "text": what + "; it opens at " + v.String()} + if c != nil { + res["tabs"] = c.status().Tabs + } + if h := b.Browsers.Health(); h.State != "ok" && c == nil { res["health"], res["text"] = h, "no browser open; "+h.Text } writeJSON(w, res) return nil } - st := br.status() + st := c.status() + ts := br.tabStatus() + // The status's page is the tab asked for. + st.URL, st.Viewport, st.Size = ts.URL, ts.Viewport, ts.Size text := "open: " + st.URL + " · " + st.Size if st.RSS > 0 { text += " (" + strconvMB(st.RSS) + ")" } - writeJSON(w, map[string]any{"running": true, "status": st, "viewport": st.Viewport, "size": st.Size, "text": text}) + if len(st.Tabs) > 1 || tab != "" { + text += "\n" + tabsText(st.Tabs) + } + writeJSON(w, map[string]any{"running": true, "status": st, "viewport": st.Viewport, "size": st.Size, "tabs": st.Tabs, "text": text}) + return nil +} + +// browserTabs lists a worktree's tabs. +func (b *Box) browserTabs(w http.ResponseWriter, r *http.Request) error { + _, wt, err := b.browserTarget(r) + if err != nil { + return err + } + c := b.Browsers.lookupChromium(wt.Path) + if c == nil { + writeJSON(w, map[string]any{"tabs": []TabStatus{}, "text": "no browser open"}) + return nil + } + st := c.status() + writeJSON(w, map[string]any{"tabs": st.Tabs, "text": tabsText(st.Tabs)}) return nil } +// tabsText is tabs as an agent reads them: one a line. +func tabsText(tabs []TabStatus) string { + var b strings.Builder + b.WriteString("tabs:") + for _, t := range tabs { + name := t.Name + if name == "" { + name = "(main)" + } + fmt.Fprintf(&b, "\n %s %s %s", name, t.URL, t.Size) + if t.Busy { + b.WriteString(" busy") + } + } + return b.String() +} + func strconvMB(n uint64) string { return fmt.Sprintf("%.0f MB", float64(n)/(1<<20)) } func (b *Box) browserClose(w http.ResponseWriter, r *http.Request) error { @@ -329,6 +410,15 @@ func (b *Box) browserClose(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } + tab, err := browserTab(r) + if err != nil { + return err + } + if tab != "" { + closed := b.Browsers.CloseTab(wt.Path, tab, "closed") + writeJSON(w, map[string]any{"closed": closed, "text": map[bool]string{true: "closed tab " + tab, false: "no tab " + tab + " was open"}[closed]}) + return nil + } closed := b.Browsers.Close(wt.Path, "closed") writeJSON(w, map[string]any{"closed": closed, "text": map[bool]string{true: "closed", false: "no browser was open"}[closed]}) return nil @@ -341,11 +431,18 @@ func (b *Box) browserScreencast(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - if err := b.before(r, "browser.watch", map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path}); err != nil { + tab, err := browserTab(r) + if err != nil { return err } - br := b.Browsers.Lookup(wt.Path) + if err := b.before(r, "browser.watch", map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path, "tab": tab}); err != nil { + return err + } + br := b.Browsers.Lookup(wt.Path, tab) if br == nil { + if tab != "" { + return httpError{http.StatusConflict, "no tab " + tab + " is open for " + loc.Name + "/" + wt.Name} + } return httpError{http.StatusConflict, "no browser is open for " + loc.Name + "/" + wt.Name} } frames, stop := br.Watch(r.Context()) @@ -472,6 +569,7 @@ func (b *Box) mountBrowser(route func(string, func(http.ResponseWriter, *http.Re route("GET "+p+"network", b.browserNetwork) route("GET "+p+"devtools", b.browserDevtools) route("GET "+p+"status", b.browserStatus) + route("GET "+p+"tabs", b.browserTabs) route("POST "+p+"close", b.browserClose) route("GET "+p+"screencast", b.browserScreencast) route("GET "+p+"shots/{name}", b.browserShotFile) diff --git a/internal/box/browserdevtools.go b/internal/box/browserdevtools.go index 25745794..652ad6f6 100644 --- a/internal/box/browserdevtools.go +++ b/internal/box/browserdevtools.go @@ -53,7 +53,11 @@ func (b *Box) browserDevtools(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - br := b.Browsers.Lookup(wt.Path) + tab, err := browserTab(r) + if err != nil { + return err + } + br := b.Browsers.Lookup(wt.Path, tab) if br == nil { writeJSON(w, BrowserDevtools{Console: []DevtoolsLine{}, Failures: []DevtoolsFailed{}}) return nil diff --git a/internal/box/browserdevtools_test.go b/internal/box/browserdevtools_test.go index e4f2df50..f624a902 100644 --- a/internal/box/browserdevtools_test.go +++ b/internal/box/browserdevtools_test.go @@ -9,7 +9,7 @@ func TestAgentBrowserDevtoolsLeaveTheAgentsLinesUnseen(t *testing.T) { br := &browser{reqs: map[string]string{}, inflight: map[string]bool{}} ev := func(method string, params any) { raw, _ := json.Marshal(params) - br.onEvent(method, "", raw) + br.onEvent(method, raw) } ev("Runtime.consoleAPICalled", map[string]any{"type": "error", "args": []map[string]any{{"value": "cart total is"}, {"description": "undefined"}}}) ev("Runtime.consoleAPICalled", map[string]any{"type": "error", "args": []map[string]any{{"value": "cart total is"}, {"description": "undefined"}}}) diff --git a/internal/box/browsertabs.go b/internal/box/browsertabs.go new file mode 100644 index 00000000..ede5dfac --- /dev/null +++ b/internal/box/browsertabs.go @@ -0,0 +1,669 @@ +package box + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/cosscom/shipyard/internal/events" +) + +// Tabs: several agents can share a worktree's browser, each in a tab of its +// own (berthd browser --tab NAME). Without tabs they shared one page, and +// each one's open took the page from under the others: their refs went +// stale, a shot could be of another agent's page, and the live view in the +// app jumped from one agent's page to the next. +// +// A tab is a page with its own CDP session, so its own refs, console, +// requests, size and watchers, and its own browser context, so its own +// cookies: one agent logging in as a user doesn't log the others in or +// out. Every tab is its own window, so every tab is visible to its page and +// renders: an agent's timers, animations and shots are real whether or not +// someone watches. Only watched tabs are cast to the app. +// +// The main tab, "", is the one Chromium starts with, in the default +// context: what agents had before tabs, and still get without --tab. Named +// tabs open on first use and close after BrowserIdle unused; the browser +// closes when all of its tabs are idle. + +// chromium is a worktree's Chromium: the process, its CDP pipe, and its +// tabs. +type chromium struct { + m *Browsers + path string + location string + worktree string + cmd *exec.Cmd + cdp *cdpConn + profile string + started time.Time + done chan struct{} + + opening sync.Mutex // one tab opens at a time + tmu sync.Mutex + tabs map[string]*browser // by name + sessions map[string]*browser // by CDP session, for events +} + +// tabName is what a tab may be called: a teammate's or a task's name. +var tabName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$`) + +// CheckTabName says what is wrong with a tab's name, if anything. +func CheckTabName(name string) error { + if name != "" && !tabName.MatchString(name) { + return fmt.Errorf("%q can't name a tab: use letters, digits, dots, dashes and underscores, at most 48", name) + } + return nil +} + +// tabLimit is how many tabs one worktree's browser may have: by the box's +// memory, 2 under 4 GB, 4 to 8 GB, 6 to 16 GB, 8 above. Each tab is a +// renderer of its own, rendering in software. +func (m *Browsers) tabLimit() int { + if m.MaxTabs > 0 { + return m.MaxTabs + } + total := memTotal() + switch { + case total == 0: + return 4 + case total < 4<<30: + return 2 + case total < 8<<30: + return 4 + case total < 16<<30: + return 6 + } + return 8 +} + +// get returns the worktree's tab, starting the browser and opening the tab +// if need be. +func (m *Browsers) get(ctx context.Context, loc Location, wt Worktree, tab string) (*browser, error) { + c, err := m.chromiumFor(ctx, loc, wt) + if err != nil { + return nil, err + } + if br := c.lookup(tab); br != nil { + br.touch() + return br, nil + } + return c.openTab(ctx, tab, m.Viewport(wt.Path, tab)) +} + +// chromiumFor returns the worktree's browser, starting one if need be. +func (m *Browsers) chromiumFor(ctx context.Context, loc Location, wt Worktree) (*chromium, error) { + m.mu.Lock() + if c := m.open[wt.Path]; c != nil { + select { + case <-c.done: + delete(m.open, wt.Path) + default: + m.mu.Unlock() + return c, nil + } + } + // At the cap, the least recently used goes. + var lru *chromium + if len(m.open) >= m.limit() { + for _, c := range m.open { + if lru == nil || c.lastUsed().Before(lru.lastUsed()) { + lru = c + } + } + } + m.mu.Unlock() + if lru != nil { + m.close(lru, "another worktree needed a browser") + } + c, err := m.launch(ctx, loc, wt) + m.noteStart(err) + if err != nil { + reason := "error" + if errors.Is(err, ErrBrowserSandbox) { + reason = "sandbox" + } + m.b.Events.Publish(events.Event{Type: "browser.failed", Box: m.b.Name, Origin: "browser", Data: map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path, "reason": reason}}) + return nil, err + } + m.mu.Lock() + m.open[wt.Path] = c + m.mu.Unlock() + m.b.Events.Publish(events.Event{Type: "browser.opened", Box: m.b.Name, Origin: "browser", Data: map[string]any{"location": loc.Name, "name": wt.Name, "path": wt.Path}}) + return c, nil +} + +func (m *Browsers) launch(ctx context.Context, loc Location, wt Worktree) (*chromium, error) { + if err := m.simulatedSandbox(); err != nil { + return nil, err + } + bin := m.Chromium + if bin == "" { + var err error + if bin, err = FindChromium(); err != nil { + return nil, err + } + } + px, err := m.b.BrowserProxies.For(m.b, wt.Path) + if err != nil { + return nil, err + } + c, err := m.start(ctx, bin, px.Addr(), wt.Path, m.Viewport(wt.Path, "")) + if err != nil { + return nil, err + } + c.location, c.worktree = loc.Name, wt.Name + return c, nil +} + +// lookupChromium returns the worktree's browser if it runs. +func (m *Browsers) lookupChromium(path string) *chromium { + m.mu.Lock() + defer m.mu.Unlock() + if c := m.open[filepath.Clean(path)]; c != nil { + select { + case <-c.done: + return nil + default: + return c + } + } + return nil +} + +// Lookup returns the worktree's tab if it is open. +func (m *Browsers) Lookup(path, tab string) *browser { + if c := m.lookupChromium(path); c != nil { + return c.lookup(tab) + } + return nil +} + +func (m *Browsers) close(c *chromium, why string) { + m.mu.Lock() + if m.open[c.path] == c { + delete(m.open, c.path) + } + m.mu.Unlock() + c.shutdown() + m.b.Events.Publish(events.Event{Type: "browser.closed", Box: m.b.Name, Origin: "browser", Data: map[string]any{"location": c.location, "name": c.worktree, "path": c.path, "reason": why}}) +} + +// Close closes a worktree's browser, every tab of it. +func (m *Browsers) Close(path, why string) bool { + if c := m.lookupChromium(path); c != nil { + m.close(c, why) + return true + } + return false +} + +// CloseTab closes one named tab of a worktree's browser. +func (m *Browsers) CloseTab(path, tab, why string) bool { + if br := m.Lookup(path, tab); br != nil && tab != "" { + br.chromium.closeTab(br, why) + return true + } + return false +} + +// CloseAll closes every browser. +func (m *Browsers) CloseAll(why string) { + m.mu.Lock() + all := make([]*chromium, 0, len(m.open)) + for _, c := range m.open { + all = append(all, c) + } + m.mu.Unlock() + for _, c := range all { + m.close(c, why) + } +} + +// CloseLRU closes the least recently used browser nobody watches, for the +// memory guard, which closes browsers before it pauses agents. +func (m *Browsers) CloseLRU(why string) (*BrowserStatus, bool) { + m.mu.Lock() + var lru *chromium + for _, c := range m.open { + if c.watchers() == 0 && (lru == nil || c.lastUsed().Before(lru.lastUsed())) { + lru = c + } + } + m.mu.Unlock() + if lru == nil { + return nil, false + } + st := lru.status() + m.close(lru, why) + return &st, true +} + +// mains is every running browser's main process and worktree, without +// the cost of status (its memory, its proxy). +func (m *Browsers) mains() []BrowserStatus { + m.mu.Lock() + defer m.mu.Unlock() + var out []BrowserStatus + for _, c := range m.open { + if c.cmd.Process != nil { + out = append(out, BrowserStatus{Location: c.location, Worktree: c.worktree, Path: c.path, PID: c.cmd.Process.Pid}) + } + } + return out +} + +// List is every running browser's status. +func (m *Browsers) List() []BrowserStatus { + m.mu.Lock() + all := make([]*chromium, 0, len(m.open)) + for _, c := range m.open { + all = append(all, c) + } + m.mu.Unlock() + out := []BrowserStatus{} + for _, c := range all { + out = append(out, c.status()) + } + return out +} + +// --- a worktree's Chromium ------------------------------------------------- + +// attachMain takes the page Chromium started with as the main tab. +func (c *chromium) attachMain(ctx context.Context, v Viewport) error { + var targets struct { + TargetInfos []struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + } `json:"targetInfos"` + } + if err := c.cdp.call(ctx, "", "Target.getTargets", nil, &targets); err != nil { + return err + } + target := "" + for _, t := range targets.TargetInfos { + if t.Type == "page" { + target = t.TargetID + break + } + } + if target == "" { + var created struct { + TargetID string `json:"targetId"` + } + if err := c.cdp.call(ctx, "", "Target.createTarget", map[string]any{"url": "about:blank"}, &created); err != nil { + return err + } + target = created.TargetID + } + _, err := c.attachTab(ctx, "", target, "", v) + return err +} + +// openTab opens a named tab: a browser context of its own, a window of its +// own in it, at the size v. At the box's cap, a tab nobody used for a +// minute makes room; else it is refused, rather than take a page another +// agent is on. +func (c *chromium) openTab(ctx context.Context, name string, v Viewport) (*browser, error) { + c.opening.Lock() + defer c.opening.Unlock() + if br := c.lookup(name); br != nil { + return br, nil + } + if name == "" { + return nil, errors.New("the browser has no main tab") + } + if limit := c.m.tabLimit(); c.counted() >= limit { + var lru *browser + for _, br := range c.list() { + if br.tab != "" && br.idleFor() > time.Minute && (lru == nil || br.lastUsedAt().Before(lru.lastUsedAt())) { + lru = br + } + } + if lru == nil { + return nil, httpError{http.StatusConflict, fmt.Sprintf("the browser for %s/%s has %d tabs open, the most this box runs at once; close one (berthd browser close --tab NAME) or wait for one to go idle", c.location, c.worktree, limit)} + } + c.closeTab(lru, "another tab needed room") + } + var bc struct { + BrowserContextID string `json:"browserContextId"` + } + if err := c.cdp.call(ctx, "", "Target.createBrowserContext", map[string]any{}, &bc); err != nil { + return nil, fmt.Errorf("opening tab %s: %w", name, err) + } + var created struct { + TargetID string `json:"targetId"` + } + if err := c.cdp.call(ctx, "", "Target.createTarget", map[string]any{"url": "about:blank", "browserContextId": bc.BrowserContextID, "newWindow": true}, &created); err != nil { + c.cdp.call(context.Background(), "", "Target.disposeBrowserContext", map[string]any{"browserContextId": bc.BrowserContextID}, nil) + return nil, fmt.Errorf("opening tab %s: %w", name, err) + } + br, err := c.attachTab(ctx, name, created.TargetID, bc.BrowserContextID, v) + if err != nil { + c.cdp.call(context.Background(), "", "Target.closeTarget", map[string]any{"targetId": created.TargetID}, nil) + c.cdp.call(context.Background(), "", "Target.disposeBrowserContext", map[string]any{"browserContextId": bc.BrowserContextID}, nil) + return nil, err + } + c.m.b.Events.Publish(events.Event{Type: "browser.tab.opened", Box: c.m.b.Name, Origin: "browser", Data: map[string]any{"location": c.location, "name": c.worktree, "path": c.path, "tab": name}}) + return br, nil +} + +// attachTab attaches to a page and makes it the tab name. +func (c *chromium) attachTab(ctx context.Context, name, target, bctx string, v Viewport) (*browser, error) { + var att struct { + SessionID string `json:"sessionId"` + } + if err := c.cdp.call(ctx, "", "Target.attachToTarget", map[string]any{"targetId": target, "flatten": true}, &att); err != nil { + return nil, err + } + now := time.Now() + br := &browser{chromium: c, tab: name, target: target, session: att.SessionID, bctx: bctx, opened: now, lastUsed: now, viewport: v, + refs: map[int64]string{}, reqs: map[string]string{}, inflight: map[string]bool{}, watchers: map[chan frame]struct{}{}} + // Known by its session before its first event. + c.tmu.Lock() + c.sessions[br.session] = br + c.tmu.Unlock() + fail := func(err error) (*browser, error) { + c.tmu.Lock() + delete(c.sessions, br.session) + c.tmu.Unlock() + return nil, err + } + for _, m := range []string{"Page.enable", "Runtime.enable", "Network.enable", "Log.enable", "DOM.enable", "Accessibility.enable"} { + if err := c.cdp.call(ctx, br.session, m, nil, nil); err != nil && !strings.HasPrefix(m, "Log") { + return fail(err) + } + } + if err := br.applyViewport(ctx, v); err != nil { + return fail(err) + } + // Downloads land in the worktree, where the agent can read them. + if c.path != "" { + dl := filepath.Join(c.path, ".berth", "browser") + os.MkdirAll(dl, 0o755) + p := map[string]any{"behavior": "allow", "downloadPath": dl} + if bctx != "" { + p["browserContextId"] = bctx + } + c.cdp.call(ctx, "", "Browser.setDownloadBehavior", p, nil) + } + c.tmu.Lock() + c.tabs[name] = br + c.tmu.Unlock() + return br, nil +} + +// closeTab closes a named tab: its page, its context, its watchers. +func (c *chromium) closeTab(br *browser, why string) { + if br.tab == "" { + return + } + c.tmu.Lock() + if c.tabs[br.tab] != br { + c.tmu.Unlock() + return + } + delete(c.tabs, br.tab) + delete(c.sessions, br.session) + c.tmu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + c.cdp.call(ctx, "", "Target.closeTarget", map[string]any{"targetId": br.target}, nil) + c.cdp.call(ctx, "", "Target.disposeBrowserContext", map[string]any{"browserContextId": br.bctx}, nil) + cancel() + br.closeWatchers() + c.m.b.Events.Publish(events.Event{Type: "browser.tab.closed", Box: c.m.b.Name, Origin: "browser", Data: map[string]any{"location": c.location, "name": c.worktree, "path": c.path, "tab": br.tab, "reason": why}}) +} + +func (c *chromium) shutdown() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + c.cdp.call(ctx, "", "Browser.close", nil, nil) + cancel() + select { + case <-c.done: + case <-time.After(3 * time.Second): + if c.cmd.Process != nil { + c.cmd.Process.Kill() + } + <-c.done + } + c.cdp.close() + os.RemoveAll(c.profile) + for _, br := range c.list() { + br.closeWatchers() + } +} + +// onEvent hands an event to the tab whose session it came on; the +// browser's own events go to the main tab. +func (c *chromium) onEvent(method, session string, params json.RawMessage) { + c.tmu.Lock() + br := c.sessions[session] + if session == "" { + br = c.tabs[""] + } + c.tmu.Unlock() + if br != nil { + br.onEvent(method, params) + } +} + +func (c *chromium) lookup(name string) *browser { + c.tmu.Lock() + defer c.tmu.Unlock() + return c.tabs[name] +} + +// list is the tabs, the main one first, then by when they opened. +func (c *chromium) list() []*browser { + c.tmu.Lock() + out := make([]*browser, 0, len(c.tabs)) + for _, br := range c.tabs { + out = append(out, br) + } + c.tmu.Unlock() + sort.Slice(out, func(i, j int) bool { + if (out[i].tab == "") != (out[j].tab == "") { + return out[i].tab == "" + } + if !out[i].opened.Equal(out[j].opened) { + return out[i].opened.Before(out[j].opened) + } + return out[i].tab < out[j].tab + }) + return out +} + +// counted is how many tabs count toward the cap: the main tab only once +// an agent has used it. +func (c *chromium) counted() int { + n := 0 + for _, br := range c.list() { + if br.tab != "" || br.tabStatus().used() { + n++ + } + } + return n +} + +// recent is the tab used last. +func (c *chromium) recent() *browser { + var out *browser + for _, br := range c.list() { + if out == nil || br.lastUsedAt().After(out.lastUsedAt()) { + out = br + } + } + return out +} + +func (c *chromium) lastUsed() time.Time { + var t time.Time + for _, br := range c.list() { + if u := br.lastUsedAt(); u.After(t) { + t = u + } + } + return t +} + +func (c *chromium) watchers() int { + n := 0 + for _, br := range c.list() { + br.mu.Lock() + n += len(br.watchers) + br.mu.Unlock() + } + return n +} + +// idleFor is how long since any tab was used, 0 while one is watched. +func (c *chromium) idleFor() time.Duration { + if c.watchers() > 0 { + return 0 + } + return time.Since(c.lastUsed()) +} + +// --- a tab ------------------------------------------------------------------ + +func (br *browser) touch() { + br.mu.Lock() + br.lastUsed = time.Now() + br.mu.Unlock() +} + +func (br *browser) lastUsedAt() time.Time { + br.mu.Lock() + defer br.mu.Unlock() + return br.lastUsed +} + +// idleFor is how long since the tab was used, 0 while it is watched or a +// command runs on it. +func (br *browser) idleFor() time.Duration { + br.mu.Lock() + defer br.mu.Unlock() + if len(br.watchers) > 0 || br.busy.Load() > 0 { + return 0 + } + return time.Since(br.lastUsed) +} + +// begin starts a command on the tab, one at a time, and returns its end. +func (br *browser) begin() func() { + br.run.Lock() + br.busy.Add(1) + br.touch() + return func() { + br.touch() + br.busy.Add(-1) + br.run.Unlock() + } +} + +func (br *browser) closeWatchers() { + br.mu.Lock() + for ch := range br.watchers { + close(ch) + delete(br.watchers, ch) + } + br.mu.Unlock() +} + +// BrowserStatus is a browser as status and lists show it: its main tab's +// page (or, without one, the tab used last), and all of its tabs. +type BrowserStatus struct { + Location string `json:"location"` + Worktree string `json:"worktree"` + Path string `json:"path"` + URL string `json:"url,omitempty"` + PID int `json:"pid"` + RSS uint64 `json:"rss_bytes,omitempty"` + Started time.Time `json:"started"` + LastUsed time.Time `json:"last_used"` + Watchers int `json:"watchers"` + Refused int `json:"refused,omitempty"` + // Viewport is the page's size, and Size it as text (1920×1080). + Viewport Viewport `json:"viewport"` + Size string `json:"size"` + // Tabs are its tabs: the main one ("") if an agent used it or there is + // no other, then the named ones as they opened. + Tabs []TabStatus `json:"tabs"` +} + +// TabStatus is one tab of a browser. +type TabStatus struct { + Name string `json:"name"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + LastUsed time.Time `json:"last_used"` + // IdleMS is how long since it was used by the box's clock, which the + // laptop's may not agree with. + IdleMS int64 `json:"idle_ms"` + Watchers int `json:"watchers"` + Viewport Viewport `json:"viewport"` + Size string `json:"size"` + // Busy is whether an agent's command runs on it now. + Busy bool `json:"busy,omitempty"` +} + +func (br *browser) tabStatus() TabStatus { + br.mu.Lock() + st := TabStatus{Name: br.tab, URL: br.url, Title: br.title, LastUsed: br.lastUsed, IdleMS: time.Since(br.lastUsed).Milliseconds(), Watchers: len(br.watchers), Viewport: br.viewport, Busy: br.busy.Load() > 0} + br.mu.Unlock() + if st.Viewport.Width == 0 { + st.Viewport = DefaultViewport + } + st.Size = st.Viewport.String() + return st +} + +// used is whether an agent has been on the tab: a main tab still on +// about:blank, with named tabs beside it, is left out of lists, unless +// someone watches it (it would look closed to them). +func (t TabStatus) used() bool { return t.URL != "" && !strings.HasPrefix(t.URL, "about:") } + +func (c *chromium) status() BrowserStatus { + st := BrowserStatus{Location: c.location, Worktree: c.worktree, Path: c.path, Started: c.started, Tabs: []TabStatus{}} + all := c.list() + for _, br := range all { + ts := br.tabStatus() + st.Watchers += ts.Watchers + if ts.LastUsed.After(st.LastUsed) { + st.LastUsed = ts.LastUsed + } + if ts.Name == "" && !ts.used() && ts.Watchers == 0 && len(all) > 1 { + continue + } + st.Tabs = append(st.Tabs, ts) + } + page := c.lookup("") + if page == nil || (!page.tabStatus().used() && len(all) > 1) { + page = c.recent() + } + if page != nil { + ts := page.tabStatus() + st.URL, st.Viewport, st.Size = ts.URL, ts.Viewport, ts.Size + } else { + st.Viewport, st.Size = DefaultViewport, DefaultViewport.String() + } + if c.cmd.Process != nil { + st.PID = c.cmd.Process.Pid + st.RSS = treeRSS(st.PID) + } + if px, err := c.m.b.BrowserProxies.For(c.m.b, c.path); err == nil { + st.Refused, _ = px.Refused() + } + return st +} diff --git a/internal/box/browsertabs_test.go b/internal/box/browsertabs_test.go new file mode 100644 index 00000000..512c1033 --- /dev/null +++ b/internal/box/browsertabs_test.go @@ -0,0 +1,268 @@ +package box + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func TestTabNames(t *testing.T) { + for _, ok := range []string{"", "a", "L2-l2-base-a", "signup_b.2"} { + if err := CheckTabName(ok); err != nil { + t.Errorf("%q: %v", ok, err) + } + } + for _, bad := range []string{"-a", "a b", "a/b", "ü", strings.Repeat("a", 49)} { + if CheckTabName(bad) == nil { + t.Errorf("%q is a name", bad) + } + } + if k := viewportKey("/w/cal-billing/", "signup-a"); k != "/w/cal-billing#tab=signup-a" { + t.Errorf("key %q", k) + } +} + +func TestATabKeepsItsOwnSizeAndOpensAtTheWorktreesOtherwise(t *testing.T) { + m := &Browsers{Dir: t.TempDir()} + phone, tablet := Viewport{390, 844, 3}, Viewport{820, 1180, 2} + m.setViewport("/w/cal-billing", "", tablet) + if v := m.Viewport("/w/cal-billing", "signup-a"); v != tablet { + t.Fatalf("a tab without a size of its own: %v", v) + } + m.setViewport("/w/cal-billing", "signup-a", phone) + if v := m.Viewport("/w/cal-billing", "signup-a"); v != phone { + t.Fatalf("the tab's own: %v", v) + } + if v := m.Viewport("/w/cal-billing", ""); v != tablet { + t.Fatalf("the main tab took the tab's size: %v", v) + } + m.forgetViewport("/w/cal-billing") + if len(m.viewports()) != 0 { + t.Fatalf("a removed worktree's sizes are kept: %v", m.viewports()) + } +} + +// Several agents in one worktree, each in its own tab, at once: each keeps +// its own page, refs, cookies and size, every tab renders whether or not it +// is in front, and the live view of one shows only that one. +func TestTabsKeepAgentsInOneWorktreeApart(t *testing.T) { + if testing.Short() { + t.Skip("starts Chromium") + } + if os.Getenv("BERTH_TEST_CHROMIUM") == "" && os.Getenv("CI") != "" { + t.Skip("set BERTH_TEST_CHROMIUM=1 to start a real Chromium in CI") + } + if _, err := FindChromium(); err != nil { + t.Skip(err) + } + b, wt, _ := browserBox(t, func(w http.ResponseWriter, r *http.Request) { + n := strings.TrimPrefix(r.URL.Path, "/p/") + // Each page counts animation frames and timer ticks, and tries + // to reach outside the worktree. + fmt.Fprintf(w, `Page %[1]s

Page %[1]s

+`, n) + }) + m := b.NewBrowsers(t.TempDir(), 2) + m.MaxTabs = 2 + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + loc, _ := b.Locations.Get(ctx, "cal") + main, err := m.get(ctx, loc, wt, "") + if errors.Is(err, ErrBrowserSandbox) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + defer m.CloseAll("test") + evs, unsub := b.Events.SubscribeNamed("test") + defer unsub() + + // Two agents open their own tabs and pages at the same time. + site := "http://billing.cal.mybox.localhost:1377" + tabs := map[string]*browser{} + opened := map[string]BrowserResult{} + var mu sync.Mutex + var wg sync.WaitGroup + for _, name := range []string{"signup-a", "signup-b"} { + wg.Add(1) + go func() { + defer wg.Done() + br, err := m.get(ctx, loc, wt, name) + if err != nil { + t.Error(err) + return + } + res, err := br.Open(ctx, site+"/p/"+name) + if err != nil { + t.Error(err) + return + } + mu.Lock() + tabs[name], opened[name] = br, res + mu.Unlock() + }() + } + wg.Wait() + if t.Failed() { + t.FailNow() + } + a, bb := tabs["signup-a"], tabs["signup-b"] + for name, res := range opened { + if !strings.Contains(res.Text, "title: Page "+name) || !strings.Contains(res.Text, `button "Go `+name+`"`) { + t.Fatalf("%s opened:\n%s", name, res.Text) + } + // The proxy keeps a tab's own context to the worktree too. + if !strings.Contains(res.Text, "example.com") { + t.Fatalf("%s: the refused fetch is not reported:\n%s", name, res.Text) + } + } + if u := main.currentURL(); strings.Contains(u, "/p/") { + t.Fatalf("the main tab moved to %s", u) + } + + // Each tab's refs are its own: a click in one changes only that one. + ref := opened["signup-a"].Text + ref = ref[strings.Index(ref, `button "Go signup-a" [`)+len(`button "Go signup-a" [`):] + ref = ref[:strings.Index(ref, "]")] + if _, err := a.Act(ctx, "click", ref, ""); err != nil { + t.Fatal(err) + } + if s, _ := a.Snapshot(ctx, false, false, "", 0); !strings.Contains(s.Text, "Clicked signup-a") { + t.Fatalf("a after the click:\n%s", s.Text) + } + if s, _ := bb.Snapshot(ctx, false, false, "", 0); !strings.Contains(s.Text, "Go signup-b") || strings.Contains(s.Text, "Clicked") { + t.Fatalf("b after a's click:\n%s", s.Text) + } + + // Every tab is visible to its page and renders, the one opened first + // as much as the last: animation frames and timers run in both. + count := func(br *browser) (int, int) { + r, _ := br.Eval(ctx, `document.visibilityState + " " + window.frames_ + " " + window.ticks`) + f := strings.Fields(strings.Trim(r.Text, `"`)) + if len(f) != 3 || f[0] != "visible" { + t.Fatalf("%s: %s", br.tab, r.Text) + } + fr, _ := strconv.Atoi(f[1]) + tk, _ := strconv.Atoi(f[2]) + return fr, tk + } + fa, ta := count(a) + fb, tb := count(bb) + time.Sleep(time.Second) + fa2, ta2 := count(a) + fb2, tb2 := count(bb) + t.Logf("in a second: a %d frames %d ticks, b %d frames %d ticks", fa2-fa, ta2-ta, fb2-fb, tb2-tb) + for _, d := range []int{fa2 - fa, fb2 - fb} { + if d < 20 { + t.Fatalf("a tab's animation frames are throttled: a %d, b %d a second", fa2-fa, fb2-fb) + } + } + for _, d := range []int{ta2 - ta, tb2 - tb} { + if d < 20 { + t.Fatalf("a tab's timers are throttled: a %d, b %d a second", ta2-ta, tb2-tb) + } + } + + // Cookies are a tab's own. + a.Eval(ctx, `document.cookie = "who=a; path=/"`) + if r, _ := bb.Eval(ctx, "document.cookie"); strings.Contains(r.Text, "who=a") { + t.Fatalf("b sees a's cookie: %s", r.Text) + } + if r, _ := a.Eval(ctx, "document.cookie"); !strings.Contains(r.Text, "who=a") { + t.Fatalf("a lost its cookie: %s", r.Text) + } + + // A size is a tab's own. + phone := Viewport{390, 844, 3} + if _, err := m.Resize(ctx, wt.Path, "signup-b", phone); err != nil { + t.Fatal(err) + } + if v := a.currentViewport(); v == phone { + t.Fatal("a took b's size") + } + if r, _ := bb.Eval(ctx, "innerWidth"); r.Text != "390" { + t.Fatalf("b's width %s", r.Text) + } + + // Watching b shows b, and a's page changing sends b's watcher nothing. + frames, stop := bb.Watch(ctx) + select { + case f := <-frames: + if !strings.HasSuffix(f.URL, "/p/signup-b") { + t.Fatalf("b's watcher got %s", f.URL) + } + case <-time.After(5 * time.Second): + t.Fatal("no frame of b") + } + if _, err := a.Open(ctx, site+"/p/elsewhere"); err != nil { + t.Fatal(err) + } + deadline := time.After(time.Second) +watch: + for { + select { + case f := <-frames: + if !strings.HasSuffix(f.URL, "/p/signup-b") { + t.Fatalf("b's watcher got a's page %s", f.URL) + } + case <-deadline: + break watch + } + } + stop() + + // The browser lists its tabs: the main one, unused, is left out. + st := main.chromium.status() + var names []string + for _, ts := range st.Tabs { + names = append(names, ts.Name) + } + // They opened at once, in either order. + sort.Strings(names) + if strings.Join(names, ",") != "signup-a,signup-b" { + t.Fatalf("tabs %v", names) + } + + // At the cap (2 here; the main tab, unused, doesn't count), a tab in + // use is not taken for another: it is refused. + if _, err := m.get(ctx, loc, wt, "onboarding"); err == nil || !strings.Contains(err.Error(), "close one") { + t.Fatalf("a fourth tab: %v", err) + } + if !m.CloseTab(wt.Path, "signup-a", "closed") || m.Lookup(wt.Path, "signup-a") != nil { + t.Fatal("signup-a didn't close") + } + if _, err := m.get(ctx, loc, wt, "onboarding"); err != nil { + t.Fatalf("after closing one: %v", err) + } + seen := map[string]bool{} + for len(evs) > 0 { + e := <-evs + if tab, _ := e.Data["tab"].(string); tab != "" { + seen[e.Type+" "+tab] = true + } + } + for _, want := range []string{"browser.tab.opened signup-a", "browser.tab.closed signup-a", "browser.navigated signup-b", "browser.tab.opened onboarding"} { + if !seen[want] { + t.Errorf("no event %s in %v", want, seen) + } + } + if pid := main.cmd.Process.Pid; pid == 0 { + t.Fatal("no process") + } else { + t.Logf("Chromium tree RSS with 3 tabs: %d MB", treeRSS(pid)>>20) + } +} diff --git a/internal/box/browserviewport.go b/internal/box/browserviewport.go index 8b5fa111..cc8e6de9 100644 --- a/internal/box/browserviewport.go +++ b/internal/box/browserviewport.go @@ -176,7 +176,8 @@ func (cur Viewport) Resolve(size, scale string) (Viewport, error) { func (m *Browsers) viewportsPath() string { return filepath.Join(m.Dir, "viewports.json") } -// viewports are the sizes agents chose, by worktree path. +// viewports are the sizes agents chose, by worktree path, and for a named +// tab by viewportKey. func (m *Browsers) viewports() map[string]Viewport { out := map[string]Viewport{} if b, err := os.ReadFile(m.viewportsPath()); err == nil { @@ -185,30 +186,49 @@ func (m *Browsers) viewports() map[string]Viewport { return out } -// Viewport is the size a worktree's browser has, or opens at. -func (m *Browsers) Viewport(path string) Viewport { +// viewportKey is where a tab's size is kept: the worktree's path for its +// main tab, with "#tab=NAME" after it for a named one. +func viewportKey(path, tab string) string { + path = filepath.Clean(path) + if tab == "" { + return path + } + return path + "#tab=" + tab +} + +// Viewport is the size a worktree's tab has, or opens at: a named tab +// without one of its own opens at the main tab's. +func (m *Browsers) Viewport(path, tab string) Viewport { m.vpMu.Lock() defer m.vpMu.Unlock() - if v, ok := m.viewports()[filepath.Clean(path)]; ok && v.Check() == nil { + all := m.viewports() + if v, ok := all[viewportKey(path, tab)]; ok && v.Check() == nil { + return v + } + if v, ok := all[viewportKey(path, "")]; ok && v.Check() == nil { return v } return DefaultViewport } -// setViewport keeps a worktree's size; the default is kept as no entry. -func (m *Browsers) setViewport(path string, v Viewport) error { +// setViewport keeps a tab's size; the default is kept as no entry. +func (m *Browsers) setViewport(path, tab string, v Viewport) error { m.vpMu.Lock() defer m.vpMu.Unlock() all := m.viewports() - path = filepath.Clean(path) + key := viewportKey(path, tab) if v == DefaultViewport { - if _, ok := all[path]; !ok { + if _, ok := all[key]; !ok { return nil } - delete(all, path) + delete(all, key) } else { - all[path] = v + all[key] = v } + return m.writeViewports(all) +} + +func (m *Browsers) writeViewports(all map[string]Viewport) error { if err := os.MkdirAll(m.Dir, 0o700); err != nil { return err } @@ -216,21 +236,32 @@ func (m *Browsers) setViewport(path string, v Viewport) error { return statefile.Write(m.viewportsPath(), append(data, '\n')) } -// forgetViewport drops a removed worktree's size. +// forgetViewport drops a removed worktree's sizes, its tabs' too. func (m *Browsers) forgetViewport(path string) { - m.setViewport(path, DefaultViewport) + m.vpMu.Lock() + defer m.vpMu.Unlock() + all := m.viewports() + key, n := viewportKey(path, ""), len(all) + for k := range all { + if k == key || strings.HasPrefix(k, key+"#tab=") { + delete(all, k) + } + } + if len(all) != n { + m.writeViewports(all) + } } -// Resize sets a worktree's size, keeps it, and applies it to its browser if -// one runs (returned, or nil). -func (m *Browsers) Resize(ctx context.Context, path string, v Viewport) (*browser, error) { +// Resize sets a tab's size, keeps it, and applies it to the tab if it is +// open (returned, or nil). +func (m *Browsers) Resize(ctx context.Context, path, tab string, v Viewport) (*browser, error) { if err := v.Check(); err != nil { return nil, err } - if err := m.setViewport(path, v); err != nil { + if err := m.setViewport(path, tab, v); err != nil { return nil, err } - br := m.Lookup(path) + br := m.Lookup(path, tab) if br == nil { return nil, nil } @@ -240,9 +271,7 @@ func (m *Browsers) Resize(ctx context.Context, path string, v Viewport) (*browse // resize applies a size to the running page: its viewport and scale, the // screencast's frames, and a fresh frame for whoever watches. func (br *browser) resize(ctx context.Context, v Viewport) error { - br.run.Lock() - defer br.run.Unlock() - br.touch() + defer br.begin()() if err := br.applyViewport(ctx, v); err != nil { return err } diff --git a/internal/box/browserviewport_test.go b/internal/box/browserviewport_test.go index 2a3bf753..05d481b2 100644 --- a/internal/box/browserviewport_test.go +++ b/internal/box/browserviewport_test.go @@ -89,47 +89,47 @@ func TestABrowserSizeIsKeptPerWorktreeUntilChanged(t *testing.T) { dir := t.TempDir() b := &Box{Name: "devbox", Events: &events.Bus{}} m := b.NewBrowsers(dir, 1) - if v := m.Viewport("/w/cal-billing"); v != DefaultViewport { + if v := m.Viewport("/w/cal-billing", ""); v != DefaultViewport { t.Fatalf("before any: %+v", v) } phone := Viewport{390, 844, 3} - if br, err := m.Resize(context.Background(), "/w/cal-billing", phone); err != nil || br != nil { + if br, err := m.Resize(context.Background(), "/w/cal-billing", "", phone); err != nil || br != nil { t.Fatalf("resize with no browser: %v %v", br, err) } - if _, err := m.Resize(context.Background(), "/w/cal-other", Viewport{100, 100, 1}); err == nil { + if _, err := m.Resize(context.Background(), "/w/cal-other", "", Viewport{100, 100, 1}); err == nil { t.Fatal("a size out of range was kept") } // Kept on disk: a berthd that restarts still has it. again := (&Box{Name: "devbox", Events: &events.Bus{}}).NewBrowsers(dir, 1) - if v := again.Viewport("/w/cal-billing/"); v != phone { + if v := again.Viewport("/w/cal-billing/", ""); v != phone { t.Fatalf("after a restart: %+v", v) } - if v := again.Viewport("/w/cal-other"); v != DefaultViewport { + if v := again.Viewport("/w/cal-other", ""); v != DefaultViewport { t.Fatalf("another worktree: %+v", v) } // A size that isn't sane any more (a hand edit) is the default. os.WriteFile(filepath.Join(dir, "viewports.json"), []byte(`{"/w/cal-billing":{"width":99999,"height":1,"scale":1}}`), 0o600) - if v := again.Viewport("/w/cal-billing"); v != DefaultViewport { + if v := again.Viewport("/w/cal-billing", ""); v != DefaultViewport { t.Fatalf("a bad saved size: %+v", v) } - again.Resize(context.Background(), "/w/cal-billing", phone) + again.Resize(context.Background(), "/w/cal-billing", "", phone) // Back to the default leaves nothing behind. - again.Resize(context.Background(), "/w/cal-billing", DefaultViewport) + again.Resize(context.Background(), "/w/cal-billing", "", DefaultViewport) if raw, _ := os.ReadFile(filepath.Join(dir, "viewports.json")); strings.Contains(string(raw), "cal-billing") { t.Fatalf("the default is kept as an entry: %s", raw) } // A removed worktree's size goes with it. - again.Resize(context.Background(), "/w/cal-billing", phone) + again.Resize(context.Background(), "/w/cal-billing", "", phone) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { again.Run(ctx); close(done) }() defer func() { cancel(); <-done }() deadline := time.Now().Add(5 * time.Second) - for again.Viewport("/w/cal-billing") == phone && time.Now().Before(deadline) { + for again.Viewport("/w/cal-billing", "") == phone && time.Now().Before(deadline) { again.b.Events.Publish(events.Event{Type: "worktree.removed", Data: map[string]any{"path": "/w/cal-billing"}}) time.Sleep(50 * time.Millisecond) } - if v := again.Viewport("/w/cal-billing"); v != DefaultViewport { + if v := again.Viewport("/w/cal-billing", ""); v != DefaultViewport { t.Fatalf("a removed worktree's size stayed: %+v", v) } } @@ -221,7 +221,7 @@ func TestTheBrowserSizeReachesARealChromium(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() loc, _ := b.Locations.Get(ctx, "cal") - br, err := m.get(ctx, loc, wt) + br, err := m.get(ctx, loc, wt, "") if errors.Is(err, ErrBrowserSandbox) { t.Skip(err) } @@ -260,7 +260,7 @@ func TestTheBrowserSizeReachesARealChromium(t *testing.T) { } // A phone at 2x, applied to the running page at once. - if _, err := m.Resize(ctx, wt.Path, Viewport{390, 844, 2}); err != nil { + if _, err := m.Resize(ctx, wt.Path, "", Viewport{390, 844, 2}); err != nil { t.Fatal(err) } if got := inner(); got != `"390 844 2"` { @@ -318,7 +318,7 @@ func TestTheBrowserSizeReachesARealChromium(t *testing.T) { br.Eval(ctx, "document.querySelector('h1').textContent = 'Changed'") checkFrame(390, 844, 780, 1688) // A size changed while someone watches reaches them. - if _, err := m.Resize(ctx, wt.Path, Viewport{1280, 800, 1}); err != nil { + if _, err := m.Resize(ctx, wt.Path, "", Viewport{1280, 800, 1}); err != nil { t.Fatal(err) } checkFrame(1280, 800, 1280, 800) @@ -326,7 +326,7 @@ func TestTheBrowserSizeReachesARealChromium(t *testing.T) { // A browser that starts again starts at the kept size. m.CloseAll("test") - br, err = m.get(ctx, loc, wt) + br, err = m.get(ctx, loc, wt, "") if err != nil { t.Fatal(err) } diff --git a/internal/box/upgrade.go b/internal/box/upgrade.go index 96a844a6..280e4c8b 100644 --- a/internal/box/upgrade.go +++ b/internal/box/upgrade.go @@ -196,7 +196,7 @@ func (b *Box) Capabilities() []string { // browser.health: GET /v1/browser/health, PUT /v1/browser/settings // and POST /v1/browser/check (browsersandbox.go). // browser.devtools: GET …/browser/devtools (browserdevtools.go). - caps = append(caps, "browser", "browser.health", "browser.devtools") + caps = append(caps, "browser", "browser.health", "browser.devtools", "browser.tabs") } if b.Events.Journal != nil { caps = append(caps, "journal") diff --git a/internal/boxcmd/boxcmd.go b/internal/boxcmd/boxcmd.go index ba7168bb..d014c3df 100644 --- a/internal/boxcmd/boxcmd.go +++ b/internal/boxcmd/boxcmd.go @@ -80,7 +80,7 @@ var usageSections = []struct { {"%[1]s run cancel %[2]sRUN", "Stop a run"}, {"%[1]s flow secret%[3]s FLOW [--scope S] [--json]", "Make a webhook flow's signing secret (shown once)"}, }}, - {"Agent browser (a headless browser on the box, per worktree)", [][2]string{ + {"Agent browser (a headless browser on the box, per worktree; --tab NAME on any of these\ngives an agent a tab of its own, so agents sharing a worktree don't share a page)", [][2]string{ {"%[1]s browser open %[2]s[LOC/WT] [PATH|URL] [--as EMAIL] [--path /x] [--size WxH|PRESET] [--scale N]", "Open the worktree's page ($BERTH_URL), logged in as EMAIL with --as;\nprints a compact snapshot with @refs"}, {"%[1]s browser resize %[2]s[LOC/WT] WxH|PRESET [--scale N]", "Set the page's size (default 1920x1080; phone 390x844, tablet, laptop); kept until changed"}, {"%[1]s browser snapshot %[2]s[LOC/WT] [--full] [--delta] [--selector SEL] [--depth N]", "The page's elements (interactive by default; capped)"}, @@ -92,7 +92,8 @@ var usageSections = []struct { {"%[1]s browser console %[2]s[LOC/WT] [--all]", "New console errors and warnings since the last look"}, {"%[1]s browser network %[2]s[LOC/WT]", "Failed requests since the last look"}, {"%[1]s browser eval %[2]s[LOC/WT] JS", "Run JavaScript in the page (output capped at 2 KB)"}, - {"%[1]s browser status|close %[2]s[LOC/WT]", "Whether it runs, or close it"}, + {"%[1]s browser status|close %[2]s[LOC/WT] [--tab NAME]", "Whether it runs, or close it (with --tab, just that tab)"}, + {"%[1]s browser tabs %[2]s[LOC/WT]", "Its tabs: each its own page, cookies and size ($BERTH_BROWSER_TAB is the default)"}, {"%[1]s browser list%[3]s [--json]", "Browsers running on the box"}, {"%[1]s browser allow%[3]s ORIGIN", "Let agents' browsers load a public origin (the box owner's)"}, {"%[1]s browser reap%[3]s [--dry-run] [--json]", "Close agent-browser (Vercel's CLI) sessions left by ended berth sessions"}, @@ -257,7 +258,7 @@ func Run(ctx context.Context, c *box.Client, args []string, out io.Writer) error case "flow secret": return flowSecret(ctx, c, rest, out) case "browser open", "browser resize", "browser snapshot", "browser click", "browser fill", "browser press", "browser select", "browser hover", "browser check", - "browser wait", "browser shot", "browser console", "browser network", "browser status", "browser close", "browser eval", "browser allow", "browser list", "browser reap": + "browser wait", "browser shot", "browser console", "browser network", "browser status", "browser close", "browser tabs", "browser eval", "browser allow", "browser list", "browser reap": return browserCmd(ctx, c, strings.TrimPrefix(cmd, "browser "), rest, out) case "shots compare", "shots baseline", "shots accept": return shotsCmd(ctx, c, strings.TrimPrefix(cmd, "shots "), rest, out) diff --git a/internal/boxcmd/boxcmd_test.go b/internal/boxcmd/boxcmd_test.go index fe49d14a..223a5eaa 100644 --- a/internal/boxcmd/boxcmd_test.go +++ b/internal/boxcmd/boxcmd_test.go @@ -378,3 +378,38 @@ func TestHelperRmKeepsTheDataUnlessPurged(t *testing.T) { } } } + +func TestBrowserTabsFromTheCommandLine(t *testing.T) { + reply := `{"text":"ok"}` + // --tab goes anywhere, and on every command. + r, _ := run(t, reply, "browser", "open", "--tab", "signup-a", "cal/billing", "/cart") + if r.path != "/v1/worktrees/cal/billing/browser/open?tab=signup-a" || r.body["url"] != "/cart" { + t.Fatalf("open: %s %v", r.path, r.body) + } + r, _ = run(t, reply, "browser", "click", "cal/billing", "@e3", "--tab=signup-a") + if r.path != "/v1/worktrees/cal/billing/browser/act?tab=signup-a" || r.body["target"] != "@e3" { + t.Fatalf("click: %s %v", r.path, r.body) + } + r, _ = run(t, reply, "browser", "console", "cal/billing", "--all", "--tab", "signup-a") + if r.path != "/v1/worktrees/cal/billing/browser/console?all=1&tab=signup-a" { + t.Fatalf("console: %s", r.path) + } + // $BERTH_BROWSER_TAB is the default, and --tab beats it. + t.Setenv("BERTH_BROWSER_TAB", "onboarding") + r, _ = run(t, reply, "browser", "snapshot", "cal/billing") + if r.path != "/v1/worktrees/cal/billing/browser/snapshot?tab=onboarding" { + t.Fatalf("from the environment: %s", r.path) + } + r, _ = run(t, reply, "browser", "close", "cal/billing", "--tab", "signup-b") + if r.method != "POST" || r.path != "/v1/worktrees/cal/billing/browser/close?tab=signup-b" { + t.Fatalf("close: %s %s", r.method, r.path) + } + t.Setenv("BERTH_BROWSER_TAB", "") + r, _ = run(t, reply, "browser", "tabs", "cal/billing") + if r.method != "GET" || r.path != "/v1/worktrees/cal/billing/browser/tabs" { + t.Fatalf("tabs: %s %s", r.method, r.path) + } + if err := Run(context.Background(), &box.Client{Doer: &recorder{reply: reply}}, []string{"browser", "open", "cal/billing", "--tab", "a b"}, io.Discard); err == nil { + t.Fatal("a tab named \"a b\"") + } +} diff --git a/internal/boxcmd/browsercmd.go b/internal/boxcmd/browsercmd.go index ac6bc64a..70527e47 100644 --- a/internal/boxcmd/browsercmd.go +++ b/internal/boxcmd/browsercmd.go @@ -33,11 +33,45 @@ func browserWorktree(pos []string) (loc, wt string, rest []string, err error) { return loc, wt, pos, nil } -func browserCall(ctx context.Context, c *box.Client, loc, wt, method, action string, in any, out io.Writer) error { +// browserTab takes --tab NAME (or --tab=NAME) out of a command's arguments, +// wherever it is: the tab the command is for, else $BERTH_BROWSER_TAB, else +// the main tab (""). Agents that share a worktree each pass their own. +func browserTab(args []string) ([]string, string, error) { + tab := os.Getenv("BERTH_BROWSER_TAB") + var rest []string + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--tab" || a == "-tab": + if i+1 >= len(args) { + return nil, "", errors.New("--tab needs a name") + } + tab = args[i+1] + i++ + case strings.HasPrefix(a, "--tab=") || strings.HasPrefix(a, "-tab="): + _, tab, _ = strings.Cut(a, "=") + default: + rest = append(rest, a) + } + } + if err := box.CheckTabName(tab); err != nil { + return nil, "", err + } + return rest, tab, nil +} + +func browserCall(ctx context.Context, c *box.Client, loc, wt, tab, method, action string, in any, out io.Writer) error { var res struct { Text string `json:"text"` } path := "/v1/worktrees/" + url.PathEscape(loc) + "/" + url.PathEscape(wt) + "/browser/" + action + if tab != "" { + sep := "?" + if strings.Contains(action, "?") { + sep = "&" + } + path += sep + "tab=" + url.QueryEscape(tab) + } if err := c.Call(ctx, method, path, in, &res); err != nil { return err } @@ -56,6 +90,10 @@ func checkSize(size, scale string) error { } func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, out io.Writer) error { + args, tab, err := browserTab(args) + if err != nil { + return err + } switch sub { case "open": fs, _ := flags(args) @@ -88,7 +126,7 @@ func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, o if err := checkSize(*size, *scale); err != nil { return err } - return browserCall(ctx, c, loc, wt, "POST", "open", map[string]string{"url": target, "size": *size, "scale": *scale, "as": *as}, out) + return browserCall(ctx, c, loc, wt, tab, "POST", "open", map[string]string{"url": target, "size": *size, "scale": *scale, "as": *as}, out) case "resize": fs, _ := flags(args) scale := fs.String("scale", "", "the device scale factor, 1 to 3 (2 for a Retina screen)") @@ -108,7 +146,7 @@ func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, o if _, err := box.DefaultViewport.Resolve(size, *scale); err != nil { return err } - return browserCall(ctx, c, loc, wt, "POST", "resize", map[string]string{"size": size, "scale": *scale}, out) + return browserCall(ctx, c, loc, wt, tab, "POST", "resize", map[string]string{"size": size, "scale": *scale}, out) case "snapshot": fs, _ := flags(args) full := fs.Bool("full", false, "every element, not just what you can act on") @@ -123,7 +161,7 @@ func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, o if err != nil { return err } - return browserCall(ctx, c, loc, wt, "POST", "snapshot", map[string]any{"full": *full, "delta": *delta, "selector": *sel, "depth": *depth}, out) + return browserCall(ctx, c, loc, wt, tab, "POST", "snapshot", map[string]any{"full": *full, "delta": *delta, "selector": *sel, "depth": *depth}, out) case "click", "fill", "press", "select", "hover", "check": fs, _ := flags(args) pos, err := parse(fs, args) @@ -148,7 +186,7 @@ func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, o default: return usageErr(usage) } - return browserCall(ctx, c, loc, wt, "POST", "act", req, out) + return browserCall(ctx, c, loc, wt, tab, "POST", "act", req, out) case "wait": fs, _ := flags(args) text := fs.String("text", "", "until the page shows this text") @@ -163,7 +201,7 @@ func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, o if err != nil { return err } - return browserCall(ctx, c, loc, wt, "POST", "wait", map[string]any{"text": *text, "url": *u, "idle": *idle, "timeout": *timeout}, out) + return browserCall(ctx, c, loc, wt, tab, "POST", "wait", map[string]any{"text": *text, "url": *u, "idle": *idle, "timeout": *timeout}, out) case "shot": fs, _ := flags(args) el := fs.String("el", "", "only this element (@REF or a CSS selector)") @@ -178,7 +216,7 @@ func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, o if err != nil { return err } - return browserCall(ctx, c, loc, wt, "POST", "shot", map[string]any{"el": *el, "full": *full, "width": *width, "native": *native}, out) + return browserCall(ctx, c, loc, wt, tab, "POST", "shot", map[string]any{"el": *el, "full": *full, "width": *width, "native": *native}, out) case "console": fs, _ := flags(args) all := fs.Bool("all", false, "every level, not just errors and warnings") @@ -194,12 +232,12 @@ func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, o if *all { q = "?all=1" } - return browserCall(ctx, c, loc, wt, "GET", "console"+q, nil, out) - case "network", "status", "close": + return browserCall(ctx, c, loc, wt, tab, "GET", "console"+q, nil, out) + case "network", "status", "close", "tabs": fs, _ := flags(args) pos, err := parse(fs, args) if err != nil || len(pos) > 1 { - return usageErr("browser network|status|close [LOC/WT]") + return usageErr("browser network|status|close|tabs [LOC/WT] [--tab NAME]") } loc, wt, _, err := browserWorktree(pos) if err != nil { @@ -209,7 +247,7 @@ func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, o if sub == "close" { method = "POST" } - return browserCall(ctx, c, loc, wt, method, sub, nil, out) + return browserCall(ctx, c, loc, wt, tab, method, sub, nil, out) case "eval": fs, _ := flags(args) pos, err := parse(fs, args) @@ -220,7 +258,7 @@ func browserCmd(ctx context.Context, c *box.Client, sub string, args []string, o if err != nil || len(rest) != 1 { return usageErr("browser eval [LOC/WT] JS") } - return browserCall(ctx, c, loc, wt, "POST", "eval", map[string]string{"js": rest[0]}, out) + return browserCall(ctx, c, loc, wt, tab, "POST", "eval", map[string]string{"js": rest[0]}, out) case "allow": fs, _ := flags(args) pos, err := parse(fs, args) diff --git a/internal/integrations/skills/berth-browser/SKILL.md b/internal/integrations/skills/berth-browser/SKILL.md index d4905dd4..2fd8f9ca 100644 --- a/internal/integrations/skills/berth-browser/SKILL.md +++ b/internal/integrations/skills/berth-browser/SKILL.md @@ -67,6 +67,34 @@ berthd browser open /cart --size 390x844 # set it and open in one step costs far more to read. - The user watching your browser live sees the size you set. +## Several agents, one worktree: tabs + +Without `--tab`, every agent in a worktree drives the same page: another +agent's `open` takes it from under you, your refs stop working, and your +shot may be of their page. When you are one of several agents (a subagent or +teammate) in the same worktree, use a tab of your own, named after you, on +every command: + +```sh +berthd browser open /signup --tab signup-a # opens the tab on first use +berthd browser click @e3 --tab signup-a +berthd browser shot --tab signup-a +berthd browser close --tab signup-a # when you're done +berthd browser tabs # every tab and what it shows +``` + +Or set it once for your shell: `export BERTH_BROWSER_TAB=signup-a`. A tab +has its own page, refs, console, size and cookies (logging in with `--as` in +one doesn't log the others in or out), and it renders whether or not anyone +watches it. A tab closes after 5 idle minutes. The box caps how many tabs a +worktree has (2 to 8, by its memory). If `open` says there are too many, +close a tab you don't need or wait for one to go idle. Don't close other +agents' tabs. + +When you hand browser work to other agents, give each one its tab's name in +its brief, for example: "use `--tab onboarding-b` on every `berthd browser` +command". + ## Rules - Use `berthd browser` for this worktree's pages, not `agent-browser`, diff --git a/internal/integrations/skills/berth-orchestrate/SKILL.md b/internal/integrations/skills/berth-orchestrate/SKILL.md index f4e9a873..a039374a 100644 --- a/internal/integrations/skills/berth-orchestrate/SKILL.md +++ b/internal/integrations/skills/berth-orchestrate/SKILL.md @@ -163,5 +163,9 @@ reports that land together arrive as one message. them and why. Do not send "yes", approve permissions, or pick options. - Do not prompt sessions you did not start unless the user asked you to. - Prefer a new worktree (`task new`) over two agents editing the same files. +- Agents (or your own subagents and teammates) that check pages in the same + worktree must not share its browser. Give each one a tab in its brief: + "use `--tab NAME` on every `berthd browser` command", with a different + name for each. - Never put secrets in prompts; they are typed into a terminal the user reads. - Stop when the goal is met. Report what each agent did and where.