// --------------------------------------------------------------------------- // Bridge -- Real persistent Antigravity CLI session management // --------------------------------------------------------------------------- // Spawns the Antigravity CLI as a persistent interactive subprocess. // Relays stdin/stdout/stderr over WebSocket so the webapp mirrors the CLI. // --------------------------------------------------------------------------- import { Server, Socket } from 'socket.io'; import { spawn, ChildProcess } from 'child_process'; import { ToolRegistry, ToolResult } from './toolRegistry'; import { CLIDetectionResult, detectCLI } from './cliDetector'; import fs from 'fs'; import path from 'path'; const SETTINGS_FILE = path.join(__dirname, '..', 'settings.json'); interface Settings { cliPath: string; autoConnect: boolean; } interface CLISession { process: ChildProcess; logs: string[]; started: number; } // Persistent state let currentSession: CLISession | null = null; let settings: Settings = loadSettings(); let latestCliStatus: CLIDetectionResult | null = null; function loadSettings(): Settings { try { if (fs.existsSync(SETTINGS_FILE)) { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8')); } } catch { } return { cliPath: '', autoConnect: false }; } function saveSettings(s: Settings): void { settings = s; fs.writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2), 'utf-8'); } export function getSettings(): Settings { return settings; } export function updateSettings(partial: Partial): Settings { settings = { ...settings, ...partial }; saveSettings(settings); return settings; } export async function redetectCLI(): Promise { // Override detection with user-configured path if (settings.cliPath) { process.env.ANTIGRAVITY_CLI_PATH = settings.cliPath; } latestCliStatus = await detectCLI(); return latestCliStatus; } export function setupBridge( io: Server, toolRegistry: ToolRegistry, cliStatus: CLIDetectionResult ): void { latestCliStatus = cliStatus; io.on('connection', (socket: Socket) => { console.log(` [Bridge] Client connected: ${socket.id}`); // Send initial state socket.emit('bridge:init', { tools: toolRegistry.listTools(), cli: { detected: latestCliStatus!.detected, version: latestCliStatus!.version, method: latestCliStatus!.method, path: latestCliStatus!.path, }, settings: settings, sessionActive: currentSession !== null, serverTime: new Date().toISOString(), }); // ------------------------------------------------------------------ // Settings management (from UI) // ------------------------------------------------------------------ socket.on('settings:get', (callback: Function) => { if (typeof callback === 'function') { callback({ settings, cli: latestCliStatus }); } }); socket.on('settings:update', async (data: Partial, callback: Function) => { const updated = updateSettings(data); console.log(` [Bridge] Settings updated:`, updated); // Re-detect with new path const newStatus = await redetectCLI(); latestCliStatus = newStatus; // Notify all clients io.emit('settings:changed', { settings: updated, cli: newStatus }); if (typeof callback === 'function') { callback({ settings: updated, cli: newStatus }); } }); // ------------------------------------------------------------------ // CLI Session management // ------------------------------------------------------------------ socket.on('cli:start', async (data: { cliPath?: string }) => { const cliPath = data?.cliPath || settings.cliPath || latestCliStatus?.path; if (!cliPath) { socket.emit('cli:error', { message: 'No CLI path configured. Go to Settings and set the path to your Antigravity CLI executable.', }); return; } // Kill existing session if (currentSession) { try { currentSession.process.kill('SIGTERM'); } catch { } currentSession = null; } console.log(` [Bridge] Starting CLI session: ${cliPath}`); io.emit('cli:starting', { path: cliPath }); try { const child = spawn(cliPath, [], { shell: true, cwd: process.cwd(), env: { ...process.env, TERM: 'dumb', NO_COLOR: '1' }, stdio: ['pipe', 'pipe', 'pipe'], }); currentSession = { process: child, logs: [], started: Date.now(), }; child.stdout?.on('data', (chunk: Buffer) => { const text = chunk.toString(); currentSession?.logs.push(text); io.emit('cli:stdout', { data: text, timestamp: Date.now() }); }); child.stderr?.on('data', (chunk: Buffer) => { const text = chunk.toString(); currentSession?.logs.push(`[stderr] ${text}`); io.emit('cli:stderr', { data: text, timestamp: Date.now() }); }); child.on('close', (code: number | null) => { console.log(` [Bridge] CLI session ended: exit code ${code}`); io.emit('cli:ended', { exitCode: code }); currentSession = null; }); child.on('error', (err: Error) => { console.error(` [Bridge] CLI error: ${err.message}`); io.emit('cli:error', { message: `Failed to start CLI: ${err.message}` }); currentSession = null; }); io.emit('cli:started', { path: cliPath, timestamp: Date.now() }); } catch (err: any) { io.emit('cli:error', { message: `Failed to spawn CLI: ${err.message}` }); } }); socket.on('cli:stop', () => { if (currentSession) { try { currentSession.process.kill('SIGTERM'); } catch { } currentSession = null; io.emit('cli:ended', { exitCode: null, reason: 'user-stopped' }); } }); socket.on('cli:status', (callback: Function) => { if (typeof callback === 'function') { callback({ active: currentSession !== null, uptime: currentSession ? Date.now() - currentSession.started : 0, logLength: currentSession?.logs.length || 0, }); } }); // ------------------------------------------------------------------ // Prompt handling -- relay to CLI or match tools // ------------------------------------------------------------------ socket.on('prompt:send', async (data: { message: string; id: string }) => { const { message, id } = data; console.log(` [Bridge] Prompt: ${message}`); // Broadcast to all clients io.emit('prompt:received', { message, id, from: 'human', timestamp: new Date().toISOString(), }); // Check for tool match first const match = toolRegistry.matchTool(message); if (match) { const { tool, params } = match; console.log(` [Bridge] Tool matched: ${tool.name}`); io.emit('tool:started', { id, toolName: tool.name, params, timestamp: new Date().toISOString() }); try { const result: ToolResult = await tool.execute(params, (progress: string) => { io.emit('tool:progress', { id, toolName: tool.name, progress, timestamp: new Date().toISOString() }); }); io.emit('tool:completed', { id, toolName: tool.name, result, timestamp: new Date().toISOString() }); } catch (err: any) { io.emit('tool:error', { id, toolName: tool.name, error: err.message, timestamp: new Date().toISOString() }); } } else if (currentSession?.process?.stdin) { // Send prompt to the running CLI process try { currentSession.process.stdin.write(message + '\n'); console.log(` [Bridge] Sent to CLI stdin: ${message}`); } catch (err: any) { io.emit('cli:error', { message: `Failed to write to CLI: ${err.message}` }); } } else { // No tool match, no CLI session io.emit('agent:message', { id, from: 'system', message: currentSession ? 'CLI session is active but stdin is unavailable.' : 'No CLI session is running. Click "Connect" in Settings to start the Antigravity CLI, or configure the path first.', timestamp: new Date().toISOString(), }); } }); // ------------------------------------------------------------------ // MCP tool discovery & invocation // ------------------------------------------------------------------ socket.on('tool:list', (callback: Function) => { if (typeof callback === 'function') { callback({ status: 'success', tools: toolRegistry.listTools() }); } }); socket.on('tool:invoke', async (data: { toolName: string; params: any }, callback: Function) => { const tool = toolRegistry.getTool(data.toolName); if (!tool) { if (typeof callback === 'function') callback({ status: 'error', message: `Tool "${data.toolName}" not found.` }); return; } try { const result = await tool.execute(data.params, (progress: string) => { io.emit('tool:progress', { toolName: data.toolName, progress, timestamp: new Date().toISOString() }); }); if (typeof callback === 'function') callback({ status: 'success', result }); } catch (err: any) { if (typeof callback === 'function') callback({ status: 'error', message: err.message }); } }); socket.on('agent:response', (data: any) => { io.emit('agent:message', { ...data, from: 'agent', timestamp: new Date().toISOString() }); }); socket.on('disconnect', () => { console.log(` [Bridge] Client disconnected: ${socket.id}`); // Don't kill CLI session on disconnect -- it persists }); }); }