import { join } from 'path';
import { homedir } from 'os';
import { createConnection } from 'net';
import { spawn, execSync } from 'child_process';
import fs from 'fs';
const CDP_PORT = 9222;
const CDP_HOST = '127.0.0.1';
const CDP_HEALTH_TIMEOUT_MS = 15_000;
const HEALTH_CHECK_INTERVAL_MS = 60_000;
const HEALTH_CHECK_FAIL_THRESHOLD = 3;
let chromeProcess = null;
let _consecutiveHealthFailures = 0;
function _ts() {
return new Date().toISOString();
}
function _caller() {
const stack = new Error().stack;
const frames = stack?.split('\n').slice(2, 4).map(l => l.trim()).join(' <- ') ?? '';
return frames;
}
const LOCK_FILE_NAME = 'chrome-cdp.lock';
function getUserDataDir() {
const configDir = process.env.PILOTDECK_CONFIG_DIR ?? join(homedir(), '.pilotdeck');
return join(configDir, 'browser-use-profile');
}
function getLockFilePath() {
return join(getUserDataDir(), LOCK_FILE_NAME);
}
function writeLock() {
try {
const dir = getUserDataDir();
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(getLockFilePath(), JSON.stringify({ pid: process.pid, ts: Date.now() }));
} catch { }
}
function removeLock() {
try {
const lockPath = getLockFilePath();
if (!fs.existsSync(lockPath)) return;
const content = fs.readFileSync(lockPath, 'utf8').trim();
const { pid } = JSON.parse(content);
if (pid === process.pid) {
fs.unlinkSync(lockPath);
}
} catch { }
}
function findChromePath() {
const platform = process.platform;
const candidates =
platform === 'darwin'
? [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
]
: ['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium'];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return null;
}
function isCDPPortOpen() {
return new Promise((resolve) => {
const socket = createConnection({ host: CDP_HOST, port: CDP_PORT });
socket.setTimeout(1500);
socket.on('connect', () => {
socket.destroy();
resolve(true);
});
socket.on('error', () => resolve(false));
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
});
}
export async function isCDPHealthy() {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CDP_HEALTH_TIMEOUT_MS);
const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, {
signal: controller.signal,
});
clearTimeout(timer);
return res.ok;
} catch {
return false;
}
}
function cleanSingletonLocks(dir) {
for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
const p = join(dir, name);
try {
if (fs.existsSync(p)) fs.unlinkSync(p);
} catch { }
}
}
function launchChrome(executablePath, userDataDir) {
cleanSingletonLocks(userDataDir);
const proc = spawn(executablePath, [
`--remote-debugging-port=${CDP_PORT}`,
`--user-data-dir=${userDataDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-features=ProfilePicker',
], {
stdio: 'ignore',
detached: true,
});
proc.unref();
proc.on('exit', () => {
if (chromeProcess === proc) chromeProcess = null;
});
return proc;
}
async function waitForCDP(maxMs = 10_000) {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
if (await isCDPHealthy()) return true;
await new Promise((r) => setTimeout(r, 250));
}
return false;
}
const CHROME_STOP_TIMEOUT_MS = 2500;
const CHROME_STOP_POLL_MS = 100;
async function killCDPPort() {
const caller = _caller();
let pidList = [];
try {
const raw = execSync(`lsof -ti :${CDP_PORT} 2>/dev/null`, { encoding: 'utf8' }).trim();
if (raw) pidList = raw.split('\n').map(Number).filter(Boolean);
} catch { }
if (pidList.length === 0) {
chromeProcess = null;
return;
}
console.warn(`[BROWSER ${_ts()}] killCDPPort: sending SIGTERM to pids=${JSON.stringify(pidList)} | caller: ${caller}`);
for (const pid of pidList) {
try { process.kill(pid, 'SIGTERM'); } catch { }
}
const deadline = Date.now() + CHROME_STOP_TIMEOUT_MS;
while (Date.now() < deadline) {
if (!(await isCDPHealthy())) {
chromeProcess = null;
return;
}
await new Promise((r) => setTimeout(r, CHROME_STOP_POLL_MS));
}
console.warn(`[BROWSER ${_ts()}] killCDPPort: SIGTERM timeout, sending SIGKILL to pids=${JSON.stringify(pidList)}`);
for (const pid of pidList) {
try { process.kill(pid, 'SIGKILL'); } catch { }
}
await new Promise((r) => setTimeout(r, 300));
chromeProcess = null;
}
export async function ensureGlobalChrome() {
if (await isCDPHealthy()) {
return `http://${CDP_HOST}:${CDP_PORT}`;
}
if (await isCDPPortOpen()) {
console.warn(`[BROWSER ${_ts()}] ensureGlobalChrome: port open but unhealthy, killing stale Chrome`);
await killCDPPort();
}
const executablePath = findChromePath();
if (!executablePath) return null;
const userDataDir = getUserDataDir();
fs.mkdirSync(userDataDir, { recursive: true });
chromeProcess = launchChrome(executablePath, userDataDir);
console.log(`[BROWSER ${_ts()}] ensureGlobalChrome: launched Chrome pid=${chromeProcess.pid}`);
writeLock();
if (await waitForCDP()) {
return `http://${CDP_HOST}:${CDP_PORT}`;
}
removeLock();
return null;
}
export async function restartGlobalChrome() {
console.warn(`[BROWSER ${_ts()}] restartGlobalChrome: killing and relaunching | caller: ${_caller()}`);
await killCDPPort();
const executablePath = findChromePath();
if (!executablePath) return null;
const userDataDir = getUserDataDir();
fs.mkdirSync(userDataDir, { recursive: true });
chromeProcess = launchChrome(executablePath, userDataDir);
console.log(`[BROWSER ${_ts()}] restartGlobalChrome: launched Chrome pid=${chromeProcess.pid}`);
writeLock();
if (await waitForCDP()) {
return `http://${CDP_HOST}:${CDP_PORT}`;
}
removeLock();
return null;
}
let healthCheckTimer = null;
export function startChromeHealthCheck(intervalMs = HEALTH_CHECK_INTERVAL_MS) {
stopChromeHealthCheck();
_consecutiveHealthFailures = 0;
healthCheckTimer = setInterval(async () => {
if (!(await isCDPHealthy())) {
_consecutiveHealthFailures++;
console.warn(`[BROWSER ${_ts()}] Health check failed (${_consecutiveHealthFailures}/${HEALTH_CHECK_FAIL_THRESHOLD})`);
if (_consecutiveHealthFailures >= HEALTH_CHECK_FAIL_THRESHOLD) {
console.warn(`[BROWSER ${_ts()}] ${HEALTH_CHECK_FAIL_THRESHOLD} consecutive failures, restarting Chrome...`);
_consecutiveHealthFailures = 0;
const url = await restartGlobalChrome();
if (url) {
process.env.CDP_URL = url;
console.log(`[BROWSER ${_ts()}] Chrome restarted at ${url}`);
} else {
console.error(`[BROWSER ${_ts()}] Chrome restart failed`);
}
}
} else {
if (_consecutiveHealthFailures > 0) {
console.log(`[BROWSER ${_ts()}] Health check recovered after ${_consecutiveHealthFailures} failures`);
}
_consecutiveHealthFailures = 0;
}
}, intervalMs);
healthCheckTimer.unref();
}
export function stopChromeHealthCheck() {
if (healthCheckTimer) {
clearInterval(healthCheckTimer);
healthCheckTimer = null;
}
}
export function shutdownGlobalChrome() {
console.warn(`[BROWSER ${_ts()}] shutdownGlobalChrome called | caller: ${_caller()}`);
stopChromeHealthCheck();
if (chromeProcess) {
console.warn(`[BROWSER ${_ts()}] shutdownGlobalChrome: sending SIGTERM to pid=${chromeProcess.pid}`);
try { chromeProcess.kill('SIGTERM'); } catch { }
chromeProcess = null;
}
removeLock();
}
let _cdpInitPromise = null;
const CDP_INCOMPATIBLE_CHROME_MAJOR = 147;
async function getChromeMajorFromCDP() {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3_000);
const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, {
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) return 0;
const data = await res.json();
const match = data.Browser?.match(/Chrome\/(\d+)/);
return match ? parseInt(match[1], 10) : 0;
} catch {
return 0;
}
}
* Lazy CDP initializer — starts Chrome only on first call, then caches the URL.
* Subsequent calls return immediately if Chrome is already healthy.
* Serializes concurrent callers so Chrome is launched at most once.
*
* On Chrome 147+, CDP_URL is intentionally NOT set so that the Agent's
* browser-use session.ts falls through to Playwright-managed launch instead
* of attempting connectOverCDP (which hangs on 147+).
*/
export async function ensureCDPUrl() {
if (process.env.CDP_URL && await isCDPHealthy()) {
return process.env.CDP_URL;
}
if (_cdpInitPromise) return _cdpInitPromise;
_cdpInitPromise = (async () => {
try {
const cdpUrl = await ensureGlobalChrome();
if (!cdpUrl) return null;
const major = await getChromeMajorFromCDP();
if (major >= CDP_INCOMPATIBLE_CHROME_MAJOR) {
console.log(
`[BROWSER ${_ts()}] Chrome ${major} detected — skipping CDP_URL ` +
`(connectOverCDP incompatible). Agent will use Playwright-managed launch.`
);
return null;
}
process.env.CDP_URL = cdpUrl;
startChromeHealthCheck(HEALTH_CHECK_INTERVAL_MS);
console.log(`[BROWSER ${_ts()}] Global Chrome ready (lazy) at ${cdpUrl}`);
return cdpUrl;
} finally {
_cdpInitPromise = null;
}
})();
return _cdpInitPromise;
}