Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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;
}

Expand Down
9 changes: 6 additions & 3 deletions extension/src/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions src/browser/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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.',
Expand Down
35 changes: 17 additions & 18 deletions src/browser/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,40 +58,39 @@ 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
// chrome.tabs reports 'complete'. Wait briefly for the new document
// 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
}
}
}

Expand Down
Loading