diff --git a/extension/src/background.ts b/extension/src/background.ts index 58bcc74a..177f0329 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -181,8 +181,27 @@ async function getAutomationWindow(workspace: string, initialUrl?: string): Prom automationSessions.set(workspace, session); console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`); resetWindowIdleTimer(workspace); - // Brief delay to let Chrome load the initial tab - await new Promise(resolve => setTimeout(resolve, 200)); + // Wait for the initial tab to finish loading instead of a fixed 200ms sleep. + const tabs = await chrome.tabs.query({ windowId: win.id! }); + if (tabs[0]?.id) { + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); // fallback cap + const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { + if (tabId === tabs[0].id && info.status === 'complete') { + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + } + }; + // Check if already complete before listening + if (tabs[0].status === 'complete') { + clearTimeout(timeout); + resolve(); + } else { + chrome.tabs.onUpdated.addListener(listener); + } + }); + } return session.windowId; } diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 83c0e2f7..4e37179b 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -121,11 +121,14 @@ export async function evaluate(tabId: number, expression: string, aggressiveRetr } catch (e) { const msg = e instanceof Error ? e.message : String(e); // Only retry on attach/debugger errors, not on JS eval errors - const isAttachError = msg.includes('attach failed') || msg.includes('Debugger is not attached') - || msg.includes('chrome-extension://') || msg.includes('Target closed'); + const isNavigateError = msg.includes('Inspected target navigated') || msg.includes('Target closed'); + const isAttachError = isNavigateError || msg.includes('attach failed') || msg.includes('Debugger is not attached') + || msg.includes('chrome-extension://'); if (isAttachError && attempt < MAX_EVAL_RETRIES) { attached.delete(tabId); // Force re-attach on next attempt - await new Promise(resolve => setTimeout(resolve, 1000)); + // SPA navigations recover quickly; debugger detach needs longer + const retryMs = isNavigateError ? 200 : 500; + await new Promise(resolve => setTimeout(resolve, retryMs)); continue; } throw e; diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index c92e578a..f62c3293 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import type { IPage } from '../types.js'; import type { IBrowserFactory } from '../runtime.js'; import { Page } from './page.js'; -import { isDaemonRunning, isExtensionConnected } from './daemon-client.js'; +import { fetchDaemonStatus, isExtensionConnected } from './daemon-client.js'; import { DEFAULT_DAEMON_PORT } from '../constants.js'; const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension @@ -60,11 +60,14 @@ export class BrowserBridge implements IBrowserFactory { const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000); const timeoutMs = effectiveSeconds * 1000; + // Single status check instead of two separate fetchDaemonStatus() calls + const status = await fetchDaemonStatus(); + // Fast path: extension already connected - if (await isExtensionConnected()) return; + if (status?.extensionConnected) return; // Daemon running but no extension — wait for extension with progress - if (await isDaemonRunning()) { + if (status !== null) { if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) { process.stderr.write('⏳ Waiting for Chrome extension to connect...\n'); process.stderr.write(' Make sure Chrome is open and the OpenCLI extension is enabled.\n'); @@ -110,7 +113,7 @@ export class BrowserBridge implements IBrowserFactory { if (await isExtensionConnected()) return; } - if (await isDaemonRunning()) { + if ((await fetchDaemonStatus()) !== null) { throw new Error( 'Daemon is running but the Browser Extension is not connected.\n' + 'Please install and enable the opencli Browser Bridge extension in Chrome.', diff --git a/src/browser/page.ts b/src/browser/page.ts index ab341686..73db6b44 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -58,25 +58,17 @@ export class Page extends BasePage { this._tabId = result.tabId; } this._lastUrl = url; - // Inject stealth anti-detection patches (guard flag prevents double-injection). - try { - await sendCommand('exec', { - code: generateStealthJs(), - ...this._cmdOpts(), - }); - } catch { - // Non-fatal: stealth is best-effort - } - // Smart settle: use DOM stability detection instead of fixed sleep. - // settleMs is now a timeout cap (default 1000ms), not a fixed wait. + // Inject stealth + settle in a single round-trip instead of two sequential exec calls. + // The stealth guard flag prevents double-injection; settle uses DOM stability detection. if (options?.waitUntil !== 'none') { const maxMs = options?.settleMs ?? 1000; - const settleOpts = { - code: waitForDomStableJs(maxMs, Math.min(500, maxMs)), + const combinedCode = `${generateStealthJs()};\n${waitForDomStableJs(maxMs, Math.min(500, maxMs))}`; + const combinedOpts = { + code: combinedCode, ...this._cmdOpts(), }; try { - await sendCommand('exec', settleOpts); + await sendCommand('exec', combinedOpts); } catch (err) { if (!isRetryableSettleError(err)) throw err; // SPA client-side redirects can invalidate the CDP target after @@ -84,14 +76,21 @@ export class Page extends BasePage { // to load, then retry the settle probe once. try { await new Promise((r) => setTimeout(r, 200)); - await sendCommand('exec', settleOpts); + await sendCommand('exec', combinedOpts); } catch (retryErr) { if (!isRetryableSettleError(retryErr)) throw retryErr; - // Retry also failed — give up silently. Settle is best-effort - // after successful navigation; the next real command will surface - // any persistent target error immediately. } } + } else { + // Even with waitUntil='none', still inject stealth (best-effort) + try { + await sendCommand('exec', { + code: generateStealthJs(), + ...this._cmdOpts(), + }); + } catch { + // Non-fatal: stealth is best-effort + } } }