/** * Browser Pilot – Background Service Worker * * Three roles: * 1. Tool execution engine (DOM, screenshots, JS, navigation) * 2. WebSocket bridge for external MCP server * 3. Message router between side panel, content scripts, and external clients */ // ============================================================ // Tool Registry // ============================================================ const TOOLS = { browser_navigate: { description: 'Navigate to a URL', params: { url: 'string', newTab: 'boolean?' }, handler: async ({ url, newTab }) => { if (newTab) { const tab = await chrome.tabs.create({ url }); return { tabId: tab.id, url }; } const tab = await getActiveTab(); await chrome.tabs.update(tab.id, { url }); return { tabId: tab.id, url }; } }, browser_go_back: { description: 'Go back in history', params: {}, handler: async () => { const t = await getActiveTab(); await chrome.tabs.goBack(t.id); return { ok: true }; } }, browser_go_forward: { description: 'Go forward in history', params: {}, handler: async () => { const t = await getActiveTab(); await chrome.tabs.goForward(t.id); return { ok: true }; } }, browser_reload: { description: 'Reload current page', params: {}, handler: async () => { const t = await getActiveTab(); await chrome.tabs.reload(t.id); return { ok: true }; } }, browser_get_tabs: { description: 'List all open tabs', params: {}, handler: async () => { const tabs = await chrome.tabs.query({}); return tabs.map(t => ({ id: t.id, title: t.title, url: t.url, active: t.active })); } }, browser_switch_tab: { description: 'Switch to tab by ID', params: { tabId: 'number' }, handler: async ({ tabId }) => { await chrome.tabs.update(tabId, { active: true }); const t = await chrome.tabs.get(tabId); await chrome.windows.update(t.windowId, { focused: true }); return { ok: true, tabId }; } }, browser_close_tab: { description: 'Close a tab', params: { tabId: 'number' }, handler: async ({ tabId }) => { await chrome.tabs.remove(tabId); return { ok: true }; } }, browser_screenshot: { description: 'Screenshot visible viewport (base64 PNG)', params: {}, handler: async () => { const tab = await getActiveTab(); const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' }); return { image: dataUrl }; } }, browser_get_page_info: { description: 'Get page URL, title, viewport, scroll position', params: {}, handler: async () => { const tab = await getActiveTab(); const info = await execInTab(tab.id, () => ({ title: document.title, url: location.href, viewport: { w: window.innerWidth, h: window.innerHeight }, scroll: { x: window.scrollX, y: window.scrollY }, docHeight: document.documentElement.scrollHeight })); return { ...info, tabId: tab.id }; } }, browser_get_text: { description: 'Get text content of page or element', params: { selector: 'string?' }, handler: async ({ selector }) => { const tab = await getActiveTab(); return await execInTab(tab.id, (sel) => { const el = sel ? document.querySelector(sel) : document.body; if (!el) return { error: 'Element not found: ' + sel }; return el.innerText; }, [selector || null]); } }, browser_get_html: { description: 'Get HTML of page or element', params: { selector: 'string?', outer: 'boolean?' }, handler: async ({ selector, outer = true }) => { const tab = await getActiveTab(); return await execInTab(tab.id, (sel, isOuter) => { const el = sel ? document.querySelector(sel) : document.documentElement; if (!el) return { error: 'Element not found: ' + sel }; return isOuter ? el.outerHTML : el.innerHTML; }, [selector || null, outer]); } }, browser_query_selector: { description: 'Query elements – returns tag, text, attributes, bounding box', params: { selector: 'string', limit: 'number?' }, handler: async ({ selector, limit = 20 }) => { const tab = await getActiveTab(); return await execInTab(tab.id, (sel, lim) => { return Array.from(document.querySelectorAll(sel)).slice(0, lim).map(el => { const r = el.getBoundingClientRect(); const attrs = {}; for (const a of el.attributes) attrs[a.name] = a.value; return { tag: el.tagName.toLowerCase(), text: (el.innerText||'').slice(0,200), attrs, box: {x:r.x,y:r.y,w:r.width,h:r.height} }; }); }, [selector, limit]); } }, browser_get_interactive_elements: { description: 'Find all interactive elements (buttons, links, inputs) with selectors', params: { viewportOnly: 'boolean?' }, handler: async ({ viewportOnly = true }) => { const tab = await getActiveTab(); return await execInTab(tab.id, (vpOnly) => { const sels = 'a[href], button, input, select, textarea, [role="button"], [onclick], [tabindex]:not([tabindex="-1"])'; const els = Array.from(document.querySelectorAll(sels)); const results = []; for (const el of els) { const r = el.getBoundingClientRect(); if (r.width === 0 && r.height === 0) continue; if (vpOnly && (r.bottom < 0 || r.top > window.innerHeight || r.right < 0 || r.left > window.innerWidth)) continue; const attrs = {}; for (const a of ['id','class','name','type','href','value','placeholder','aria-label','role','title']) if (el.getAttribute(a)) attrs[a] = el.getAttribute(a); let selector = ''; if (el.id) selector = '#' + el.id; else if (el.name) selector = el.tagName.toLowerCase() + '[name="' + el.name + '"]'; else { const parts = []; let cur = el; while (cur && cur !== document.body && parts.length < 4) { let s = cur.tagName.toLowerCase(); if (cur.id) { parts.unshift('#' + cur.id); break; } const parent = cur.parentElement; if (parent) { const sibs = Array.from(parent.children).filter(c => c.tagName === cur.tagName); if (sibs.length > 1) s += ':nth-of-type(' + (sibs.indexOf(cur)+1) + ')'; } parts.unshift(s); cur = cur.parentElement; } selector = parts.join(' > '); } results.push({ tag: el.tagName.toLowerCase(), text: (el.innerText||el.value||'').slice(0,80), attrs, selector, box:{x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)} }); if (results.length >= 50) break; } return results; }, [viewportOnly]); } }, browser_click: { description: 'Click element by CSS selector', params: { selector: 'string' }, handler: async ({ selector }) => { const tab = await getActiveTab(); return await execInTab(tab.id, (sel) => { const el = document.querySelector(sel); if (!el) return { error: 'Not found: ' + sel }; el.scrollIntoView({ block: 'center', behavior: 'instant' }); el.click(); return { clicked: sel, tag: el.tagName.toLowerCase(), text: (el.innerText||'').slice(0,80) }; }, [selector]); } }, browser_type: { description: 'Type text into input/textarea', params: { selector: 'string', text: 'string', clear: 'boolean?' }, handler: async ({ selector, text, clear = true }) => { const tab = await getActiveTab(); return await execInTab(tab.id, (sel, txt, clr) => { const el = document.querySelector(sel); if (!el) return { error: 'Not found: ' + sel }; el.focus(); if (clr) { el.value = ''; el.dispatchEvent(new Event('input', {bubbles:true})); } // Simulate character-by-character for React/Angular for (const ch of txt) { el.value += ch; el.dispatchEvent(new Event('input', {bubbles:true})); } el.dispatchEvent(new Event('change', {bubbles:true})); return { typed: txt, selector: sel }; }, [selector, text, clear]); } }, browser_select: { description: 'Select option in