Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/firefox-bidi/index.ts

Raw
/**
 * 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<number>;
	sessionId: string | null;
	capabilities: Record<string, unknown> | null;
	ws: WebSocket | null;
	nextId: number;
	pending: Map<number, (r: BidiResponse) => 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<WebSocket> {
	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<string, unknown>;
		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<WebSocket> {
	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<Browser> {
	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<string>((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<boolean> {
	for (const signal of ["SIGTERM", "SIGKILL"] as const) {
		if (child.exitCode !== null || child.signalCode !== null) return true;
		await new Promise<void>((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<void> {
	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<boolean> = {
	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<string | undefined> = {
	key: "firefox-bidi.executablePath",
	parse: parsePath,
	default: undefined,
	env: "FIREFOX_BIN",
};

// Precedence: trusted project, user, Gecko default (~/Downloads).
const SCREENCAST_SETTING: SettingDeclaration<string | undefined> = {
	key: "firefox-bidi.screencastDir",
	parse: parsePath,
	default: undefined,
};

function setting<T>(
	pi: ExtensionAPI,
	ctx: ExtensionContext,
	declaration: SettingDeclaration<T>,
): 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<number, BidiResponse>();
	let spoolBudget: SpoolBudget = { bytes: 0 };
	const retainedSpoolDirs = new Set<string>();
	let operation = Promise.resolve();
	const serialize = <T>(fn: () => Promise<T>): Promise<T> => {
		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<BidiEvent | null | undefined> = 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<string, unknown>)?.sessionId
						) {
							const r = sn.result as Record<string, unknown>;
							b.sessionId = r.sessionId as string;
							b.capabilities = r.capabilities as Record<string, unknown>;
						}

						const waits = await Promise.all(
							methods.map(async (method, index) => {
								const hit =
									observed[index] !== undefined
										? observed[index]
										: await new Promise<BidiEvent | null>((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();
			}
		}),
	);
}