import { type } from "@oh-my-pi/omptype"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { isRecord, logger, untilAborted } from "@oh-my-pi/pi-utils"; import type { EvalPreludeContext, EvalPreludeDefinition } from "../eval/preludes"; import type { ToolSession } from "../sdk"; import { enforceInlineByteCap } from "@oh-my-pi/pi-tui/tools/streaming-output"; import { resolveCmuxKind } from "./browser/cmux/rpc"; import { describeRelayTargets, listRelayTargets, type RelayTarget, resolveSpawnArgs } from "./browser/attach"; import { acquireBrowser, browserKey, type BrowserKind, type BrowserKindTag, holdBrowser, normalizeConnectedCdpUrl, releaseBrowser, } from "./browser/registry"; import { ensureChromiumExecutable } from "./browser/launch"; import { resolveInitScriptSources } from "./browser/open-options"; import { resolveRelayKind } from "./browser/relay/kind"; import { relayExtensionNotice } from "./browser/relay/probe"; import { describeTernTargets, listTernBlocks, type TernTarget, ternTargets } from "./browser/tern/blocks"; import { resolveTernKind } from "./browser/tern/kind"; import { isTernUnavailable } from "./browser/tern/wire"; import type { AriaSnapshotOptions } from "./browser/aria/aria-snapshot"; import type { ScreenshotResult } from "./browser/tab-protocol"; import type { OutputMeta } from "@oh-my-pi/pi-tui/tools/output-meta"; import { type AcquireTabResult, acquireTab, cancelIdleCloseForOwner, dropHeadlessTabs, getTab, listTabs, releaseAllTabs, releaseTabsForOwner, releaseIdleTabsForOwner, releaseTab, runInTab, type TabSession, type TernTabSession, ternBlocksTaken, } from "./browser/tab-supervisor"; import { renderTabCall, tabCallBudgetSeconds } from "./browser/tab-call"; import { resolveToCwd } from "./path-utils"; import { renderCallChain, renderFunctionRun } from "./run-code"; import { ToolAbortError, throwIfAborted } from "./tool-errors"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import { toolResult } from "./tool-result"; import { clampTimeout } from "./tool-timeouts"; import { cfgBrowserCdpUrl, cfgBrowserCmux, cfgBrowserHeadless, cfgBrowserIdleCloseSec, cfgBrowserRelay, cfgBrowserRelayUrl, cfgBrowserTern, } from "./browser/settings"; import { cfgToolsMaxTimeout } from "./settings"; export type { AriaSnapshotOptions } from "./browser/aria/aria-snapshot"; /** First-use boundary for the generated Playwright ARIA evaluator bundle. */ export function buildAriaSnapshotScript(selector: string | undefined, options: AriaSnapshotOptions = {}): string { return require("./browser/aria/aria-snapshot").buildAriaSnapshotScript(selector, options); } /** First-use boundary for ARIA-ref parsing; keeps evaluator construction out of tool registration. */ export function parseAriaRefSelector(selector: string): string | null { return require("./browser/aria/aria-snapshot").parseAriaRefSelector(selector); } export { cmuxSnapshotToObservation, mapWaitUntil, resolveCmuxKind, serializeEval } from "./browser/cmux/rpc"; export { CmuxSocketClient } from "./browser/cmux/socket-client"; export { extractMarkdownOutline, extractReadableFromHtml, filterMarkdownSections, type ReadableExtractOptions, type ReadableFormat, type ReadableResult, } from "./browser/readable"; export { ariaSnapshotBaselineKey, collectAriaSnapshotRefs, diffAriaSnapshot, postProcessAriaSnapshot, type AriaSnapshotBaseline, type AriaSnapshotDiffResult, type SnapshotPostProcessOptions, } from "./browser/snapshot-plus"; export { DEFAULT_RELAY_URL, type RelayKind, resolveRelayKind } from "./browser/relay/kind"; export { type TernKind, resolveTernKind } from "./browser/tern/kind"; export type { Observation, ObservationEntry } from "./browser/tab-protocol"; const DEFAULT_TAB_NAME = "main"; const BROWSER_RUN_SCOPE: readonly string[] = ["tab", "page", "browser", "wait", "assert"]; const appSchema = type({ "path?": type("string").describe("binary path to spawn"), "cdp_url?": type("string").describe("existing cdp endpoint"), "relay?": type("boolean").describe( "true drives the user's own tabs via the omp browser relay; false skips a relay the settings enable", ), "tern?": type("boolean").describe("inside Tern: true forces a Tern picture-in-picture, false opts out"), "args?": type("string[]").describe("extra cli args"), "target?": type("string").describe( "URL/title substring picking the page to drive; inside Tern, adopts the user's browser block (or its id)", ), }); const tabCallStepSchema = type({ method: "string", args: "unknown[]", }); const browserSchema = type({ action: type("'open' | 'close' | 'run' | 'call' | 'tabs' | 'targets'").describe("operation"), "name?": type("string").describe("tab id (default 'main')"), "url?": type("string").describe("url to open"), "app?": appSchema, "viewport?": { width: "number", height: "number", "scale?": "number", }, "wait_until?": type("'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'").describe( "navigation wait condition", ), "dialogs?": type("'accept' | 'dismiss'").describe("auto-handle dialogs"), "allowed_domains?": type("string[]").describe("allowed request hostnames"), "init_scripts?": type("string[]").describe("document-start JavaScript sources or cwd-relative file paths"), "downloads?": type("string").describe("cwd-relative download directory"), "user_agent?": type("string").describe("tab user agent override"), "ignore_https_errors?": type("boolean").describe("ignore invalid HTTPS certificates"), "allow_file_access?": type("boolean").describe("allow file URLs to read local files"), "headed?": type("boolean").describe("override the configured Chromium display mode"), "code?": type("string").describe("js body to run in tab"), "fn?": type("string").describe("serialized JavaScript function to run in tab"), "args?": type("unknown[]").describe("arguments passed to a serialized function"), "chain?": tabCallStepSchema.array(), "timeout?": type("number").describe("timeout in seconds"), "all?": type("boolean").describe("release every managed tab this agent opened"), "kill?": type("boolean").describe("also kill spawned-app browsers"), "persist?": type("boolean").describe( "keep tab live across turn settle and idle close; on a relay tab omp opened, keep it open after the session ends", ), }); type BrowserParams = typeof browserSchema.infer; interface BrowserPreludeDetails { meta?: OutputMeta; action: BrowserParams["action"]; name: string; url?: string; browser?: BrowserKindTag; viewport?: { width: number; height: number; deviceScaleFactor?: number }; screenshots?: ScreenshotResult[]; value?: unknown; } /** * The browser an open drives, by precedence: explicit `app.*` options, the * relay, `browser.cdpUrl`, Tern (inside a Tern pane, unless `app.tern: false`; * `app.tern: true` forces it, `app.target` selects it over the `browser.tern` * setting to adopt a block; `headed` does not opt out), a cmux surface, then * Chromium. */ export function resolveBrowserKind( params: BrowserParams, session: ToolSession, env: Record = process.env, ): BrowserKind { const app = params.app; if (app?.cdp_url) { return { kind: "connected", cdpUrl: app.cdp_url.replace(/\/+$/, "") }; } if (app?.path) { const exe = resolveToCwd(app.path, session.cwd); const args = resolveSpawnArgs(exe, app.args, session.cwd); if (params.ignore_https_errors && !args.includes("--ignore-certificate-errors")) { args.push("--ignore-certificate-errors"); } if (params.allow_file_access && !args.includes("--allow-file-access-from-files")) { args.push("--allow-file-access-from-files"); } return { kind: "spawned", path: exe, args }; } const relayUrl = cfgBrowserRelayUrl.get(session.settings); // Explicit app.relay wins over every setting; PI_BROWSER_RELAY stays the // final kill switch (a relay that is down would otherwise brick the tool). if (app?.relay) { const relayKind = resolveRelayKind({ settingEnabled: true, url: relayUrl }, env); if (relayKind) return relayKind; } if (app?.tern === true) { const ternKind = resolveTernKind({ settingEnabled: true }, env); if (!ternKind) { throw new ToolError( "app.tern:true requires running inside a Tern pane (TERN_PANE_SOCKET and TERN_PANE are not set) with PI_BROWSER_TERN not set to 0.", ); } return ternKind; } // Relay before cdpUrl among settings: enabling the opt-out-by-default relay // is a deliberate mode selection, while cdpUrl is a standing fallback // endpoint. A configured endpoint is a default, not an override: explicit // app options win. if (app?.relay !== false) { const relayKind = resolveRelayKind( { settingEnabled: cfgBrowserRelay.get(session.settings), url: relayUrl, }, env, ); if (relayKind) return relayKind; } const configuredCdpUrl = cfgBrowserCdpUrl.get(session.settings)?.trim(); if (configuredCdpUrl) { return { kind: "connected", cdpUrl: configuredCdpUrl.replace(/\/+$/, "") }; } if (app?.tern !== false) { // `app.target` inside Tern names a browser block to adopt: an explicit request, like `app.tern: true`. const ternKind = resolveTernKind( { settingEnabled: app?.target !== undefined || cfgBrowserTern.get(session.settings) }, env, ); if (ternKind) return ternKind; } const cmuxKind = resolveCmuxKind( { settingEnabled: cfgBrowserCmux.get(session.settings), }, env, ); if (cmuxKind) { return cmuxKind; } return chromiumKind(params, session); } /** The Chromium browser an open launches when no attach/app mode applies. */ function chromiumKind(params: BrowserParams, session: ToolSession): BrowserKind { const headless = params.headed === undefined ? cfgBrowserHeadless.get(session.settings) : !params.headed; return { kind: "headless", headless, ignoreHttpsErrors: params.ignore_https_errors, allowFileAccess: params.allow_file_access, }; } /** Create the enabled-only browser host prelude for one tool session. */ export function createBrowserPrelude(session: ToolSession): EvalPreludeDefinition { // Eval-first-use boundary: source/declaration assets stay unloaded until a // JavaScript or Python kernel actually asks for its enabled preludes. const { createBrowserPreludeDefinition } = require("./browser/prelude-definition"); return createBrowserPreludeDefinition(session, { invoke: (parameters: unknown, context: EvalPreludeContext) => invokeBrowser(session, parameters, context), status: describeBrowserCall, // The documentation promises omp's own browser for an open without `app`; while // the relay takes that open, the prompt names where `app.relay: false` goes instead. relayFallback: resolveBrowserKind({ action: "open" }, session).kind === "relay" ? resolveBrowserKind({ action: "open", app: { relay: false } }, session) : undefined, }); } /** Status-tree line for a completed browser call: `open main https://…`, `main.id(5).click()`, `close all`. */ function describeBrowserCall(parameters: unknown, result: AgentToolResult): string | undefined { const parsed = browserSchema(parameters); if (parsed instanceof type.errors) return undefined; const name = parsed.name ?? DEFAULT_TAB_NAME; switch (parsed.action) { case "open": { const url = isRecord(result.details) ? result.details.url : undefined; return typeof url === "string" && url.length > 0 ? `open ${name} ${url}` : `open ${name}`; } case "close": return parsed.all ? "close all" : `close ${name}`; case "run": return `${name}.run(${parsed.fn !== undefined ? "fn" : (parsed.code?.trim().split("\n", 1)[0] ?? "")})`; case "call": return `${name}.${renderCallChain(parsed.chain ?? [])}`; case "tabs": return "tabs"; case "targets": return "targets"; } } /** Drop headless tabs so a browser mode change applies to the next open. */ export async function restartBrowserForModeChange(): Promise { await dropHeadlessTabs(); } /** * Owner key for tabs this session opens: the agent's stable kernel owner id, * which survives `/new`, `/resume`, fork, and branch (unlike the conversation * session id) and is shared with its advisors, like eval kernels and computer * sessions. Subagents get their own. */ function tabOwnerId(session: ToolSession): string | undefined { return session.getEvalKernelOwnerId?.() ?? undefined; } /** * Best-effort idle-close sweep for the calling session's owned headless * tabs. Never throws — callers detach it (`void`) so a slow reap cannot * delay the open it follows. */ function sweepIdleOwnedTabs(session: ToolSession): Promise { const ownerId = tabOwnerId(session); if (!ownerId) return Promise.resolve(0); const idleSec = cfgBrowserIdleCloseSec.get(session.settings); if (!(idleSec > 0)) { cancelIdleCloseForOwner(ownerId); return Promise.resolve(0); } return releaseIdleTabsForOwner(ownerId, { idleMs: idleSec * 1000 }).catch((error: unknown) => { logger.debug("Browser idle-close sweep failed", { error: error instanceof Error ? error.message : String(error), }); return 0; }); } async function invokeBrowser( session: ToolSession, parameters: unknown, context: EvalPreludeContext, ): Promise> { const parsed = browserSchema(parameters); if (parsed instanceof type.errors) { throw new ToolError(`browser received invalid arguments: ${parsed.summary}`); } try { throwIfAborted(context.signal); const requested = parsed.timeout ?? (parsed.action === "call" ? tabCallBudgetSeconds(parsed.chain) : undefined); const timeoutSeconds = clampTimeout("browser", requested, cfgToolsMaxTimeout.get(session.settings)); const timeoutMs = timeoutSeconds * 1000; const name = parsed.name ?? DEFAULT_TAB_NAME; const details: BrowserPreludeDetails = { action: parsed.action, name }; switch (parsed.action) { case "open": return await openBrowser(session, name, parsed, details, timeoutMs, context.signal); case "close": return await closeBrowser(session, name, parsed, details, timeoutMs, context.signal); case "tabs": details.value = listTabs(); return toolResult(details).done(); case "targets": return await listTargets(session, parsed, details, timeoutMs, context.signal); case "run": case "call": return await runBrowser(session, name, parsed, details, timeoutMs, context.signal); } } catch (error) { if (error instanceof ToolAbortError) throw error; if (error instanceof Error && error.name === "AbortError") { throw new ToolAbortError(); } throw error; } } /** * Open (or reuse) a tab. A Tern PiP chosen automatically (not forced with * `app.tern: true`) falls back to Chromium when Tern cannot host it — no * window, an unsupported platform, a refused or failed connection — and the * result says so; a tab that already fell back keeps its browser. Adopting a * Tern block (`app.target`) never falls back: Chromium would ignore the target. */ async function openBrowser( session: ToolSession, name: string, params: BrowserParams, details: BrowserPreludeDetails, timeoutMs: number, signal?: AbortSignal, ): Promise> { const resolved = resolveBrowserKind(params, session); const autoTern = resolved.kind === "tern" && params.app?.tern !== true && params.app?.target === undefined; const existing = getTab(name); const kind = autoTern && existing && existing.kindTag !== "tern" ? existing.browser.kind : resolved; const startedAt = performance.now(); try { return await openOnKind(session, name, params, details, kind, timeoutMs, [], signal); } catch (error) { if (kind.kind !== "tern" || !autoTern || !isTernUnavailable(error) || signal?.aborted) throw error; // A Tern tab under this name whose window went away still holds the name: release // that one (and only that one) so the Chromium tab can take it. if (getTab(name)?.kindTag === "tern") await releaseTab(name, { kill: false, timeoutMs }).catch(() => false); const note = `Tern cannot show a browser here (${error.message}); opened Chromium instead. Pass app.tern:false to skip Tern, or app.tern:true to require it.`; logger.debug("Tern browser unavailable; falling back to Chromium", { error: error.message }); const remainingMs = Math.max(1, timeoutMs - (performance.now() - startedAt)); return await openOnKind( session, name, params, details, chromiumKind(params, session), remainingMs, [note], signal, ); } } async function openOnKind( session: ToolSession, name: string, params: BrowserParams, details: BrowserPreludeDetails, kind: BrowserKind, timeoutMs: number, notes: readonly string[], signal?: AbortSignal, ): Promise> { const downloadsPath = params.downloads === undefined ? undefined : resolveToCwd(params.downloads, session.cwd); details.browser = kind.kind; // If a tab with this name already exists on a different browser kind, fail fast — caller must close first. const existing = getTab(name); if (existing && browserKey(existing.browser.kind) !== browserKey(kind)) { throw new ToolError( `Tab ${JSON.stringify(name)} is bound to a different browser (${describeKind(existing.browser.kind)}). Close it first.`, ); } // Chrome's extension debugger API exposes no Security domain, so the relay cannot honor the override. if (params.ignore_https_errors && kind.kind === "relay") { throw new ToolError( "ignore_https_errors is not supported through the Chrome relay: the extension debugger API does not expose the CDP Security domain. Pass app.relay:false to use omp's Chromium instead.", ); } // Chrome never lets an extension choose where downloads go. Refused here, before an existing tab is closed to reopen. if (params.downloads !== undefined && kind.kind === "relay") { throw new ToolError( "The omp browser relay cannot choose where Chrome saves downloads: open the tab without `downloads`, and tab.waitForDownload() and tab.downloads() report where Chrome saved each file.", ); } // First browser use may have to download Chrome for Testing (~180 MB). // That is a one-time install, not part of the open, so it runs before the // deadline below starts: charged against the 30s default it timed out on // connections where installation alone exceeds that budget. // The download promise is module-cached, so a caller abort here leaves it // finishing in the background and the next open picks up the result. if (kind.kind === "headless") await untilAborted(signal, () => ensureChromiumExecutable()); // The requested timeout must cover the *entire* open — browser // acquisition (CDP discovery/connect), queued tab acquisition, worker // creation, and navigation — not only `acquireTab`. Compose one deadline // from the caller signal and `params.timeout` and thread it through both // stages so a stalled acquisition rejects at the requested boundary. // Capture the deadline start as well: `acquireTab` counts its // worker-init time against this same budget via `deadlineStartMs` // instead of restarting the clock after acquisition. const deadlineStart = performance.now(); const timeoutSignal = AbortSignal.timeout(timeoutMs); const openSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; // The step in flight when the deadline fires; a timeout names it. let stage = `launching or connecting to the browser (${describeKind(kind)})`; try { const browser = await untilAborted(openSignal, () => acquireBrowser(kind, { cwd: session.cwd, viewport: params.viewport ? { width: params.viewport.width, height: params.viewport.height, deviceScaleFactor: params.viewport.scale, } : undefined, signal: openSignal, }), ); // Hold one open-acquisition lease across the whole tab acquisition. // A freshly-created browser sits in the registry at refCount 0 until a // tab takes a hold; without this lease an abort/timeout mid-acquisition // (or a sibling open of a different tab name on the same browser that // fails) could dispose it out from under this operation. The lease is // released exactly once — the success and failure paths are mutually // exclusive — transferring ownership to the published tab on success or // rolling the fresh browser back on failure. holdBrowser(browser); let result: AcquireTabResult; let relayNotice: string | null = null; try { // Read before the tab is acquired, so a cancel or the deadline landing during the read leaves no tab to close. if (kind.kind === "relay") { stage = "reading the relay extension version"; relayNotice = await relayExtensionNotice(normalizeConnectedCdpUrl(kind.cdpUrl), openSignal); throwIfAborted(openSignal); } stage = `opening tab ${JSON.stringify(name)}`; const initScripts = await untilAborted(openSignal, () => resolveInitScriptSources(params.init_scripts, session.cwd), ); // Worker-init options cannot be applied to a live tab: recycle it so the // reopened tab starts with them. if ( existing && (initScripts.length > 0 || params.downloads !== undefined || params.user_agent !== undefined || params.ignore_https_errors === true) ) { stage = `closing tab ${JSON.stringify(name)} to reopen it`; await untilAborted(openSignal, () => releaseTab(name, { kill: false, timeoutMs })); stage = `opening tab ${JSON.stringify(name)}`; } result = await untilAborted(openSignal, () => acquireTab(name, browser, { url: params.url, waitUntil: params.wait_until, viewport: params.viewport ? { width: params.viewport.width, height: params.viewport.height, deviceScaleFactor: params.viewport.scale, } : undefined, target: params.app?.target, timeoutMs, deadlineStartMs: deadlineStart, dialogs: params.dialogs, allowedDomains: params.allowed_domains, initScripts, downloadsPath, userAgent: params.user_agent, ignoreHttpsErrors: params.ignore_https_errors, signal: openSignal, ownerSessionId: tabOwnerId(session), // Omitted stays undefined: creation defaults it to false // while reuse by the owner leaves a set value alone. persist: params.persist, }), ); } catch (error) { await releaseBrowser(browser, { kill: "subprocess" in browser && browser.subprocess !== undefined, }); throw error; } await releaseBrowser(browser, { kill: false }); // Opportunistic idle-close sweep for long turns that rarely settle: // close owned tabs idle past the timeout. Detached by design (same // as the orphan-target sweep on attach) — failures only log. Freeze // is deliberately NOT done here: freezing a sibling with an // in-flight run would stall it mid-execution, while turn_end is // race-free by construction (all tool results are paired). void sweepIdleOwnedTabs(session); const tab = result.tab; const url = tab.info.url; const title = tab.info.title ?? ""; details.url = url; details.viewport = tab.info.viewport; const verb = result.created ? "Opened" : "Reused"; const lines = [ `${verb} tab ${JSON.stringify(name)} on ${describeBrowser(tab)}`, `URL: ${url}`, title ? `Title: ${title}` : null, ...(tab.backend === "tern" ? ternNotes(tab) : []), ...notes, result.note, relayNotice, ].filter((line): line is string => typeof line === "string"); return toolResult(details).text(lines.join("\n")).done(); } catch (error) { // Caller cancellation stays a ToolAbortError; the requested timeout // becomes a timeout ToolError naming the stalled step; anything else // passes through unchanged. if (signal?.aborted) throw error instanceof ToolAbortError ? error : new ToolAbortError(); if (timeoutSignal.aborted) throw new ToolError(`Browser open timed out after ${timeoutMs}ms while ${stage}`); throw error; } } /** * The user's own pages `app.target` can adopt, from the browser an open with * `app.target` would drive: a Tern window's browser blocks, or the relay's * Chrome pages. The browser is acquired as an open does (the loopback relay * starts on demand) and released again. */ async function listTargets( session: ToolSession, params: BrowserParams, details: BrowserPreludeDetails, timeoutMs: number, signal?: AbortSignal, ): Promise> { // Resolve as an open naming a target: inside Tern that selects Tern even when the `browser.tern` setting is off. const kind = resolveBrowserKind({ ...params, app: { ...params.app, target: params.app?.target ?? "" } }, session); if (kind.kind !== "tern" && kind.kind !== "relay") { throw new ToolError( `No pages to adopt here (the browser would be ${describeKind(kind)}): targets exist only inside a Tern pane (its browser blocks) or with the omp relay (app.relay: true or the browser.relay setting).`, ); } details.browser = kind.kind; const timeoutSignal = AbortSignal.timeout(timeoutMs); const listSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; try { const browser = await untilAborted(listSignal, () => acquireBrowser(kind, { cwd: session.cwd, signal: listSignal }), ); holdBrowser(browser); try { let targets: TernTarget[] | RelayTarget[]; let text: string; if (kind.kind === "relay") { targets = await untilAborted(listSignal, () => listRelayTargets(kind.cdpUrl, listSignal)); text = describeRelayTargets(targets); } else if ("tern" in browser) { const blocks = await listTernBlocks(browser.tern, { timeoutMs, signal: listSignal }); const taken = ternBlocksTaken(); targets = ternTargets(blocks, taken); text = describeTernTargets(blocks, taken); } else { throw new ToolError(`${describeKind(kind)} has no pages to adopt.`); } details.value = targets; return toolResult(details).text(text).done(); } finally { await releaseBrowser(browser, { kill: false }); } } catch (error) { if (signal?.aborted) throw error instanceof ToolAbortError ? error : new ToolAbortError(); if (timeoutSignal.aborted) throw new ToolError(`Listing browser targets timed out after ${timeoutMs}ms`); throw error; } } async function closeBrowser( session: ToolSession, name: string, params: BrowserParams, details: BrowserPreludeDetails, timeoutMs: number, signal?: AbortSignal, ): Promise> { const kill = !!params.kill; if (params.all) { // Only the caller's own tabs: another agent's tabs (a parent's, a sibling's) // are reaped when that agent disposes. No owner (SDK callers) keeps the // process-wide release. const ownerId = tabOwnerId(session); const count = await untilAborted(signal, () => ownerId ? releaseTabsForOwner(ownerId, { kill, timeoutMs }) : releaseAllTabs({ kill, timeoutMs }), ); const text = `Released ${count} managed tab${count === 1 ? "" : "s"}`; return toolResult(details).text(text).done(); } const closed = await untilAborted(signal, () => releaseTab(name, { kill, timeoutMs })); const text = closed ? `Released managed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`; return toolResult(details).text(text).done(); } function resolveBrowserRunCode(params: BrowserParams): string { if (params.action === "call") return renderTabCall(params.chain ?? []); const code = params.code?.trim(); const fn = params.fn?.trim(); if ((code === undefined || code.length === 0) === (fn === undefined || fn.length === 0)) { throw new ToolError("Action 'run' requires exactly one of 'code' or 'fn'."); } if (fn !== undefined && fn.length > 0) { return renderFunctionRun(fn, BROWSER_RUN_SCOPE, params.args ?? []); } return code ?? ""; } async function runBrowser( session: ToolSession, name: string, params: BrowserParams, details: BrowserPreludeDetails, timeoutMs: number, signal?: AbortSignal, ): Promise> { const code = resolveBrowserRunCode(params); const tab = getTab(name); if (tab) { details.browser = tab.browser.kind.kind; details.url = tab.info.url; } const { displays, returnValue, screenshots } = await runInTab(name, { code, timeoutMs, signal, session, waitsForDownload: params.action === "call" && params.chain?.[0]?.method === "waitForDownload", }); if (screenshots.length) details.screenshots = screenshots; if (returnValue !== undefined) details.value = returnValue; const content = [...displays]; const textOnly = content .filter((part): part is { type: "text"; text: string } => part.type === "text") .map(part => part.text) .join("\n"); // Final defense at the host-result boundary: a single run can display // tens of KB (large JSON returns, dumped observations). Cap the combined // text inline; the full text stays recoverable via the artifact footer // when allocation succeeds. const cappedText = await enforceInlineByteCap(textOnly, { saveArtifact: full => saveBrowserOutputArtifact(session, full), }); const nonText = content.filter(part => part.type !== "text"); if (cappedText.length === 0) return toolResult(details).content(nonText).done(); return toolResult(details) .content([...nonText, { type: "text", text: cappedText }]) .done(); } /** Persist over-cap browser run output as a session artifact; mirrors the bash minimizer's save path. */ async function saveBrowserOutputArtifact(session: ToolSession, fullText: string): Promise { try { const alloc = await session.allocateOutputArtifact?.("browser-original"); if (!alloc?.path || !alloc.id) return undefined; await Bun.write(alloc.path, fullText); return alloc.id; } catch { return undefined; } } function describeBrowser(tab: TabSession): string { if (tab.backend === "tern") { const adopted = tab.ternTab.adopted; if (!adopted) return `Tern browser picture-in-picture (pane ${tab.browser.kind.pane})`; return `the user's Tern browser block ${adopted.block} (${adopted.owner === null ? "docked" : "picture-in-picture"})`; } const handle = tab.browser; if (!("browser" in handle)) { return `cmux browser (${handle.kind.surface ?? "split"})`; } switch (handle.kind.kind) { case "headless": return `headless browser (${handle.kind.headless ? "hidden" : "visible"}${handle.sharedDaemon ? ", shared" : ""})`; case "spawned": return `spawned ${handle.kind.path} (pid ${handle.pid ?? "?"})`; case "connected": return `connected ${handle.cdpUrl ?? handle.kind.cdpUrl}`; case "relay": return `relay ${handle.cdpUrl ?? handle.kind.cdpUrl}`; } } /** Where a Tern tab's page is for the user: whose block it is, and whether it is on screen. */ function ternNotes(tab: TernTabSession): string[] { const notes: string[] = []; const adopted = tab.ternTab.adopted; if (adopted) { notes.push( `This is the user's own Tern browser block ${adopted.block} (${JSON.stringify(tab.info.title ?? "")} at ${tab.info.url}); closing the tab releases it to the user without closing it.`, ); } if (tab.ternTab.shown === false) { notes.push( adopted ? `Not on screen: the user is viewing another Tern tab or session. Block ${adopted.block} shows once they switch to the tab holding it; tell them where it is.` : `Not on screen: the user hid pictures in picture (⌃⌥⌘P), or this Tern shows them only in the tab holding this omp pane (block ${tab.browser.kind.pane}); tell them where it is.`, ); } return notes; } function describeKind(kind: BrowserKind): string { switch (kind.kind) { case "headless": return `headless ${kind.headless ? "hidden" : "visible"}`; case "spawned": return `spawned:${kind.path}`; case "connected": return `connected:${kind.cdpUrl}`; case "relay": return `relay:${kind.cdpUrl}`; case "cmux": return `cmux:${kind.surface ?? "split"}`; case "tern": return `tern:pane ${kind.pane}`; } }