diff --git a/.changeset/clean-dogs-chew.md b/.changeset/clean-dogs-chew.md new file mode 100644 index 000000000..c82b08ea6 --- /dev/null +++ b/.changeset/clean-dogs-chew.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/browse-cli": patch +--- + +feat(cli): add --connect flag for daemon mode with existing Chrome diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 51a349cb2..325c745df 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -288,7 +288,11 @@ interface DaemonResponse { // Default viewport matching Stagehand core const DEFAULT_VIEWPORT = { width: 1288, height: 711 }; -async function runDaemon(session: string, headless: boolean): Promise { +async function runDaemon( + session: string, + headless: boolean, + connectUrl?: string, +): Promise { // Only clean daemon state files (socket, pid, etc.), not client-written config (context) await cleanupDaemonStateFiles(session); @@ -356,12 +360,18 @@ async function runDaemon(session: string, headless: boolean): Promise { } : {}), } - : { - localBrowserLaunchOptions: { - headless, - viewport: DEFAULT_VIEWPORT, - }, - }), + : connectUrl + ? { + localBrowserLaunchOptions: { + cdpUrl: connectUrl, + }, + } + : { + localBrowserLaunchOptions: { + headless, + viewport: DEFAULT_VIEWPORT, + }, + }), }); // Persist mode so status command can report it @@ -1288,6 +1298,7 @@ async function sendCommand( command: string, args: unknown[], headless: boolean = false, + connectUrl?: string, ): Promise { const maxRetries = 3; @@ -1318,14 +1329,14 @@ async function sendCommand( // Attempt 1: Try to restart daemon without cleanup if (attempt === 1) { - await ensureDaemon(session, headless); + await ensureDaemon(session, headless, connectUrl); continue; } // Final attempt: Full cleanup and restart - await killChromeProcesses(session); + if (!connectUrl) await killChromeProcesses(session); await cleanupStaleFiles(session); - await ensureDaemon(session, headless); + await ensureDaemon(session, headless, connectUrl); } } @@ -1344,7 +1355,11 @@ async function stopDaemonAndCleanup(session: string): Promise { await cleanupStaleFiles(session); } -async function ensureDaemon(session: string, headless: boolean): Promise { +async function ensureDaemon( + session: string, + headless: boolean, + connectUrl?: string, +): Promise { const wantMode = await getDesiredMode(session); assertModeSupported(wantMode); @@ -1373,7 +1388,9 @@ async function ensureDaemon(session: string, headless: boolean): Promise { await stopDaemonAndCleanup(session); } - const args = ["--session", session, "daemon"]; + const args = ["--session", session]; + if (connectUrl) args.push("--connect", connectUrl); + args.push("daemon"); if (headless) args.push("--headless"); const child = spawn(process.argv[0], [process.argv[1], ...args], { @@ -1432,6 +1449,7 @@ async function ensureDaemon(session: string, headless: boolean): Promise { interface GlobalOpts { ws?: string; + connect?: string; headless?: boolean; headed?: boolean; json?: boolean; @@ -1460,7 +1478,7 @@ async function runCommand(command: string, args: unknown[]): Promise { const opts = program.opts(); const session = getSession(opts); const headless = isHeadless(opts); - // If --ws provided, bypass daemon and connect directly + // If --ws provided, bypass daemon and connect directly (stateless, no ref caching) if (opts.ws) { const stagehand = new Stagehand({ env: "LOCAL", @@ -1478,8 +1496,9 @@ async function runCommand(command: string, args: unknown[]): Promise { } } - await ensureDaemon(session, headless); - return sendCommand(session, command, args, headless); + // --connect uses daemon mode (persistent refs) but attaches to existing Chrome + await ensureDaemon(session, headless, opts.connect); + return sendCommand(session, command, args, headless, opts.connect); } program @@ -1490,6 +1509,10 @@ program "--ws ", "CDP WebSocket URL (bypasses daemon, direct connection)", ) + .option( + "--connect ", + "CDP WebSocket URL for daemon to attach to (persistent refs, no Chrome launch)", + ) .option("--headless", "Run Chrome in headless mode") .option("--headed", "Run Chrome with visible window (default)") .option("--json", "Output as JSON", false) @@ -1644,7 +1667,7 @@ program .description("Run as daemon (internal use)") .action(async () => { const opts = program.opts(); - await runDaemon(getSession(opts), isHeadless(opts)); + await runDaemon(getSession(opts), isHeadless(opts), opts.connect); }); // ==================== NAVIGATION ==================== diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 7c318caf7..6bae34265 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -231,6 +231,22 @@ export class V3Context { }); } + // Close unnavigable chrome:// tabs so the new about:blank tab becomes + // the only visible tab. Without this, --connect mode leaves the original + // chrome://newtab tab visible while the new page hides behind it. + try { + const targets = await this.conn.getTargets(); + for (const t of targets) { + if (t.type === "page" && t.url?.startsWith("chrome://")) { + await this.conn + .send("Target.closeTarget", { targetId: t.targetId }) + .catch(() => {}); + } + } + } catch { + // best effort + } + await this.newPage("about:blank"); }