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();
}
}),
);
}