Files
moeka-project/services/computer-use-mcp/src/chrome-session-manager.ts
T

420 lines
12 KiB
TypeScript

/**
* Chrome Session Manager — agent-owned Chrome window lifecycle.
*
* Responsibilities:
* - Launch a dedicated Chrome profile with CDP
* - Track the launched browser PID
* - Bring agent window to front / restore user's previous foreground
*
* macOS only. Uses AppleScript and `open` CLI for Chrome lifecycle control.
*/
import type { ChromeSessionInfo, ComputerUseConfig } from './types'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { runProcess } from './utils/process'
import { sleep } from './utils/sleep'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CHROME_APP_NAME = 'Google Chrome'
const DEFAULT_CDP_PORT = 9222
const DEFAULT_CDP_PORT_SCAN_ATTEMPTS = 20
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
export interface ChromeSessionManager {
/**
* Ensure the agent has a usable Chrome window.
*
* - No active agent session → launch a dedicated Chrome profile with CDP
* - Existing agent session still has a live Chrome window → reuse it
*
* Human-owned Chrome instances are not reused. The agent always launches its
* own profile so browser-dom/CDP capture has a stable endpoint.
*/
ensureAgentWindow: (options?: { url?: string, cdpPort?: number }) => Promise<ChromeSessionInfo>
/**
* Bring the agent's Chrome window to the foreground.
* Returns false if the tracked session is missing, Chrome is no longer
* running, or the tracked window is gone.
*/
bringToFront: () => Promise<boolean>
/**
* Restore the user's previous foreground app (recorded at session start).
*/
restorePreviousForeground: () => Promise<void>
/**
* Get the current session info (null if no session).
*/
getSessionInfo: () => ChromeSessionInfo | null
/**
* End the session. Does NOT close Chrome — just clears the tracked state.
*/
endSession: () => void
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export function createChromeSessionManager(
config: ComputerUseConfig,
options?: { onSessionLost?: () => void },
): ChromeSessionManager {
let session: ChromeSessionInfo | null = null
let previousForegroundApp: string | undefined
const onSessionLost = options?.onSessionLost
let activeProfileDir: string | undefined
// -- Helpers ------------------------------------------------------------
async function isChromeRunning(): Promise<boolean> {
try {
const { stdout } = await runProcess('pgrep', ['-x', 'Google Chrome'], {
timeoutMs: config.timeoutMs,
})
return stdout.trim().length > 0
}
catch {
// pgrep exits non-zero when no match
return false
}
}
async function isProcessAlive(pid: number): Promise<boolean> {
try {
const { stdout } = await runProcess('ps', ['-p', String(pid), '-o', 'pid='], {
timeoutMs: config.timeoutMs,
})
return stdout.trim().length > 0
}
catch {
return false
}
}
async function hasChromeWindow(pid: number): Promise<boolean> {
try {
const { stdout } = await runProcess(config.binaries.osascript, [
'-e',
`tell application "System Events" to get count of windows of (first application process whose unix id is ${pid})`,
], { timeoutMs: config.timeoutMs })
return Number.parseInt(stdout.trim(), 10) > 0
}
catch {
return false
}
}
async function terminateChromeProcess(pid: number): Promise<void> {
await runProcess('kill', ['-TERM', String(pid)], { timeoutMs: config.timeoutMs }).catch(() => {})
await sleep(250)
if (!await isProcessAlive(pid)) {
return
}
await runProcess('kill', ['-KILL', String(pid)], { timeoutMs: config.timeoutMs }).catch(() => {})
await sleep(250)
if (await isProcessAlive(pid)) {
throw new Error(`Failed to terminate stale Chrome process ${pid}`)
}
}
async function findAndTerminateChromeByProfile(profileDir: string, cdpPort: number): Promise<void> {
const pid = await getChromePidForProfile(profileDir, cdpPort)
if (!pid) {
return
}
await terminateChromeProcess(pid)
}
async function clearSessionState(): Promise<void> {
session = null
previousForegroundApp = undefined
if (activeProfileDir) {
const profileDir = activeProfileDir
activeProfileDir = undefined
await rm(profileDir, { recursive: true, force: true }).catch(() => {})
return
}
activeProfileDir = undefined
}
async function getChromePidForProfile(profileDir: string, cdpPort: number): Promise<number | undefined> {
try {
const { stdout } = await runProcess('ps', ['-axww', '-o', 'pid=,command='], {
timeoutMs: config.timeoutMs,
})
const matchingLine = stdout
.split('\n')
.map(line => line.trim())
.find(line =>
line.includes('/Contents/MacOS/Google Chrome')
&& !line.includes('Helper')
&& line.includes(`--user-data-dir=${profileDir}`)
&& line.includes(`--remote-debugging-port=${cdpPort}`),
)
if (!matchingLine) {
return undefined
}
const pidText = matchingLine.split(/\s+/u)[0]
const pid = Number(pidText)
return Number.isFinite(pid) ? pid : undefined
}
catch {
return undefined
}
}
async function getTrackedChromePid(trackedSession: ChromeSessionInfo): Promise<number | undefined> {
if (!activeProfileDir || !trackedSession.cdpUrl) {
return undefined
}
try {
const cdpPort = Number.parseInt(new URL(trackedSession.cdpUrl).port, 10)
if (!Number.isFinite(cdpPort)) {
return undefined
}
return await getChromePidForProfile(activeProfileDir, cdpPort)
}
catch {
return undefined
}
}
async function getCurrentForegroundApp(): Promise<string | undefined> {
try {
const { stdout } = await runProcess(config.binaries.osascript, [
'-e',
'tell application "System Events" to get name of first application process whose frontmost is true',
], { timeoutMs: config.timeoutMs })
return stdout.trim() || undefined
}
catch {
return undefined
}
}
async function canListenOnPort(port: number): Promise<boolean> {
return await new Promise<boolean>((resolvePromise) => {
const server = createServer()
server.once('error', () => {
resolvePromise(false)
})
server.listen(port, '127.0.0.1', () => {
server.close(() => resolvePromise(true))
})
})
}
async function resolveLaunchCdpPort(requestedPort: number, explicit: boolean): Promise<number> {
if (explicit) {
return requestedPort
}
for (let index = 0; index < DEFAULT_CDP_PORT_SCAN_ATTEMPTS; index += 1) {
const candidate = requestedPort + index
if (await canListenOnPort(candidate)) {
return candidate
}
}
throw new Error(`Could not find an available Chrome CDP port starting from ${requestedPort}`)
}
async function launchChromeWithCdp(cdpPort: number, profileDir: string, url?: string): Promise<void> {
// Chrome uses the user-data-dir root "First Run" sentinel to decide
// whether the branded first-run dialog should appear.
await writeFile(join(profileDir, 'First Run'), '').catch(() => {})
const args = [
'-na',
CHROME_APP_NAME,
'--args',
'--new-window',
'--no-first-run',
'--no-default-browser-check',
'--disable-default-apps',
'--disable-features=ChromeWhatsNewUI',
`--remote-debugging-port=${cdpPort}`,
`--user-data-dir=${profileDir}`,
]
if (url) {
args.push(url)
}
await runProcess(config.binaries.open, args, {
timeoutMs: config.timeoutMs,
})
// Wait for Chrome to finish launching
await sleep(2000)
}
async function activateChrome(): Promise<void> {
await runProcess(config.binaries.osascript, [
'-e',
`tell application "${CHROME_APP_NAME}" to activate`,
], { timeoutMs: config.timeoutMs })
}
async function activateApp(appName: string): Promise<void> {
try {
await runProcess(config.binaries.osascript, [
'-e',
`tell application "${appName}" to activate`,
], { timeoutMs: config.timeoutMs })
}
catch {
// Best-effort: the app might have been closed
}
}
// -- Public API ---------------------------------------------------------
return {
async ensureAgentWindow(options) {
let ensureOutcome: ChromeSessionInfo['ensureOutcome'] = 'launched'
if (session) {
const trackedSession = session
const trackedChromePid = await getTrackedChromePid(trackedSession)
if (trackedChromePid === trackedSession.pid) {
const stillRunning = await isProcessAlive(trackedSession.pid)
const stillHasWindow = stillRunning && await hasChromeWindow(trackedSession.pid)
if (stillHasWindow) {
trackedSession.ensureOutcome = 'reused'
return trackedSession
}
try {
if (stillRunning) {
ensureOutcome = 'recreated_after_missing_window'
await terminateChromeProcess(trackedSession.pid)
}
else {
ensureOutcome = 'recreated_after_process_exit'
}
}
finally {
// Chrome died or the tracked window disappeared — clear stale session.
await clearSessionState()
onSessionLost?.()
}
}
else {
// The tracked PID no longer resolves to the expected Chrome profile.
// Treat it as stale and relaunch without touching that PID.
ensureOutcome = 'recreated_after_process_exit'
await clearSessionState()
onSessionLost?.()
}
}
// Record the user's current foreground app before we steal focus.
previousForegroundApp = await getCurrentForegroundApp()
const cdpPort = await resolveLaunchCdpPort(
options?.cdpPort ?? DEFAULT_CDP_PORT,
options?.cdpPort !== undefined,
)
const wasAlreadyRunning = await isChromeRunning()
await mkdir(config.sessionRoot, { recursive: true })
activeProfileDir = await mkdtemp(join(config.sessionRoot, 'chrome-profile-'))
try {
// Always launch a dedicated profile so CDP is stable even when Chrome is already running.
await launchChromeWithCdp(cdpPort, activeProfileDir, options?.url)
// Bring Chrome to front.
await activateChrome()
// Brief wait for activation.
await sleep(300)
const deadline = Date.now() + config.timeoutMs
let pid: number | undefined
while (Date.now() < deadline) {
pid = await getChromePidForProfile(activeProfileDir, cdpPort)
if (pid) {
break
}
await sleep(250)
}
if (!pid) {
throw new Error('Failed to get Chrome PID after launch')
}
session = {
ensureOutcome,
wasAlreadyRunning,
windowId: `${pid}:0:${CHROME_APP_NAME}`,
cdpUrl: `http://127.0.0.1:${cdpPort}`,
pid,
agentOwned: true,
initialUrl: options?.url,
createdAt: new Date().toISOString(),
}
return session
}
catch (error) {
await findAndTerminateChromeByProfile(activeProfileDir, cdpPort)
await clearSessionState()
throw error
}
},
async bringToFront() {
if (!session)
return false
const stillRunning = await isProcessAlive(session.pid)
const stillHasWindow = stillRunning && await hasChromeWindow(session.pid)
if (!stillHasWindow) {
await clearSessionState()
onSessionLost?.()
return false
}
await activateChrome()
return true
},
async restorePreviousForeground() {
if (previousForegroundApp && previousForegroundApp !== CHROME_APP_NAME) {
await activateApp(previousForegroundApp)
}
},
getSessionInfo() {
return session
},
endSession() {
const hadSession = session !== null
void clearSessionState()
if (hadSession) {
onSessionLost?.()
}
},
}
}