Files
harness-tools/extensions/fast-browser/command.ts
T
jb fd63b0ccec
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s
fast-browser-with-jev
2026-09-20 12:24:08 -04:00

740 lines
29 KiB
TypeScript

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<object, string[]> = 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<string, unknown>;
const rightObject = right as Record<string, unknown>;
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<string, unknown>)[key],
(right as Record<string, unknown>)[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<keyof FastBrowserConfig> = [
"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<string, unknown>)[key];
else (draft as Record<string, unknown>)[key] = value;
}
async function editEndpoint(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<string, unknown>).executablePath;
applyDraftValue(draft, "browser", next);
} catch (error) {
notify(ctx, errorText(error), "error");
}
return true;
}
async function editBrowserHeadless(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
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<boolean> {
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<string, unknown>;
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<boolean> {
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<string, unknown>;
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<boolean> {
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<FastBrowserSettingsViewEvent | undefined> {
const inherited = inheritedValues(settings, scope);
return ctx.ui.custom<FastBrowserSettingsViewEvent | undefined>((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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void>;
/** Launch a saved named profile for interactive setup or automation. */
launchBrowser?: (
ctx: ExtensionContext,
purpose: "setup" | "automation",
) => Promise<FastBrowserLaunchResult>;
/** 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<void> {
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();
}