feat: browse connect/disconnect/focus CLI commands

- connect: pre-server command that discovers browser, starts server in CDP mode
- disconnect: drops CDP connection, restarts in headless mode
- focus: brings browser window to foreground via osascript (macOS)
- status: now shows Mode: cdp | launched | headed
- startServer() accepts extra env vars for CDP URL/port passthrough

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-21 10:23:35 -07:00
parent 1dc9055c99
commit 115c97fcbb
3 changed files with 138 additions and 2 deletions
+68 -2
View File
@@ -83,6 +83,8 @@ interface ServerState {
startedAt: string;
serverPath: string;
binaryVersion?: string;
mode?: 'launched' | 'cdp';
cdpPort?: number;
}
// ─── State File ────────────────────────────────────────────────
@@ -161,7 +163,7 @@ function cleanupLegacyState(): void {
}
// ─── Server Lifecycle ──────────────────────────────────────────
async function startServer(): Promise<ServerState> {
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
ensureStateDir(config);
// Clean up stale state file
@@ -176,7 +178,7 @@ async function startServer(): Promise<ServerState> {
: ['bun', 'run', SERVER_SCRIPT];
const proc = Bun.spawn(serverCmd, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv },
});
// Don't hold the CLI open
@@ -342,6 +344,70 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
const command = args[0];
const commandArgs = args.slice(1);
// ─── CDP Connect (pre-server command) ───────────────────────
// connect must be handled BEFORE ensureServer() because it needs
// to restart the server with CDP env vars.
if (command === 'connect') {
const { discoverAndConnect } = await import('./chrome-launcher');
// Parse args: connect [browser] [--port N]
let preferredBrowser: string | undefined;
let port = 9222;
for (let i = 0; i < commandArgs.length; i++) {
if (commandArgs[i] === '--port' && commandArgs[i + 1]) {
port = parseInt(commandArgs[i + 1], 10);
i++;
} else if (!commandArgs[i].startsWith('-')) {
preferredBrowser = commandArgs[i];
}
}
// Check if already in CDP mode
const existingState = readState();
if (existingState && existingState.mode === 'cdp') {
console.log('Already connected to real browser via CDP.');
process.exit(0);
}
// Kill existing server if running
if (existingState) {
try { process.kill(existingState.pid, 'SIGTERM'); } catch {}
try { fs.unlinkSync(config.stateFile); } catch {}
// Wait for clean shutdown
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Discover and connect to browser
console.log(`Discovering browser${preferredBrowser ? ` (${preferredBrowser})` : ''}...`);
try {
const result = await discoverAndConnect(preferredBrowser, port);
console.log(`Found ${result.browser} CDP at port ${result.port}`);
// Start server with CDP env vars
const newState = await startServer({
BROWSE_CDP_URL: result.wsUrl,
BROWSE_CDP_PORT: String(result.port),
});
// Print connected status
const resp = await fetch(`http://127.0.0.1:${newState.port}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${newState.token}`,
},
body: JSON.stringify({ command: 'tabs', args: [] }),
signal: AbortSignal.timeout(5000),
});
const tabList = await resp.text();
console.log(`Connected to ${result.browser} via CDP\n${tabList}`);
} catch (err: any) {
console.error(`[browse] Connect failed: ${err.message}`);
process.exit(1);
}
process.exit(0);
}
// Special case: chain reads from stdin
if (command === 'chain' && commandArgs.length === 0) {
const stdin = await Bun.stdin.text();
+5
View File
@@ -31,6 +31,7 @@ export const META_COMMANDS = new Set([
'chain', 'diff',
'url', 'snapshot',
'handoff', 'resume',
'connect', 'disconnect', 'focus',
]);
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
@@ -98,6 +99,10 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
// Handoff
'handoff': { category: 'Server', description: 'Open visible Chrome at current page for user takeover', usage: 'handoff [message]' },
'resume': { category: 'Server', description: 'Re-snapshot after user takeover, return control to AI', usage: 'resume' },
// CDP
'connect': { category: 'Server', description: 'Connect to real Chrome/Comet browser via CDP', usage: 'connect [browser] [--port N]' },
'disconnect': { category: 'Server', description: 'Disconnect from real browser, return to headless mode' },
'focus': { category: 'Server', description: 'Bring connected browser window to foreground (macOS)', usage: 'focus [@ref]' },
};
// Load-time validation: descriptions must cover exactly the command sets
+65
View File
@@ -61,8 +61,10 @@ export async function handleMetaCommand(
case 'status': {
const page = bm.getPage();
const tabs = bm.getTabCount();
const mode = bm.getConnectionMode();
return [
`Status: healthy`,
`Mode: ${mode}`,
`URL: ${page.url()}`,
`Tabs: ${tabs}`,
`PID: ${process.pid}`,
@@ -263,6 +265,69 @@ export async function handleMetaCommand(
return `RESUMED\n${snapshot}`;
}
// ─── CDP Connect ────────────────────────────────────
case 'connect': {
// connect is handled as a pre-server command in cli.ts
// If we get here, server is already running — tell the user
if (bm.getConnectionMode() === 'cdp') {
return 'Already connected to real browser via CDP.';
}
return 'The connect command must be run from the CLI (not sent to a running server). Run: $B connect [browser]';
}
case 'disconnect': {
if (bm.getConnectionMode() !== 'cdp') {
return 'Not in CDP mode — nothing to disconnect.';
}
// Signal that we want a restart in headless mode
console.log('[browse] Disconnecting from real browser. Restarting in headless mode.');
await shutdown();
return 'Disconnected from real browser. Server will restart in headless mode on next command.';
}
case 'focus': {
if (bm.getConnectionMode() !== 'cdp') {
return 'focus requires CDP mode. Run `$B connect` first.';
}
try {
const { execSync } = await import('child_process');
// Detect which browser we're connected to from the CDP info
// For now, try common app names
const appNames = ['Comet', 'Google Chrome', 'Arc', 'Brave Browser', 'Microsoft Edge'];
let activated = false;
for (const appName of appNames) {
try {
execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000 });
activated = true;
break;
} catch {
// Try next browser
}
}
if (!activated) {
return 'Could not bring browser to foreground. macOS only.';
}
// If a ref was passed, scroll it into view
if (args.length > 0 && args[0].startsWith('@')) {
try {
const resolved = await bm.resolveRef(args[0]);
if ('locator' in resolved) {
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
return `Browser activated. Scrolled ${args[0]} into view.`;
}
} catch {
// Ref not found — still activated the browser
}
}
return 'Browser window activated.';
} catch (err: any) {
return `focus failed: ${err.message}. macOS only.`;
}
}
default:
throw new Error(`Unknown meta command: ${command}`);
}