diff --git a/extension/dist/background.js b/extension/dist/background.js index 68494313..f4ea272a 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -5,8 +5,33 @@ const WS_RECONNECT_BASE_DELAY = 2e3; const WS_RECONNECT_MAX_DELAY = 6e4; const attached = /* @__PURE__ */ new Set(); +function isDebuggableUrl$1(url) { + if (!url) return false; + return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://"); +} async function ensureAttached(tabId) { - if (attached.has(tabId)) return; + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + attached.delete(tabId); + throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; + attached.delete(tabId); + throw new Error(`Tab ${tabId} no longer exists`); + } + if (attached.has(tabId)) { + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression: "1", + returnByValue: true + }); + return; + } catch { + attached.delete(tabId); + } + } try { await chrome.debugger.attach({ tabId }, "1.3"); } catch (e) { @@ -89,6 +114,17 @@ function registerListeners() { chrome.debugger.onDetach.addListener((source) => { if (source.tabId) attached.delete(source.tabId); }); + chrome.tabs.onUpdated.addListener((tabId, info) => { + if (info.url && !isDebuggableUrl$1(info.url)) { + if (attached.has(tabId)) { + attached.delete(tabId); + try { + chrome.debugger.detach({ tabId }); + } catch { + } + } + } + }); } let ws = null; @@ -161,7 +197,7 @@ function scheduleReconnect() { }, delay); } const automationSessions = /* @__PURE__ */ new Map(); -const WINDOW_IDLE_TIMEOUT = 3e4; +const WINDOW_IDLE_TIMEOUT = 12e4; function getWorkspaceKey(workspace) { return workspace?.trim() || "default"; } @@ -265,17 +301,30 @@ async function handleCommand(cmd) { }; } } -function isWebUrl(url) { +function isDebuggableUrl(url) { if (!url) return false; return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://"); } async function resolveTabId(tabId, workspace) { - if (tabId !== void 0) return tabId; + if (tabId !== void 0) { + try { + const tab = await chrome.tabs.get(tabId); + if (isDebuggableUrl(tab.url)) return tabId; + console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); + } catch { + console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); + } + } const windowId = await getAutomationWindow(workspace); const tabs = await chrome.tabs.query({ windowId }); - const webTab = tabs.find((t) => t.id && isWebUrl(t.url)); - if (webTab?.id) return webTab.id; - if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id; + const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); + if (debuggableTab?.id) return debuggableTab.id; + const reuseTab = tabs.find((t) => t.id); + if (reuseTab?.id) { + await chrome.tabs.update(reuseTab.id, { url: "about:blank" }); + await new Promise((resolve) => setTimeout(resolve, 200)); + return reuseTab.id; + } const newTab = await chrome.tabs.create({ windowId, url: "about:blank", active: true }); if (!newTab.id) throw new Error("Failed to create tab in automation window"); return newTab.id; @@ -292,7 +341,7 @@ async function listAutomationTabs(workspace) { } async function listAutomationWebTabs(workspace) { const tabs = await listAutomationTabs(workspace); - return tabs.filter((tab) => isWebUrl(tab.url)); + return tabs.filter((tab) => isDebuggableUrl(tab.url)); } async function handleExec(cmd, workspace) { if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; @@ -307,28 +356,47 @@ async function handleExec(cmd, workspace) { async function handleNavigate(cmd, workspace) { if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; const tabId = await resolveTabId(cmd.tabId, workspace); - await chrome.tabs.update(tabId, { url: cmd.url }); + const beforeTab = await chrome.tabs.get(tabId); + const beforeUrl = beforeTab.url ?? ""; + const targetUrl = cmd.url; + await chrome.tabs.update(tabId, { url: targetUrl }); + let timedOut = false; await new Promise((resolve) => { - chrome.tabs.get(tabId).then((tab2) => { - if (tab2.status === "complete") { + let urlChanged = false; + const listener = (id, info, tab2) => { + if (id !== tabId) return; + if (info.url && info.url !== beforeUrl) { + urlChanged = true; + } + if (urlChanged && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); resolve(); - return; } - const listener = (id, info) => { - if (id === tabId && info.status === "complete") { + }; + chrome.tabs.onUpdated.addListener(listener); + setTimeout(async () => { + try { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.url !== beforeUrl && currentTab.status === "complete") { chrome.tabs.onUpdated.removeListener(listener); resolve(); } - }; - chrome.tabs.onUpdated.addListener(listener); - setTimeout(() => { - chrome.tabs.onUpdated.removeListener(listener); - resolve(); - }, 15e3); - }); + } catch { + } + }, 100); + setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + timedOut = true; + console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); + resolve(); + }, 15e3); }); const tab = await chrome.tabs.get(tabId); - return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } }; + return { + id: cmd.id, + ok: true, + data: { title: tab.title, url: tab.url, tabId, timedOut } + }; } async function handleTabs(cmd, workspace) { switch (cmd.op) { @@ -425,7 +493,7 @@ async function handleSessions(cmd) { const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ workspace, windowId: session.windowId, - tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length, + tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) }))); return { id: cmd.id, ok: true, data }; diff --git a/extension/src/background.ts b/extension/src/background.ts index c4d20303..eca411f5 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -97,7 +97,7 @@ type AutomationSession = { }; const automationSessions = new Map(); -const WINDOW_IDLE_TIMEOUT = 30000; // 30s +const WINDOW_IDLE_TIMEOUT = 120000; // 120s — longer to survive slow pipelines function getWorkspaceKey(workspace?: string): string { return workspace?.trim() || 'default'; @@ -238,7 +238,20 @@ function isDebuggableUrl(url?: string): boolean { * Otherwise, find or create a tab in the dedicated automation window. */ async function resolveTabId(tabId: number | undefined, workspace: string): Promise { - if (tabId !== undefined) return tabId; + // Even when an explicit tabId is provided, validate it is still debuggable. + // This prevents issues when extensions hijack the tab URL to chrome-extension:// + // or when the tab has been closed by the user. + if (tabId !== undefined) { + try { + const tab = await chrome.tabs.get(tabId); + if (isDebuggableUrl(tab.url)) return tabId; + // Tab exists but URL is not debuggable — fall through to auto-resolve + console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); + } catch { + // Tab was closed — fall through to auto-resolve + console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); + } + } // Get (or create) the automation window const windowId = await getAutomationWindow(workspace); @@ -255,6 +268,8 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi const reuseTab = tabs.find(t => t.id); if (reuseTab?.id) { await chrome.tabs.update(reuseTab.id, { url: 'about:blank' }); + // Wait briefly for the navigation to take effect + await new Promise(resolve => setTimeout(resolve, 200)); return reuseTab.id; } @@ -294,31 +309,62 @@ async function handleExec(cmd: Command, workspace: string): Promise { async function handleNavigate(cmd: Command, workspace: string): Promise { if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' }; const tabId = await resolveTabId(cmd.tabId, workspace); - await chrome.tabs.update(tabId, { url: cmd.url }); - // Wait for page to finish loading, checking current status first to avoid race + // Capture the current URL before navigation to detect actual URL change + const beforeTab = await chrome.tabs.get(tabId); + const beforeUrl = beforeTab.url ?? ''; + const targetUrl = cmd.url; + + await chrome.tabs.update(tabId, { url: targetUrl }); + + // Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete' + // This avoids the race where 'complete' fires for the OLD URL (e.g. about:blank) + let timedOut = false; await new Promise((resolve) => { - // Check if already complete (e.g. cached pages) - chrome.tabs.get(tabId).then(tab => { - if (tab.status === 'complete') { resolve(); return; } + let urlChanged = false; + + const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => { + if (id !== tabId) return; + + // Track URL change (new URL differs from the one before navigation) + if (info.url && info.url !== beforeUrl) { + urlChanged = true; + } - const listener = (id: number, info: chrome.tabs.TabChangeInfo) => { - if (id === tabId && info.status === 'complete') { + // Only resolve when both URL has changed AND status is complete + if (urlChanged && info.status === 'complete') { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }; + chrome.tabs.onUpdated.addListener(listener); + + // Also check if the tab already navigated (e.g. instant cache hit) + setTimeout(async () => { + try { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.url !== beforeUrl && currentTab.status === 'complete') { chrome.tabs.onUpdated.removeListener(listener); resolve(); } - }; - chrome.tabs.onUpdated.addListener(listener); - // Timeout fallback - setTimeout(() => { - chrome.tabs.onUpdated.removeListener(listener); - resolve(); - }, 15000); - }); + } catch { /* tab gone */ } + }, 100); + + // Timeout fallback with warning + setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + timedOut = true; + console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); + resolve(); + }, 15000); }); const tab = await chrome.tabs.get(tabId); - return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } }; + return { + id: cmd.id, + ok: true, + data: { title: tab.title, url: tab.url, tabId, timedOut }, + }; } async function handleTabs(cmd: Command, workspace: string): Promise { diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index ac9fe38a..bcf12413 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -8,8 +8,40 @@ const attached = new Set(); +/** Check if a URL can be attached via CDP */ +function isDebuggableUrl(url?: string): boolean { + if (!url) return false; + return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://'); +} + async function ensureAttached(tabId: number): Promise { - if (attached.has(tabId)) return; + // Verify the tab URL is debuggable before attempting attach + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl(tab.url)) { + // Invalidate cache if previously attached + attached.delete(tabId); + throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`); + } + } catch (e) { + // Re-throw our own error, catch only chrome.tabs.get failures + if (e instanceof Error && e.message.startsWith('Cannot debug tab')) throw e; + attached.delete(tabId); + throw new Error(`Tab ${tabId} no longer exists`); + } + + if (attached.has(tabId)) { + // Verify the debugger is still actually attached by sending a harmless command + try { + await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: '1', returnByValue: true, + }); + return; // Still attached and working + } catch { + // Stale cache entry — need to re-attach + attached.delete(tabId); + } + } try { await chrome.debugger.attach({ tabId }, '1.3'); @@ -122,4 +154,13 @@ export function registerListeners(): void { chrome.debugger.onDetach.addListener((source) => { if (source.tabId) attached.delete(source.tabId); }); + // Invalidate attached cache when tab URL changes to non-debuggable + chrome.tabs.onUpdated.addListener((tabId, info) => { + if (info.url && !isDebuggableUrl(info.url)) { + if (attached.has(tabId)) { + attached.delete(tabId); + try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ } + } + } + }); } diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 6ddd1021..20e4fb50 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -7,6 +7,8 @@ const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10); const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`; +import type { BrowserSessionInfo } from '../types.js'; + let _idCounter = 0; function generateId(): string { @@ -69,17 +71,19 @@ export async function isExtensionConnected(): Promise { /** * Send a command to the daemon and wait for a result. - * Retries up to 3 times with 500ms delay for transient failures. + * Retries up to 4 times: network errors retry at 500ms, + * transient extension errors retry at 1500ms. */ export async function sendCommand( action: DaemonCommand['action'], params: Omit = {}, ): Promise { - const id = generateId(); - const command: DaemonCommand = { id, action, ...params }; - const maxRetries = 3; + const maxRetries = 4; for (let attempt = 1; attempt <= maxRetries; attempt++) { + // Generate a fresh ID per attempt to avoid daemon-side duplicate detection + const id = generateId(); + const command: DaemonCommand = { id, action, ...params }; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30000); @@ -95,6 +99,17 @@ export async function sendCommand( const result = (await res.json()) as DaemonResult; if (!result.ok) { + // Check if error is a transient extension issue worth retrying + const errMsg = result.error ?? ''; + const isTransient = errMsg.includes('Extension disconnected') + || errMsg.includes('Extension not connected') + || errMsg.includes('attach failed') + || errMsg.includes('no longer exists'); + if (isTransient && attempt < maxRetries) { + // Longer delay for extension recovery (service worker restart) + await new Promise(r => setTimeout(r, 1500)); + continue; + } throw new Error(result.error ?? 'Daemon command failed'); } @@ -117,4 +132,4 @@ export async function listSessions(): Promise { const result = await sendCommand('sessions'); return Array.isArray(result) ? result : []; } -import type { BrowserSessionInfo } from '../types.js'; + diff --git a/src/browser/mcp.ts b/src/browser/mcp.ts index 655810ae..c5e80634 100644 --- a/src/browser/mcp.ts +++ b/src/browser/mcp.ts @@ -55,7 +55,9 @@ export class BrowserBridge { } private async _ensureDaemon(timeoutSeconds?: number): Promise { - const timeoutMs = Math.max(1, timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000)) * 1000; + // Use default if not provided, zero, or negative + const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000); + const timeoutMs = effectiveSeconds * 1000; if (await isExtensionConnected()) return; if (await isDaemonRunning()) { diff --git a/src/browser/page.ts b/src/browser/page.ts index 9fdadf9b..967a17ca 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -186,14 +186,19 @@ export class Page implements IPage { async closeTab(index?: number): Promise { await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) }); + // Invalidate cached tabId — the closed tab might have been our active one. + // We can't know for sure (close-by-index doesn't return tabId), so reset. + this._tabId = undefined; } async newTab(): Promise { - await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() }); + const result = await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() }) as { tabId?: number }; + if (result?.tabId) this._tabId = result.tabId; } async selectTab(index: number): Promise { - await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() }); + const result = await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() }) as { selected?: number }; + if (result?.selected) this._tabId = result.selected; } async networkRequests(includeStatic: boolean = false): Promise { diff --git a/src/daemon.ts b/src/daemon.ts index 8e07a95b..8423d066 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -142,6 +142,27 @@ wss.on('connection', (ws: WebSocket) => { console.error('[daemon] Extension connected'); extensionWs = ws; + // ── Heartbeat: ping every 15s, close if 2 pongs missed ── + let missedPongs = 0; + const heartbeatInterval = setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) { + clearInterval(heartbeatInterval); + return; + } + if (missedPongs >= 2) { + console.error('[daemon] Extension heartbeat lost, closing connection'); + clearInterval(heartbeatInterval); + ws.terminate(); + return; + } + missedPongs++; + ws.ping(); + }, 15000); + + ws.on('pong', () => { + missedPongs = 0; + }); + ws.on('message', (data: RawData) => { try { const msg = JSON.parse(data.toString()); @@ -168,6 +189,7 @@ wss.on('connection', (ws: WebSocket) => { ws.on('close', () => { console.error('[daemon] Extension disconnected'); + clearInterval(heartbeatInterval); if (extensionWs === ws) { extensionWs = null; // Reject all pending requests since the extension is gone @@ -180,6 +202,7 @@ wss.on('connection', (ws: WebSocket) => { }); ws.on('error', () => { + clearInterval(heartbeatInterval); if (extensionWs === ws) extensionWs = null; }); }); diff --git a/src/pipeline/executor.ts b/src/pipeline/executor.ts index 49d4face..2135beea 100644 --- a/src/pipeline/executor.ts +++ b/src/pipeline/executor.ts @@ -2,7 +2,7 @@ * Pipeline executor: runs YAML pipeline steps sequentially. */ -import chalk from 'chalk'; + import type { IPage } from '../types.js'; import { getStep, type StepHandler } from './registry.js'; import { log } from '../logger.js'; @@ -11,8 +11,13 @@ import { ConfigError } from '../errors.js'; export interface PipelineContext { args?: Record; debug?: boolean; + /** Max retry attempts per step (default: 2 for browser steps, 0 for others) */ + stepRetries?: number; } +/** Steps that interact with the browser and may fail transiently */ +const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot', 'scroll']); + export async function executePipeline( page: IPage | null, pipeline: unknown[], @@ -23,28 +28,68 @@ export async function executePipeline( let data: unknown = null; const total = pipeline.length; - for (let i = 0; i < pipeline.length; i++) { - const step = pipeline[i]; - if (!step || typeof step !== 'object') continue; - for (const [op, params] of Object.entries(step)) { - if (debug) debugStepStart(i + 1, total, op, params); + try { + for (let i = 0; i < pipeline.length; i++) { + const step = pipeline[i]; + if (!step || typeof step !== 'object') continue; + for (const [op, params] of Object.entries(step)) { + if (debug) debugStepStart(i + 1, total, op, params); - const handler = getStep(op); - if (handler) { - data = await handler(page, params, data, args); - } else { - throw new ConfigError( - `Unknown pipeline step "${op}" at index ${i}.`, - 'Check the YAML pipeline step name or register the custom step before execution.', - ); - } + const handler = getStep(op); + if (handler) { + data = await executeStepWithRetry(handler, page, params, data, args, op, ctx.stepRetries); + } else { + throw new ConfigError( + `Unknown pipeline step "${op}" at index ${i}.`, + 'Check the YAML pipeline step name or register the custom step before execution.', + ); + } - if (debug) debugStepResult(op, data); + if (debug) debugStepResult(op, data); + } + } + } catch (err) { + // Attempt cleanup: close automation window on pipeline failure + if (page && typeof (page as unknown as Record).closeWindow === 'function') { + try { await (page as unknown as { closeWindow: () => Promise }).closeWindow(); } catch { /* ignore */ } } + throw err; } return data; } +async function executeStepWithRetry( + handler: StepHandler, + page: IPage | null, + params: unknown, + data: unknown, + args: Record, + op: string, + configRetries?: number, +): Promise { + const maxRetries = configRetries ?? (BROWSER_STEPS.has(op) ? 2 : 0); + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await handler(page, params, data, args); + } catch (err) { + if (attempt >= maxRetries) throw err; + // Only retry on transient browser errors + const msg = err instanceof Error ? err.message : ''; + const isTransient = msg.includes('Extension disconnected') + || msg.includes('attach failed') + || msg.includes('no longer exists') + || msg.includes('CDP connection') + || msg.includes('Daemon command failed'); + if (!isTransient) throw err; + // Brief delay before retry + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + // Unreachable + throw new Error(`Step "${op}" failed after ${maxRetries} retries`); +} + function debugStepStart(stepNum: number, total: number, op: string, params: unknown): void { let preview = ''; if (typeof params === 'string') {