/** * 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 | 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 { 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 { 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(fn: () => Promise, label: string, retries = 2): Promise { 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 { 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 { 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 { 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 { const { py, env } = await ensureVenv(); mkdirSync(dirname(BRIDGE_LOG), { recursive: true }); const logFd = openSync(BRIDGE_LOG, "a"); let child: ReturnType; 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 { 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 { 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 { 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(fn: () => Promise, label: string): Promise { 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-.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: {}, }; }, }); }