import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; import { sessionsService } from './modules/providers/services/sessions.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { createNormalizedMessage } from './shared/utils.js'; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const activeOpenCodeProcesses = new Map(); function readOpenCodeSessionId(event) { if (!event || typeof event !== 'object') { return null; } return event.sessionID || event.sessionId || null; } async function spawnOpenCode(command, options = {}, ws) { return new Promise((resolve, reject) => { const { sessionId, projectPath, cwd, model, sessionSummary } = options; const workingDir = cwd || projectPath || process.cwd(); const processKey = sessionId || Date.now().toString(); let capturedSessionId = sessionId || null; let sessionCreatedSent = false; let stdoutLineBuffer = ''; let terminalNotificationSent = false; const args = ['run', '--format', 'json']; if (sessionId) { args.push('--session', sessionId); } if (model) { args.push('--model', model); } if (command && command.trim()) { args.push(command.trim()); } const notifyTerminalState = ({ code = null, error = null } = {}) => { if (terminalNotificationSent) { return; } terminalNotificationSent = true; const finalSessionId = capturedSessionId || sessionId || processKey; if (code === 0 && !error) { notifyRunStopped({ userId: ws?.userId || null, provider: 'opencode', sessionId: finalSessionId, sessionName: sessionSummary, stopReason: 'completed', }); return; } notifyRunFailed({ userId: ws?.userId || null, provider: 'opencode', sessionId: finalSessionId, sessionName: sessionSummary, error: error || `OpenCode CLI exited with code ${code}`, }); }; const opencodeProcess = spawnFunction('opencode', args, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env }, }); activeOpenCodeProcesses.set(processKey, opencodeProcess); opencodeProcess.sessionId = processKey; opencodeProcess.stdin.end(); const registerSession = (nextSessionId) => { if (!nextSessionId || capturedSessionId === nextSessionId) { return; } capturedSessionId = nextSessionId; if (processKey !== capturedSessionId) { activeOpenCodeProcesses.delete(processKey); activeOpenCodeProcesses.set(capturedSessionId, opencodeProcess); } opencodeProcess.sessionId = capturedSessionId; if (ws.setSessionId && typeof ws.setSessionId === 'function') { ws.setSessionId(capturedSessionId); } if (!sessionId && !sessionCreatedSent) { sessionCreatedSent = true; ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'opencode', })); } }; const processOpenCodeOutputLine = (line) => { if (!line || !line.trim()) { return; } try { const response = JSON.parse(line); registerSession(readOpenCodeSessionId(response)); const normalized = sessionsService.normalizeMessage( 'opencode', response, capturedSessionId || sessionId || null, ); for (const msg of normalized) { ws.send(msg); } } catch { ws.send(createNormalizedMessage({ kind: 'stream_delta', content: line, sessionId: capturedSessionId || sessionId || null, provider: 'opencode', })); } }; opencodeProcess.stdout.on('data', (data) => { stdoutLineBuffer += data.toString(); const completeLines = stdoutLineBuffer.split(/\r?\n/); stdoutLineBuffer = completeLines.pop() || ''; completeLines.forEach((line) => { processOpenCodeOutputLine(line.trim()); }); }); opencodeProcess.stderr.on('data', (data) => { const stderrText = data.toString(); if (!stderrText.trim()) { return; } ws.send(createNormalizedMessage({ kind: 'error', content: stderrText, sessionId: capturedSessionId || sessionId || null, provider: 'opencode', })); }); opencodeProcess.on('close', async (code) => { const finalSessionId = capturedSessionId || sessionId || processKey; activeOpenCodeProcesses.delete(finalSessionId); activeOpenCodeProcesses.delete(processKey); if (stdoutLineBuffer.trim()) { processOpenCodeOutputLine(stdoutLineBuffer.trim()); stdoutLineBuffer = ''; } ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'opencode', })); if (code === 0) { notifyTerminalState({ code }); resolve(); return; } if (code === 127 || code === null) { const installed = await providerAuthService.isProviderInstalled('opencode'); if (!installed) { ws.send(createNormalizedMessage({ kind: 'error', content: 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/', sessionId: finalSessionId, provider: 'opencode', })); } } notifyTerminalState({ code }); reject(new Error(code === null ? 'OpenCode CLI process was terminated' : `OpenCode CLI exited with code ${code}`)); }); opencodeProcess.on('error', async (error) => { const finalSessionId = capturedSessionId || sessionId || processKey; activeOpenCodeProcesses.delete(finalSessionId); activeOpenCodeProcesses.delete(processKey); const installed = await providerAuthService.isProviderInstalled('opencode'); const errorContent = !installed ? 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/' : error.message; ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: finalSessionId, provider: 'opencode', })); notifyTerminalState({ error }); reject(error); }); }); } function abortOpenCodeSession(sessionId) { const process = activeOpenCodeProcesses.get(sessionId); if (!process) { return false; } process.kill('SIGTERM'); activeOpenCodeProcesses.delete(sessionId); return true; } function isOpenCodeSessionActive(sessionId) { return activeOpenCodeProcesses.has(sessionId); } function getActiveOpenCodeSessions() { return Array.from(activeOpenCodeProcesses.keys()); } export { spawnOpenCode, abortOpenCodeSession, isOpenCodeSessionActive, getActiveOpenCodeSessions, };