/** * ZCLAW Gateway Manager * * Manages the OpenClaw Gateway subprocess lifecycle: * - Start/stop Gateway daemon * - Health checking * - Auto-restart on crash * - Configuration management */ import { spawn, ChildProcess, execSync } from 'child_process'; import { EventEmitter } from 'events'; import * as path from 'path'; import * as fs from 'fs'; export type GatewayStatus = 'stopped' | 'starting' | 'running' | 'error' | 'stopping'; export interface GatewayManagerOptions { /** OpenClaw home directory (default: ~/.openclaw) */ home?: string; /** Gateway port (default: 18789) */ port?: number; /** Gateway host (default: 127.0.0.1) */ host?: string; /** Auth token for Gateway connections */ token?: string; /** Auto-restart on crash */ autoRestart?: boolean; /** Max restart attempts */ maxRestarts?: number; /** Health check interval in ms (default: 15000) */ healthCheckInterval?: number; } export interface GatewayInfo { status: GatewayStatus; pid?: number; port: number; host: string; wsUrl: string; version?: string; uptime?: number; error?: string; } export class GatewayManager extends EventEmitter { private process: ChildProcess | null = null; private status: GatewayStatus = 'stopped'; private startTime: number = 0; private restartCount: number = 0; private healthTimer: ReturnType | null = null; private options: Required; private lastError: string | null = null; constructor(opts: GatewayManagerOptions = {}) { super(); this.options = { home: opts.home || path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw'), port: opts.port || 18789, host: opts.host || '127.0.0.1', token: opts.token || '', autoRestart: opts.autoRestart ?? true, maxRestarts: opts.maxRestarts || 5, healthCheckInterval: opts.healthCheckInterval || 15000, }; } /** Get current gateway info */ getInfo(): GatewayInfo { return { status: this.status, pid: this.process?.pid, port: this.options.port, host: this.options.host, wsUrl: `ws://${this.options.host}:${this.options.port}`, version: this.getVersion(), uptime: this.status === 'running' ? Date.now() - this.startTime : undefined, error: this.lastError || undefined, }; } /** Check if OpenClaw is installed */ isInstalled(): boolean { try { execSync('openclaw --version', { encoding: 'utf-8', stdio: 'pipe' }); return true; } catch { return false; } } /** Get OpenClaw version */ getVersion(): string | undefined { try { return execSync('openclaw --version', { encoding: 'utf-8', stdio: 'pipe' }).trim(); } catch { return undefined; } } /** Start the OpenClaw Gateway */ async start(): Promise { if (this.status === 'running' || this.status === 'starting') { return; } if (!this.isInstalled()) { this.setStatus('error'); this.lastError = 'OpenClaw is not installed'; throw new Error('OpenClaw is not installed. Run: npm install -g openclaw@latest'); } this.setStatus('starting'); this.lastError = null; try { // Check if Gateway is already running (external instance) const alreadyRunning = await this.checkHealth(); if (alreadyRunning) { this.setStatus('running'); this.startTime = Date.now(); this.startHealthCheck(); this.emit('connected', { external: true }); return; } // Start Gateway as subprocess const env: Record = { ...process.env as Record, OPENCLAW_HOME: this.options.home, }; if (this.options.token) { env.OPENCLAW_GATEWAY_TOKEN = this.options.token; } this.process = spawn('openclaw', ['gateway'], { env, stdio: ['ignore', 'pipe', 'pipe'], detached: false, }); // Capture stdout this.process.stdout?.on('data', (data: Buffer) => { const output = data.toString(); this.emit('log', { level: 'info', message: output.trim() }); // Detect when Gateway is ready if (output.includes('Gateway listening') || output.includes('ready')) { this.setStatus('running'); this.startTime = Date.now(); this.restartCount = 0; this.startHealthCheck(); } }); // Capture stderr this.process.stderr?.on('data', (data: Buffer) => { const output = data.toString(); this.emit('log', { level: 'error', message: output.trim() }); }); // Handle process exit this.process.on('exit', (code, signal) => { const wasRunning = this.status === 'running'; this.process = null; this.stopHealthCheck(); if (this.status === 'stopping') { this.setStatus('stopped'); return; } if (code !== 0 && wasRunning && this.options.autoRestart) { this.restartCount++; if (this.restartCount <= this.options.maxRestarts) { this.lastError = `Gateway crashed (exit code: ${code}), restarting (${this.restartCount}/${this.options.maxRestarts})`; this.emit('log', { level: 'warn', message: this.lastError }); setTimeout(() => this.start(), 2000); return; } } this.lastError = `Gateway exited with code ${code}, signal ${signal}`; this.setStatus(code === 0 ? 'stopped' : 'error'); }); // Handle process error this.process.on('error', (err) => { this.lastError = err.message; this.setStatus('error'); this.process = null; }); // Wait for Gateway to be ready (timeout 30s) await this.waitForReady(30000); } catch (err: any) { this.lastError = err.message; this.setStatus('error'); throw err; } } /** Stop the Gateway */ async stop(): Promise { if (this.status === 'stopped' || this.status === 'stopping') { return; } this.setStatus('stopping'); this.stopHealthCheck(); if (this.process) { // Send SIGTERM for graceful shutdown this.process.kill('SIGTERM'); // Wait up to 10s for graceful exit await new Promise((resolve) => { const timeout = setTimeout(() => { if (this.process) { this.process.kill('SIGKILL'); } resolve(); }, 10000); if (this.process) { this.process.once('exit', () => { clearTimeout(timeout); resolve(); }); } else { clearTimeout(timeout); resolve(); } }); } this.process = null; this.setStatus('stopped'); } /** Restart the Gateway */ async restart(): Promise { await this.stop(); await this.start(); } /** Check Gateway health via HTTP */ async checkHealth(): Promise { try { const url = `http://${this.options.host}:${this.options.port}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeout); return response.ok || response.status === 200 || response.status === 101; } catch { return false; } } /** Wait for Gateway to become ready */ private waitForReady(timeoutMs: number): Promise { return new Promise((resolve, reject) => { const start = Date.now(); const check = async () => { if (this.status === 'running') { resolve(); return; } if (this.status === 'error') { reject(new Error(this.lastError || 'Gateway failed to start')); return; } if (Date.now() - start > timeoutMs) { // Try health check as last resort const healthy = await this.checkHealth(); if (healthy) { this.setStatus('running'); this.startTime = Date.now(); this.startHealthCheck(); resolve(); return; } reject(new Error('Gateway startup timed out')); return; } setTimeout(check, 1000); }; check(); }); } /** Start periodic health checks */ private startHealthCheck() { this.stopHealthCheck(); this.healthTimer = setInterval(async () => { const healthy = await this.checkHealth(); if (!healthy && this.status === 'running') { this.emit('log', { level: 'warn', message: 'Health check failed' }); // Don't immediately mark as error — may be transient } }, this.options.healthCheckInterval); } /** Stop health checks */ private stopHealthCheck() { if (this.healthTimer) { clearInterval(this.healthTimer); this.healthTimer = null; } } /** Update status and emit event */ private setStatus(status: GatewayStatus) { const prev = this.status; this.status = status; if (prev !== status) { this.emit('status', { status, previous: prev }); } } }