import { execFileSync, spawn, type ChildProcess } from 'child_process' import { existsSync, readFileSync } from 'fs' import { createConnection, createServer } from 'net' import { dirname, isAbsolute, join, resolve } from 'path' import { logger } from '../../logger' import { detectHermesHome, getHermesBin } from '../hermes-path' import { DEFAULT_AGENT_BRIDGE_ENDPOINT } from './client' const DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS = 120000 const DEFAULT_AGENT_BRIDGE_RESTART_DELAY_MS = 1000 const MAX_AGENT_BRIDGE_RESTART_DELAY_MS = 30000 const OPENROUTER_WEB_UI_ATTRIBUTION_ENV = { HERMES_OPENROUTER_APP_REFERER: 'https://www.xinmi.cloud', HERMES_OPENROUTER_APP_TITLE: 'Hermes Web UI', HERMES_OPENROUTER_APP_CATEGORIES: 'cli-agent,personal-agent', } as const export interface AgentBridgeManagerOptions { endpoint?: string python?: string agentRoot?: string hermesHome?: string startupTimeoutMs?: number } export interface BridgeCommand { command: string argsPrefix: string[] agentRoot?: string hermesHome: string } export interface AgentBridgeManagerRuntimeState { endpoint: string running: boolean ready: boolean pid?: number starting: boolean stopping: boolean restartScheduled: boolean restartAttempts: number } function envPositiveInt(name: string): number | undefined { const raw = process.env[name] if (!raw) return undefined const value = Number(raw) return Number.isFinite(value) && value > 0 ? value : undefined } export function buildAgentBridgeProcessEnv(endpoint: string, hermesHome: string | undefined, agentRoot: string | undefined): NodeJS.ProcessEnv { return { ...process.env, HERMES_AGENT_BRIDGE_ENDPOINT: endpoint, HERMES_HOME: hermesHome, HERMES_OPENROUTER_APP_REFERER: process.env.HERMES_OPENROUTER_APP_REFERER || OPENROUTER_WEB_UI_ATTRIBUTION_ENV.HERMES_OPENROUTER_APP_REFERER, HERMES_OPENROUTER_APP_TITLE: process.env.HERMES_OPENROUTER_APP_TITLE || OPENROUTER_WEB_UI_ATTRIBUTION_ENV.HERMES_OPENROUTER_APP_TITLE, HERMES_OPENROUTER_APP_CATEGORIES: process.env.HERMES_OPENROUTER_APP_CATEGORIES || OPENROUTER_WEB_UI_ATTRIBUTION_ENV.HERMES_OPENROUTER_APP_CATEGORIES, ...(agentRoot ? { HERMES_AGENT_ROOT: agentRoot } : {}), } } function pathCandidates(agentRoot?: string): string[] { if (!agentRoot) return [] return process.platform === 'win32' ? [ join(agentRoot, 'venv', 'Scripts', 'python.exe'), join(agentRoot, 'venv', 'Scripts', 'python3.exe'), join(agentRoot, '.venv', 'Scripts', 'python.exe'), join(agentRoot, '.venv', 'Scripts', 'python3.exe'), ] : [ join(agentRoot, 'venv', 'bin', 'python3'), join(agentRoot, 'venv', 'bin', 'python'), join(agentRoot, '.venv', 'bin', 'python3'), join(agentRoot, '.venv', 'bin', 'python'), ] } function uvCandidates(agentRoot?: string): string[] { if (!agentRoot) { return [ process.env.HERMES_AGENT_BRIDGE_UV, process.env.UV, ].filter((value): value is string => !!value && value.trim().length > 0) } return [ process.env.HERMES_AGENT_BRIDGE_UV, process.env.UV, ...(process.platform === 'win32' ? [ agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.exe') : '', agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.cmd') : '', agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.exe') : '', agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.cmd') : '', ] : [ agentRoot ? join(agentRoot, 'venv', 'bin', 'uv') : '', agentRoot ? join(agentRoot, '.venv', 'bin', 'uv') : '', ]), 'uv', ].filter((value): value is string => !!value && value.trim().length > 0) } function resolveExecutable(command: string): string | undefined { const trimmed = command.trim() if (!trimmed) return undefined if (isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\')) { return existsSync(trimmed) ? resolve(trimmed) : undefined } try { const lookup = process.platform === 'win32' ? execFileSync('where.exe', [trimmed], { encoding: 'utf-8', windowsHide: true }) : execFileSync('which', [trimmed], { encoding: 'utf-8' }) return lookup.split(/\r?\n/).map(line => line.trim()).find(Boolean) } catch { return undefined } } function agentRootFromHermesBin(): string | undefined { const hermesBin = resolveExecutable(getHermesBin()) if (!hermesBin) return undefined const binDir = dirname(hermesBin) const rootCandidates = [ resolve(binDir, '..'), resolve(binDir, '..', '..'), resolve(binDir, '..', 'hermes-agent'), resolve(binDir, '..', 'lib', 'hermes-agent'), resolve(binDir, '..', '..', 'hermes-agent'), ] const root = rootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py'))) if (root) return root try { const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0] const match = first.match(/^#!\s*(.+)$/) const python = match?.[1]?.trim().split(/\s+/)[0] if (python) { const pyDir = dirname(python) const shebangRootCandidates = [ resolve(pyDir, '..', '..'), resolve(pyDir, '..', '..', 'hermes-agent'), resolve(pyDir, '..', '..', 'lib', 'hermes-agent'), ] return shebangRootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py'))) } } catch {} return undefined } function hermesBinPython(): string | undefined { const hermesBin = resolveExecutable(getHermesBin()) if (!hermesBin) return undefined try { const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0] const match = first.match(/^#!\s*(.+)$/) const python = match?.[1]?.trim().split(/\s+/)[0] return python && existsSync(python) ? python : undefined } catch { return undefined } } function firstExistingExecutable(candidates: string[]): string | undefined { for (const candidate of candidates) { if (!isAbsolute(candidate) && !candidate.includes('/') && !candidate.includes('\\')) { const resolved = resolveExecutable(candidate) if (resolved) return resolved continue } try { if (existsSync(candidate)) return candidate } catch {} } return undefined } function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): string | undefined { const candidates = [ explicit, process.env.HERMES_AGENT_ROOT, join(hermesHome, 'hermes-agent'), agentRootFromHermesBin(), process.cwd(), join(process.cwd(), 'hermes-agent'), '/usr/local/lib/hermes-agent', '/usr/local/hermes-agent', '/opt/hermes/hermes-agent', '/opt/hermes-agent', ].filter((value): value is string => !!value && value.trim().length > 0) return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py'))) } export function resolveAgentBridgeCommand(options: AgentBridgeManagerOptions = {}): BridgeCommand { const hermesHome = options.hermesHome || detectHermesHome() const agentRoot = resolveAgentRoot(options.agentRoot, hermesHome) const explicitPython = options.python || process.env.HERMES_AGENT_BRIDGE_PYTHON if (explicitPython) { return { command: explicitPython, argsPrefix: [], agentRoot, hermesHome } } const venvPython = firstExistingExecutable(pathCandidates(agentRoot)) if (venvPython) { return { command: venvPython, argsPrefix: [], agentRoot, hermesHome } } const shebangPython = hermesBinPython() if (shebangPython && existsSync(shebangPython)) { return { command: shebangPython, argsPrefix: [], agentRoot, hermesHome } } const uv = firstExistingExecutable(uvCandidates(agentRoot)) if (uv) { const prefix = ['run'] if (agentRoot) prefix.push('--project', agentRoot) prefix.push('python') return { command: uv, argsPrefix: prefix, agentRoot, hermesHome } } const fallback = firstExistingExecutable([ process.env.PYTHON || '', ...(process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python']), ]) || (process.platform === 'win32' ? 'python' : 'python3') return { command: fallback, argsPrefix: [], agentRoot, hermesHome } } function bridgeScriptPath(): string { const candidates = [ // Built server: dist/server/index.js -> dist/server/agent-bridge/hermes_bridge.py resolve(__dirname, 'agent-bridge', 'hermes_bridge.py'), // ts-node/dev source tree. resolve(__dirname, 'services/hermes/agent-bridge/hermes_bridge.py'), resolve(process.cwd(), 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'), ] const found = candidates.find(candidate => existsSync(candidate)) if (!found) { throw new Error(`agent bridge Python script not found. Tried: ${candidates.join(', ')}`) } return found } function isTcpEndpoint(endpoint: string): boolean { return endpoint.startsWith('tcp://') } function isDesktopRuntime(): boolean { return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true' } async function canListenTcpEndpoint(endpoint: string): Promise { const url = new URL(endpoint) const host = url.hostname || '127.0.0.1' const port = Number(url.port) if (!Number.isFinite(port) || port <= 0) return false return await new Promise((resolveAvailable) => { const probe = createServer() const done = (available: boolean) => { probe.removeAllListeners() resolveAvailable(available) } probe.once('error', () => done(false)) probe.listen(port, host, () => { probe.close(() => done(true)) }) }) } async function canConnectTcpEndpoint(endpoint: string): Promise { const url = new URL(endpoint) const host = url.hostname || '127.0.0.1' const port = Number(url.port) if (!Number.isFinite(port) || port <= 0) return false return await new Promise((resolveConnected) => { const socket = createConnection({ port, host }) const done = (connected: boolean) => { socket.removeAllListeners() socket.destroy() resolveConnected(connected) } socket.setTimeout(250) socket.once('connect', () => done(true)) socket.once('timeout', () => done(false)) socket.once('error', () => done(false)) }) } function tcpEndpointPort(endpoint: string): number | undefined { if (!isTcpEndpoint(endpoint)) return undefined const url = new URL(endpoint) const port = Number(url.port) return Number.isFinite(port) && port > 0 ? port : undefined } function windowsListeningPidsOnPort(port: number): number[] { try { const output = execFileSync('netstat.exe', ['-ano', '-p', 'tcp'], { windowsHide: true }).toString('utf8') const pids = new Set() for (const line of output.split(/\r?\n/)) { const parts = line.trim().split(/\s+/) if (parts.length < 5) continue const [proto, localAddress, , state, pidRaw] = parts if (proto.toUpperCase() !== 'TCP' || state.toUpperCase() !== 'LISTENING') continue if (!localAddress.endsWith(`:${port}`)) continue const pid = Number(pidRaw) if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) pids.add(pid) } return [...pids] } catch { return [] } } async function waitForTcpEndpoint(endpoint: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (await canListenTcpEndpoint(endpoint)) return true await new Promise(resolve => setTimeout(resolve, 100)) } return canListenTcpEndpoint(endpoint) } async function killWindowsEndpointOccupants(endpoint: string): Promise { const port = tcpEndpointPort(endpoint) if (!port) return const pids = windowsListeningPidsOnPort(port) if (!pids.length) return for (const pid of pids) { try { logger.warn('[agent-bridge] killing stale process tree pid=%d on bridge port %d', pid, port) execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf-8', windowsHide: true }) } catch (err) { logger.warn(err, '[agent-bridge] failed to kill stale bridge process pid=%d', pid) } } await waitForTcpEndpoint(endpoint, 3000) } export class AgentBridgeManager { endpoint: string private readonly options: AgentBridgeManagerOptions private readonly explicitEndpoint: boolean private child: ChildProcess | null = null private starting: Promise | null = null private ready = false private stopping = false private restartTimer: NodeJS.Timeout | null = null private restartAttempts = 0 constructor(options: AgentBridgeManagerOptions = {}) { this.options = options this.explicitEndpoint = Boolean(options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT) this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT } get running(): boolean { return !!this.child && !this.child.killed && this.ready } getRuntimeState(): AgentBridgeManagerRuntimeState { return { endpoint: this.endpoint, running: this.running, ready: this.ready, pid: this.child?.pid, starting: !!this.starting, stopping: this.stopping, restartScheduled: !!this.restartTimer, restartAttempts: this.restartAttempts, } } async start(): Promise { if (this.running) return if (this.starting) return this.starting this.stopping = false if (this.restartTimer) { clearTimeout(this.restartTimer) this.restartTimer = null } this.starting = this.startProcess() try { await this.starting } finally { this.starting = null } } private async startProcess(): Promise { const script = bridgeScriptPath() const command = resolveAgentBridgeCommand(this.options) await this.prepareEndpoint() const args = [...command.argsPrefix, script, '--endpoint', this.endpoint] const agentRoot = command.agentRoot const hermesHome = command.hermesHome if (agentRoot) args.push('--agent-root', agentRoot) if (hermesHome) args.push('--hermes-home', hermesHome) const env = buildAgentBridgeProcessEnv(this.endpoint, hermesHome, agentRoot) logger.info('[agent-bridge] starting: %s %s', command.command, args.join(' ')) const child = spawn(command.command, args, { env, cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }) this.child = child this.ready = false child.once('exit', (code, signal) => { const shouldRestart = this.ready && !this.stopping && this.child === child && this.autoRestartEnabled() logger.warn('[agent-bridge] exited code=%s signal=%s', code, signal) this.ready = false if (this.child === child) this.child = null if (shouldRestart) this.scheduleRestart(code, signal) }) child.stderr?.on('data', chunk => { const text = String(chunk).trim() if (text) logger.warn('[agent-bridge] %s', text) }) await new Promise((resolveReady, rejectReady) => { let buffered = '' const startupTimeoutMs = this.options.startupTimeoutMs ?? envPositiveInt('HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS') ?? DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS const timeout = setTimeout(() => { cleanup() rejectReady(new Error(`agent bridge did not become ready within ${startupTimeoutMs}ms`)) }, startupTimeoutMs) const cleanup = () => { clearTimeout(timeout) child.off('exit', onExitBeforeReady) child.off('error', onError) } const markReady = () => { if (readyResolved) return this.ready = true this.restartAttempts = 0 readyResolved = true cleanup() child.stdout?.off('data', onStdout) resolveReady() } const onError = (err: Error) => { cleanup() child.stdout?.off('data', onStdout) rejectReady(err) } const onExitBeforeReady = (code: number | null, signal: NodeJS.Signals | null) => { cleanup() child.stdout?.off('data', onStdout) rejectReady(new Error(`agent bridge exited before ready code=${code} signal=${signal}`)) } let readyResolved = false const onStdout = (chunk: Buffer) => { const text = chunk.toString('utf8') buffered += text for (;;) { const newline = buffered.indexOf('\n') if (newline < 0) break const line = buffered.slice(0, newline).trim() buffered = buffered.slice(newline + 1) if (!line) continue logger.info('[agent-bridge] %s', line) if (!readyResolved) { try { const parsed = JSON.parse(line) if (parsed?.event === 'ready') { markReady() return } } catch {} } } } child.once('error', onError) child.once('exit', onExitBeforeReady) child.stdout?.on('data', onStdout) if (isDesktopRuntime() && isTcpEndpoint(this.endpoint)) { const probe = async () => { while (!readyResolved && !child.killed) { if (await canConnectTcpEndpoint(this.endpoint)) { markReady() return } await new Promise(resolve => setTimeout(resolve, 100)) } } probe().catch(onError) } }) logger.info('[agent-bridge] ready at %s', this.endpoint) } private async prepareEndpoint(): Promise { if (!this.explicitEndpoint && process.platform === 'win32' && isTcpEndpoint(this.endpoint)) { if (!(await canListenTcpEndpoint(this.endpoint))) { await killWindowsEndpointOccupants(this.endpoint) } } process.env.HERMES_AGENT_BRIDGE_ENDPOINT = this.endpoint } private autoRestartEnabled(): boolean { const raw = String(process.env.HERMES_AGENT_BRIDGE_AUTO_RESTART || '').trim().toLowerCase() return !['0', 'false', 'no', 'off'].includes(raw) } private scheduleRestart(code: number | null, signal: NodeJS.Signals | null): void { if (this.restartTimer || this.stopping) return this.restartAttempts += 1 const envDelay = envPositiveInt('HERMES_AGENT_BRIDGE_RESTART_DELAY_MS') ?? DEFAULT_AGENT_BRIDGE_RESTART_DELAY_MS const delayMs = Math.min( MAX_AGENT_BRIDGE_RESTART_DELAY_MS, envDelay * Math.max(1, this.restartAttempts), ) logger.warn( '[agent-bridge] broker exited unexpectedly code=%s signal=%s; restarting in %dms (attempt %d)', code, signal, delayMs, this.restartAttempts, ) this.restartTimer = setTimeout(() => { this.restartTimer = null if (this.stopping) return this.start().catch((err) => { logger.warn(err, '[agent-bridge] automatic restart failed') if (!this.stopping) this.scheduleRestart(null, null) }) }, delayMs) } async stop(): Promise { this.stopping = true if (this.restartTimer) { clearTimeout(this.restartTimer) this.restartTimer = null } const child = this.child if (!child) return this.ready = false this.child = null await new Promise((resolveStop) => { const timeout = setTimeout(() => { if (!child.killed) child.kill('SIGKILL') resolveStop() }, 1500) child.once('exit', () => { clearTimeout(timeout) resolveStop() }) if (!child.killed) { child.kill('SIGTERM') } }) } } let singleton: AgentBridgeManager | null = null export function getAgentBridgeManager(): AgentBridgeManager { if (!singleton) singleton = new AgentBridgeManager() return singleton } export async function startAgentBridgeManager(): Promise { const manager = getAgentBridgeManager() await manager.start() return manager }