diff --git a/.gitignore b/.gitignore index 37052e6..8a0320a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,14 @@ dist/ coverage/ .playwright-mcp/ FEATURE_COMPARISON.md +.grepai/ + +# Claude & AI config +.claude/ +.quint/ +.serena/ +.mcp.json + +# Experimental +openspec/ +*.png diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b05ba31 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**surf-cli** is a CLI tool for AI agents to control Chrome browser. It provides a Unix socket-based API that enables any AI agent to automate browser tasks via Chrome DevTools Protocol (CDP), with automatic fallback to chrome.scripting API. + +Architecture: `CLI → Unix Socket (/tmp/surf.sock) → Native Host → Chrome Extension → CDP/Scripting API` + +## Common Commands + +```bash +# Development +npm run dev # Watch mode with live rebuild +npm run build # Production build (Vite → native/*.cjs + dist/) +npm run check # TypeScript type checking +npm run lint # Biome linting +npm run lint:fix # Fix lint issues + +# Testing +npm run test # Run all tests +npm run test:watch # Watch mode +npm run test:coverage # With coverage report + +# Additional scripts +npm run format # Format code with Biome +npm run lint:test # Lint test files only +npm run test:ui # Run tests with Vitest UI + +# Extension loading +npm run install:native # Install native host (requires extension ID from chrome://extensions) +npm run uninstall:native # Uninstall native host + # Binary installs to: ~/.surf/bin/ (add to PATH) +``` + +## Project Structure + +``` +native/ # Compiled CLI and native host (TypeScript → CJS via Vite) + cli.cjs # CLI entry point, argument parsing, socket communication + host.cjs # Native host server, request handling, tool execution + mcp-server.cjs # Model Context Protocol server implementation + *-client.cjs # AI clients (chatgpt, gemini, grok, perplexity, claude, aistudio) + do-*.cjs # Workflow parsing and execution + network-store.cjs # Network request capture and storage + protocol.cjs # Protocol utilities + config.cjs # Config file handling (~/.surf/surf.json) + device-presets.cjs # Device emulation presets +dist/ # Chrome extension (loaded in chrome://extensions) + service-worker/index.js # Main service worker for CDP communication + content/ # Content scripts (accessibility-tree, visual-indicator) + options/ # Extension options page +skills/ # AI agent skill files for surf integration +``` + +## Key Architecture Notes + +### 1. CLI Flow (native/cli.cjs) +- Entry point registered in package.json `bin` +- Commands organized in `TOOLS` groups: ai, batch, bookmark, cookie, dialog, element, emulate, form, frame, history, input, js, locate, nav, network, page, perf, scroll, search, tab, wait, window, workflow, zoom +- `parseArgs()` handles argument parsing +- Sends JSON requests via Unix socket to host + +### 2. Host Flow (native/host.cjs) +- Listens on `/tmp/surf.sock` for incoming requests +- `handleToolRequest()` dispatches tool execution +- `executeBatch()` handles multi-step batch operations +- Automatic retry with exponential backoff for CDP failures +- AI request queue (`processAiQueue`) for sequential AI queries + +### 3. Protocol +- JSON over Unix socket: `{type, method, params, id, tabId, windowId}` +- Response: `{type, id, result, error}` +- Auto-capture screenshots after click, type, scroll operations + +### 4. AI Clients (native/*-client.cjs) +- **ChatGPT** (chatgpt-client.cjs): Uses browser session, supports file attachments +- **Gemini** (gemini-client.cjs): Image generation/editing, YouTube analysis +- **Grok** (grok-client.cjs): X.com integration, model validation +- **Perplexity** (perplexity-client.cjs): Research mode, file attachments +- **Claude** (claude-client.cjs): Claude API integration +- **AI Studio** (aistudio-client.cjs, aistudio-build.cjs): Google AI Studio, app building +- All use browser cookies—no API keys needed + +### 5. MCP Server (native/mcp-server.cjs) +- Implements @modelcontextprotocol/sdk +- Uses StdioServerTransport for stdio-based communication +- Tool schemas defined in TOOL_SCHEMAS constant + +### 6. Workflows +- `do` command parses pipe-separated commands: `'go "url" | click e5 | screenshot'` +- do-parser.cjs: Parses workflow syntax +- do-executor.cjs: Executes steps with auto-waits between operations + +### 7. Network Capture +- Automatic logging to `/tmp/surf/` (configurable via SURF_NETWORK_PATH) +- 24-hour TTL, 200MB max +- Filter by origin, method, status, type + +## Testing + +Tests use Vitest. Run a single test: + +```bash +npm run test -- native/tests/ +``` + +## CLI Aliases + +| Alias | Command | +|-------|---------| +| `snap` | `screenshot` | +| `read` | `page.read` | +| `find` | `search` | +| `go` | `navigate` | +| `net` | `network` | + +## Command Groups + +| Group | Commands | +|-------|----------| +| `workflow` | `do`, `workflow.list`, `workflow.info`, `workflow.validate` | +| `window.*` | `new`, `list`, `focus`, `close`, `resize` | +| `tab.*` | `list`, `new`, `switch`, `close`, `name`, `unname`, `named`, `group`, `ungroup`, `groups`, `reload` | +| `scroll.*` | `top`, `bottom`, `to`, `info` | +| `page.*` | `read`, `text`, `state` | +| `locate.*` | `role`, `text`, `label` | +| `element.*` | `styles` | +| `frame.*` | `list`, `switch`, `main`, `js` | +| `wait.*` | `element`, `network`, `url`, `dom`, `load` | +| `cookie.*` | `list`, `get`, `set`, `clear` | +| `emulate.*` | `network`, `cpu`, `geo`, `device`, `viewport`, `touch` | +| `perf.*` | `start`, `stop`, `metrics` | +| `network.*` | `get`, `body`, `curl`, `origins`, `clear`, `stats`, `export`, `path` | + +## AI Mode (aimode) + +- `surf aimode "query"` - Uses udm=50 (auto mode, has copy button) +- `surf aimode "query" --pro` - Uses nem=143 (pro mode) + +## Process Management + +- Find surf PID: `wmic process where "name='node.exe'" get processid,commandline` +- Kill only surf: `taskkill //F //PID ` (NOT all node processes) + +## Debugging AI Clients + +- Use `surf tab.new` for testing - don't navigate in user's active tab +- Find selectors with: `surf js "document.querySelector('...').outerHTML"` +- Use `surf page.read` to see accessibility tree with element refs + +## Surf Claude Fix History + +- Selectors: `textarea[placeholder*="How can I help you"]`, `button[aria-label="Send message"]`, `.font-claude-response-body` +- Cookie check: accepts `sessionKey`, `anthropic-device-id`, or `ARID` cookies + +## Extension Structure (dist/) + +``` +dist/ + service-worker/index.js # Main service worker, CDP communication + content/ + accessibility-tree.js # Page accessibility tree extraction + visual-indicator.js # Visual element labels overlay + options/ + options.html/js # Extension settings page + icons/ # Extension icons (16, 48, 128px) + manifest.json # Extension manifest +``` + +## Configuration + +- Config location: `~/.surf/surf.json` (user) or `./.surf/` (project) +- Multi-browser support: `chrome`, `chromium`, `brave`, `edge`, `arc`, `helium` + +## MCP Server + +The MCP server (`native/mcp-server.cjs`) provides Model Context Protocol integration: +- Uses `@modelcontextprotocol/sdk` +- Tool schemas defined in `TOOL_SCHEMAS` constant +- Communication via stdio (`StdioServerTransport`) + +## Important Implementation Details + +- Screenshot resize uses `sips` (macOS) or ImageMagick (Linux) +- CDP falls back to `chrome.scripting` API on restricted pages +- Screenshots fallback to `captureVisibleTab` when CDP capture fails +- Element refs (`e1`, `e2`...) are stable identifiers from accessibility tree +- First CDP operation on new tab takes ~100-500ms (debugger attachment) +- Cannot automate `chrome://` pages (Chrome restriction) + +## Troubleshooting + +- Socket not found: Ensure native host is running (`npm run install:native`) +- Extension not loading: Check chrome://extensions for errors +- CDP failures: Ensure debuggable tabs exist +- Permission denied on socket: Check that no other surf instance is running diff --git a/native/aimode-client.cjs b/native/aimode-client.cjs new file mode 100644 index 0000000..8c7c5b6 --- /dev/null +++ b/native/aimode-client.cjs @@ -0,0 +1,372 @@ +/** + * AI Mode (Google) Client for surf-cli + * + * CDP-based client for Google search with AI mode (udm=50=auto, nem=143=pro). + * Uses browser automation to interact with Google's AI search. + */ + +const AIMODE_URL_AUTO = "https://www.google.com/search?udm=50&q="; +const AIMODE_URL_PRO = "https://www.google.com/search?nem=143&q="; + +const SELECTORS = { + searchInput: 'textarea[name="q"], input[name="q"], input[aria-label="Search"]', + resultContainer: '#main, [role="main"], .GybnWb, . Response-container', + answer: '.X7NTVe, .的气, [data-initq], .reply-content, .AdD1h', +}; + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function buildClickDispatcher() { + return `function dispatchClickSequence(target){ + if(!target || !(target instanceof EventTarget)) return false; + const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']; + for (const type of types) { + const common = { bubbles: true, cancelable: true, view: window }; + let event; + if (type.startsWith('pointer') && 'PointerEvent' in window) { + event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' }); + } else { + event = new MouseEvent(type, common); + } + target.dispatchEvent(event); + } + return true; + }`; +} + +function hasRequiredCookies(cookies) { + if (!cookies || !Array.isArray(cookies)) return false; + // Check for Google session cookies + const hasSession = cookies.some(c => + c.name.includes('SID') || + c.name.includes('HSID') || + c.name === '__Secure-1PAPISID' || + c.name === '__Secure-1PSID' + ); + return hasSession || cookies.length > 0; +} + +async function evaluate(cdp, expression) { + const result = await cdp(expression); + if (result.exceptionDetails) { + const desc = result.exceptionDetails.exception?.description || + result.exceptionDetails.text || + "Evaluation failed"; + throw new Error(desc); + } + if (result.error) { + throw new Error(result.error); + } + return result.result?.value; +} + +async function waitForPageLoad(cdp, timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ready = await evaluate(cdp, "document.readyState"); + if (ready === "complete" || ready === "interactive") { + await delay(500); + return; + } + await delay(100); + } + throw new Error("Page did not load in time"); +} + +async function waitForSearchBox(cdp, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + const selectors = JSON.stringify(SELECTORS.searchInput.split(", ")); + while (Date.now() < deadline) { + const found = await evaluate( + cdp, + `(() => { + const selectors = ${selectors}; + for (const selector of selectors) { + const node = document.querySelector(selector); + if (node && !node.hasAttribute('disabled')) { + return true; + } + } + return false; + })()` + ); + if (found) return true; + await delay(200); + } + return false; +} + +async function typeQuery(cdp, inputCdp, query) { + const selectors = SELECTORS.searchInput.split(", "); + + // Click on search box first + await evaluate( + cdp, + `(() => { + const selectors = ${JSON.stringify(selectors)}; + for (const selector of selectors) { + const node = document.querySelector(selector); + if (node) { + node.focus(); + return true; + } + } + return false; + })()` + ); + + await delay(200); + + // Type the query + await inputCdp("Input.insertText", { text: query }); + + await delay(100); +} + +async function pressEnter(cdp, inputCdp) { + await inputCdp("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Enter", + text: "\r" + }); + await inputCdp("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Enter", + text: "\r" + }); +} + +async function waitForResponse(cdp, timeoutMs, log = () => {}) { + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + let stableCount = 0; + let checkCount = 0; + + while (Date.now() < deadline) { + await delay(1000); + checkCount++; + + const content = await getAnswerContent(cdp, log); + log(`Check ${checkCount}: content length = ${content?.length || 0}`); + + if (content && content !== lastContent) { + lastContent = content; + stableCount = 0; + log(` -> New content found, length: ${content.length}`); + } else if (content && content === lastContent) { + stableCount++; + log(` -> Stable count: ${stableCount}`); + if (stableCount >= 3) { + return { text: content }; + } + } else { + // Check if we're still loading + const isLoading = await evaluate( + cdp, + `(() => { + const spinner = document.querySelector('.pRzye, .l4cyt, [role="progressbar"]'); + return spinner !== null; + })()` + ); + log(` -> Loading: ${isLoading}, lastContent length: ${lastContent.length}`); + if (!isLoading && lastContent) { + return { text: lastContent }; + } + } + } + + // Timeout - return what we have + const content = await getAnswerContent(cdp); + log(`Final content length: ${content?.length || 0}`); + return { text: content || "Response timeout" }; +} + +async function getAnswerContent(cdp, log = () => {}) { + try { + const debugInfo = await evaluate( + cdp, + `(() => { + const info = { + readyState: document.readyState, + url: window.location.href, + aimfl: null, + aimc: null, + main: null, + bodyLength: document.body.textContent.length, + bodyPreview: document.body.textContent.substring(0, 200) + }; + try { info.aimfl = document.querySelector('[data-subtree="aimfl"]')?.textContent?.substring(0, 100); } catch(e) {} + try { info.aimc = document.querySelector('[data-subtree="aimc"]')?.textContent?.substring(0, 100); } catch(e) {} + try { info.main = document.querySelector('main')?.textContent?.substring(0, 100); } catch(e) {} + return info; + })()` + ); + log(`Debug: ${JSON.stringify(debugInfo)}`); + + const result = await evaluate( + cdp, + `(() => { + // Primary: Try data-subtree="aimc" - AI response with UI elements (FULL response) + const aimc = document.querySelector('[data-subtree="aimc"]'); + if (aimc) { + const text = aimc.textContent.trim(); + // Extract just the response before "AI responses may include mistakes" + const idx = text.indexOf('AI responses may include mistakes'); + if (idx > 0) { + return text.substring(0, idx).trim(); + } + return text; + } + + // Secondary: Try data-subtree="aimfl" - clean AI response text (shorter) + const aimfl = document.querySelector('[data-subtree="aimfl"]'); + if (aimfl) { + const text = aimfl.textContent.trim(); + if (text.length > 0) return text; + } + + // Fallback: legacy selectors + const legacySelectors = ['.X7NTVe', '.GybnWb', '.reply-content', '.AdD1h']; + for (const selector of legacySelectors) { + const el = document.querySelector(selector); + if (el) { + return el.textContent.trim(); + } + } + + // Check for any element containing significant text + const main = document.querySelector('main, #main, [role="main"]'); + if (main && main.textContent.length > 100) { + return main.textContent.trim().substring(0, 3000); + } + + // Last resort: body text cleaned + return document.body.textContent.trim().replace(/\\s+/g, ' ').substring(0, 3000); + })()` + ); + + return result || ""; + } catch (err) { + log(`Error in getAnswerContent: ${err.message}`); + // If evaluation fails, return empty to trigger fallback + return ""; + } +} + +async function query(options) { + const { + prompt: query, + pro = false, + timeout = 120000, + getCookies, + createTab, + closeTab, + cdpEvaluate, + cdpCommand, + log = () => {}, + } = options; + + const searchUrl = pro ? AIMODE_URL_PRO : AIMODE_URL_AUTO; + const startTime = Date.now(); + const debugLog = (msg) => { + console.error(`[AIMODE DEBUG] ${msg}`); + log(msg); + }; + + debugLog("Starting aimode query"); + + const { cookies } = await getCookies(); + const cookieNames = cookies?.map(c => c.name) || []; + if (!hasRequiredCookies(cookies)) { + debugLog(`Warning: No Google cookies found. Found: ${cookieNames.join(", ")}`); + } + debugLog(`Got ${cookies.length} cookies`); + + // For aimode, we'll navigate directly to the search URL instead of using CDP + // This avoids issues with tab creation timing + const fullSearchUrl = searchUrl + encodeURIComponent(query); + debugLog(`Will navigate to: ${fullSearchUrl}`); + + // Create a basic tab info - we'll use CDP navigate instead + const tabInfo = await createTab(); + const { tabId } = tabInfo; + if (!tabId) { + throw new Error("Failed to create tab"); + } + debugLog(`Created tab ${tabId}`); + + const cdp = (expr) => cdpEvaluate(tabId, expr); + const inputCdp = (method, params) => cdpCommand(tabId, method, params); + + try { + // Wait for tab to be ready + await delay(2000); + + // Navigate to Google search with the query + debugLog(`Navigating to: ${fullSearchUrl}`); + + // Navigate using CDP + await cdp(`window.location.href = "${fullSearchUrl}"`); + + await waitForPageLoad(cdp); + debugLog("Page loaded"); + + // Wait for AI to finish thinking/loading + debugLog("Waiting for AI thinking to complete..."); + const deadline = Date.now() + 60000; // max 60s wait + let thinking = true; + + while (thinking && Date.now() < deadline) { + const status = await evaluate( + cdp, + `(() => { + // Check if thinking/loading element exists + const loading = document.querySelector('.qewEec, [jsuid*="Creating layout"]'); + const hasAI = document.querySelector('[data-subtree="aimc"]') !== null || + document.querySelector('[data-subtree="aimfl"]') !== null; + return { loading: loading !== null, hasAI: hasAI }; + })()` + ); + debugLog(`Status: loading=${status.loading}, hasAI=${status.hasAI}`); + + if (status.hasAI) { + thinking = false; + debugLog("AI response detected!"); + } else if (!status.loading) { + // No loading, but no AI - might be no response available + debugLog("No loading, checking if AI will respond..."); + await delay(2000); + const retryStatus = await evaluate( + cdp, + `(() => document.querySelector('[data-subtree="aimc"]') !== null || + document.querySelector('[data-subtree="aimfl"]') !== null)` + ); + if (!retryStatus) { + debugLog("No AI response available"); + break; + } + thinking = false; + } else { + await delay(2000); + } + } + + // Get response content + const response = await waitForResponse(cdp, timeout, debugLog); + debugLog(`Response received (${response.text.length} chars)`); + + return { + response: response.text, + url: fullSearchUrl, + tookMs: Date.now() - startTime, + }; + } finally { + if (tabId) { + await closeTab(tabId).catch(e => console.error('[aimode] Tab cleanup error:', e.message)); + } + } +} + +module.exports = { query, hasRequiredCookies }; diff --git a/native/chatgpt-client.cjs b/native/chatgpt-client.cjs index aec9aca..a303bba 100644 --- a/native/chatgpt-client.cjs +++ b/native/chatgpt-client.cjs @@ -39,7 +39,7 @@ function buildClickDispatcher() { function hasRequiredCookies(cookies) { if (!cookies || !Array.isArray(cookies)) return false; const sessionCookie = cookies.find( - (c) => c.name === "__Secure-next-auth.session-token" && c.value + (c) => c.name.startsWith("__Secure-next-auth.session-token") && c.value ); return Boolean(sessionCookie); } @@ -391,7 +391,7 @@ async function waitForResponse(cdp, timeoutMs = 2700000) { async function query(options) { const { - prompt, + prompt: originalPrompt, model, file, timeout = 2700000, @@ -400,13 +400,17 @@ async function query(options) { closeTab, cdpEvaluate, cdpCommand, + uploadFile, log = () => {}, } = options; + + let prompt = originalPrompt; const startTime = Date.now(); log("Starting ChatGPT query"); const { cookies } = await getCookies(); + const cookieNames = cookies?.map(c => c.name) || []; if (!hasRequiredCookies(cookies)) { - throw new Error("ChatGPT login required"); + throw new Error(`ChatGPT login required. Found ${cookies?.length || 0} cookies: ${cookieNames.join(", ")}`); } log(`Got ${cookies.length} cookies`); const tabInfo = await createTab(); @@ -415,10 +419,10 @@ async function query(options) { throw new Error("Failed to create ChatGPT tab"); } log(`Created tab ${tabId}`); - + const cdp = (expr) => cdpEvaluate(tabId, expr); const inputCdp = (method, params) => cdpCommand(tabId, method, params); - + try { await waitForPageLoad(cdp); log("Page loaded"); @@ -426,8 +430,9 @@ async function query(options) { throw new Error("Cloudflare challenge detected - complete in browser"); } const loginStatus = await checkLoginStatus(cdp); + log(`DEBUG loginStatus: ${JSON.stringify(loginStatus)}`); if (loginStatus.status !== 200 || loginStatus.hasLoginCta) { - throw new Error("ChatGPT login required"); + throw new Error(`ChatGPT login required. loginStatus: ${JSON.stringify(loginStatus)}`); } log("Login verified"); const promptReady = await waitForPromptReady(cdp); @@ -440,7 +445,27 @@ async function query(options) { log(`Selected model: ${selectedLabel}`); } if (file) { - throw new Error("File upload not yet implemented"); + const fs = require("fs"); + const path = require("path"); + + const absolutePath = path.resolve(process.cwd(), file); + if (!fs.existsSync(absolutePath)) { + throw new Error(`File not found: ${file}`); + } + + const fileName = path.basename(absolutePath); + const fileExt = path.extname(absolutePath).toLowerCase(); + + // Text-based extensions only + const textExtensions = [".js", ".ts", ".tsx", ".jsx", ".py", ".java", ".c", ".cpp", ".h", ".hpp", ".go", ".rs", ".rb", ".php", ".html", ".htm", ".css", ".scss", ".less", ".json", ".md", ".txt", ".sh", ".bash", ".zsh", ".yaml", ".yml", ".xml", ".sql", ".gitignore", ".env", ".toml", ".ini", ".cfg", ".conf", ".log", ".csv", ".tsv"]; + + if (!textExtensions.includes(fileExt)) { + throw new Error(`Unsupported file type: ${fileExt}. Only text files are supported.`); + } + + const fileContent = fs.readFileSync(absolutePath, "utf-8"); + prompt = `File: ${fileName}\n\n\`\`\`\n${fileContent}\n\`\`\`\n\n---\n\n${prompt}`; + log(`Attached file: ${fileName} (${fileContent.length} chars)`); } await typePrompt(cdp, inputCdp, prompt); log("Prompt typed"); @@ -455,7 +480,9 @@ async function query(options) { tookMs: Date.now() - startTime, }; } finally { - await closeTab(tabId).catch(() => {}); + if (tabId) { + await closeTab(tabId).catch(e => console.error('[chatgpt] Tab cleanup error:', e.message)); + } } } diff --git a/native/claude-client.cjs b/native/claude-client.cjs new file mode 100644 index 0000000..ba7c833 --- /dev/null +++ b/native/claude-client.cjs @@ -0,0 +1,346 @@ +/** + * Claude Web Client for surf-cli + * + * CDP-based client for claude.ai using browser automation. + * Similar approach to the ChatGPT client. + */ + +const CLAUDE_URL = "https://claude.ai/"; + +const SELECTORS = { + promptTextarea: 'textarea[placeholder*="How can I help you"], textarea[placeholder*="message"], #composer-input, [data-testid="composer-input"], div[contenteditable="true"][role="textbox"]', + sendButton: 'button[aria-label="Send message"], button[data-testid="send-button"], button[type="submit"]', + assistantMessage: '[data-is-streaming="false"], .font-claude-response, [data-turn-author="assistant"]', + stopButton: '[data-testid="stop-button"], button[aria-label="Stop"]', + conversationTurn: '[data-is-streaming="false"], .font-claude-response, [data-turn-author="assistant"]', +}; + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function buildClickDispatcher() { + return `function dispatchClickSequence(target){ + if(!target || !(target instanceof EventTarget)) return false; + const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']; + for (const type of types) { + const common = { bubbles: true, cancelable: true, view: window }; + let event; + if (type.startsWith('pointer') && 'PointerEvent' in window) { + event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' }); + } else { + event = new MouseEvent(type, common); + } + target.dispatchEvent(event); + } + return true; + }`; +} + +function hasRequiredCookies(cookies) { + if (!cookies || !Array.isArray(cookies)) return false; + // Check for session-related cookies (sessionKey, any session cookie) + // or device ID cookies that indicate authenticated session + const validCookie = cookies.find( + (c) => c.value && ( + c.name.includes("session") || + c.name === "anthropic-device-id" || + c.name === "ARID" + ) + ); + return Boolean(validCookie); +} + +async function evaluate(cdp, expression) { + const result = await cdp(expression); + if (result.exceptionDetails) { + const desc = result.exceptionDetails.exception?.description || + result.exceptionDetails.text || + "Evaluation failed"; + throw new Error(desc); + } + if (result.error) { + throw new Error(result.error); + } + return result.result?.value; +} + +async function waitForPageLoad(cdp, timeoutMs = 45000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ready = await evaluate(cdp, "document.readyState"); + if (ready === "complete" || ready === "interactive") { + return; + } + await delay(100); + } + throw new Error("Page did not load in time"); +} + +async function checkLoginStatus(cdp) { + const result = await evaluate(cdp, `(() => { + const buttons = Array.from(document.querySelectorAll('button, a')); + const hasSignIn = buttons.some(b => { + const text = (b.textContent || '').toLowerCase().trim(); + return text === 'sign in' || text === 'log in' || text.includes('sign in'); + }); + const hasAccount = buttons.some(b => { + const text = (b.textContent || '').toLowerCase(); + const label = (b.getAttribute('aria-label') || '').toLowerCase(); + return text.includes('account') || label.includes('account') || label.includes('profile'); + }); + return { + loggedIn: hasAccount || !hasSignIn, + hasSignIn + }; + })()`); + return result || { loggedIn: false }; +} + +async function waitForPromptReady(cdp, timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + const selectors = JSON.stringify(SELECTORS.promptTextarea.split(", ")); + while (Date.now() < deadline) { + const found = await evaluate( + cdp, + `(() => { + const selectors = ${selectors}; + for (const selector of selectors) { + const node = document.querySelector(selector); + if (node && !node.hasAttribute('disabled')) { + return true; + } + } + return false; + })()` + ); + if (found) return true; + await delay(200); + } + return false; +} + +async function typePrompt(cdp, inputCdp, prompt) { + const selectors = SELECTORS.promptTextarea.split(", "); + const textarea = await evaluate( + cdp, + `(() => { + const selectors = ${JSON.stringify(selectors)}; + for (const selector of selectors) { + const node = document.querySelector(selector); + if (node) return selector; + } + return null; + })()` + ); + + if (!textarea) { + throw new Error("Prompt input not found"); + } + + // Use JavaScript to type (more reliable) + await evaluate( + cdp, + `(() => { + const selectors = ${JSON.stringify(selectors)}; + const node = document.querySelector(selectors[0]); + if (!node) return false; + + // Clear existing content + node.value = ''; + + // Focus the input + node.focus(); + + return true; + })()` + ); + + // Type the prompt + await inputCdp("Input.insertText", { text: prompt }); + + // Small delay after typing + await delay(100); +} + +async function clickSend(cdp, inputCdp) { + // Try multiple methods to click send + const clicked = await evaluate( + cdp, + `(() => { + ${buildClickDispatcher()} + const selectors = ${JSON.stringify(SELECTORS.sendButton.split(", "))}; + for (const selector of selectors) { + const btn = document.querySelector(selector); + if (btn) { + dispatchClickSequence(btn); + return true; + } + } + return false; + })()` + ); + + if (!clicked) { + // Fallback: try pressing Enter + await inputCdp("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Enter", + text: "\r" + }); + await inputCdp("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Enter", + text: "\r" + }); + } + + await delay(500); +} + +async function waitForResponse(cdp, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + let stableCount = 0; + + while (Date.now() < deadline) { + // Check if stop button is gone (generation complete) + const isGenerating = await evaluate( + cdp, + `(() => { + const selectors = ${JSON.stringify(SELECTORS.stopButton.split(", "))}; + for (const selector of selectors) { + if (document.querySelector(selector)) return true; + } + return false; + })()` + ); + + if (!isGenerating) { + // Wait a bit more for final content + await delay(1000); + + const content = await getAssistantContent(cdp); + if (content && content !== lastContent) { + lastContent = content; + stableCount = 0; + } else if (content && content === lastContent) { + stableCount++; + if (stableCount >= 3) { + return { text: content }; + } + } + } + + await delay(500); + } + + // Timeout - return what we have + const content = await getAssistantContent(cdp); + return { text: content || "Response timeout" }; +} + +async function getAssistantContent(cdp) { + const result = await evaluate( + cdp, + `(() => { + const selectors = ${JSON.stringify(SELECTORS.conversationTurn.split(", "))}; + let content = ""; + + for (const selector of selectors) { + const elements = document.querySelectorAll(selector); + if (elements.length > 0) { + const lastEl = elements[elements.length - 1]; + // Clone and remove feedback buttons and other UI elements + const clone = lastEl.cloneNode(true); + const removeSelectors = [ + 'button', '[role="button"]', '.feedback', + '[data-testid="feedback"]', '.thumbs-up', '.thumbs-down', + 'div[aria-label="Good response"]', 'div[aria-label="Bad response"]' + ]; + removeSelectors.forEach(sel => { + clone.querySelectorAll(sel).forEach(el => el.remove()); + }); + content = clone.textContent || ""; + break; + } + } + + return content.trim(); + })()` + ); + + return result || ""; +} + +async function query(options) { + const { + prompt: originalPrompt, + model, + timeout = 300000, + getCookies, + createTab, + closeTab, + cdpEvaluate, + cdpCommand, + log = () => {}, + } = options; + + const prompt = originalPrompt; + const startTime = Date.now(); + log("Starting Claude.ai query"); + + const { cookies } = await getCookies(); + const cookieNames = cookies?.map(c => c.name) || []; + if (!hasRequiredCookies(cookies)) { + throw new Error(`Claude.ai login required. Found ${cookies?.length || 0} cookies: ${cookieNames.join(", ")}`); + } + log(`Got ${cookies.length} cookies`); + + const tabInfo = await createTab(); + const { tabId } = tabInfo; + if (!tabId) { + throw new Error("Failed to create Claude.ai tab"); + } + log(`Created tab ${tabId}`); + + const cdp = (expr) => cdpEvaluate(tabId, expr); + const inputCdp = (method, params) => cdpCommand(tabId, method, params); + + try { + await waitForPageLoad(cdp); + log("Page loaded"); + + const loginStatus = await checkLoginStatus(cdp); + log(`DEBUG loginStatus: ${JSON.stringify(loginStatus)}`); + if (!loginStatus.loggedIn) { + throw new Error(`Claude.ai login required. Status: ${JSON.stringify(loginStatus)}`); + } + log("Login verified"); + + const promptReady = await waitForPromptReady(cdp); + if (!promptReady) { + throw new Error("Prompt textarea not ready"); + } + log("Prompt ready"); + + await typePrompt(cdp, inputCdp, prompt); + log("Prompt typed"); + + await clickSend(cdp, inputCdp); + log("Prompt sent, waiting for response..."); + + const response = await waitForResponse(cdp, timeout); + log(`Response received (${response.text.length} chars)`); + + return { + response: response.text, + model: model || "claude-3-5-sonnet", + tookMs: Date.now() - startTime, + }; + } finally { + await closeTab(tabId).catch(() => {}); + } +} + +module.exports = { query, hasRequiredCookies, CLAUDE_URL }; diff --git a/native/cli.cjs b/native/cli.cjs index a090de7..dc05d99 100755 --- a/native/cli.cjs +++ b/native/cli.cjs @@ -342,10 +342,10 @@ const TOOLS = { ai: { desc: "AI assistants (ChatGPT, Gemini)", commands: { - "chatgpt": { - desc: "Send prompt to ChatGPT (uses browser cookies)", - args: ["query"], - opts: { + "chatgpt": { + desc: "Send prompt to ChatGPT (uses browser cookies)", + args: ["query"], + opts: { "with-page": "Include current page context", model: "Model: gpt-4o, o1, etc.", file: "Attach file", @@ -358,6 +358,34 @@ const TOOLS = { { cmd: 'chatgpt "analyze" --model gpt-4o', desc: "Specify model" }, ] }, + "claude": { + desc: "Send prompt to Claude AI (uses browser cookies)", + args: ["query"], + opts: { + "with-page": "Include current page context", + model: "Model: claude-3-5-sonnet (default), claude-3-opus, etc.", + timeout: "Timeout in seconds (default: 300)" + }, + examples: [ + { cmd: 'claude "explain quantum computing"', desc: "Basic query" }, + { cmd: 'claude "summarize" --with-page', desc: "With page context" }, + { cmd: 'claude "review this code"', desc: "Code review" }, + ] + }, + "aimode": { + desc: "Query Google AI Mode (udm=50=auto, nem=143=pro)", + args: ["query"], + opts: { + "with-page": "Include current page context", + timeout: "Timeout in seconds (default: 120)", + pro: "Use nem=143 (pro mode) instead of udm=50 (auto)" + }, + examples: [ + { cmd: 'aimode "what is quantum computing"', desc: "Basic query (auto mode)" }, + { cmd: 'aimode "summarize" --with-page', desc: "With page context" }, + { cmd: 'aimode "question" --pro', desc: "Use pro mode (nem=143)" }, + ] + }, "gemini": { desc: "Send prompt to Gemini (uses browser cookies)", args: ["query"], @@ -2414,7 +2442,7 @@ if (args[0] === "workflow.validate") { } } -const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl", "no-save", "no-auto-wait"]; +const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl", "no-save", "no-auto-wait", "pro"]; const AUTO_SCREENSHOT_TOOLS = ["click", "type", "key", "smart_type", "form.fill", "form_input", "drag", "hover", "scroll", "scroll.top", "scroll.bottom", "scroll.to", "dialog.accept", "dialog.dismiss", "js", "eval"]; @@ -2515,6 +2543,8 @@ const PRIMARY_ARG_MAP = { ai: "query", gemini: "query", chatgpt: "query", + claude: "query", + aimode: "query", perplexity: "query", grok: "query", aistudio: "query", @@ -2900,7 +2930,7 @@ const socket = net.createConnection(SOCKET_PATH, () => { socket.write(JSON.stringify(request) + "\n"); }); -const AI_TOOLS = ["smoke", "chatgpt", "gemini", "perplexity", "grok", "aistudio", "aistudio.build", "ai"]; +const AI_TOOLS = ["smoke", "chatgpt", "claude", "aimode", "gemini", "perplexity", "grok", "aistudio", "aistudio.build", "ai"]; let requestTimeout = AI_TOOLS.includes(tool) ? 300000 : 30000; if (tool === "aistudio.build") { const userTimeoutSec = parseInt(options.timeout || "600", 10); @@ -2945,9 +2975,15 @@ socket.on("data", (data) => { socket.on("error", (err) => { clearTimeout(timeout); if (err.code === "ENOENT") { - console.error("Error: Socket not found. Is Chrome running with the extension?"); + console.error("Error: Socket not found."); + console.error("SURF_NOT_RUNNING: Is Chrome running with the surf extension? Run: surf tab.new"); } else if (err.code === "ECONNREFUSED") { console.error("Error: Connection refused. Native host not running."); + console.error("SURF_NOT_RUNNING: Try running 'surf tab.new' to start surf."); + } else if (err.code === "ETIMEDOUT" || err.message.includes("timeout")) { + console.error("Error: Connection timed out."); + console.error("SURF_TIMEOUT: Chrome windows may be stuck. Try closing Chrome windows manually and retry."); + console.error("If problem persists, run: taskkill /F /IM chrome.exe"); } else { console.error("Error:", err.message); } @@ -3195,6 +3231,12 @@ async function handleResponse(response) { meta.push(`${((data.tookMs || 0) / 1000).toFixed(1)}s`); console.error(`\n[${meta.join(' | ')}]`); if (data.url) console.error(`URL: ${data.url}`); + } else if (tool === "aimode" && data?.response) { + console.log(data.response); + const meta = []; + meta.push(`${((data.tookMs || 0) / 1000).toFixed(1)}s`); + console.error(`\n[${meta.join(' | ')}]`); + if (data.url) console.error(`URL: ${data.url}`); } else if (tool === "window.list" && data?.windows) { if (data.windows.length === 0) { console.log("No windows. Use 'surf window.new' to create one."); diff --git a/native/config.cjs b/native/config.cjs index ff50281..f3086aa 100644 --- a/native/config.cjs +++ b/native/config.cjs @@ -21,6 +21,21 @@ const STARTER_CONFIG = { } }; +const COUNCIL_CONFIG = { + defaultProviders: ['chatgpt', 'gemini', 'aimode'], + timeouts: { + chatgpt: 300000, // 5 min + gemini: 180000, // 3 min + aimode: 120000 // 2 min + }, + overallTimeout: 480000, // 8 min + zombieRecovery: { + enabled: true, + maxRetries: 2, + cleanupTimeout: 30000 + } +}; + // Grok models can be customized in surf.json if X.com UI changes: // { // "grok": { @@ -99,4 +114,5 @@ module.exports = { createStarterConfig, clearCache, STARTER_CONFIG, + COUNCIL_CONFIG, }; diff --git a/native/do-executor.cjs b/native/do-executor.cjs index 85ada96..2dd48c0 100644 --- a/native/do-executor.cjs +++ b/native/do-executor.cjs @@ -105,9 +105,9 @@ function sendDoRequest(toolName, toolArgs, context = {}) { sock.on("error", (e) => { if (e.code === "ENOENT") { - reject(new Error("Socket not found. Is Chrome running with the extension?")); + reject(new Error("Socket not found.\nSURF_NOT_RUNNING: Is Chrome running with the surf extension? Run: surf tab.new")); } else if (e.code === "ECONNREFUSED") { - reject(new Error("Connection refused. Native host not running.")); + reject(new Error('Connection refused. Native host not running.\nSURF_NOT_RUNNING: Try running \'surf tab.new\' to start surf.')); } else { reject(e); } @@ -115,7 +115,7 @@ function sendDoRequest(toolName, toolArgs, context = {}) { const timeoutId = setTimeout(() => { sock.destroy(); - reject(new Error("Request timeout")); + reject(new Error("Request timeout.\nSURF_TIMEOUT: Chrome windows may be stuck. Try closing Chrome windows manually and retry.\nIf problem persists, run: taskkill /F /IM chrome.exe")); }, 30000); sock.on("close", () => clearTimeout(timeoutId)); diff --git a/native/host-helpers.cjs b/native/host-helpers.cjs index c7f24c0..9058b70 100644 --- a/native/host-helpers.cjs +++ b/native/host-helpers.cjs @@ -1020,14 +1020,34 @@ function mapToolToMessage(tool, args, tabId) { return { type: "HISTORY_SEARCH", query: a.query, limit: a.limit !== undefined ? parseInt(a.limit, 10) : 20 }; case "chatgpt": if (!a.query) throw new Error("query required"); - return { - type: "CHATGPT_QUERY", - query: a.query, + return { + type: "CHATGPT_QUERY", + query: a.query, model: a.model, withPage: a["with-page"], file: a.file, timeout: a.timeout ? parseInt(a.timeout, 10) * 1000 : 2700000, - ...baseMsg + ...baseMsg + }; + case "claude": + if (!a.query) throw new Error("query required"); + return { + type: "CLAUDE_QUERY", + query: a.query, + model: a.model, + withPage: a["with-page"], + timeout: a.timeout ? parseInt(a.timeout, 10) * 1000 : 300000, + ...baseMsg + }; + case "aimode": + if (!a.query) throw new Error("query required"); + return { + type: "AIMODE_QUERY", + query: a.query, + withPage: a["with-page"], + pro: a.pro === true, + timeout: a.timeout ? parseInt(a.timeout, 10) * 1000 : 120000, + ...baseMsg }; case "gemini": if (!a.query && !a["generate-image"]) throw new Error("query required"); diff --git a/native/host.cjs b/native/host.cjs index c1c8c90..b832bca 100755 --- a/native/host.cjs +++ b/native/host.cjs @@ -7,6 +7,8 @@ const https = require("https"); const { execSync } = require("child_process"); const { GoogleGenerativeAI } = require("@google/generative-ai"); const chatgptClient = require("./chatgpt-client.cjs"); +const claudeClient = require("./claude-client.cjs"); +const aimodeClient = require("./aimode-client.cjs"); const geminiClient = require("./gemini-client.cjs"); const perplexityClient = require("./perplexity-client.cjs"); const grokClient = require("./grok-client.cjs"); @@ -19,6 +21,27 @@ const SURF_TMP = IS_WIN ? path.join(os.tmpdir(), "surf") : "/tmp"; const SOCKET_PATH = IS_WIN ? "//./pipe/surf" : "/tmp/surf.sock"; if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} } +// Surf-specific error codes with actionable hints +const SURF_ERRORS = { + SURF_TIMEOUT: { + code: "SURF_TIMEOUT", + message: "Surf command timed out.", + hint: "Chrome windows may be stuck. Try closing Chrome windows manually and retry. If problem persists, run: taskkill /F /IM chrome.exe" + }, + SURF_ZOMBIE_DETECTED: { + code: "SURF_ZOMBIE_DETECTED", + message: "Zombie Chrome windows detected.", + hint: "Force closing zombie processes..." + }, + SURF_NOT_RUNNING: { + code: "SURF_NOT_RUNNING", + message: "Surf is not running or socket is not available.", + hint: "Is Chrome running with the surf extension? Run: surf tab.new" + } +}; + + + // Cross-platform image resize (macOS: sips, Linux: ImageMagick) function resizeImage(filePath, maxSize) { const platform = process.platform; @@ -534,6 +557,16 @@ function handleToolRequest(msg, socket) { }); writeMessage({ type: "CHATGPT_CDP_COMMAND", tabId, method, params, id: cmdId }); }), + uploadFile: (tabId, filePaths) => new Promise((resolve) => { + const uploadId = ++requestCounter; + pendingToolRequests.set(uploadId, { + socket: null, + originalId: null, + tool: "upload_file", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "UPLOAD_FILE_TO_TAB", tabId, filePaths, id: uploadId }); + }), log: (msg) => log(`[chatgpt] ${msg}`) }); @@ -644,7 +677,207 @@ function handleToolRequest(msg, socket) { return; } - + + if (extensionMsg.type === "CLAUDE_QUERY") { + const { query, model, withPage, timeout } = extensionMsg; + + queueAiRequest(async () => { + let pageContext = null; + if (withPage) { + const pageResult = await new Promise((resolve) => { + const pageId = ++requestCounter; + pendingToolRequests.set(pageId, { + socket: null, + originalId: null, + tool: "read_page", + onComplete: resolve + }); + writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId }); + }); + if (pageResult && !pageResult.error) { + pageContext = { + url: pageResult.url, + text: pageResult.text || pageResult.pageContent || "" + }; + } + } + + let fullPrompt = query; + if (pageContext) { + fullPrompt = `Page: ${pageContext.url}\n\n${pageContext.text}\n\n---\n\n${query}`; + } + + const result = await claudeClient.query({ + prompt: fullPrompt, + model, + timeout: timeout || 300000, + getCookies: () => new Promise((resolve) => { + const cookieId = ++requestCounter; + pendingToolRequests.set(cookieId, { + socket: null, + originalId: null, + tool: "get_cookies", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "GET_CLAUDE_COOKIES", id: cookieId }); + }), + createTab: () => new Promise((resolve) => { + const tabCreateId = ++requestCounter; + pendingToolRequests.set(tabCreateId, { + socket: null, + originalId: null, + tool: "create_tab", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "CLAUDE_NEW_TAB", id: tabCreateId }); + }), + closeTab: (tabIdToClose) => new Promise((resolve) => { + const tabCloseId = ++requestCounter; + pendingToolRequests.set(tabCloseId, { + socket: null, + originalId: null, + tool: "close_tab", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "CLAUDE_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId }); + }), + cdpEvaluate: (tabId, expression) => new Promise((resolve) => { + const evalId = ++requestCounter; + pendingToolRequests.set(evalId, { + socket: null, + originalId: null, + tool: "cdp_evaluate", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "CLAUDE_EVALUATE", tabId, expression, id: evalId }); + }), + cdpCommand: (tabId, method, params) => new Promise((resolve) => { + const cmdId = ++requestCounter; + pendingToolRequests.set(cmdId, { + socket: null, + originalId: null, + tool: "cdp_command", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "CLAUDE_CDP_COMMAND", tabId, method, params, id: cmdId }); + }), + log: (msg) => log(`[claude] ${msg}`) + }); + + return result; + }).then((result) => { + sendToolResponse(socket, originalId, { + response: result.response, + model: result.model, + tookMs: result.tookMs + }, null); + }).catch((err) => { + sendToolResponse(socket, originalId, null, err.message); + }); + + return; + } + + if (extensionMsg.type === "AIMODE_QUERY") { + const { query, withPage, pro, timeout } = extensionMsg; + + queueAiRequest(async () => { + let pageContext = null; + if (withPage) { + const pageResult = await new Promise((resolve) => { + const pageId = ++requestCounter; + pendingToolRequests.set(pageId, { + socket: null, + originalId: null, + tool: "read_page", + onComplete: resolve + }); + writeMessage({ type: "GET_PAGE_TEXT", tabId: extensionMsg.tabId, id: pageId }); + }); + if (pageResult && !pageResult.error) { + pageContext = { + url: pageResult.url, + text: pageResult.text || pageResult.pageContent || "" + }; + } + } + + let fullPrompt = query; + if (pageContext) { + fullPrompt = `Page: ${pageContext.url}\n\n${pageContext.text}\n\n---\n\n${query}`; + } + + const result = await aimodeClient.query({ + prompt: fullPrompt, + pro: pro || false, + timeout: timeout || 120000, + getCookies: () => new Promise((resolve) => { + const cookieId = ++requestCounter; + pendingToolRequests.set(cookieId, { + socket: null, + originalId: null, + tool: "get_cookies", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "GET_GOOGLE_COOKIES", id: cookieId }); + }), + createTab: () => new Promise((resolve) => { + const tabCreateId = ++requestCounter; + pendingToolRequests.set(tabCreateId, { + socket: null, + originalId: null, + tool: "create_tab", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "AIMODE_NEW_TAB", pro: pro || false, id: tabCreateId }); + }), + closeTab: (tabIdToClose) => new Promise((resolve) => { + const tabCloseId = ++requestCounter; + pendingToolRequests.set(tabCloseId, { + socket: null, + originalId: null, + tool: "close_tab", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "AIMODE_CLOSE_TAB", tabId: tabIdToClose, id: tabCloseId }); + }), + cdpEvaluate: (tabId, expression) => new Promise((resolve) => { + const evalId = ++requestCounter; + pendingToolRequests.set(evalId, { + socket: null, + originalId: null, + tool: "cdp_evaluate", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "AIMODE_EVALUATE", tabId, expression, id: evalId }); + }), + cdpCommand: (tabId, method, params) => new Promise((resolve) => { + const cmdId = ++requestCounter; + pendingToolRequests.set(cmdId, { + socket: null, + originalId: null, + tool: "cdp_command", + onComplete: (r) => resolve(r) + }); + writeMessage({ type: "AIMODE_CDP_COMMAND", tabId, method, params, id: cmdId }); + }), + log: (msg) => log(`[aimode] ${msg}`) + }); + + return result; + }).then((result) => { + sendToolResponse(socket, originalId, { + response: result.response, + url: result.url, + tookMs: result.tookMs + }, null); + }).catch((err) => { + sendToolResponse(socket, originalId, null, err.message); + }); + + return; + } + if (extensionMsg.type === "GEMINI_QUERY") { const { query, model, withPage, file, generateImage, editImage, output, youtube, aspectRatio, timeout } = extensionMsg; @@ -1559,7 +1792,8 @@ process.stdin.on("end", () => { try { socket.write(JSON.stringify({ type: "extension_disconnected", - message: "Surf extension was reloaded. Restart your command." + code: SURF_ERRORS.SURF_NOT_RUNNING.code, + message: `${SURF_ERRORS.SURF_NOT_RUNNING.message} ${SURF_ERRORS.SURF_NOT_RUNNING.hint}` }) + "\n"); socket.end(); } catch (e) { @@ -1694,6 +1928,15 @@ server.listen(SOCKET_PATH, () => { server.on("error", (err) => { log(`Server error: ${err.message}`); + if (err.code === "EADDRINUSE") { + console.error(`Error: Socket ${SOCKET_PATH} is already in use.`); + console.error("Another surf instance may be running. Try: taskkill /F /IM chrome.exe"); + } else if (err.code === "EACCES") { + console.error(`Error: Permission denied for socket ${SOCKET_PATH}.`); + console.error("Try checking socket permissions or running with elevated privileges."); + } else { + console.error(`Error: ${err.message}`); + } }); process.on("SIGTERM", () => { diff --git a/package-lock.json b/package-lock.json index b2871c0..a860b8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "surf-cli", - "version": "2.6.0", + "version": "2.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "surf-cli", - "version": "2.6.0", + "version": "2.7.1", "license": "MIT", "dependencies": { "@google/generative-ai": "^0.24.1", @@ -23,6 +23,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.4", + "@crxjs/vite-plugin": "^2.3.0", "@types/chrome": "^0.1.37", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", @@ -254,6 +255,81 @@ "node": ">=14.21.3" } }, + "node_modules/@crxjs/vite-plugin": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.3.0.tgz", + "integrity": "sha512-+0CNVGS4bB30OoaF1vUsHVwWU1Lm7MxI0XWY9Fd/Ob+ZVTZgEFNqJ1ZC69IVwQsoYhY0sMQLvpLWiFIuDz8htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^4.1.2", + "@webcomponents/custom-elements": "^1.5.0", + "acorn-walk": "^8.2.0", + "cheerio": "^1.0.0-rc.10", + "convert-source-map": "^1.7.0", + "debug": "^4.3.3", + "es-module-lexer": "^0.10.0", + "fast-glob": "^3.2.11", + "fs-extra": "^10.0.1", + "jsesc": "^3.0.2", + "magic-string": "^0.30.12", + "pathe": "^2.0.1", + "picocolors": "^1.1.1", + "react-refresh": "^0.13.0", + "rollup": "2.79.2", + "rxjs": "7.5.7" + } + }, + "node_modules/@crxjs/vite-plugin/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@crxjs/vite-plugin/node_modules/es-module-lexer": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.10.5.tgz", + "integrity": "sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@crxjs/vite-plugin/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@crxjs/vite-plugin/node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -758,6 +834,44 @@ } } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1336,6 +1450,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webcomponents/custom-elements": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", + "integrity": "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1349,6 +1470,32 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -1509,6 +1656,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", @@ -1706,6 +1873,50 @@ "node": ">=18" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cipher-base": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", @@ -1753,6 +1964,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1879,6 +2097,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1966,6 +2214,21 @@ "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, "node_modules/domain-browser": { "version": "4.22.0", "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-4.22.0.tgz", @@ -1978,6 +2241,50 @@ "url": "https://bevry.me/fund" } }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2028,6 +2335,46 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2245,6 +2592,23 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -2261,6 +2625,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2285,6 +2659,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2362,6 +2749,21 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2431,6 +2833,19 @@ "node": ">= 0.4" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2443,6 +2858,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2555,6 +2977,39 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2684,6 +3139,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -2703,6 +3168,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-nan": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", @@ -2719,6 +3197,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -2834,6 +3322,19 @@ "dev": true, "license": "MIT" }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2846,6 +3347,19 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2939,6 +3453,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/miller-rabin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", @@ -3114,6 +3665,19 @@ "node": ">= 6" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3270,6 +3834,59 @@ "node": ">= 0.10" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3496,6 +4113,27 @@ "node": ">=0.4.x" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -3539,6 +4177,16 @@ "node": ">= 0.10" } }, + "node_modules/react-refresh": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz", + "integrity": "sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -3589,6 +4237,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/ripemd160": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", @@ -3675,6 +4334,40 @@ "node": ">= 18" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", + "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4137,6 +4830,19 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4156,6 +4862,13 @@ "node": ">=6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/tty-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", @@ -4204,6 +4917,26 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4430,6 +5163,43 @@ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 3eac59f..7cbf273 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.4", + "@crxjs/vite-plugin": "^2.3.0", "@types/chrome": "^0.1.37", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", diff --git a/skills/surf-council/SKILL.md b/skills/surf-council/SKILL.md new file mode 100644 index 0000000..91d677b --- /dev/null +++ b/skills/surf-council/SKILL.md @@ -0,0 +1,168 @@ +--- +name: surf-council +description: Run multiple AI providers in parallel and synthesize responses. Use when you need to query ChatGPT, Gemini, and AI Mode simultaneously for better coverage, or when one provider fails and you want automatic fallback. +--- + +# Surf Council - Multi-AI Parallel Query + +Run multiple AI providers simultaneously and get aggregated, synthesized responses. + +## Core Function + +```javascript +const { councilQuery } = require("./skills/surf-council/council.cjs"); + +const result = await councilQuery({ + query: "your question here", + providers: ["chatgpt", "gemini", "aimode"], // optional, defaults all + withPage: false, // optional, include page context + perProviderTimeouts: { chatgpt: 300000 }, // optional, ms per provider + onProviderResult: (result) => { /* called per provider */ } +}); +``` + +## Return Value + +```javascript +{ + results: [ // Array of all provider results + { success: true, provider: "chatgpt", result: {...}, duration: 45000 }, + { success: false, provider: "gemini", error: "timeout", duration: 180000 }, + { success: true, provider: "aimode", result: {...}, duration: 30000 }, + ], + synthesized: {...}, // Best response (chatgpt > gemini > aimode priority) + primaryProvider: "chatgpt", // Which provider provided the synthesis + successfulProviders: ["chatgpt", "aimode"], + failedProviders: [{ provider: "gemini", error: "timeout" }], + timedOut: false, +} +``` + +## Provider Priority + +When multiple providers succeed, synthesis uses this priority order: + +1. **ChatGPT** - Primary (most capable for general queries) +2. **Gemini** - Secondary (good for research, file analysis) +3. **AI Mode** - Tertiary (fast, good for quick lookups) + +If only Gemini and AI Mode succeed, Gemini's response is synthesized. + +## Timeouts (from COUNCIL_CONFIG) + +| Provider | Default Timeout | +|----------|----------------| +| chatgpt | 300000ms (5 min) | +| gemini | 180000ms (3 min) | +| aimode | 120000ms (2 min) | +| overall | 480000ms (8 min) | + +## Examples + +### Basic Multi-Provider Query + +```javascript +const { councilQuery } = require("./skills/surf-council/council.cjs"); + +const result = await councilQuery({ + query: "Explain quantum entanglement in simple terms", +}); + +// result.synthesized contains ChatGPT's response (primary) +// result.successfulProviders = ["chatgpt", "gemini", "aimode"] if all succeed +``` + +### With Page Context + +```javascript +const result = await councilQuery({ + query: "What does this code do?", + withPage: true, // Includes current browser page content +}); +``` + +### Custom Providers and Timeouts + +```javascript +const result = await councilQuery({ + query: "Quick question", + providers: ["chatgpt", "aimode"], // Skip gemini + perProviderTimeouts: { + chatgpt: 60000, // 1 minute for chatgpt + aimode: 30000, // 30 seconds for aimode + }, +}); +``` + +### Real-Time Provider Callbacks + +```javascript +const result = await councilQuery({ + query: "Research this topic", + providers: ["chatgpt", "gemini", "aimode"], + onProviderResult: (providerResult) => { + if (providerResult.success) { + console.log(`${providerResult.provider} succeeded in ${providerResult.duration}ms`); + } else { + console.log(`${providerResult.provider} failed: ${providerResult.error}`); + } + }, +}); +``` + +### Handling Partial Failures + +```javascript +const result = await councilQuery({ + query: "Complex query", +}); + +// Even if some providers fail, you get results +if (result.successfulProviders.length > 0) { + console.log(`Got response from ${result.primaryProvider}`); +} + +// Check what failed +for (const failure of result.failedProviders) { + console.log(`${failure.provider} failed: ${failure.error}`); +} +``` + +## CLI Equivalent + +```bash +# The council runs providers via surf CLI commands +surf chatgpt "query" +surf gemini "query" +surf aimode "query" + +# These run in parallel and results are aggregated +``` + +## Error Handling + +- **All providers fail**: `synthesized` is `null`, check `failedProviders` for errors +- **Overall timeout (8 min)**: `timedOut: true`, partial results returned +- **Invalid provider**: Skipped with warning in `failedProviders` +- **Callback errors**: Ignored (non-blocking) + +## Zombie Recovery Integration + +The council automatically handles zombie window recovery: + +- Uses `socket-health.cjs` to check surf socket health before queries +- Uses `zombie-detector.cjs` to detect and clean up orphaned windows +- Recovery happens silently in the background + +## When to Use Council + +**Use Council when:** +- You need broader coverage (different AIs have different strengths) +- You want automatic fallback if one provider fails +- You're doing research and want multiple perspectives +- You need faster responses (parallel vs sequential) + +**Use Single Provider when:** +- You have a preferred provider for your use case +- You need provider-specific features (e.g., Gemini file upload) +- Bandwidth or resource is limited diff --git a/skills/surf-council/council.cjs b/skills/surf-council/council.cjs new file mode 100644 index 0000000..9195378 --- /dev/null +++ b/skills/surf-council/council.cjs @@ -0,0 +1,298 @@ +/** + * Council Core - Multi-AI Provider Orchestration + * Runs multiple AI providers in parallel and synthesizes responses. + */ + +const { spawn } = require("child_process"); +const path = require("path"); + +// Default providers in priority order (first successful is preferred for synthesis) +const DEFAULT_PROVIDERS = ["chatgpt", "gemini", "aimode"]; + +// Priority order for synthesis when multiple providers succeed +const PROVIDER_PRIORITY = ["chatgpt", "gemini", "aimode"]; + +const DEFAULT_TIMEOUT_MS = { + chatgpt: 300000, // 5 min + gemini: 180000, // 3 min + aimode: 120000, // 2 min +}; + +const OVERALL_TIMEOUT_MS = 480000; // 8 min + +/** + * Run a single provider query via surf CLI + * @param {string} provider - Provider name (chatgpt, gemini, aimode) + * @param {string} query - The query to send + * @param {object} opts - Options { withPage, timeoutMs } + * @returns {Promise<{success: boolean, result?: any, error?: string, provider: string}>} + */ +function runProviderQuery(provider, query, opts = {}) { + const { withPage = false, timeoutMs = DEFAULT_TIMEOUT_MS[provider] || 120000 } = opts; + + return new Promise((resolve) => { + let resolved = false; + let stdout = ""; + let stderr = ""; + const startTime = Date.now(); + + const args = [provider, `"${query.replace(/"/g, '\\"')}"`]; + if (withPage) { + args.push("--with-page"); + } + + const child = spawn("surf", args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + resolve({ + success: false, + provider, + error: `timeout after ${timeoutMs}ms`, + duration: Date.now() - startTime, + }); + } + }, timeoutMs); + + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + resolve({ + success: false, + provider, + error: err.message, + duration: Date.now() - startTime, + }); + } + }); + + child.on("close", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + cleanup(); + + if (code !== 0) { + const errorMsg = stderr.trim() || `exit code ${code}`; + resolve({ + success: false, + provider, + error: errorMsg, + duration: Date.now() - startTime, + }); + return; + } + + // Parse output - try to extract JSON result + try { + // Find JSON in output (surf may print other text) + const jsonMatch = stdout.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const result = JSON.parse(jsonMatch[0]); + resolve({ + success: true, + provider, + result, + duration: Date.now() - startTime, + }); + } else { + // No JSON found - treat as plain text success + resolve({ + success: true, + provider, + result: { text: stdout.trim() }, + duration: Date.now() - startTime, + }); + } + } catch (parseErr) { + // JSON parse failed - still a successful execution with raw output + resolve({ + success: true, + provider, + result: { text: stdout.trim() }, + duration: Date.now() - startTime, + }); + } + }); + + function cleanup() { + try { + child.kill(); + } catch { + // Ignore kill errors + } + } + }); +} + +/** + * Run all providers in parallel using Promise.allSettled + * @param {string[]} providers - Array of provider names + * @param {string} query - The query to send + * @param {object} opts - Options { withPage, perProviderTimeouts } + * @returns {Promise>} + */ +function runAllProviders(providers, query, opts = {}) { + const { withPage = false, perProviderTimeouts = {} } = opts; + + const promises = providers.map((provider) => + runProviderQuery(provider, query, { + withPage, + timeoutMs: perProviderTimeouts[provider] || DEFAULT_TIMEOUT_MS[provider] || 120000, + }) + ); + + return Promise.allSettled(promises); +} + +/** + * Synthesize a response from multiple provider results + * Uses priority order: chatgpt > gemini > aimode + * @param {Array} results - Array of successful results + * @param {string[]} successfulProviders - List of providers that succeeded + * @returns {object} - The synthesized response + */ +function synthesizeResponse(results, successfulProviders) { + // Sort by priority + const sorted = PROVIDER_PRIORITY.filter((p) => successfulProviders.includes(p)); + + if (sorted.length === 0) { + return { + synthesized: null, + primaryProvider: null, + note: "No providers succeeded", + }; + } + + const primaryProvider = sorted[0]; + const primaryResult = results.find( + (r) => r.provider === primaryProvider && r.success + ); + + return { + synthesized: primaryResult?.result || null, + primaryProvider, + allProvidersTried: PROVIDER_PRIORITY.filter((p) => + results.some((r) => r.provider === p) + ), + successfulProviders: sorted, + }; +} + +/** + * Main council query function + * @param {object} options + * @param {string} options.query - The query to send to all providers + * @param {string[]} [options.providers] - Provider names (default: ['chatgpt', 'gemini', 'aimode']) + * @param {object} [options.perProviderTimeouts] - Custom timeouts per provider in ms + * @param {boolean} [options.withPage] - Include current page context + * @param {function} [options.onProviderResult] - Callback for each provider result + * @returns {Promise<{results, synthesized, successfulProviders, failedProviders}>} + */ +async function councilQuery(options = {}) { + const { + query, + providers = DEFAULT_PROVIDERS, + perProviderTimeouts = {}, + withPage = false, + onProviderResult = null, + } = options; + + if (!query) { + throw new Error("councilQuery requires a query string"); + } + + // Set up overall timeout + const overallTimeout = new Promise((resolve) => + setTimeout(() => { + resolve({ timedOut: true }); + }, OVERALL_TIMEOUT_MS) + ); + + // Run all providers in parallel + const providerPromise = runAllProviders(providers, query, { + withPage, + perProviderTimeouts, + }); + + // Race between providers and overall timeout + const raceResult = await Promise.race([providerPromise, overallTimeout]); + + let results; + let timedOut = false; + + if (raceResult.timedOut) { + timedOut = true; + results = []; + } else { + results = raceResult.map((result) => { + if (result.status === "fulfilled") { + return result.value; + } else { + return { + success: false, + provider: "unknown", + error: result.reason?.message || "unknown error", + }; + } + }); + } + + // Notify callback for each result + if (onProviderResult) { + for (const result of results) { + try { + onProviderResult(result); + } catch (callbackErr) { + // Ignore callback errors + } + } + } + + // Separate successful and failed + const successfulProviders = results + .filter((r) => r.success) + .map((r) => r.provider); + + const failedProviders = results + .filter((r) => !r.success) + .map((r) => ({ provider: r.provider, error: r.error })); + + // Synthesize response from priority order + const synthesis = synthesizeResponse(results, successfulProviders); + + return { + results, + synthesized: synthesis.synthesized, + primaryProvider: synthesis.primaryProvider, + successfulProviders, + failedProviders: timedOut + ? [...failedProviders, { provider: "overall", error: "overall timeout" }] + : failedProviders, + timedOut, + }; +} + +module.exports = { + councilQuery, + runProviderQuery, + synthesizeResponse, + DEFAULT_PROVIDERS, + PROVIDER_PRIORITY, + DEFAULT_TIMEOUT_MS, +}; diff --git a/skills/surf-council/providers/aimode.cjs b/skills/surf-council/providers/aimode.cjs new file mode 100644 index 0000000..01a33c6 --- /dev/null +++ b/skills/surf-council/providers/aimode.cjs @@ -0,0 +1,130 @@ +/** + * AI Mode Provider for Surf Council + * Wraps 'surf aimode' CLI command with timeout and zombie recovery. + */ + +const { spawn } = require("child_process"); +const { detectZombieWindows, recoverFromZombies } = require("../recovery/zombie-detector.cjs"); + +const DEFAULT_TIMEOUT_MS = 120000; // 2 minutes + +/** + * Query AI Mode via surf CLI + * @param {Object} options + * @param {string} options.query - The query to send + * @param {number} options.timeout - Timeout in ms (default: 120000) + * @param {boolean} options.withPage - Include current page context + * @param {string} options.surfCommand - Surf command path (default: 'surf') + * @param {boolean} options.pro - Use pro mode (nem=143) instead of auto mode (udm=50) + * @returns {Promise<{success: boolean, response?: string, error?: string, partialResult?: string, tookMs: number}>} + */ +async function query(options = {}) { + const { + query, + timeout = DEFAULT_TIMEOUT_MS, + withPage = false, + surfCommand = "surf", + pro = false, + } = options; + + const startTime = Date.now(); + + try { + const result = await spawnWithTimeout(surfCommand, buildArgs(query, withPage, timeout, pro), timeout); + return { + success: true, + response: result.stdout, + tookMs: Date.now() - startTime, + }; + } catch (error) { + // Check for zombie windows and attempt recovery + const zombies = await detectZombieWindows(surfCommand); + if (zombies.length > 0) { + await recoverFromZombies(zombies, surfCommand); + return { + success: false, + error: "zombie_recovered", + partialResult: error.message, + tookMs: Date.now() - startTime, + }; + } + + return { + success: false, + error: error.message, + partialResult: error.partialResult, + tookMs: Date.now() - startTime, + }; + } +} + +function buildArgs(query, withPage, timeout, pro) { + const args = ["aimode", JSON.stringify(query), "--timeout", String(Math.floor(timeout / 1000))]; + if (withPage) { + args.push("--with-page"); + } + if (pro) { + args.push("--pro"); + } + return args; +} + +function spawnWithTimeout(command, args, timeoutMs) { + return new Promise((resolve, reject) => { + let resolved = false; + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error("Request timeout")); + } + }, timeoutMs + 10000); + + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + reject(new Error(err.message)); + } + }); + + child.on("close", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + cleanup(); + + if (code === 0) { + resolve({ stdout: stdout.trim(), stderr: stderr.trim() }); + } else { + reject(new Error(stderr.trim() || `exit code ${code}`)); + } + }); + + function cleanup() { + try { + child.kill(); + } catch { + // Ignore + } + } + }); +} + +module.exports = { query }; diff --git a/skills/surf-council/providers/chatgpt.cjs b/skills/surf-council/providers/chatgpt.cjs new file mode 100644 index 0000000..fdd7ea3 --- /dev/null +++ b/skills/surf-council/providers/chatgpt.cjs @@ -0,0 +1,125 @@ +/** + * ChatGPT Provider for Surf Council + * Wraps 'surf chatgpt' CLI command with timeout and zombie recovery. + */ + +const { spawn } = require("child_process"); +const { detectZombieWindows, recoverFromZombies } = require("../recovery/zombie-detector.cjs"); + +const DEFAULT_TIMEOUT_MS = 300000; // 5 minutes + +/** + * Query ChatGPT via surf CLI + * @param {Object} options + * @param {string} options.query - The query to send + * @param {number} options.timeout - Timeout in ms (default: 300000) + * @param {boolean} options.withPage - Include current page context + * @param {string} options.surfCommand - Surf command path (default: 'surf') + * @returns {Promise<{success: boolean, response?: string, error?: string, partialResult?: string, tookMs: number}>} + */ +async function query(options = {}) { + const { + query, + timeout = DEFAULT_TIMEOUT_MS, + withPage = false, + surfCommand = "surf", + } = options; + + const startTime = Date.now(); + + try { + const result = await spawnWithTimeout(surfCommand, buildArgs(query, withPage, timeout), timeout); + return { + success: true, + response: result.stdout, + tookMs: Date.now() - startTime, + }; + } catch (error) { + // Check for zombie windows and attempt recovery + const zombies = await detectZombieWindows(surfCommand); + if (zombies.length > 0) { + await recoverFromZombies(zombies, surfCommand); + return { + success: false, + error: "zombie_recovered", + partialResult: error.message, + tookMs: Date.now() - startTime, + }; + } + + return { + success: false, + error: error.message, + partialResult: error.partialResult, + tookMs: Date.now() - startTime, + }; + } +} + +function buildArgs(query, withPage, timeout) { + const args = ["chatgpt", JSON.stringify(query), "--timeout", String(Math.floor(timeout / 1000))]; + if (withPage) { + args.push("--with-page"); + } + return args; +} + +function spawnWithTimeout(command, args, timeoutMs) { + return new Promise((resolve, reject) => { + let resolved = false; + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error("Request timeout")); + } + }, timeoutMs + 10000); + + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + reject(new Error(err.message)); + } + }); + + child.on("close", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + cleanup(); + + if (code === 0) { + resolve({ stdout: stdout.trim(), stderr: stderr.trim() }); + } else { + reject(new Error(stderr.trim() || `exit code ${code}`)); + } + }); + + function cleanup() { + try { + child.kill(); + } catch { + // Ignore + } + } + }); +} + +module.exports = { query }; diff --git a/skills/surf-council/providers/gemini.cjs b/skills/surf-council/providers/gemini.cjs new file mode 100644 index 0000000..277aaee --- /dev/null +++ b/skills/surf-council/providers/gemini.cjs @@ -0,0 +1,130 @@ +/** + * Gemini Provider for Surf Council + * Wraps 'surf gemini' CLI command with timeout and zombie recovery. + */ + +const { spawn } = require("child_process"); +const { detectZombieWindows, recoverFromZombies } = require("../recovery/zombie-detector.cjs"); + +const DEFAULT_TIMEOUT_MS = 180000; // 3 minutes + +/** + * Query Gemini via surf CLI + * @param {Object} options + * @param {string} options.query - The query to send + * @param {number} options.timeout - Timeout in ms (default: 180000) + * @param {boolean} options.withPage - Include current page context + * @param {string} options.surfCommand - Surf command path (default: 'surf') + * @param {string} options.model - Gemini model to use + * @returns {Promise<{success: boolean, response?: string, error?: string, partialResult?: string, tookMs: number}>} + */ +async function query(options = {}) { + const { + query, + timeout = DEFAULT_TIMEOUT_MS, + withPage = false, + surfCommand = "surf", + model, + } = options; + + const startTime = Date.now(); + + try { + const result = await spawnWithTimeout(surfCommand, buildArgs(query, withPage, timeout, model), timeout); + return { + success: true, + response: result.stdout, + tookMs: Date.now() - startTime, + }; + } catch (error) { + // Check for zombie windows and attempt recovery + const zombies = await detectZombieWindows(surfCommand); + if (zombies.length > 0) { + await recoverFromZombies(zombies, surfCommand); + return { + success: false, + error: "zombie_recovered", + partialResult: error.message, + tookMs: Date.now() - startTime, + }; + } + + return { + success: false, + error: error.message, + partialResult: error.partialResult, + tookMs: Date.now() - startTime, + }; + } +} + +function buildArgs(query, withPage, timeout, model) { + const args = ["gemini", JSON.stringify(query), "--timeout", String(Math.floor(timeout / 1000))]; + if (withPage) { + args.push("--with-page"); + } + if (model) { + args.push("--model", model); + } + return args; +} + +function spawnWithTimeout(command, args, timeoutMs) { + return new Promise((resolve, reject) => { + let resolved = false; + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error("Request timeout")); + } + }, timeoutMs + 10000); + + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + reject(new Error(err.message)); + } + }); + + child.on("close", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + cleanup(); + + if (code === 0) { + resolve({ stdout: stdout.trim(), stderr: stderr.trim() }); + } else { + reject(new Error(stderr.trim() || `exit code ${code}`)); + } + }); + + function cleanup() { + try { + child.kill(); + } catch { + // Ignore + } + } + }); +} + +module.exports = { query }; diff --git a/skills/surf-council/recovery/socket-health.cjs b/skills/surf-council/recovery/socket-health.cjs new file mode 100644 index 0000000..fd0c445 --- /dev/null +++ b/skills/surf-council/recovery/socket-health.cjs @@ -0,0 +1,97 @@ +/** + * Socket Health Check Module + * Checks if the surf socket is responsive by running 'surf window list' + * with a 10 second timeout. + */ + +const { spawn } = require("child_process"); + +const DEFAULT_TIMEOUT_MS = 10000; + +/** + * Check if the surf socket is healthy by spawning 'surf window list' + * @param {string} surfCommand - The surf command to run (default: 'surf') + * @returns {Promise<{healthy: boolean, windows?: Array, reason?: string}>} + */ +async function isSocketHealthy(surfCommand = "surf") { + return new Promise((resolve) => { + let resolved = false; + let stdout = ""; + let stderr = ""; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + resolve({ healthy: false, reason: "timeout" }); + } + }, DEFAULT_TIMEOUT_MS); + + const child = spawn(surfCommand, ["window", "list"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + resolve({ healthy: false, reason: err.message }); + } + }); + + child.on("close", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + cleanup(); + + if (code !== 0) { + // Non-zero exit code indicates failure + const errorMsg = stderr.trim() || `exit code ${code}`; + resolve({ healthy: false, reason: errorMsg }); + return; + } + + // Try to parse the output as JSON + try { + // The window list output is JSON: { windows: [...] } + // But the CLI may also print non-JSON text, so find the JSON part + const jsonMatch = stdout.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + if (parsed.windows !== undefined) { + resolve({ healthy: true, windows: parsed.windows }); + return; + } + } + // If no JSON found or no windows key, treat as healthy with empty result + resolve({ healthy: true, windows: [] }); + } catch { + // Parse error - socket might be responding but returning unexpected format + // This could indicate a stuck or corrupted state + const errorMsg = stderr.trim() || "invalid response format"; + resolve({ healthy: false, reason: errorMsg }); + } + }); + + function cleanup() { + try { + child.kill(); + } catch { + // Ignore kill errors + } + } + }); +} + +module.exports = { isSocketHealthy }; diff --git a/skills/surf-council/recovery/zombie-detector.cjs b/skills/surf-council/recovery/zombie-detector.cjs new file mode 100644 index 0000000..3f7af1b --- /dev/null +++ b/skills/surf-council/recovery/zombie-detector.cjs @@ -0,0 +1,114 @@ +/** + * Zombie Window Detection Module + * Detects and recovers from orphaned surf-managed windows that have no active tabs. + */ + +const { spawn } = require("child_process"); +const { isSocketHealthy } = require("./socket-health.cjs"); + +const DEFAULT_TIMEOUT_MS = 10000; + +/** + * Detect zombie windows - surf-managed windows with no active tabs. + * A zombie window is a window that surf created but which now has 0 tabs. + * @param {string} surfCommand - The surf command to run (default: 'surf') + * @returns {Promise>} + */ +async function detectZombieWindows(surfCommand = "surf") { + const health = await isSocketHealthy(surfCommand); + + if (!health.healthy || !health.windows) { + // If socket isn't healthy, we can't detect zombies + return []; + } + + // Zombie = window with 0 tabs (surf-created but all tabs closed) + const zombies = health.windows.filter(w => w.tabCount === 0); + + return zombies; +} + +/** + * Force-close zombie windows via 'surf window close ' + * @param {Array<{id: number}>} zombies - Array of zombie window objects + * @param {string} surfCommand - The surf command to run (default: 'surf') + * @returns {Promise<{closed: number, failed: Array<{id: number, reason: string}>}>} + */ +async function recoverFromZombies(zombies, surfCommand = "surf") { + const results = { closed: 0, failed: [] }; + + for (const zombie of zombies) { + try { + await closeWindow(zombie.id, surfCommand); + results.closed++; + } catch (err) { + results.failed.push({ id: zombie.id, reason: err.message }); + } + } + + return results; +} + +/** + * Close a window by ID using surf command + * @param {number} windowId - Window ID to close + * @param {string} surfCommand - The surf command to run + * @returns {Promise} + */ +function closeWindow(windowId, surfCommand = "surf") { + return new Promise((resolve, reject) => { + let resolved = false; + let stderr = ""; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error(`timeout closing window ${windowId}`)); + } + }, DEFAULT_TIMEOUT_MS); + + const child = spawn(surfCommand, ["window", "close", String(windowId)], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + cleanup(); + reject(err); + } + }); + + child.on("close", (code) => { + if (resolved) return; + resolved = true; + clearTimeout(timeout); + cleanup(); + + if (code !== 0) { + const errorMsg = stderr.trim() || `exit code ${code}`; + reject(new Error(`failed to close window ${windowId}: ${errorMsg}`)); + return; + } + + resolve(); + }); + + function cleanup() { + try { + child.kill(); + } catch { + // Ignore kill errors + } + } + }); +} + +module.exports = { detectZombieWindows, recoverFromZombies }; diff --git a/src/service-worker/index.ts b/src/service-worker/index.ts index 01b79a6..cdf0645 100644 --- a/src/service-worker/index.ts +++ b/src/service-worker/index.ts @@ -2621,13 +2621,17 @@ export async function handleMessage( } case "CHATGPT_NEW_TAB": { - const tab = await chrome.tabs.create({ + // Open in new window (background, no focus steal) + const window = await chrome.windows.create({ url: "https://chatgpt.com/", - active: true, + focused: false, + type: "normal", }); - if (!tab.id) throw new Error("Failed to create tab"); - const currentTab = await chrome.tabs.get(tab.id); - if (currentTab.status !== "complete") { + if (!window.id) throw new Error("Failed to create window"); + const tabs = await chrome.tabs.query({ windowId: window.id }); + const tab = tabs[0]; + if (!tab?.id) throw new Error("Failed to get tab from window"); + if (tab.status !== "complete") { await new Promise((resolve) => { const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { if (tabId === tab.id && info.status === "complete") { @@ -2643,7 +2647,6 @@ export async function handleMessage( }); } await cdp.attach(tab.id); - // Wait for JS runtime to be ready after CDP attach await waitForRuntimeReady(tab.id, 10000); return { tabId: tab.id }; } @@ -2672,14 +2675,73 @@ export async function handleMessage( return result; } + case "CLAUDE_NEW_TAB": { + // Open in new window (background, no focus steal) + const window = await chrome.windows.create({ + url: "https://claude.ai/", + focused: false, + type: "normal", + }); + if (!window.id) throw new Error("Failed to create window"); + const tabs = await chrome.tabs.query({ windowId: window.id }); + const tab = tabs[0]; + if (!tab?.id) throw new Error("Failed to get tab from window"); + if (tab.status !== "complete") { + await new Promise((resolve) => { + const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { + if (tabId === tab.id && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }; + chrome.tabs.onUpdated.addListener(listener); + setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + }, 30000); + }); + } + await cdp.attach(tab.id); + await waitForRuntimeReady(tab.id, 10000); + return { tabId: tab.id }; + } + + case "CLAUDE_CLOSE_TAB": { + const claudeTabId = message.tabId; + if (claudeTabId) { + try { + await cdp.detach(claudeTabId); + } catch {} + try { + await chrome.tabs.remove(claudeTabId); + } catch {} + } + return { success: true }; + } + + case "CLAUDE_CDP_COMMAND": { + const { method, params } = message; + const result = await cdp.sendCommand(message.tabId, method, params || {}); + return result; + } + + case "CLAUDE_EVALUATE": { + const result = await cdp.evaluateScript(message.tabId, message.expression); + return result; + } + case "PERPLEXITY_NEW_TAB": { - const tab = await chrome.tabs.create({ + // Open in new window (background, no focus steal) + const window = await chrome.windows.create({ url: "https://www.perplexity.ai/", - active: true, + focused: false, + type: "normal", }); - if (!tab.id) throw new Error("Failed to create tab"); - const currentTab = await chrome.tabs.get(tab.id); - if (currentTab.status !== "complete") { + if (!window.id) throw new Error("Failed to create window"); + const tabs = await chrome.tabs.query({ windowId: window.id }); + const tab = tabs[0]; + if (!tab?.id) throw new Error("Failed to get tab from window"); + if (tab.status !== "complete") { await new Promise((resolve) => { const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { if (tabId === tab.id && info.status === "complete") { @@ -2695,7 +2757,6 @@ export async function handleMessage( }); } await cdp.attach(tab.id); - // Wait for JS runtime to be ready after CDP attach await waitForRuntimeReady(tab.id, 10000); return { tabId: tab.id }; } @@ -2758,13 +2819,17 @@ export async function handleMessage( } case "GROK_NEW_TAB": { - const tab = await chrome.tabs.create({ + // Open in new window (background, no focus steal) + const window = await chrome.windows.create({ url: "https://x.com/i/grok", - active: true, + focused: false, + type: "normal", }); - if (!tab.id) throw new Error("Failed to create tab"); - const currentTab = await chrome.tabs.get(tab.id); - if (currentTab.status !== "complete") { + if (!window.id) throw new Error("Failed to create window"); + const tabs = await chrome.tabs.query({ windowId: window.id }); + const tab = tabs[0]; + if (!tab?.id) throw new Error("Failed to get tab from window"); + if (tab.status !== "complete") { await new Promise((resolve) => { const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { if (tabId === tab.id && info.status === "complete") { @@ -2780,7 +2845,6 @@ export async function handleMessage( }); } await cdp.attach(tab.id); - // Wait for JS runtime to be ready after CDP attach await waitForRuntimeReady(tab.id, 10000); return { tabId: tab.id }; } @@ -2810,11 +2874,16 @@ export async function handleMessage( } case "GEMINI_NEW_TAB": { - const tab = await chrome.tabs.create({ + // Open in new window (background, no focus steal) + const window = await chrome.windows.create({ url: "https://gemini.google.com/app", - active: true, + focused: false, + type: "normal", }); - if (!tab.id) throw new Error("Failed to create tab"); + if (!window.id) throw new Error("Failed to create window"); + const tabs = await chrome.tabs.query({ windowId: window.id }); + const tab = tabs[0]; + if (!tab?.id) throw new Error("Failed to get tab from window"); return { tabId: tab.id }; } @@ -2931,10 +3000,17 @@ export async function handleMessage( case "AISTUDIO_NEW_TAB": { const url = message.url || "https://aistudio.google.com/prompts/new_chat"; - const tab = await chrome.tabs.create({ url, active: true }); - if (!tab.id) throw new Error("Failed to create tab"); - const currentTab = await chrome.tabs.get(tab.id); - if (currentTab.status !== "complete") { + // Open in new window (background, no focus steal) + const window = await chrome.windows.create({ + url: url, + focused: false, + type: "normal", + }); + if (!window.id) throw new Error("Failed to create window"); + const tabs = await chrome.tabs.query({ windowId: window.id }); + const tab = tabs[0]; + if (!tab?.id) throw new Error("Failed to get tab from window"); + if (tab.status !== "complete") { await new Promise((resolve) => { const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { if (tabId === tab.id && info.status === "complete") { @@ -2972,6 +3048,56 @@ export async function handleMessage( return await cdp.evaluateScript(message.tabId, message.expression); } + case "AIMODE_NEW_TAB": { + // Open in new window (background, no focus steal) + const window = await chrome.windows.create({ + url: message.pro + ? "https://www.google.com/search?nem=143&q=" + : "https://www.google.com/search?udm=50&q=", + focused: false, + type: "normal", + }); + if (!window.id) throw new Error("Failed to create window"); + const tabs = await chrome.tabs.query({ windowId: window.id }); + const tab = tabs[0]; + if (!tab?.id) throw new Error("Failed to get tab from window"); + if (tab.status !== "complete") { + await new Promise((resolve) => { + const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { + if (tabId === tab.id && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }; + chrome.tabs.onUpdated.addListener(listener); + setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + }, 30000); + }); + } + await cdp.attach(tab.id); + await waitForRuntimeReady(tab.id, 10000); + return { tabId: tab.id }; + } + + case "AIMODE_CLOSE_TAB": { + if (message.tabId) { + await cdp.detach(message.tabId); + await chrome.tabs.remove(message.tabId); + } + return { success: true }; + } + + case "AIMODE_CDP_COMMAND": { + const { method, params } = message; + return await cdp.sendCommand(message.tabId, method, params || {}); + } + + case "AIMODE_EVALUATE": { + return await cdp.evaluateScript(message.tabId, message.expression); + } + case "DOWNLOADS_SEARCH": { const results = await chrome.downloads.search(message.searchParams || {}); return { @@ -3019,6 +3145,12 @@ export async function handleMessage( return { cookies: Array.from(seen.values()) }; } + case "GET_CLAUDE_COOKIES": { + const cookies = await chrome.cookies.getAll({ domain: ".claude.ai" }); + const anthropicCookies = await chrome.cookies.getAll({ domain: ".anthropic.com" }); + return { cookies: [...cookies, ...anthropicCookies] }; + } + case "WINDOW_NEW": { // Default to a usable blank page if no URL provided const url = message.url || 'data:text/html,Surf Agent

Agent Window

Ready for automation

'; @@ -3127,14 +3259,17 @@ const COMMANDS_WITHOUT_TAB = new Set([ "LIST_TABS", "NEW_TAB", "TABS_NEW", "CLOSE_TABS", "SWITCH_TAB", "TABS_SWITCH", "TABS_REGISTER", "TABS_UNREGISTER", "TABS_LIST_NAMED", "TABS_GET_BY_NAME", "CREATE_TAB_GROUP", "UNGROUP_TABS", "LIST_TAB_GROUPS", "GET_HISTORY", "SEARCH_HISTORY", - "GET_COOKIES", "SET_COOKIE", "DELETE_COOKIES", "GET_BOOKMARKS", "ADD_BOOKMARK", + "GET_COOKIES", "SET_COOKIE", "DELETE_COOKIES", "GET_BOOKMARKS", "ADD_BOOKMARK", "DELETE_BOOKMARK", "DIALOG_DISMISS", "DIALOG_ACCEPT", "DIALOG_INFO", "CHATGPT_NEW_TAB", "CHATGPT_CLOSE_TAB", "CHATGPT_EVALUATE", "CHATGPT_CDP_COMMAND", "GET_CHATGPT_COOKIES", "GET_GOOGLE_COOKIES", "GET_TWITTER_COOKIES", + "CLAUDE_NEW_TAB", "CLAUDE_CLOSE_TAB", "CLAUDE_EVALUATE", "CLAUDE_CDP_COMMAND", + "GET_CLAUDE_COOKIES", "PERPLEXITY_NEW_TAB", "PERPLEXITY_CLOSE_TAB", "PERPLEXITY_EVALUATE", "PERPLEXITY_CDP_COMMAND", "GROK_NEW_TAB", "GROK_CLOSE_TAB", "GROK_EVALUATE", "GROK_CDP_COMMAND", "GEMINI_NEW_TAB", "GEMINI_CLOSE_TAB", "GEMINI_FETCH_URL", "UPLOAD_FILE_TO_TAB", "AISTUDIO_NEW_TAB", "AISTUDIO_CLOSE_TAB", "AISTUDIO_EVALUATE", "AISTUDIO_CDP_COMMAND", + "AIMODE_NEW_TAB", "AIMODE_CLOSE_TAB", "AIMODE_EVALUATE", "AIMODE_CDP_COMMAND", "DOWNLOADS_SEARCH", "WINDOW_NEW", "WINDOW_LIST", "WINDOW_FOCUS", "WINDOW_CLOSE", "WINDOW_RESIZE", "EMULATE_DEVICE_LIST"