/** * Firefox BiDi extension: raw WebDriver BiDi frames from the agent. * * - `firefox_bidi` tool: batch of raw {id?, method, params} frames, verbatim * BiDi responses + events. Inactive until `/firefox`. * - `/firefox` command: activates the tool (launches Firefox lazily). * - `/firefox close`: shuts browser down and deactivates the tool. * * Firefox is resolved from `firefox-bidi.executablePath` (`FIREFOX_BIN`) or PATH; a missing binary is * reported with a warning and every launch uses a fresh temp profile. */ import { type ChildProcess, spawn } from "node:child_process"; import { accessSync, constants as fsConstants } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { type ExtensionAPI, type ExtensionContext, keyHint, } from "@earendil-works/pi-coding-agent"; import { type AutocompleteItem, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { assemblePayload, type BidiResponse, type Frame, resolveScreencastDestinations, runBatch, trimById, } from "./lib.ts"; import { closeDebug, dbg, span } from "./src/debug.ts"; import { findExecutable } from "./src/pi-ext-executable.ts"; import { parseBooleanSetting, registerSettingFlag, resolveSetting, type SettingDeclaration, } from "./src/pi-ext-settings.ts"; // ── state (one browser per Pi session) ────────────────────────────────────── interface BidiEvent { type: "event"; method: string; params: unknown; } type FrameStatus = { id: number; method: string; status: string; spooled?: string; }; type BidiToolDetails = { sessionId: string | null; responses: number; events: number; frames: FrameStatus[]; wait?: | { timedOut: true; method: string } | BidiEvent | Array<{ timedOut: true; method: string } | BidiEvent>; }; interface Ledger { subscriptions: string[]; intercepts: string[]; preloadScripts: string[]; } interface SpoolBudget { bytes: number; } interface Browser { child: ChildProcess; port: number; profileDir: string; spoolDir: string; spoolBytes: number; spoolBudget?: SpoolBudget; access: boolean; headed: boolean; prefs: string[]; usedIds: Set; sessionId: string | null; capabilities: Record | null; ws: WebSocket | null; nextId: number; pending: Map void>; waiters: Array<{ test: (e: BidiEvent) => boolean; resolve: (e: BidiEvent | null) => void; }>; events: BidiEvent[]; ledger: Ledger; } const EVENT_CAP = 200; const CONNECT_TIMEOUT = 10_000; const DEFAULT_FRAME_TIMEOUT = 30_000; const BY_ID_KEEP = 500; function display(text: string, limit = 80): string { return text.replace(/[\x00-\x1f\x7f-\x9f]/g, "?").slice(0, limit); } function resultPreview(text: string): string { let formatted = text; if (text.length <= 16_000) { try { const value = JSON.parse(text) as { responses?: unknown[] }; formatted = JSON.stringify(value.responses ?? value, null, 2); } catch { // Tool errors may not be JSON. } } const safe = formatted.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "?"); const preview = safe.split("\n").slice(0, 24).join("\n").slice(0, 2400); return preview.length < safe.length ? `${preview}\n… UI preview truncated` : preview; } const SKILL_PATH = fileURLToPath( new URL("./skills/firefox-bidi/SKILL.md", import.meta.url), ); // ── binary resolution ─────────────────────────────────────────────────────── const FIREFOX_ACCESS = process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK; function resolveOnPath(name: string): string | undefined { return findExecutable( process.platform === "win32" ? [`${name}.exe`, `${name}.cmd`, name] : [name], ); } function resolveFirefoxBinary(executablePath: string | undefined): string { if (executablePath) { try { accessSync(executablePath, FIREFOX_ACCESS); return executablePath; } catch { throw new Error( `firefox not found: ${executablePath} — set FIREFOX_BIN or install Firefox on PATH`, ); } } // PATH hits are already verified by resolveOnPath; the miss gets the same // friendly error as an unusable executable path const hit = resolveOnPath("firefox"); if (!hit) throw new Error( "firefox not found: firefox — set FIREFOX_BIN or install Firefox on PATH", ); return hit; } // ── websocket plumbing ────────────────────────────────────────────────────── function connect(url: string, retries = 0): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(url); const t = setTimeout(() => { ws.close(); reject(new Error(`connect timeout: ${url}`)); }, CONNECT_TIMEOUT); ws.onopen = () => { clearTimeout(t); resolve(ws); }; ws.onerror = () => { clearTimeout(t); if (retries > 0) setTimeout(() => connect(url, retries - 1).then(resolve, reject), 300); else reject(new Error(`connect failed: ${url}`)); }; }); } function attachSocket(b: Browser, ws: WebSocket): void { b.ws = ws; ws.onmessage = (m) => { if (b.ws !== ws) return; let frame: Record; try { frame = JSON.parse(String(m.data)); } catch { return; } if (frame.type === "event") { const ev = frame as unknown as BidiEvent; const size = Buffer.byteLength(JSON.stringify(ev)); const safe = size > 8192 ? { type: "event" as const, method: ev.method.slice(0, 256), params: { truncated: true, size, context: String( (ev.params as { context?: unknown } | null)?.context ?? "", ).slice(0, 256), }, } : ev; b.events.push(safe); if (b.events.length > EVENT_CAP) b.events.shift(); const hit = b.waiters.findIndex((waiter) => waiter.test(ev)); if (hit !== -1) b.waiters.splice(hit, 1)[0].resolve(safe); return; } const id = frame.id as number; const r = b.pending.get(id); if (r) { b.pending.delete(id); r(frame as unknown as BidiResponse); } }; ws.onclose = () => { if (b.ws !== ws) return; b.ws = null; for (const [, r] of b.pending) r({ id: -1, type: "connection-closed" }); b.pending.clear(); for (const waiter of b.waiters.splice(0)) waiter.resolve(null); }; } async function sessionSocket(b: Browser): Promise { if (b.ws && b.ws.readyState === WebSocket.OPEN) return b.ws; if (b.sessionId) { try { const ws = await connect( `ws://127.0.0.1:${b.port}/session/${b.sessionId}`, 3, ); attachSocket(b, ws); return ws; } catch { b.sessionId = null; // stale id, browser still alive } } const ws = await connect(`ws://127.0.0.1:${b.port}/session`, 3); attachSocket(b, ws); return ws; } // ── launch / teardown ─────────────────────────────────────────────────────── export async function launch( opts: { headed?: boolean; access?: boolean; prefs?: string[]; spoolBudget?: SpoolBudget; executablePath?: string; }, onSurvivor: (browser: Browser) => void, start = spawn, stop = stopChild, ): Promise { const finish = span?.("firefox.launch", { access: opts.access ?? false, mode: opts.headed ? "headed" : "headless", }); let profileDir: string | undefined; let spoolDir: string | undefined; let child: ChildProcess | undefined; let survivor: Browser | undefined; try { const bin = resolveFirefoxBinary(opts.executablePath); profileDir = await mkdtemp(join(tmpdir(), "pi-firefox-")); spoolDir = await mkdtemp(join(tmpdir(), "pi-firefox-spool-")); if (opts.prefs?.length) { const userJs = opts.prefs .map((p) => { const eq = p.indexOf("="); if (eq <= 0) throw new Error(`--pref must be key=value, got ${p}`); const k = p.slice(0, eq); let v: unknown = p.slice(eq + 1); try { v = JSON.parse(String(v)); } catch { /* keep string */ } return `user_pref(${JSON.stringify(k)}, ${JSON.stringify(v)});`; }) .join("\n"); await writeFile(join(profileDir, "user.js"), userJs + "\n"); } const args = [ "--headless", "--no-remote", "--profile", profileDir, "--remote-debugging-port", "0", ]; if (opts.headed) args.shift(); if (opts.access) args.push("--remote-allow-system-access"); const spawned = start(bin, args, { stdio: ["ignore", "pipe", "pipe"] }); child = spawned; const b: Browser = { child, port: 0, profileDir, spoolDir, spoolBytes: 0, spoolBudget: opts.spoolBudget, access: !!opts.access, headed: !!opts.headed, prefs: opts.prefs ?? [], usedIds: new Set(), sessionId: null, capabilities: null, ws: null, nextId: 1, pending: new Map(), waiters: [], events: [], ledger: { subscriptions: [], intercepts: [], preloadScripts: [] }, }; survivor = b; const url = await new Promise((resolve, reject) => { const t = setTimeout( () => reject(new Error("firefox: no BiDi listener within 30s")), 30_000, ); const fail = (error: Error) => { clearTimeout(t); reject(error); }; spawned.once("error", fail); const grab = (buf: Buffer) => { const m = String(buf).match(/WebDriver BiDi listening on (ws:\/\/\S+)/); if (m) { clearTimeout(t); spawned.off("error", fail); resolve(m[1]); } }; spawned.stdout?.on("data", grab); spawned.stderr?.on("data", grab); spawned.on("exit", (code) => { clearTimeout(t); reject(new Error(`firefox exited immediately (code ${code})`)); }); }); child.stdout?.destroy(); child.stderr?.destroy(); b.port = Number(new URL(url).port); finish?.("finish", { status: "ready" }); return b; } catch (error) { const exited = !child || (await stop(child)); if (!exited && survivor) onSurvivor(survivor); if (exited) { if (profileDir) await rm(profileDir, { recursive: true, force: true }); if (spoolDir) await rm(spoolDir, { recursive: true, force: true }); } finish?.("error", { kind: "launch" }); throw exited ? error : new Error( `${String(error)}; Firefox still alive, profile retained at ${survivor?.profileDir ?? "unknown"}`, ); } } async function stopChild(child: ChildProcess): Promise { for (const signal of ["SIGTERM", "SIGKILL"] as const) { if (child.exitCode !== null || child.signalCode !== null) return true; await new Promise((resolve) => { const done = () => { clearTimeout(timer); child.off("exit", done); resolve(); }; const timer = setTimeout(done, 2000); child.once("exit", done); try { child.kill(signal); } catch { done(); } }); } return child.exitCode !== null || child.signalCode !== null; } export async function teardown(b: Browser | null): Promise { if (!b) return; const finish = span?.("firefox.teardown"); try { try { b.ws?.close(); } catch { /* Continue terminating the child. */ } for (const waiter of b.waiters.splice(0)) waiter.resolve(null); if (!(await stopChild(b.child))) throw new Error( `Firefox did not exit; profile retained at ${b.profileDir}`, ); await rm(b.profileDir, { recursive: true, force: true }); // Spooled files are referenced by earlier tool results: do not evict them. if (!b.spoolBytes) await rm(b.spoolDir, { recursive: true, force: true }); finish?.(); } catch (error) { finish?.("error", { kind: "teardown" }); throw error; } } // ── screencast destination ────────────────────────────────────────────── // ── settings ──────────────────────────────────────────────────────────────── function parsePath(raw: unknown): string { if (typeof raw !== "string" || !raw.trim()) throw new Error("expected a non-empty string"); return raw; } // Precedence: --firefox, PI_FIREFOX, trusted project, user, false. const ENABLE_SETTING: SettingDeclaration = { key: "firefox-bidi.enable", parse: parseBooleanSetting, default: false, flag: { name: "firefox", type: "boolean", description: "Enable firefox_bidi at startup", }, env: "PI_FIREFOX", }; // Precedence: FIREFOX_BIN, trusted project, user, PATH lookup. const EXECUTABLE_SETTING: SettingDeclaration = { key: "firefox-bidi.executablePath", parse: parsePath, default: undefined, env: "FIREFOX_BIN", }; // Precedence: trusted project, user, Gecko default (~/Downloads). const SCREENCAST_SETTING: SettingDeclaration = { key: "firefox-bidi.screencastDir", parse: parsePath, default: undefined, }; function setting( pi: ExtensionAPI, ctx: ExtensionContext, declaration: SettingDeclaration, ): T { const result = resolveSetting(pi, ctx, declaration); if (!result.ok) throw new Error(result.error); return result.value; } // ── extension ─────────────────────────────────────────────────────────────── export default function firefoxBidiExtension(pi: ExtensionAPI) { registerSettingFlag(pi, ENABLE_SETTING); let browser: Browser | null = null; let lastById = new Map(); let spoolBudget: SpoolBudget = { bytes: 0 }; const retainedSpoolDirs = new Set(); let operation = Promise.resolve(); const serialize = (fn: () => Promise): Promise => { const next = operation.then(fn); operation = next.then( () => {}, () => {}, ); return next; }; const close = async () => { const closing = browser; await teardown(closing); if (closing?.spoolBytes) retainedSpoolDirs.add(closing.spoolDir); browser = null; lastById = new Map(); }; const cleanupRetainedSpools = async () => { for (const dir of retainedSpoolDirs) { await rm(dir, { recursive: true, force: true }); retainedSpoolDirs.delete(dir); } spoolBudget = { bytes: 0 }; }; const activate = () => { const tools = pi.getActiveTools(); if (!tools.includes("firefox_bidi")) pi.setActiveTools([...tools, "firefox_bidi"]); }; pi.registerCommand("firefox", { description: "Activate the firefox_bidi tool (/firefox), or tear down (/firefox close)", getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { const filtered = [{ value: "close", label: "close" }].filter((i) => i.value.startsWith(prefix), ); return filtered.length > 0 ? filtered : null; }, handler: async (args, ctx) => { const closing = args.trim() === "close"; const finish = span?.("firefox.command", { mode: closing ? "close" : "activate", }); try { if (closing) { await serialize(close); pi.setActiveTools( pi.getActiveTools().filter((t) => t !== "firefox_bidi"), ); ctx.ui.notify("Firefox stopped, tool deactivated", "info"); finish?.(); return; } activate(); ctx.ui.notify( "firefox_bidi activated — load skill firefox-bidi before composing frames", "info", ); finish?.(); } catch (error) { finish?.("error", { kind: "command" }); throw error; } }, }); pi.registerTool({ name: "firefox_bidi", label: "Firefox BiDi", description: "Drive Firefox with raw WebDriver BiDi frames (W3C protocol). " + "Send a batch of {id?, method, params} frames; get verbatim responses plus captured events. " + "Load the firefox-bidi skill before composing frames.", promptGuidelines: [ "Batch related commands into one call; include session.new with capabilities on first use.", 'Reference earlier results inside later frames with {{id.result.path}} placeholders, e.g. navigate params context: "{{2.result.context}}".', ], promptSnippet: "Drive Firefox with raw WebDriver BiDi frames", executionMode: "sequential", parameters: Type.Object({ frames: Type.Array( Type.Object( { id: Type.Optional( Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "unique positive int; auto-assigned when omitted", }), ), method: Type.String({ maxLength: 128, description: "module.command, e.g. browsingContext.navigate", }), params: Type.Optional( Type.Record(Type.String(), Type.Unknown(), { description: "command params object", }), ), }, { additionalProperties: false }, ), { description: "raw BiDi frames, executed sequentially", maxItems: 64 }, ), wait: Type.Optional( Type.Union([ Type.String({ maxLength: 128, description: "wait for event method arriving during this call", }), Type.Array(Type.String({ maxLength: 128 }), { minItems: 1, maxItems: 8, description: "wait for each event; repeated methods require distinct events", }), ]), ), waitContext: Type.Optional( Type.String({ description: "only match event params.context" }), ), waitTimeoutMs: Type.Optional(Type.Integer({ minimum: 1, default: 5000 })), frameTimeoutMs: Type.Optional( Type.Integer({ minimum: 1, default: 30000 }), ), launch: Type.Optional( Type.Object( { headed: Type.Optional( Type.Boolean({ default: false, description: "default headless" }), ), access: Type.Optional( Type.Boolean({ default: false, description: "privileged system realm; implies fresh temp profile", }), ), prefs: Type.Optional( Type.Array(Type.String(), { description: "pref=value entries, values parsed as JSON", }), ), }, { additionalProperties: false }, ), ), }), renderCall(args, theme, context) { const frames = args.frames; let text = theme.fg("toolTitle", theme.bold(" Firefox")); if (context.expanded) { text += theme.fg( "dim", ` · ${frames.length} frame${frames.length === 1 ? "" : "s"}`, ); for (const [index, frame] of frames.slice(0, 12).entries()) text += `\n${theme.fg("dim", `${index + 1}. `)}${theme.fg("accent", display(frame.method, 64))}`; if (frames.length > 12) text += `\n${theme.fg("muted", `+${frames.length - 12} more`)}`; } else { const summary = frames .slice(0, 2) .map((frame) => display(frame.method, 64)) .join(" → "); text += theme.fg("accent", ` · ${summary || "no frames"}`); if (frames.length > 2) text += theme.fg("dim", ` +${frames.length - 2}`); text += theme.fg( "dim", ` (${keyHint("app.tools.expand", "to expand")})`, ); } return new Text(text, 0, 0); }, renderResult(result, { expanded, isPartial }, theme) { const details = result.details as BidiToolDetails | undefined; if (!details) { const content = result.content[0]; const message = content?.type === "text" ? content.text : "Firefox BiDi failed"; return new Text( theme.fg( "error", expanded ? resultPreview(message) : display(message, 200), ), 0, 0, ); } const failures = details.frames.filter( (frame) => frame.status !== "success", ).length; let text = theme.fg( isPartial || failures ? "warning" : "success", `${isPartial ? "" : failures ? "✗ " : "✓ "}${details.responses} frame${details.responses === 1 ? "" : "s"}${isPartial ? " running" : ""}${failures ? ` · ${failures} failed/skipped` : ""}`, ); const timedOut = details.wait && (Array.isArray(details.wait) ? details.wait : [details.wait]) .filter((entry) => "timedOut" in entry) .map((entry) => entry.method); if (timedOut?.length) text += theme.fg( "warning", ` · wait timed out: ${timedOut.join(", ")}`, ); if (expanded) { if (details.events) text += theme.fg("dim", ` · ${details.events} events`); for (const frame of details.frames.slice(-20)) text += `\n${theme.fg(frame.status === "success" ? "dim" : "warning", `${frame.id} ${display(frame.method)}: ${display(frame.status)}${frame.spooled ? ` → ${display(frame.spooled, 240)}` : ""}`)}`; if (details.frames.length > 20) text += theme.fg( "dim", `\n... ${details.frames.length - 20} earlier frames`, ); if (details.sessionId) text += theme.fg("dim", `\nsession: ${display(details.sessionId)}`); const content = result.content[0]; if (!isPartial && content?.type === "text") { text += `\n${theme.fg("dim", "Result preview (raw BiDi may contain page data):")}`; text += `\n${resultPreview(content.text) .split("\n") .map((line) => theme.fg("toolOutput", line)) .join("\n")}`; } } return new Text(text, 0, 0); }, async execute(_id, params, signal, onUpdate, ctx) { return serialize(async () => { const finish = span?.("bidi.batch", { count: params.frames.length, timeoutMs: params.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT, }); try { if (signal?.aborted) throw new Error("Firefox BiDi aborted"); onUpdate?.({ content: [{ type: "text", text: "Starting Firefox BiDi…" }], details: { sessionId: browser?.sessionId ?? null, responses: 0, events: 0, frames: [], }, }); await resolveScreencastDestinations(params.frames, () => setting(pi, ctx, SCREENCAST_SETTING), ); if ( browser && (browser.child.exitCode !== null || browser.child.signalCode !== null) ) await close(); if ( browser && params.launch && ((params.launch.access !== undefined && params.launch.access !== browser.access) || (params.launch.headed !== undefined && params.launch.headed !== browser.headed) || (params.launch.prefs !== undefined && JSON.stringify(params.launch.prefs) !== JSON.stringify(browser.prefs))) ) throw new Error( "Firefox already running with different launch options; /firefox close before relaunch", ); if (!browser) { browser = await launch( { headed: params.launch?.headed, access: params.launch?.access, prefs: params.launch?.prefs, spoolBudget, executablePath: setting(pi, ctx, EXECUTABLE_SETTING), }, (survivor) => { browser = survivor; }, ); } const b = browser; if (!b.port) throw new Error( `Firefox launch failed but child is still alive; /firefox close to retry cleanup; profile retained at ${b.profileDir}`, ); const ws = await sessionSocket(b); // validate + assign ids const assigned = params.frames.map((f) => ({ ...f })); const seen = new Set(b.usedIds); for (const f of assigned) { if (f.id === undefined) continue; if (seen.has(f.id)) throw new Error(`duplicate or reused frame id ${f.id}`); seen.add(f.id); } for (const f of assigned) { if (f.id === undefined) { while (seen.has(b.nextId)) b.nextId++; if (b.nextId > Number.MAX_SAFE_INTEGER) throw new Error("BiDi frame ID space exhausted"); f.id = b.nextId++; seen.add(f.id); } } for (const id of seen) b.usedIds.add(id); const timeoutMs = params.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT; const methods = typeof params.wait === "string" ? [params.wait] : (params.wait ?? []); const observed: Array = Array( methods.length, ).fill(undefined); const notifications: Array< ((event: BidiEvent | null) => void) | undefined > = []; const waiters = methods.map((method, index) => ({ test: (event: BidiEvent) => event.method === method && (params.waitContext === undefined || (( event.params as { context?: unknown; source?: { context?: unknown }; } | null )?.context ?? (event.params as { source?: { context?: unknown } } | null) ?.source?.context) === params.waitContext), resolve: (event: BidiEvent | null) => { observed[index] = event; notifications[index]?.(event); }, })); b.waiters.push(...waiters); try { const byId = new Map(lastById); // seed with previous batch: cross-call refs work const frames: FrameStatus[] = []; const { responses, texts } = await runBatch( ws, b, // ids were assigned above; the optional tool schema cannot express that assigned as Frame[], byId, timeoutMs, (response, completed) => { const spooled = ( response.result as { spooled?: string } | undefined )?.spooled; frames.push({ id: response.id, method: response.method ?? "", status: response.skipped ? "skipped" : (response.type ?? "unknown"), ...(spooled ? { spooled } : {}), }); onUpdate?.({ content: [ { type: "text", text: `${completed}/${params.frames.length} frames`, }, ], details: { sessionId: b.sessionId, responses: completed, events: b.events.length, frames: frames.slice(-20), }, }); }, signal, ); // Deferred spool writes may replace a provisional success with an error. for (const [index, response] of responses.entries()) { frames[index].status = response.skipped ? "skipped" : (response.type ?? "unknown"); if (response.error === "pi spool write failed") delete frames[index].spooled; } const sn = responses.find((r) => r.method === "session.new"); if ( sn?.type === "success" && (sn.result as Record)?.sessionId ) { const r = sn.result as Record; b.sessionId = r.sessionId as string; b.capabilities = r.capabilities as Record; } const waits = await Promise.all( methods.map(async (method, index) => { const hit = observed[index] !== undefined ? observed[index] : await new Promise((resolve) => { const finish = (event: BidiEvent | null) => { clearTimeout(t); signal?.removeEventListener("abort", abort); b.waiters = b.waiters.filter( (entry) => entry !== waiters[index], ); resolve(event); }; const abort = () => finish(null); const t = setTimeout( () => finish(null), params.waitTimeoutMs ?? 5000, ); notifications[index] = finish; signal?.addEventListener("abort", abort, { once: true, }); if (signal?.aborted) abort(); }); return hit ?? { timedOut: true as const, method }; }), ); const wait = typeof params.wait === "string" ? waits[0] : params.wait ? waits : undefined; const events = b.events.slice(-EVENT_CAP); lastById = trimById(byId, BY_ID_KEEP); const text = assemblePayload( { sessionId: b.sessionId, port: b.port, capabilities: b.capabilities, }, texts, events, EVENT_CAP, params.wait ? wait : undefined, ); const result = { content: [{ type: "text" as const, text }], details: { sessionId: b.sessionId, responses: responses.length, events: events.length, frames, ...(wait ? { wait } : {}), }, }; const skipped = responses.filter( (response) => response.skipped, ).length; const succeeded = responses.filter( (response) => !response.skipped && response.type === "success", ).length; const failed = responses.length - skipped - succeeded; const waitTimedOut = waits.filter( (wait) => wait !== null && "timedOut" in wait, ).length; finish?.("finish", { count: responses.length, succeeded, failed, skipped, waitTimedOut, status: "complete", }); return result; } finally { b.waiters = b.waiters.filter((entry) => !waiters.includes(entry)); } } catch (e) { finish?.("error", { kind: "batch" }); throw new Error(e instanceof Error ? e.message : String(e)); } }); }, }); pi.on("resources_discover", () => ({ skillPaths: [SKILL_PATH] })); pi.on("session_start", async (_event, ctx) => { dbg?.("session.start"); await serialize(async () => { await close(); await cleanupRetainedSpools(); }); // tool ships registered but inactive: /firefox (or firefox-bidi.enable, // PI_FIREFOX=1) activates it, so the initial prompt stays free of the // tool contract const enable = resolveSetting(pi, ctx, ENABLE_SETTING); if (!enable.ok) ctx.ui.notify(`firefox-bidi: ${enable.error}; tool inactive`, "warning"); if (enable.ok && enable.value) activate(); else pi.setActiveTools( pi.getActiveTools().filter((t) => t !== "firefox_bidi"), ); const executable = resolveSetting(pi, ctx, EXECUTABLE_SETTING); if (!executable.ok) ctx.ui.notify(`firefox-bidi: ${executable.error}`, "warning"); else if (!executable.value && !resolveOnPath("firefox")) ctx.ui.notify( "firefox-bidi: firefox not on PATH; set FIREFOX_BIN or install Firefox before /firefox", "warning", ); }); pi.on("session_shutdown", () => serialize(async () => { dbg?.("session.shutdown"); try { await close(); await cleanupRetainedSpools(); } finally { closeDebug(); } }), ); }