917 lines
32 KiB
TypeScript
917 lines
32 KiB
TypeScript
/**
|
|
* firefox-bidi.ts — Estensione pi: controllo bidirezionale di Firefox
|
|
* via WebDriver BiDi, con screenshot e delegazione verso Antigravity CLI.
|
|
*
|
|
* Installazione (solo se autorizzata):
|
|
* cp firefox-bidi.ts ~/.pi/agent/extensions/ # poi /reload in pi
|
|
*
|
|
* Architettura:
|
|
* pi (questa estensione) ──HTTP JSON──► Browser Bridge (python)
|
|
* └──WebSocket BiDi──► Firefox (ws://127.0.0.1:9222)
|
|
*
|
|
* Il Bridge mantiene la sessione BiDi (id→Future, event loop) ed espone
|
|
* API HTTP semplici; questa estensione registra tool pi che le chiamano.
|
|
* La parte browser del bridge è in bridge/bridge.py (vedi DESIGN.md).
|
|
*
|
|
* Gestione errori (v0.2):
|
|
* - timeout su ogni chiamata HTTP (AbortController)
|
|
* - retry con backoff esponenziale per errori transitori (ECONNREFUSED, 5xx, timeout)
|
|
* - riavvio automatico di Firefox su sessione BiDi orfana
|
|
* ("Maximum number of active sessions" dopo un kill senza session.end)
|
|
* - chiusura pulita della sessione in browser_stop (evita sessioni orfane)
|
|
* - errori tipizzati (BridgeError) e logging con prefisso [firefox-bidi]
|
|
*
|
|
* Zero dipendenze npm: si usa solo fetch di Node 22 + child_process.
|
|
*/
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import { Type } from "typebox";
|
|
import { spawn, execFile } from "node:child_process";
|
|
import { closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { Socket } from "node:net";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const BRIDGE_URL = process.env.FIREFOX_BIDI_BRIDGE ?? "http://127.0.0.1:8787";
|
|
const BRIDGE_TOKEN = process.env.FIREFOX_BIDI_TOKEN ?? "firefox-bidi-local";
|
|
const SCRIPT =
|
|
process.env.FIREFOX_BIDI_SCRIPT ?? "/home/enne2/Dev/firefox-bidi/start-firefox-bidi.sh";
|
|
const BRIDGE_PY =
|
|
process.env.FIREFOX_BIDI_BRIDGE_PY ?? "/home/enne2/Dev/firefox-bidi/bridge/bridge.py";
|
|
const BRIDGE_PYLIBS =
|
|
process.env.FIREFOX_BIDI_PYLIBS ?? "/home/enne2/Dev/firefox-bidi/pylibs";
|
|
|
|
// Ambiente Python isolato (venv) con auto-installazione delle dipendenze:
|
|
// rende l'estensione autocontenuta (niente dipendenze dal Python di sistema).
|
|
const VENV_DIR =
|
|
process.env.FIREFOX_BIDI_VENV ?? join(homedir(), ".local", "share", "pi-firefox-bidi", "venv");
|
|
const VENV_PY = join(VENV_DIR, "bin", "python");
|
|
|
|
const DEFAULT_TIMEOUT_MS = 120_000; // comandi BiDi lunghi (navigate wait:"complete")
|
|
const STATUS_TIMEOUT_MS = 5_000;
|
|
const EVAL_TIMEOUT_MS = 30_000; // fail-fast per operazioni veloci
|
|
const SCREENSHOT_TIMEOUT_MS = 30_000;
|
|
const EVENTS_TIMEOUT_MS = 10_000;
|
|
const SEND_TIMEOUT_MS = 60_000;
|
|
const BRIDGE_STABLE_MS = 3_000;
|
|
const BRIDGE_LOG =
|
|
process.env.FIREFOX_BIDI_BRIDGE_LOG ?? join(homedir(), ".local", "state", "pi-firefox-bidi", "bridge.log");
|
|
|
|
let bridgeProcess: ReturnType<typeof spawn> | null = null;
|
|
|
|
const log = (...a: unknown[]) => console.log("[firefox-bidi]", ...a);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trasporto HTTP con timeout, errori tipizzati, retry
|
|
// ---------------------------------------------------------------------------
|
|
class BridgeError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
path: string,
|
|
body: unknown,
|
|
) {
|
|
super(`bridge ${path}: HTTP ${status} ${JSON.stringify(body).slice(0, 300)}`);
|
|
this.name = "BridgeError";
|
|
}
|
|
}
|
|
|
|
async function fetchWithTimeout(
|
|
url: string,
|
|
init: RequestInit,
|
|
timeoutMs: number,
|
|
): Promise<Response> {
|
|
const ac = new AbortController();
|
|
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
try {
|
|
return await fetch(url, { ...init, signal: ac.signal });
|
|
} catch (err) {
|
|
if (ac.signal.aborted) {
|
|
throw new Error(
|
|
`timeout dopo ${timeoutMs}ms: ${url} non ha risposto (bridge bloccato o Firefox non risponde). ` +
|
|
`Verifica lo stato con browser_status.`,
|
|
);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function bridge(
|
|
path: string,
|
|
init?: RequestInit,
|
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
): Promise<any> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetchWithTimeout(
|
|
`${BRIDGE_URL}${path}`,
|
|
{
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: `Bearer ${BRIDGE_TOKEN}`,
|
|
},
|
|
...init,
|
|
},
|
|
timeoutMs,
|
|
);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
throw new Error(
|
|
`bridge non raggiungibile (${BRIDGE_URL}): ${msg}. ` +
|
|
`Avvia il bridge con browser_start prima di usare i comandi browser_*.`,
|
|
);
|
|
}
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new BridgeError(res.status, path, body);
|
|
return body;
|
|
}
|
|
|
|
function isTransient(err: unknown): boolean {
|
|
if (err instanceof BridgeError) return err.status >= 500;
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
// NOTA: "session not created" NON è transitorio: richiede il riavvio
|
|
// della sessione (gestito da withSessionRecovery), non un semplice retry.
|
|
return /ECONNREFUSED|fetch failed|abort|timeout|socket hang up/i.test(msg);
|
|
}
|
|
|
|
function isSessionError(err: unknown): boolean {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return /session not created|sessione BiDi chiusa|session\.end/i.test(msg);
|
|
}
|
|
|
|
async function withRetry<T>(fn: () => Promise<T>, label: string, retries = 2): Promise<T> {
|
|
let lastErr: unknown;
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
lastErr = err;
|
|
if (!isTransient(err) || attempt === retries) break;
|
|
// backoff esponenziale con jitter (±50%) per evitare thundering herd
|
|
const base = 500 * 2 ** attempt;
|
|
const delay = Math.round(base * (0.5 + Math.random() * 0.5));
|
|
log(`${label}: tentativo ${attempt + 1} fallito (${(err as Error).message}), retry tra ${delay}ms`);
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
}
|
|
}
|
|
throw new Error(
|
|
`${label} fallito dopo ${retries + 1} tentativi: ${(lastErr as Error).message}`,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ciclo di vita: Firefox + bridge, con recupero da sessione orfana e gestione conflitti
|
|
// ---------------------------------------------------------------------------
|
|
export type ConflictAction = "close" | "wait" | "dedicated" | "ask";
|
|
|
|
interface ConflictCheck {
|
|
portBusy: boolean;
|
|
firefoxRunning: boolean;
|
|
mainProfile: string;
|
|
}
|
|
|
|
async function checkFirefoxConflict(): Promise<ConflictCheck> {
|
|
try {
|
|
const { stdout } = await execFileAsync(SCRIPT, ["check"], { timeout: 5_000 });
|
|
return JSON.parse(stdout.trim());
|
|
} catch {
|
|
return { portBusy: await isPortOpen(9222), firefoxRunning: false, mainProfile: "" };
|
|
}
|
|
}
|
|
|
|
async function bridgeAlive(): Promise<boolean> {
|
|
try {
|
|
const status = await bridge("/status", undefined, STATUS_TIMEOUT_MS);
|
|
return status.firefox === true && status.sessionActive === true && Boolean(status.context);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function startFirefox(
|
|
profile: "dedicated" | "main",
|
|
action: ConflictAction = "ask",
|
|
ctx?: any,
|
|
): Promise<void> {
|
|
let effectiveProfile = profile;
|
|
let resolvedAction = action;
|
|
|
|
if (profile === "main") {
|
|
const conflict = await checkFirefoxConflict();
|
|
if (conflict.firefoxRunning || conflict.portBusy) {
|
|
if (resolvedAction === "ask") {
|
|
if (ctx?.ui?.select) {
|
|
const prompt =
|
|
conflict.firefoxRunning
|
|
? "Firefox o il profilo utente predefinito sono già attivi in un'altra sessione. Come desideri procedere?"
|
|
: "La porta WebDriver BiDi (9222) è già occupata da un'altra sessione. Come desideri procedere?";
|
|
const choice = await ctx.ui.select(prompt, [
|
|
"Chiudi il browser/sessione attiva e avvia con BiDi (profilo principale)",
|
|
"Attendi la fine dell'altra sessione",
|
|
"Apri un profilo separato/indipendente (bidi-profile)",
|
|
"Annulla operazione",
|
|
]);
|
|
|
|
if (!choice || choice.includes("Annulla")) {
|
|
throw new Error("Avvio Firefox BiDi annullato dall'utente.");
|
|
} else if (choice.includes("Chiudi")) {
|
|
resolvedAction = "close";
|
|
} else if (choice.includes("Attendi")) {
|
|
resolvedAction = "wait";
|
|
} else if (choice.includes("separato")) {
|
|
resolvedAction = "dedicated";
|
|
effectiveProfile = "dedicated";
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
"Conflitto rilevato: Firefox o il profilo principale sono già in esecuzione in un'altra sessione. " +
|
|
"Specifica il parametro 'onConflict' ('close', 'wait', 'dedicated') per scegliere l'azione desiderata.",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const cmd = effectiveProfile === "main" ? "start-main" : "start";
|
|
const env = {
|
|
...process.env,
|
|
FIREFOX_BIDI_ACTION: resolvedAction,
|
|
};
|
|
await execFileAsync(SCRIPT, [cmd], { timeout: 90_000, env });
|
|
}
|
|
|
|
/**
|
|
* Garantisce un ambiente Python locale (venv) con le dipendenze del bridge
|
|
* (websockets). Auto-installazione al primo avvio: rende l'estensione
|
|
* autocontenuta, senza dipendere dal Python di sistema.
|
|
* Fallback: python3 di sistema + PYTHONPATH (comportamento legacy).
|
|
*/
|
|
async function ensureVenv(): Promise<{ py: string; env: NodeJS.ProcessEnv }> {
|
|
// Fast path: venv già pronto (python + websockets importabile)
|
|
try {
|
|
await execFileAsync(VENV_PY, ["-c", "import websockets"], { timeout: 10_000 });
|
|
return { py: VENV_PY, env: { ...process.env } };
|
|
} catch {
|
|
/* venv assente o incompleto: crea/ripara */
|
|
}
|
|
|
|
log("creazione venv isolato per il bridge (primo avvio)...");
|
|
try {
|
|
await execFileAsync("python3", ["-m", "venv", VENV_DIR], { timeout: 60_000 });
|
|
// pip nel venv: ensurepip (se python3-venv presente) oppure pip di sistema --target
|
|
try {
|
|
await execFileAsync(VENV_PY, ["-m", "ensurepip", "--upgrade"], { timeout: 60_000 });
|
|
} catch {
|
|
// niente ensurepip (Debian senza python3-venv): installa nel site-packages
|
|
// del venv usando il pip di sistema (--target).
|
|
const { stdout } = await execFileAsync(
|
|
VENV_PY,
|
|
["-c", "import site; print(site.getsitepackages()[0])"],
|
|
{ timeout: 10_000 },
|
|
);
|
|
const sitePkgs = stdout.trim();
|
|
await execFileAsync(
|
|
"python3",
|
|
["-m", "pip", "install", "--quiet", "--target", sitePkgs, "websockets"],
|
|
{ timeout: 120_000 },
|
|
);
|
|
}
|
|
// verifica finale: websockets importabile nel venv
|
|
await execFileAsync(VENV_PY, ["-c", "import websockets"], { timeout: 10_000 });
|
|
log(`venv pronto: ${VENV_PY}`);
|
|
return { py: VENV_PY, env: { ...process.env } };
|
|
} catch (err) {
|
|
log(`venv fallito (${(err as Error).message}), fallback a python3 + PYTHONPATH`);
|
|
return {
|
|
py: "python3",
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: [BRIDGE_PYLIBS, process.env.PYTHONPATH ?? ""].filter(Boolean).join(":"),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
async function startBridgeProcess(): Promise<void> {
|
|
const { py, env } = await ensureVenv();
|
|
mkdirSync(dirname(BRIDGE_LOG), { recursive: true });
|
|
const logFd = openSync(BRIDGE_LOG, "a");
|
|
let child: ReturnType<typeof spawn>;
|
|
try {
|
|
writeSync(logFd, `\n[extension] ${new Date().toISOString()} start ${py} ${BRIDGE_PY}\n`);
|
|
child = spawn(py, [BRIDGE_PY], {
|
|
stdio: ["ignore", logFd, logFd],
|
|
detached: true,
|
|
env,
|
|
});
|
|
} finally {
|
|
closeSync(logFd);
|
|
}
|
|
bridgeProcess = child;
|
|
child.once("error", (err) => log(`bridge spawn error: ${err.message}; log=${BRIDGE_LOG}`));
|
|
child.once("exit", (code, signal) => {
|
|
log(`bridge terminato: code=${code ?? "null"} signal=${signal ?? "null"}; log=${BRIDGE_LOG}`);
|
|
if (bridgeProcess === child) bridgeProcess = null;
|
|
});
|
|
child.unref();
|
|
}
|
|
|
|
async function waitBridgeReady(timeoutMs = 15_000): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let stableSince = 0;
|
|
while (Date.now() < deadline) {
|
|
if (await bridgeAlive()) {
|
|
if (stableSince === 0) stableSince = Date.now();
|
|
if (Date.now() - stableSince >= BRIDGE_STABLE_MS) return true;
|
|
} else {
|
|
stableSince = 0;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Garantisce un bridge stabile senza terminare o riavviare Firefox. */
|
|
/**
|
|
* Verifica se Firefox Remote Agent è già in ascolto sulla porta BiDi (9222).
|
|
* Usato da ensureBridge per NON riavviare un Firefox già attivo (istanza
|
|
* --no-remote non agganciabile dallo script di start).
|
|
*/
|
|
function isPortOpen(port: number, host = "127.0.0.1"): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const sock = new Socket();
|
|
sock.setTimeout(2_000);
|
|
sock.once("connect", () => {
|
|
sock.destroy();
|
|
resolve(true);
|
|
});
|
|
sock.once("timeout", () => {
|
|
sock.destroy();
|
|
resolve(false);
|
|
});
|
|
sock.once("error", () => {
|
|
sock.destroy();
|
|
resolve(false);
|
|
});
|
|
sock.connect(port, host);
|
|
});
|
|
}
|
|
|
|
async function ensureBridge(
|
|
profile: "dedicated" | "main" = "dedicated",
|
|
onConflict: ConflictAction = "ask",
|
|
ctx?: any,
|
|
): Promise<void> {
|
|
if (await bridgeAlive()) return;
|
|
|
|
if (onConflict === "close" || !(await isPortOpen(9222))) {
|
|
await startFirefox(profile, onConflict, ctx);
|
|
} else {
|
|
log("Firefox già attivo su 9222: avvio solo il bridge");
|
|
}
|
|
|
|
await startBridgeProcess();
|
|
if (await waitBridgeReady(8_000)) return;
|
|
|
|
// Se fallisce e non è stato fatto un restart pulito, proviamo a sbloccare la sessione orfana
|
|
log("Bridge non pronto, tentativo recupero sessione orfana riavviando Firefox...");
|
|
await startFirefox(profile, "close", ctx);
|
|
await startBridgeProcess();
|
|
if (await waitBridgeReady(15_000)) return;
|
|
|
|
throw new Error(
|
|
`Bridge BiDi non stabile per ${BRIDGE_STABLE_MS}ms. ` +
|
|
`Verifica ${BRIDGE_LOG} e lo stato della porta 9222.`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Non riavvia o termina Firefox automaticamente: una sessione BiDi chiusa
|
|
* viene riportata in modo esplicito e browser_start può ricreare il bridge.
|
|
*/
|
|
async function withSessionRecovery<T>(fn: () => Promise<T>, label: string): Promise<T> {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
if (isSessionError(err)) {
|
|
log(`${label}: sessione BiDi non valida (${(err as Error).message}); nessun auto-kill eseguito`);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
// -------------------------------------------------------------------------
|
|
// Ciclo di vita del browser
|
|
// -------------------------------------------------------------------------
|
|
pi.registerTool({
|
|
name: "browser_start",
|
|
label: "Browser Start",
|
|
description:
|
|
"Avvia Firefox con WebDriver BiDi (porta 9222) e il Browser Bridge. " +
|
|
"profile=main usa il profilo principale (login/cookie reali); " +
|
|
"profile=dedicated (default) usa il profilo di automazione isolato bidi-profile. " +
|
|
"In caso di conflitto su profilo o porta già attivi, consente di chiedere all'utente o " +
|
|
"specificare l'azione desiderata (close, wait, dedicated).",
|
|
parameters: Type.Object({
|
|
profile: Type.Optional(
|
|
Type.Enum(
|
|
{ dedicated: "dedicated", main: "main" },
|
|
{ description: "Profilo Firefox: dedicated (automazione) o main (login reali)", default: "dedicated" },
|
|
),
|
|
),
|
|
onConflict: Type.Optional(
|
|
Type.Enum(
|
|
{ close: "close", wait: "wait", dedicated: "dedicated", ask: "ask" },
|
|
{
|
|
description:
|
|
"Azione se il profilo/browser è già attivo: 'close' (chiude l'istanza e avvia BiDi), " +
|
|
"'wait' (attende il rilascio), 'dedicated' (avvia un profilo indipendente), 'ask' (chiede conferma all'utente)",
|
|
default: "ask",
|
|
},
|
|
),
|
|
),
|
|
}),
|
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
await ensureBridge(params.profile ?? "dedicated", params.onConflict ?? "ask", ctx);
|
|
const st = await bridge("/status");
|
|
return {
|
|
content: [{ type: "text", text: `Firefox BiDi attivo: ${JSON.stringify(st)}` }],
|
|
details: st,
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "browser_stop",
|
|
label: "Browser Stop",
|
|
description:
|
|
"Termina l'istanza Firefox BiDi e il bridge. Chiude prima la sessione BiDi " +
|
|
"(session.end) per evitare sessioni orfane sul Remote Agent.",
|
|
parameters: Type.Object({}),
|
|
async execute() {
|
|
// 1) chiusura pulita della sessione BiDi (evita "Maximum number of active sessions")
|
|
try {
|
|
await bridge("/session/close", { method: "POST" }, 5_000);
|
|
} catch {
|
|
/* bridge già giù o sessione assente: ok */
|
|
}
|
|
// 2) SIGINT al bridge → il finally di bridge.py esegue close_session
|
|
if (bridgeProcess) {
|
|
bridgeProcess.kill("SIGINT");
|
|
bridgeProcess = null;
|
|
}
|
|
// 3) stop Firefox
|
|
await execFileAsync(SCRIPT, ["stop"]).catch(() => {});
|
|
return {
|
|
content: [{ type: "text", text: "Firefox BiDi fermato (sessione chiusa)." }],
|
|
details: {},
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "browser_status",
|
|
label: "Browser Status",
|
|
description: "Stato di Firefox BiDi e del bridge (sessioni, contesti aperti).",
|
|
parameters: Type.Object({}),
|
|
async execute() {
|
|
const st = await bridge("/status");
|
|
return { content: [{ type: "text", text: JSON.stringify(st, null, 2) }], details: st };
|
|
},
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Navigazione e comandi BiDi
|
|
// -------------------------------------------------------------------------
|
|
pi.registerTool({
|
|
name: "browser_navigate",
|
|
label: "Browser Navigate",
|
|
description: "Naviga il contesto principale verso un URL e attende il carico.",
|
|
parameters: Type.Object({
|
|
url: Type.String({ description: "URL completo (es. https://example.com)" }),
|
|
}),
|
|
async execute(_id, params) {
|
|
await ensureBridge();
|
|
const r = await withSessionRecovery(
|
|
() =>
|
|
withRetry(
|
|
() =>
|
|
bridge(
|
|
"/navigate",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ url: params.url }),
|
|
},
|
|
DEFAULT_TIMEOUT_MS,
|
|
),
|
|
"navigate",
|
|
),
|
|
"navigate",
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: `Navigazione OK: ${r.url} (context ${r.context})` }],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "browser_screenshot",
|
|
label: "Browser Screenshot",
|
|
description:
|
|
"Cattura uno screenshot (PNG) della pagina corrente e lo salva su disco. " +
|
|
"Il percorso viene restituito e l'immagine è allegata al contesto.",
|
|
parameters: Type.Object({
|
|
path: Type.Optional(
|
|
Type.String({ description: "Percorso di salvataggio (default: /tmp/firefox-bidi/shot-<ts>.png)" }),
|
|
),
|
|
fullPage: Type.Optional(Type.Boolean({ description: "Screenshot dell'intera pagina" })),
|
|
}),
|
|
async execute(_id, params) {
|
|
await ensureBridge();
|
|
const r = await withSessionRecovery(
|
|
() =>
|
|
withRetry(
|
|
() =>
|
|
bridge(
|
|
"/screenshot",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }),
|
|
},
|
|
SCREENSHOT_TIMEOUT_MS,
|
|
),
|
|
"screenshot",
|
|
),
|
|
"screenshot",
|
|
);
|
|
const pngB64 = readFileSync(r.path).toString("base64");
|
|
return {
|
|
content: [
|
|
{ type: "text", text: `Screenshot salvato in ${r.path} (${r.bytes} bytes)` },
|
|
{ type: "image", data: pngB64, mimeType: "image/png" },
|
|
],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "browser_eval",
|
|
label: "Browser Eval",
|
|
description:
|
|
"Esegue JavaScript nella pagina (script.evaluate BiDi). Restituisce il valore JSON.",
|
|
parameters: Type.Object({
|
|
expression: Type.String({ description: "Espressione JS da valutare" }),
|
|
awaitPromise: Type.Optional(Type.Boolean({ default: true })),
|
|
}),
|
|
async execute(_id, params) {
|
|
await ensureBridge();
|
|
const r = await withSessionRecovery(
|
|
() =>
|
|
withRetry(
|
|
() =>
|
|
bridge(
|
|
"/eval",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(params),
|
|
},
|
|
EVAL_TIMEOUT_MS,
|
|
),
|
|
"eval",
|
|
),
|
|
"eval",
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: `Result: ${JSON.stringify(r.result ?? r, null, 2)}` }],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "browser_send",
|
|
label: "Browser BiDi Send",
|
|
description:
|
|
"Invia un comando WebDriver BiDi grezzo (power users), es. browsingContext.captureScreenshot, " +
|
|
"session.subscribe, network.getCookies, log.read.",
|
|
parameters: Type.Object({
|
|
method: Type.String({ description: "Nome metodo BiDi, es. browsingContext.captureScreenshot" }),
|
|
params: Type.Optional(Type.Record(Type.String(), Type.Any(), { default: {} })),
|
|
}),
|
|
async execute(_id, params) {
|
|
await ensureBridge();
|
|
const r = await withSessionRecovery(
|
|
() =>
|
|
bridge(
|
|
"/cmd",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(params),
|
|
},
|
|
SEND_TIMEOUT_MS,
|
|
),
|
|
"browser_send",
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Eventi asincroni (push browser → pi)
|
|
// -------------------------------------------------------------------------
|
|
pi.registerTool({
|
|
name: "browser_events",
|
|
label: "Browser Events",
|
|
description:
|
|
"Legge gli eventi BiDi accumulati dal bridge (log.entryAdded, network.responseCompleted, " +
|
|
"browsingContext.*, script.*). Opzionalmente sottoscrive un modulo prima.",
|
|
parameters: Type.Object({
|
|
action: Type.Enum(
|
|
{ list: "list", subscribe: "subscribe", drain: "drain" },
|
|
{ description: "list=eventi in coda, subscribe=iscrivi un modulo, drain=svuota la coda" },
|
|
),
|
|
module: Type.Optional(
|
|
Type.String({ description: "Modulo BiDi da sottoscrivere, es. log, network, browsingContext" }),
|
|
),
|
|
clear: Type.Optional(Type.Boolean({ default: false })),
|
|
}),
|
|
async execute(_id, params) {
|
|
await ensureBridge();
|
|
const r = await bridge(
|
|
"/events",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(params),
|
|
},
|
|
EVENTS_TIMEOUT_MS,
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Delegazione asincrona a Antigravity CLI (pi → agy)
|
|
// -------------------------------------------------------------------------
|
|
pi.registerTool({
|
|
name: "agy_delegate",
|
|
label: "Antigravity Delegate",
|
|
description:
|
|
"Delega un task a Antigravity CLI in modalità headless (stream-json NDJSON). " +
|
|
"Usa --conversation per continuare una conversazione agy esistente. " +
|
|
"L'output (init/step_update/result) viene restituito come testo.",
|
|
parameters: Type.Object({
|
|
prompt: Type.String({ description: "Task per l'agente agy" }),
|
|
conversation: Type.Optional(Type.String({ description: "ID conversazione agy da riprendere" })),
|
|
model: Type.Optional(Type.String({ description: "Modello agy (es. Gemini 3.1 Pro)" })),
|
|
timeout: Type.Optional(Type.Number({ default: 300, description: "Timeout secondi" })),
|
|
}),
|
|
async execute(_id, params) {
|
|
const args = [
|
|
"-p", params.prompt,
|
|
"--output-format", "stream-json",
|
|
"--print-timeout", `${params.timeout ?? 300}m`,
|
|
];
|
|
if (params.conversation) args.push("--conversation", params.conversation);
|
|
if (params.model) args.push("--model", params.model);
|
|
let stdout: string, stderr: string;
|
|
try {
|
|
({ stdout, stderr } = await execFileAsync("agy", args, {
|
|
timeout: ((params.timeout ?? 300) + 30) * 1000,
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
}));
|
|
} catch (err) {
|
|
const e = err as NodeJS.ErrnoException & { stderr?: string };
|
|
if (e.code === "ENOENT") throw new Error("agy non trovato in PATH (Antigravity CLI non installato?)");
|
|
throw new Error(`agy fallito (exit ${e.code ?? "?"}): ${(e.stderr || e.message || "").slice(0, 500)}`);
|
|
}
|
|
// NDJSON → riepilogo compatto per il contesto pi
|
|
const lines = stdout
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((l) => {
|
|
try {
|
|
return JSON.parse(l);
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
const init = lines.find((e) => e?.type === "init");
|
|
const result = lines.find((e) => e?.type === "result");
|
|
const summary = {
|
|
conversation_id: init?.conversation_id ?? result?.conversation_id ?? params.conversation,
|
|
status: result?.status,
|
|
response: result?.response,
|
|
duration: result?.duration,
|
|
stderr: stderr.slice(0, 500),
|
|
};
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `agy → ${summary.status ?? "?"} | conversation=${summary.conversation_id}\n${summary.response ?? ""}${summary.stderr ? `\n[stderr] ${summary.stderr}` : ""}`,
|
|
},
|
|
],
|
|
details: summary,
|
|
};
|
|
},
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Registrazione e analisi attività di navigazione (Activity Recorder JSONL)
|
|
// -------------------------------------------------------------------------
|
|
pi.registerTool({
|
|
name: "browser_record_start",
|
|
label: "Browser Record Start",
|
|
description:
|
|
"Avvia o riconfigura la registrazione continua degli eventi e delle attività di navigazione " +
|
|
"(URL, domini, navigazioni, caricamento DOM, errori e log di console) su un file JSONL.",
|
|
parameters: Type.Object({
|
|
path: Type.Optional(
|
|
Type.String({
|
|
description:
|
|
"Percorso assoluto del file JSONL (default: ~/.local/state/pi-firefox-bidi/activity.jsonl)",
|
|
}),
|
|
),
|
|
events: Type.Optional(
|
|
Type.Array(Type.String(), {
|
|
description: "Lista di eventi BiDi da registrare (opzionale)",
|
|
}),
|
|
),
|
|
}),
|
|
async execute(_id, params) {
|
|
await ensureBridge();
|
|
const r = await bridge("/recorder/start", {
|
|
method: "POST",
|
|
body: JSON.stringify(params),
|
|
});
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Registrazione attività attiva su file: ${r.file}`,
|
|
},
|
|
],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "browser_record_stop",
|
|
label: "Browser Record Stop",
|
|
description: "Sospende la registrazione delle attività di navigazione su file.",
|
|
parameters: Type.Object({}),
|
|
async execute() {
|
|
await ensureBridge();
|
|
const r = await bridge("/recorder/stop", { method: "POST" });
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Registrazione attività sospesa. Eventi totali registrati: ${r.totalEvents} (${r.file})`,
|
|
},
|
|
],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "browser_record_status",
|
|
label: "Browser Record Status",
|
|
description: "Mostra lo stato del registratore attività, il percorso del file JSONL e il numero di eventi.",
|
|
parameters: Type.Object({}),
|
|
async execute() {
|
|
await ensureBridge();
|
|
const r = await bridge("/recorder/status");
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "browser_record_get",
|
|
label: "Browser Record Get",
|
|
description:
|
|
"Recupera le attività di navigazione registrate dal file JSONL per analisi (formato compatto o JSON grezzo).",
|
|
parameters: Type.Object({
|
|
limit: Type.Optional(
|
|
Type.Number({ description: "Numero massimo di eventi recenti da recuperare (default 30)", default: 30 }),
|
|
),
|
|
format: Type.Optional(
|
|
Type.Enum(
|
|
{ summary: "summary", json: "json" },
|
|
{ description: "Formato: 'summary' (timeline compatta) o 'json' (oggetti completi)", default: "summary" },
|
|
),
|
|
),
|
|
}),
|
|
async execute(_id, params) {
|
|
await ensureBridge();
|
|
const r = await bridge("/recorder/get", {
|
|
method: "POST",
|
|
body: JSON.stringify({ limit: params.limit ?? 30 }),
|
|
});
|
|
const records: any[] = r.records ?? [];
|
|
if (params.format === "json") {
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
|
|
details: r,
|
|
};
|
|
}
|
|
|
|
if (records.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: `Nessuna attività registrata nel log (${r.file}).` }],
|
|
details: r,
|
|
};
|
|
}
|
|
|
|
const lines = records.map((rec) => {
|
|
const timeStr = rec.timestamp ? rec.timestamp.split("T")[1]?.slice(0, 8) : "--:--:--";
|
|
if (rec.event === "navigation_started") {
|
|
return `[${timeStr}] 🌐 Navigazione: ${rec.url} (dominio: ${rec.domain ?? "-"})`;
|
|
} else if (rec.event === "page_loaded") {
|
|
return `[${timeStr}] ⏱ Caricato: ${rec.url ?? ""} in ${rec.durationMs ?? "?"}ms`;
|
|
} else if (rec.event === "dom_ready") {
|
|
return `[${timeStr}] 📄 DOM Ready: ${rec.url ?? ""}`;
|
|
} else if (rec.event === "console_log") {
|
|
return `[${timeStr}] 💬 [Console ${rec.level ?? "log"}]: ${rec.text ?? ""}`;
|
|
}
|
|
return `[${timeStr}] ⚡ ${rec.event}: ${rec.url ?? JSON.stringify(rec)}`;
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Ultime ${records.length} attività registrate (${r.file}):\n\n` + lines.join("\n"),
|
|
},
|
|
],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: "browser_record_clear",
|
|
label: "Browser Record Clear",
|
|
description: "Svuota il file di log delle attività e azzera il contatore.",
|
|
parameters: Type.Object({}),
|
|
async execute() {
|
|
await ensureBridge();
|
|
const r = await bridge("/recorder/clear", { method: "POST" });
|
|
return {
|
|
content: [{ type: "text", text: "Log attività svuotato con successo." }],
|
|
details: r,
|
|
};
|
|
},
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Notifica asincrona (agy/bridge → pi): webhook locale
|
|
// -------------------------------------------------------------------------
|
|
pi.registerTool({
|
|
name: "bridge_notify",
|
|
label: "Bridge Notify (webhook)",
|
|
description:
|
|
"Invia un evento al webhook locale di pi (http://127.0.0.1:8790/webhook): " +
|
|
"usato da agy o dal bridge per segnalare task completi, errori o eventi browser.",
|
|
parameters: Type.Object({
|
|
event: Type.String({ description: "Nome evento, es. task.completed, browser.event" }),
|
|
payload: Type.Optional(Type.Record(Type.String(), Type.Any(), { default: {} })),
|
|
}),
|
|
async execute(_id, params) {
|
|
await fetchWithTimeout(
|
|
"http://127.0.0.1:8790/webhook",
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ event: params.event, payload: params.payload ?? {} }),
|
|
},
|
|
5_000,
|
|
).catch((e) => {
|
|
throw new Error(`webhook: ${e.message}`);
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: "Evento inoltrato al webhook pi." }],
|
|
details: {},
|
|
};
|
|
},
|
|
});
|
|
}
|