import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { FAST_BROWSER_ALLOWED_ORIGINS, FAST_BROWSER_CDP_URL, FAST_BROWSER_PYTHON, FAST_BROWSER_TEXT_MODEL, FAST_BROWSER_TEXT_REASONING, FAST_BROWSER_TYPESAFE_KEY_ENV, FAST_BROWSER_TYPESAFE_KEY_FILE, FAST_BROWSER_TYPESAFE_KEY_PATH, FastBrowserConfigError, inspectFastBrowserConfig, inspectFastBrowserConfigSettings, parseFastBrowserConfig, readFastBrowserConfigFile, removeFastBrowserConfigFile, validateBrowserProfileName, type ConfigOverrides, type FastBrowserConfig, type FastBrowserConfigSettings, type FastBrowserConfigSource, type FastBrowserBrowserConfig, writeFastBrowserConfigFile, } from "./config.ts"; import { FAST_BROWSER_SETTING_IDS, FastBrowserSettingsView, type FastBrowserSettingId, type FastBrowserSettingsViewEvent, } from "./settings-view.ts"; const REASONING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const; const MODEL_INHERIT = "(unset override — inherit)"; const CLOSE = "Close"; const MANUAL_SETUP = "Launch named profile for manual setup"; const AUTOMATED_LAUNCH = "Launch automated browser session"; const FAST_BROWSER_SESSION_MESSAGE = "fast-browser-session"; const MAX_CONFIG_STRING = 4_000; let modelChoicesCache: WeakMap = new WeakMap(); function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void { ctx.ui.notify(message, level); } function errorText(error: unknown): string { if (error instanceof FastBrowserConfigError) return error.message; return "Unable to read fast-browser configuration"; } function launchErrorText(error: unknown): string { if (error instanceof FastBrowserConfigError) return error.message; return "Unable to launch fast-browser session; close other users of the named profile and check browser configuration"; } function cloneValues(values: ConfigOverrides): ConfigOverrides { return { ...values, ...(values.allowedOrigins === undefined ? {} : { allowedOrigins: values.allowedOrigins === "*" ? "*" : [...values.allowedOrigins] }), ...(values.typesafeKey ? { typesafeKey: { ...values.typesafeKey } } : {}), ...(values.browser ? { browser: { ...values.browser } } : {}), }; } function valuesEqual(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true; if (Array.isArray(left) || Array.isArray(right)) { if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; return left.every((value, index) => valuesEqual(value, right[index])); } if (left && right && typeof left === "object" && typeof right === "object") { const leftObject = left as Record; const rightObject = right as Record; const keys = new Set([...Object.keys(leftObject), ...Object.keys(rightObject)]); return [...keys].every((key) => valuesEqual(leftObject[key], rightObject[key])); } return false; } function configOverridesEqual(left: ConfigOverrides, right: ConfigOverrides): boolean { const keys = new Set([...Object.keys(left), ...Object.keys(right)]); return [...keys].every((key) => valuesEqual( (left as Record)[key], (right as Record)[key], )); } function sourceText(source: FastBrowserConfigSource | undefined): string { if (!source) return "unknown"; if (source.path) return `${source.source} (${source.path})`; return source.source; } function displayValue(key: keyof FastBrowserConfig, value: unknown): string { if (value === undefined) return "(unset)"; if (key === "allowedOrigins") return value === "*" ? "All web origins" : JSON.stringify(value); if (key === "typesafeKey" && value && typeof value === "object") { const reference = value as { env?: string; file?: string; key?: string }; return reference.env ? `{ env: ${reference.env} }` : `{ file: ${reference.file}${reference.key ? `, key: ${reference.key}` : ""} }`; } if (key === "browser" && value && typeof value === "object") { const browser = value as FastBrowserBrowserConfig; return JSON.stringify(browser); } return String(value); } function viewMessage(settings: FastBrowserConfigSettings, config: FastBrowserConfig | undefined): string { const values = settings.values; const names: Array = [ "cdpUrl", "allowedOrigins", "python", "textModel", "textReasoning", "typesafeKey", "browser", ]; const lines = [ "Fast Browser configuration", "Precedence: per-run override > project > global > environment > defaults", `Project path: ${settings.projectPath}`, `Global path: ${settings.globalPath}`, ]; for (const name of names) { lines.push(`${name}: ${displayValue(name, values[name])} [${sourceText(settings.sources[name])}]`); } if (!config) lines.push("Status: incomplete — configure allowed origins and attach/launch settings before a tool call"); return lines.join("\n"); } function boundedInput(value: string, field: string): string { const trimmed = value.trim(); if (!trimmed || trimmed.length > MAX_CONFIG_STRING || /[\0\r\n]/.test(trimmed)) { throw new FastBrowserConfigError(`${field} must be a bounded non-empty value`); } return trimmed; } function validateOriginInput(value: string): FastBrowserConfig["allowedOrigins"] { let parsed: unknown; try { parsed = JSON.parse(value); } catch { throw new FastBrowserConfigError(`${FAST_BROWSER_ALLOWED_ORIGINS} must be '*' or a JSON array of exact origins`); } // Reuse the strict loader validator without reading or writing files. The // endpoint is intentionally a harmless loopback placeholder for this check. const env: NodeJS.ProcessEnv = { [FAST_BROWSER_CDP_URL]: "http://127.0.0.1:9222", [FAST_BROWSER_ALLOWED_ORIGINS]: JSON.stringify(parsed), }; return parseFastBrowserConfig(env).allowedOrigins; } function modelChoices(ctx: ExtensionContext, current: string | undefined): string[] { const registry = (ctx.modelRegistry as unknown as { getAvailable?: () => Array<{ provider?: string; id?: string }>; } | undefined) ?? {}; const registryObject = registry as object; let choices = modelChoicesCache.get(registryObject); if (!choices) { const available = registry.getAvailable?.() ?? []; choices = available .map((model) => { if (!model || typeof model.id !== "string" || !model.id.trim()) return undefined; const provider = typeof model.provider === "string" && model.provider.trim() ? `${model.provider}/` : ""; return `${provider}${model.id}`; }) .filter((value): value is string => value !== undefined); choices = [...new Set(choices)].sort(); modelChoicesCache.set(registryObject, choices); } const result = [MODEL_INHERIT, ...choices]; if (current && !result.includes(current)) result.splice(1, 0, current); return result; } function applyDraftValue(draft: ConfigOverrides, key: keyof FastBrowserConfig, value: unknown): void { if (value === undefined) delete (draft as Record)[key]; else (draft as Record)[key] = value; } async function editEndpoint(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const value = await ctx.ui.input("CDP endpoint (http loopback URL)", draft.cdpUrl ?? "http://127.0.0.1:9222"); if (value === undefined) return false; try { // Full validation also rejects credentials, non-loopback hosts, and paths. parseFastBrowserConfig({ [FAST_BROWSER_CDP_URL]: value, [FAST_BROWSER_ALLOWED_ORIGINS]: "[\"https://example.invalid\"]", }); applyDraftValue(draft, "cdpUrl", boundedInput(value, FAST_BROWSER_CDP_URL)); } catch (error) { notify(ctx, errorText(error), "error"); return true; } return true; } const ORIGIN_ALL = "All web origins (*)"; const ORIGIN_SPECIFIC = "Specific origins"; const ORIGIN_INHERIT = "(unset override — inherit)"; async function editOrigins(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const mode = await ctx.ui.select("Allowed origins", [ORIGIN_ALL, ORIGIN_SPECIFIC, ORIGIN_INHERIT]); if (mode === undefined) return false; if (mode === ORIGIN_ALL) { applyDraftValue(draft, "allowedOrigins", "*"); return true; } if (mode === ORIGIN_INHERIT) { applyDraftValue(draft, "allowedOrigins", undefined); return true; } if (mode !== ORIGIN_SPECIFIC) { notify(ctx, "Invalid allowed origins policy", "error"); return true; } const current = Array.isArray(draft.allowedOrigins) ? draft.allowedOrigins : []; const value = await ctx.ui.input( "Allowed origins (JSON array of exact http(s) origins)", JSON.stringify(current), ); if (value === undefined) return false; try { applyDraftValue(draft, "allowedOrigins", validateOriginInput(value)); } catch (error) { notify(ctx, errorText(error), "error"); } return true; } async function editPython(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const value = await ctx.ui.input("Python executable (blank to use default)", draft.python ?? ""); if (value === undefined) return false; try { applyDraftValue(draft, "python", value.trim() ? boundedInput(value, "python") : undefined); } catch (error) { notify(ctx, errorText(error), "error"); } return true; } async function editModel(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const selected = await ctx.ui.select("Auxiliary text model", modelChoices(ctx, draft.textModel)); if (selected === undefined) return false; applyDraftValue(draft, "textModel", selected === MODEL_INHERIT ? undefined : selected); return true; } async function editReasoning(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const selected = await ctx.ui.select("Auxiliary text reasoning", [...REASONING_LEVELS]); if (selected === undefined) return false; if (!REASONING_LEVELS.includes(selected as typeof REASONING_LEVELS[number])) { notify(ctx, "Invalid text reasoning level", "error"); return true; } applyDraftValue(draft, "textReasoning", selected); return true; } function currentBrowser(draft: ConfigOverrides): FastBrowserBrowserConfig { const browser = draft.browser; return browser ? { ...browser } : { mode: "attach" }; } async function editBrowserMode(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const browser = currentBrowser(draft); const mode = await ctx.ui.select("Browser mode", ["attach", "launch"]); if (mode === undefined) return false; if (mode !== "attach" && mode !== "launch") { notify(ctx, "Invalid browser mode", "error"); return true; } if (mode === browser.mode) { // Re-selecting the current mode must not discard launch attributes staged // from an inherited browser or previously saved in this scope. applyDraftValue(draft, "browser", { ...browser }); } else if (mode === "attach") { applyDraftValue(draft, "browser", { mode: "attach" }); } else { applyDraftValue(draft, "browser", { mode: "launch", ...(browser.product ? { product: browser.product } : {}) }); } return true; } async function editBrowserProduct(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const browser = currentBrowser(draft); const product = await ctx.ui.select("Browser product for launch", ["vivaldi", "chrome", "chromium"]); if (product === undefined) return false; if (product !== "vivaldi" && product !== "chrome" && product !== "chromium") { notify(ctx, "Firefox is not supported; use Vivaldi, Chrome, or Chromium", "error"); return true; } applyDraftValue(draft, "browser", { ...browser, product }); return true; } async function editBrowserExecutable(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const browser = currentBrowser(draft); if (browser.mode === "attach") { notify(ctx, "Executable path is a launch setting; switch browser mode to launch first", "warning"); return true; } const value = await ctx.ui.input("Browser executable path (blank for product default)", browser.executablePath ?? ""); if (value === undefined) return false; try { const executablePath = value.trim() ? boundedInput(value, "browser.executablePath") : undefined; applyDraftValue(draft, "browser", { ...browser, ...(executablePath === undefined ? {} : { executablePath }), ...(executablePath === undefined ? { executablePath: undefined } : {}), }); const next = currentBrowser(draft); if (next.executablePath === undefined) delete (next as unknown as Record).executablePath; applyDraftValue(draft, "browser", next); } catch (error) { notify(ctx, errorText(error), "error"); } return true; } async function editBrowserHeadless(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const browser = currentBrowser(draft); if (browser.mode === "attach") { notify(ctx, "Headless is a launch setting; switch browser mode to launch first", "warning"); return true; } const value = await ctx.ui.select("Launch browser headless", ["false", "true"]); if (value === undefined) return false; if (value !== "false" && value !== "true") { notify(ctx, "Invalid headless setting", "error"); return true; } applyDraftValue(draft, "browser", { ...browser, headless: value === "true" }); return true; } async function editBrowserProfile(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const browser = currentBrowser(draft); if (browser.mode === "attach") { notify(ctx, "Profile is a launch setting; switch browser mode to launch first", "warning"); return true; } const value = await ctx.ui.input( "Persistent browser profile name (blank for disposable)", browser.profile ?? "", ); if (value === undefined) return false; try { const profile = value.trim(); const next = { ...browser } as unknown as Record; if (profile) next.profile = validateBrowserProfileName(profile); else delete next.profile; applyDraftValue(draft, "browser", next); } catch (error) { notify(ctx, errorText(error), "error"); } return true; } async function editBrowserStartUrl(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const browser = currentBrowser(draft); if (browser.mode === "attach") { notify(ctx, "Start URL is a launch setting; switch browser mode to launch first", "warning"); return true; } const value = await ctx.ui.input("Launch start URL (must use an allowed origin)", browser.startUrl ?? ""); if (value === undefined) return false; try { const startUrl = value.trim() ? boundedInput(value, "browser.startUrl") : undefined; if (startUrl) { const parsed = new URL(startUrl); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new FastBrowserConfigError("browser.startUrl must be an http(s) URL"); if (parsed.username || parsed.password || parsed.hostname.includes("*")) throw new FastBrowserConfigError("browser.startUrl must not contain credentials or wildcards"); } const next = { ...browser } as unknown as Record; if (startUrl) next.startUrl = startUrl; else delete next.startUrl; applyDraftValue(draft, "browser", next); } catch (error) { notify(ctx, errorText(error), "error"); } return true; } async function editTypesafeKey(ctx: ExtensionContext, draft: ConfigOverrides): Promise { const current = draft.typesafeKey; const mode = await ctx.ui.select("Typesafe key source (reference only)", [ "(unset override — inherit)", "Environment variable", "File reference", ]); if (mode === undefined) return false; if (mode === "(unset override — inherit)") { applyDraftValue(draft, "typesafeKey", undefined); return true; } if (mode === "Environment variable") { const name = await ctx.ui.input( "Environment variable name (not the secret value)", current && "env" in current ? current.env : "TYPESAFE_API_KEY", ); if (name === undefined) return false; try { const env = boundedInput(name, "typesafeKey.env"); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(env)) throw new FastBrowserConfigError("typesafeKey.env must be an environment variable name"); applyDraftValue(draft, "typesafeKey", { env }); } catch (error) { notify(ctx, errorText(error), "error"); } return true; } const file = await ctx.ui.input( "Credential JSON file path (the file contents are never displayed)", current && "file" in current ? current.file : "", ); if (file === undefined) return false; const key = await ctx.ui.input( "Credential JSON key (optional; this is not a password)", current && "file" in current ? current.key ?? "" : "", ); if (key === undefined) return false; try { const filePath = boundedInput(file, "typesafeKey.file"); const keyPath = key.trim() ? boundedInput(key, "typesafeKey.key") : undefined; applyDraftValue(draft, "typesafeKey", keyPath ? { file: filePath, key: keyPath } : { file: filePath }); } catch (error) { notify(ctx, errorText(error), "error"); } return true; } function environmentInheritedValues(): ConfigOverrides { const env = { ...process.env }; const hasCdp = Boolean(env[FAST_BROWSER_CDP_URL]?.trim()); const hasOrigins = Boolean(env[FAST_BROWSER_ALLOWED_ORIGINS]?.trim()); // parseFastBrowserConfig is deliberately reused with a harmless endpoint so // this view validates exactly like the loader without resolving credentials. if (!hasCdp) env[FAST_BROWSER_CDP_URL] = "http://127.0.0.1:9222"; const parsed = parseFastBrowserConfig(env); const values: ConfigOverrides = { textReasoning: parsed.textReasoning ?? "off", allowedOrigins: hasOrigins ? parsed.allowedOrigins : "*", }; if (hasCdp) values.cdpUrl = parsed.cdpUrl; if (env[FAST_BROWSER_PYTHON]?.trim()) values.python = parsed.python; if (env[FAST_BROWSER_TEXT_MODEL]?.trim()) values.textModel = parsed.textModel; if (env[FAST_BROWSER_TEXT_REASONING]?.trim()) values.textReasoning = parsed.textReasoning; const keyEnv = env[FAST_BROWSER_TYPESAFE_KEY_ENV]?.trim(); const keyFile = env[FAST_BROWSER_TYPESAFE_KEY_FILE]?.trim(); const keyPath = env[FAST_BROWSER_TYPESAFE_KEY_PATH]?.trim(); if (keyEnv) values.typesafeKey = { env: keyEnv }; else if (keyFile) values.typesafeKey = { file: keyFile, ...(keyPath ? { key: keyPath } : {}) }; return values; } function inheritedValues(settings: FastBrowserConfigSettings, scope: "global" | "project"): ConfigOverrides { // Both scope editors need values below the scope being edited. Reconstruct // that chain explicitly so an existing project override never appears as a // global default, and unsetting a project draft reveals global/env values. const inherited = environmentInheritedValues(); if (scope === "project") { Object.assign(inherited, cloneValues(readFastBrowserConfigFile(settings.globalPath, "global"))); } return inherited; } async function showScopeSettings( ctx: ExtensionContext, settings: FastBrowserConfigSettings, scope: "global" | "project", draft: ConfigOverrides, ): Promise { const inherited = inheritedValues(settings, scope); return ctx.ui.custom((tui, _theme, _keybindings, done) => { const view = new FastBrowserSettingsView({ scope, draft, inherited, onEvent: done, }); return { render: (width: number) => view.render(width), handleInput: (data: string) => { view.handleInput(data); tui.requestRender(); }, invalidate: () => view.invalidate(), }; }); } async function saveScope( ctx: ExtensionContext, path: string, draft: ConfigOverrides, ): Promise { try { // Scope files are intentionally partial. Required endpoint/origin checks // happen only when the browser tool resolves the merged configuration. writeFastBrowserConfigFile(path, draft); return true; } catch (error) { notify(ctx, errorText(error), "error"); return false; } } async function editById(ctx: ExtensionContext, draft: ConfigOverrides, id: FastBrowserSettingId): Promise { switch (id) { case FAST_BROWSER_SETTING_IDS.cdpUrl: return editEndpoint(ctx, draft); case FAST_BROWSER_SETTING_IDS.allowedOrigins: return editOrigins(ctx, draft); case FAST_BROWSER_SETTING_IDS.python: return editPython(ctx, draft); case FAST_BROWSER_SETTING_IDS.textModel: return editModel(ctx, draft); case FAST_BROWSER_SETTING_IDS.textReasoning: return editReasoning(ctx, draft); case FAST_BROWSER_SETTING_IDS.typesafeKey: return editTypesafeKey(ctx, draft); case FAST_BROWSER_SETTING_IDS.browserMode: return editBrowserMode(ctx, draft); case FAST_BROWSER_SETTING_IDS.browserProduct: return editBrowserProduct(ctx, draft); case FAST_BROWSER_SETTING_IDS.browserExecutable: return editBrowserExecutable(ctx, draft); case FAST_BROWSER_SETTING_IDS.browserHeadless: return editBrowserHeadless(ctx, draft); case FAST_BROWSER_SETTING_IDS.browserStartUrl: return editBrowserStartUrl(ctx, draft); case FAST_BROWSER_SETTING_IDS.browserProfile: return editBrowserProfile(ctx, draft); } } function isBrowserSetting(id: FastBrowserSettingId): boolean { return id === FAST_BROWSER_SETTING_IDS.browserMode || id === FAST_BROWSER_SETTING_IDS.browserProduct || id === FAST_BROWSER_SETTING_IDS.browserExecutable || id === FAST_BROWSER_SETTING_IDS.browserHeadless || id === FAST_BROWSER_SETTING_IDS.browserStartUrl || id === FAST_BROWSER_SETTING_IDS.browserProfile; } function stageInheritedBrowser( draft: ConfigOverrides, inherited: ConfigOverrides, id: FastBrowserSettingId, ): boolean { if (isBrowserSetting(id) && draft.browser === undefined && inherited.browser !== undefined) { draft.browser = { ...inherited.browser }; return true; } return false; } function discardUnchangedInheritedBrowser( draft: ConfigOverrides, inherited: ConfigOverrides, wasSeeded: boolean, ): void { if (wasSeeded && valuesEqual(draft.browser, inherited.browser)) delete draft.browser; } async function editScope(ctx: ExtensionContext, settings: FastBrowserConfigSettings, scope: "global" | "project"): Promise { const path = scope === "global" ? settings.globalPath : settings.projectPath; let draft: ConfigOverrides; try { draft = cloneValues(readFastBrowserConfigFile(path, scope)); } catch (error) { notify(ctx, errorText(error), "error"); return; } const inherited = inheritedValues(settings, scope); while (true) { const selected = await showScopeSettings(ctx, settings, scope, draft); if (!selected || selected.kind === "cancel") return; // Keep the displayed draft committed to disk. An edit dialog may be // cancelled or rejected without changing either the file or this draft. const staged = cloneValues(draft); const inheritedBrowserSeeded = stageInheritedBrowser(staged, inherited, selected.id); if (!(await editById(ctx, staged, selected.id))) continue; discardUnchangedInheritedBrowser(staged, inherited, inheritedBrowserSeeded); if (configOverridesEqual(draft, staged)) continue; if (await saveScope(ctx, path, staged)) draft = staged; } } async function resetProject(ctx: ExtensionContext, settings: FastBrowserConfigSettings): Promise { try { removeFastBrowserConfigFile(settings.projectPath); notify(ctx, `Reset project fast-browser override (${settings.projectPath})`, "info"); } catch (error) { notify(ctx, errorText(error), "error"); } } export interface FastBrowserLaunchResult { sessionId: string; profile?: string; } export interface FastBrowserSessionMessage extends FastBrowserLaunchResult { purpose: "setup" | "automation"; } export interface FastBrowserCommandActions { stopBrowser?: () => Promise; /** Launch a saved named profile for interactive setup or automation. */ launchBrowser?: ( ctx: ExtensionContext, purpose: "setup" | "automation", ) => Promise; /** Persist launch metadata in the conversation without triggering a turn. */ sendSessionMessage?: (message: FastBrowserSessionMessage) => void; } function sessionMessageContent(message: FastBrowserSessionMessage): string { const details = JSON.stringify(message); return message.purpose === "setup" ? `Fast Browser manual setup session is active: ${details}. This session is not automatable; close it fully before launching automation.` : `Fast Browser automated session is ready: ${details}. Reuse its sessionId with fast_browser_observe or fast_browser_run.`; } async function runFastBrowserCommand(ctx: ExtensionContext, actions: FastBrowserCommandActions = {}): Promise { if (ctx.mode !== "tui") { notify(ctx, "/fast-browser requires Pi TUI mode", "error"); return; } const cwd = ctx.cwd || process.cwd(); const readEffective = (): { settings: FastBrowserConfigSettings; config?: FastBrowserConfig } => { const settings = inspectFastBrowserConfigSettings(cwd); let config: FastBrowserConfig | undefined; try { config = inspectFastBrowserConfig(cwd).config; } catch (error) { if (!(error instanceof FastBrowserConfigError)) throw error; } return { settings, config }; }; let current: { settings: FastBrowserConfigSettings; config?: FastBrowserConfig }; try { current = readEffective(); } catch (error) { // Existing malformed files are never overwritten by a settings command. notify(ctx, errorText(error), "error"); return; } const menu = [ "View effective configuration", "Edit global settings", "Edit project settings", "Reset project override", ...(actions.launchBrowser ? [MANUAL_SETUP, AUTOMATED_LAUNCH] : []), ...(actions.stopBrowser ? ["Close all browser sessions in this Pi instance"] : []), CLOSE, ]; while (true) { const selected = await ctx.ui.select("Fast Browser settings", menu); if (selected === undefined || selected === CLOSE) return; if (selected === menu[0]) { notify(ctx, viewMessage(current.settings, current.config), "info"); } else if (selected === menu[1] || selected === menu[2]) { await editScope(ctx, current.settings, selected === menu[1] ? "global" : "project"); try { current = readEffective(); } catch (error) { notify(ctx, errorText(error), "error"); return; } } else if (selected === menu[3]) { await resetProject(ctx, current.settings); try { current = readEffective(); } catch (error) { notify(ctx, errorText(error), "error"); return; } } else if (actions.launchBrowser && (selected === MANUAL_SETUP || selected === AUTOMATED_LAUNCH)) { try { const purpose = selected === MANUAL_SETUP ? "setup" : "automation"; const launched = await actions.launchBrowser(ctx, purpose); const metadata: FastBrowserSessionMessage = { sessionId: launched.sessionId, purpose, ...(launched.profile ? { profile: launched.profile } : {}), }; actions.sendSessionMessage?.(metadata); const profile = launched.profile ? ` profile ${launched.profile}` : ""; notify( ctx, purpose === "setup" ? `Launched manual browser setup${profile}; session ${launched.sessionId} is not automatable and must be closed before automation` : `Launched automated browser${profile}; use session ${launched.sessionId} with fast_browser_observe or fast_browser_run`, "info", ); } catch (error) { notify(ctx, launchErrorText(error), "error"); } } else if (actions.stopBrowser && selected === "Close all browser sessions in this Pi instance") { try { await actions.stopBrowser(); notify(ctx, "Closed this Pi instance's browser sessions; external browser processes were left running", "info"); } catch { notify(ctx, "Unable to close every browser session; cleanup can be retried", "error"); } } } } /** Register the native, bounded `/fast-browser` settings command. */ export function registerFastBrowserCommand(pi: ExtensionAPI, actions: FastBrowserCommandActions = {}): void { const commandActions: FastBrowserCommandActions = actions.sendSessionMessage ? actions : { ...actions, sendSessionMessage: (message) => { pi.sendMessage({ customType: FAST_BROWSER_SESSION_MESSAGE, content: sessionMessageContent(message), display: true, // Keep details deliberately limited to the opaque session metadata; // no resolved configuration or credential values cross this boundary. details: message, }, { triggerTurn: false, deliverAs: "nextTurn", }); }, }; pi.registerCommand("fast-browser", { description: "View and edit bounded fast-browser configuration", handler: async (_args, ctx) => { await runFastBrowserCommand(ctx, commandActions); }, }); } export default registerFastBrowserCommand; // Test seam: clear only the local model catalogue cache; it never contacts a // provider and is intentionally not exported from the extension entrypoint. export function resetFastBrowserModelChoicesCacheForTests(): void { modelChoicesCache = new WeakMap(); }