From 5df7ef1c8dd1e3d1b8b673e2ae6562e88fc6f252 Mon Sep 17 00:00:00 2001 From: southseact-3d Date: Thu, 19 Feb 2026 19:50:02 +0000 Subject: [PATCH] add opencode one process --- chat/OPENCODE_SERVER_INTEGRATION.md | 1275 +++++++++++++++++++++++++++ 1 file changed, 1275 insertions(+) create mode 100644 chat/OPENCODE_SERVER_INTEGRATION.md diff --git a/chat/OPENCODE_SERVER_INTEGRATION.md b/chat/OPENCODE_SERVER_INTEGRATION.md new file mode 100644 index 0000000..b57a531 --- /dev/null +++ b/chat/OPENCODE_SERVER_INTEGRATION.md @@ -0,0 +1,1275 @@ +# OpenCode Server Integration Guide + +## Overview + +This guide details how to integrate OpenCode's `serve` command with the chat server to enable a persistent single-process architecture, reducing resource consumption when multiple sessions are running concurrently. + +## Current Architecture vs. Proposed + +### Current (Per-Message Spawning) + +``` +┌─────────────┐ spawn ┌─────────────┐ +│ Chat Server │ ──────────────▶│ OpenCode │ (Process 1) +│ │ │ --session A │ +│ │ └─────────────┘ +│ │ spawn ┌─────────────┐ +│ │ ──────────────▶│ OpenCode │ (Process 2) +│ │ │ --session B │ +└─────────────┘ └─────────────┘ + +Memory: 2 processes × ~300MB = ~600MB +Startup: ~1-2s latency per message +``` + +### Proposed (Persistent Server) + +``` +┌─────────────┐ HTTP/WS ┌─────────────┐ +│ Chat Server │ ◀─────────────▶│ OpenCode │ (Single Process) +│ │ │ serve │ +│ │ │ :4096 │ +│ │ │ │ +│ Session A ─┼── POST /session/A/message +│ Session B ─┼── POST /session/B/message +└─────────────┘ └─────────────┘ + +Memory: 1 process × ~400MB = ~400MB +Startup: ~10-50ms latency per message +``` + +## OpenCode Server API Reference + +### Server Startup + +```bash +opencode serve --port 4096 --hostname 127.0.0.1 +``` + +**Options:** +- `--port`: Port number (default: 4096, auto-selects if 4096 is busy) +- `--hostname`: Bind address (default: 127.0.0.1) +- `--mdns`: Enable mDNS discovery (optional) +- `--cors`: CORS whitelist origins (optional) + +**Environment Variables:** +- `OPENCODE_SERVER_PASSWORD`: Basic auth password (optional but recommended) +- `OPENCODE_SERVER_USERNAME`: Basic auth username (default: "opencode") + +### Key Endpoints + +#### Session Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/session` | List all sessions | +| POST | `/session` | Create new session | +| GET | `/session/:sessionID` | Get session details | +| DELETE | `/session/:sessionID` | Delete session | +| PATCH | `/session/:sessionID` | Update session (title, etc.) | +| POST | `/session/:sessionID/abort` | Abort running task | + +#### Messaging + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/session/:sessionID/message` | Get all messages | +| POST | `/session/:sessionID/message` | Send prompt (streaming response) | +| POST | `/session/:sessionID/prompt_async` | Send prompt (non-blocking) | +| POST | `/session/:sessionID/command` | Send command | +| POST | `/session/:sessionID/shell` | Execute shell command | + +#### Events + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/event` | SSE stream of all events | +| GET | `/session/status` | Get all session statuses | + +#### Project Context + +All endpoints accept either: +- Query parameter: `?directory=/path/to/project` +- Header: `x-opencode-directory: /path/to/project` + +### Request/Response Schemas + +#### Create Session + +```typescript +// POST /session +// Request body (optional) +{ + title?: string, + agent?: string, // Agent ID (e.g., "general", "code-review") + model?: { + providerID: string, + modelID: string + } +} + +// Response +{ + id: string, + title: string, + directory: string, + time: { + created: number, + updated: number + }, + // ... other fields +} +``` + +#### Send Message + +```typescript +// POST /session/:sessionID/message +// Request body +{ + prompt: { + parts: Array<{ type: "text", text: string } | { type: "image", url: string }> + }, + agent?: string, + model?: { + providerID: string, + modelID: string + } +} + +// Response (streaming) +{ + info: { + id: string, + sessionID: string, + role: "assistant", + // ... + }, + parts: Array +} +``` + +#### Event Stream (SSE) + +```typescript +// GET /event +// Events are JSON objects separated by newlines + +// Event types include: +{ + type: "server.connected", + properties: {} +} + +{ + type: "message.part", + properties: { + sessionID: string, + messageID: string, + part: { ... } + } +} + +{ + type: "message.complete", + properties: { + sessionID: string, + messageID: string + } +} + +{ + type: "session.status", + properties: { + sessionID: string, + status: "idle" | "busy" | "error" + } +} + +{ + type: "server.heartbeat", + properties: {} +} +``` + +## Implementation Plan + +### Phase 1: OpencodeProcessManager Enhancement + +**File:** `server.js` (lines 1117-1380) + +#### 1.1 Add Connection State + +```javascript +class OpencodeProcessManager { + constructor() { + this.process = null; + this.isReady = false; + this.baseUrl = 'http://127.0.0.1:4096'; + this.pendingRequests = new Map(); + this.eventSource = null; + this.eventHandlers = new Map(); // sessionId -> callback[] + this.lastActivity = Date.now(); + this.sessionWorkspaces = new Map(); // sessionId -> { directory, userId, appId } + this.authHeader = null; + this.connectionAttempts = 0; + this.maxConnectionAttempts = 3; + this.healthCheckInterval = null; + } +} +``` + +#### 1.2 Server Startup Method + +```javascript +async start() { + if (this.process && this.isReady) { + log('OpenCode server already running'); + return; + } + + log('Starting OpenCode server...'); + + const cliCommand = resolveCliCommand('opencode'); + const port = Number(process.env.OPENCODE_SERVER_PORT || 4096); + const hostname = process.env.OPENCODE_SERVER_HOST || '127.0.0.1'; + + // Set auth if password is configured + const password = process.env.OPENCODE_SERVER_PASSWORD; + if (password) { + const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'; + this.authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); + } + + try { + // Verify CLI exists + fsSync.accessSync(cliCommand, fsSync.constants.X_OK); + } catch (err) { + throw new Error(`OpenCode CLI not found: ${cliCommand}`); + } + + // Start the serve process + const args = ['serve', '--port', String(port), '--hostname', hostname]; + + this.process = spawn(cliCommand, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + OPENCODE_SERVER_PASSWORD: password || '', + OPENCODE_SERVER_USERNAME: process.env.OPENCODE_SERVER_USERNAME || 'opencode' + } + }); + + // Handle process events + this.process.stdout.on('data', (data) => { + const text = data.toString(); + log('OpenCode server stdout', { text: text.slice(0, 200) }); + + // Detect ready signal + if (text.includes('listening on http://')) { + this.isReady = true; + this.connectionAttempts = 0; + log('OpenCode server ready', { port, hostname }); + + // Start event subscription + this.subscribeToEvents(); + + // Start health check + this.startHealthCheck(); + } + }); + + this.process.stderr.on('data', (data) => { + log('OpenCode server stderr', { text: data.toString().slice(0, 200) }); + }); + + this.process.on('error', (err) => { + log('OpenCode server error', { error: String(err) }); + this.isReady = false; + this.handleServerError(err); + }); + + this.process.on('exit', (code, signal) => { + log('OpenCode server exited', { code, signal }); + this.isReady = false; + this.process = null; + this.stopHealthCheck(); + this.closeEventSource(); + + // Auto-restart if not shutting down + if (!isShuttingDown && this.connectionAttempts < this.maxConnectionAttempts) { + this.connectionAttempts++; + log('Auto-restarting OpenCode server', { attempt: this.connectionAttempts }); + setTimeout(() => this.start().catch(e => + log('Failed to restart OpenCode server', { error: String(e) }) + ), 2000 * this.connectionAttempts); + } + }); + + // Wait for ready signal + await this.waitForReady(30000); + this.baseUrl = `http://${hostname}:${port}`; +} +``` + +#### 1.3 Health Check Implementation + +```javascript +startHealthCheck() { + if (this.healthCheckInterval) return; + + this.healthCheckInterval = setInterval(async () => { + if (!this.isReady || !this.process) return; + + try { + const response = await fetch(`${this.baseUrl}/session/status`, { + method: 'GET', + headers: this.getHeaders() + }); + + if (!response.ok) { + log('Health check failed', { status: response.status }); + if (response.status >= 500) { + this.isReady = false; + } + } + } catch (err) { + log('Health check error', { error: String(err) }); + this.isReady = false; + } + }, 30000); // Every 30 seconds +} + +stopHealthCheck() { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } +} +``` + +#### 1.4 Event Subscription (SSE) + +```javascript +subscribeToEvents() { + if (this.eventSource) { + this.closeEventSource(); + } + + // Use native HTTP client for SSE (Node.js doesn't have EventSource by default) + const url = new URL('/event', this.baseUrl); + + const headers = { + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache' + }; + + if (this.authHeader) { + headers['Authorization'] = this.authHeader; + } + + const req = http.request({ + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'GET', + headers + }, (res) => { + let buffer = ''; + + res.on('data', (chunk) => { + buffer += chunk.toString(); + + // Process complete SSE messages + const lines = buffer.split('\n\n'); + buffer = lines.pop() || ''; // Keep incomplete message in buffer + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const event = JSON.parse(line.slice(6)); + this.handleServerEvent(event); + } catch (e) { + log('Failed to parse SSE event', { line: line.slice(0, 100) }); + } + } + } + }); + + res.on('end', () => { + log('SSE connection closed'); + this.eventSource = null; + + // Reconnect if server is still running + if (this.isReady && !isShuttingDown) { + setTimeout(() => this.subscribeToEvents(), 1000); + } + }); + }); + + req.on('error', (err) => { + log('SSE connection error', { error: String(err) }); + this.eventSource = null; + + if (this.isReady && !isShuttingDown) { + setTimeout(() => this.subscribeToEvents(), 2000); + } + }); + + req.end(); + this.eventSource = req; +} + +closeEventSource() { + if (this.eventSource) { + this.eventSource.destroy(); + this.eventSource = null; + } +} + +handleServerEvent(event) { + // Route events to appropriate handlers + const { type, properties } = event; + + switch (type) { + case 'server.connected': + log('SSE connected to OpenCode server'); + break; + + case 'server.heartbeat': + // No action needed, just keeps connection alive + break; + + case 'message.part': + this.handleMessagePart(properties); + break; + + case 'message.complete': + this.handleMessageComplete(properties); + break; + + case 'session.status': + this.handleSessionStatus(properties); + break; + + case 'tool.call': + this.handleToolCall(properties); + break; + + case 'tool.result': + this.handleToolResult(properties); + break; + + case 'instance.disposed': + log('OpenCode instance disposed'); + break; + + default: + log('Unhandled server event', { type, properties }); + } +} +``` + +#### 1.5 HTTP Request Helper + +```javascript +getHeaders(directory = null) { + const headers = { + 'Content-Type': 'application/json' + }; + + if (this.authHeader) { + headers['Authorization'] = this.authHeader; + } + + if (directory) { + headers['x-opencode-directory'] = directory; + } + + return headers; +} + +async request(method, path, body = null, directory = null) { + if (!this.isReady) { + throw new Error('OpenCode server not ready'); + } + + const url = new URL(path, this.baseUrl); + + if (directory) { + url.searchParams.set('directory', directory); + } + + const options = { + method, + headers: this.getHeaders(directory) + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + if (!response.ok) { + const errorText = await response.text(); + const error = new Error(`OpenCode API error: ${response.status} ${errorText}`); + error.status = response.status; + throw error; + } + + // Handle streaming responses + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/json')) { + return response.json(); + } + + return response; +} +``` + +#### 1.6 Session Management + +```javascript +async createSession(workspaceDir, options = {}) { + const response = await this.request('POST', '/session', { + title: options.title || `Session ${Date.now()}`, + agent: options.agent, + model: options.model + }, workspaceDir); + + const sessionId = response.id; + + // Track session + this.sessionWorkspaces.set(sessionId, { + directory: workspaceDir, + userId: options.userId, + appId: options.appId, + createdAt: Date.now() + }); + + return response; +} + +async getSession(sessionId, directory) { + return this.request('GET', `/session/${sessionId}`, null, directory); +} + +async listSessions(directory) { + return this.request('GET', '/session', null, directory); +} + +async deleteSession(sessionId, directory) { + const result = await this.request('DELETE', `/session/${sessionId}`, null, directory); + this.sessionWorkspaces.delete(sessionId); + return result; +} +``` + +#### 1.7 Message Sending + +```javascript +async sendMessage(sessionId, workspaceDir, message, options = {}) { + const body = { + prompt: { + parts: [{ type: 'text', text: message.content }] + } + }; + + // Add images if present + if (message.attachments?.length) { + for (const attachment of message.attachments) { + if (attachment.type?.startsWith('image/')) { + body.prompt.parts.push({ + type: 'image', + url: attachment.url // Must be a data URL or accessible URL + }); + } + } + } + + // Add agent/model if specified + if (options.agent) body.agent = options.agent; + if (options.model) body.model = options.model; + + return this.request('POST', `/session/${sessionId}/message`, body, workspaceDir); +} + +async sendMessageAsync(sessionId, workspaceDir, message, options = {}) { + // Non-blocking version - returns immediately, events come via SSE + const body = { + prompt: { + parts: [{ type: 'text', text: message.content }] + } + }; + + if (options.agent) body.agent = options.agent; + if (options.model) body.model = options.model; + + await this.request('POST', `/session/${sessionId}/prompt_async`, body, workspaceDir); +} + +async abortSession(sessionId, directory) { + return this.request('POST', `/session/${sessionId}/abort`, null, directory); +} +``` + +#### 1.8 Event Handlers for Streaming + +```javascript +handleMessagePart(properties) { + const { sessionID, messageID, part } = properties; + + // Find the pending request for this session + const pending = this.pendingRequests.get(messageID); + if (pending?.streamCallback) { + pending.streamCallback({ + type: 'part', + part + }); + } + + // Also notify session-specific handlers + const handlers = this.eventHandlers.get(sessionID); + if (handlers) { + for (const handler of handlers) { + handler({ type: 'part', messageID, part }); + } + } +} + +handleMessageComplete(properties) { + const { sessionID, messageID } = properties; + + // Resolve the pending request + const pending = this.pendingRequests.get(messageID); + if (pending) { + pending.resolve({ sessionID, messageID }); + this.pendingRequests.delete(messageID); + } + + // Notify session handlers + const handlers = this.eventHandlers.get(sessionID); + if (handlers) { + for (const handler of handlers) { + handler({ type: 'complete', messageID }); + } + } +} + +handleSessionStatus(properties) { + const { sessionID, status } = properties; + + const workspace = this.sessionWorkspaces.get(sessionID); + if (workspace) { + workspace.status = status; + workspace.lastStatusUpdate = Date.now(); + } + + log('Session status update', { sessionID, status }); +} + +handleToolCall(properties) { + const { sessionID, messageID, tool, input } = properties; + + // Forward to stream callback for real-time display + const pending = this.pendingRequests.get(messageID); + if (pending?.streamCallback) { + pending.streamCallback({ + type: 'tool_call', + tool, + input + }); + } +} + +handleToolResult(properties) { + const { sessionID, messageID, tool, output } = properties; + + const pending = this.pendingRequests.get(messageID); + if (pending?.streamCallback) { + pending.streamCallback({ + type: 'tool_result', + tool, + output + }); + } +} + +// Register/unregister event handlers for a session +registerEventHandler(sessionId, handler) { + if (!this.eventHandlers.has(sessionId)) { + this.eventHandlers.set(sessionId, new Set()); + } + this.eventHandlers.get(sessionId).add(handler); + + return () => { + this.eventHandlers.get(sessionId)?.delete(handler); + if (this.eventHandlers.get(sessionId)?.size === 0) { + this.eventHandlers.delete(sessionId); + } + }; +} +``` + +#### 1.9 Cleanup and Shutdown + +```javascript +async stop() { + log('Stopping OpenCode server...'); + + this.isReady = false; + this.stopHealthCheck(); + this.closeEventSource(); + + // Reject all pending requests + for (const [messageId, pending] of this.pendingRequests) { + pending.reject(new Error('OpenCode server shutting down')); + } + this.pendingRequests.clear(); + this.eventHandlers.clear(); + this.sessionWorkspaces.clear(); + + if (this.process) { + // Graceful shutdown + this.process.kill('SIGTERM'); + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (this.process) { + log('Force killing OpenCode server'); + this.process.kill('SIGKILL'); + } + resolve(); + }, 5000); + + if (this.process) { + this.process.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + } else { + clearTimeout(timeout); + resolve(); + } + }); + + this.process = null; + } + + log('OpenCode server stopped'); +} + +getStats() { + return { + isRunning: !!this.process, + isReady: this.isReady, + baseUrl: this.baseUrl, + activeSessions: this.sessionWorkspaces.size, + pendingRequests: this.pendingRequests.size, + eventHandlers: this.eventHandlers.size, + lastActivity: this.lastActivity, + idleTime: Date.now() - this.lastActivity + }; +} +``` + +### Phase 2: Integration with Chat Server + +#### 2.1 Modify sendToOpencode Function + +**File:** `server.js` (around line 9519) + +```javascript +async function sendToOpencode({ session, model, content, message, cli, streamCallback, opencodeSessionId }) { + const messageKey = message?.id || 'unknown'; + + // Check if server mode is available + if (opencodeManager.isReady) { + log('Using OpenCode server mode', { sessionId: session.id, messageId: messageKey }); + + try { + // Ensure session exists in OpenCode + let ocSessionId = opencodeSessionId || session.opencodeSessionId; + + if (!ocSessionId) { + // Create new session in OpenCode server + const newSession = await opencodeManager.createSession(session.workspaceDir, { + title: session.title || `Chat ${session.id}`, + userId: session.userId, + appId: session.appId + }); + ocSessionId = newSession.id; + session.opencodeSessionId = ocSessionId; + session.initialOpencodeSessionId = ocSessionId; + await persistState(); + log('Created new OpenCode session', { ocSessionId }); + } + + // Register stream callback + let streamUnregister = null; + if (streamCallback) { + streamUnregister = opencodeManager.registerEventHandler(ocSessionId, (event) => { + streamCallback(event); + }); + } + + // Track this request + const requestId = messageKey; + opencodeManager.pendingRequests.set(requestId, { + resolve: () => {}, + reject: () => {}, + streamCallback, + started: Date.now() + }); + + // Send message + const response = await opencodeManager.sendMessageAsync(ocSessionId, session.workspaceDir, { + content, + attachments: message?.attachments + }, { + model: model ? { providerID: 'openrouter', modelID: model } : undefined + }); + + // Messages will come via SSE, handled by event handlers + // Return a promise that resolves when complete + return new Promise((resolve, reject) => { + const pending = opencodeManager.pendingRequests.get(requestId); + if (pending) { + pending.resolve = (result) => { + if (streamUnregister) streamUnregister(); + opencodeManager.pendingRequests.delete(requestId); + resolve(result); + }; + pending.reject = (error) => { + if (streamUnregister) streamUnregister(); + opencodeManager.pendingRequests.delete(requestId); + reject(error); + }; + } + + // Timeout safety + setTimeout(() => { + if (opencodeManager.pendingRequests.has(requestId)) { + if (streamUnregister) streamUnregister(); + opencodeManager.pendingRequests.delete(requestId); + reject(new Error('OpenCode request timed out')); + } + }, 600000); // 10 minutes + }); + + } catch (error) { + log('OpenCode server mode failed, falling back to spawn', { error: String(error) }); + // Fall through to spawn-based execution + } + } + + // Fallback: Original spawn-based implementation + return sendToOpencodeViaSpawn({ session, model, content, message, cli, streamCallback, opencodeSessionId }); +} +``` + +#### 2.2 Initialize Server on Startup + +**File:** `server.js` (in the startup section, around where server starts) + +```javascript +async function initializeOpencodeServer() { + const useServerMode = process.env.OPENCODE_SERVER_MODE !== 'false'; + + if (useServerMode) { + try { + log('Initializing OpenCode server mode...'); + await opencodeManager.start(); + log('OpenCode server mode initialized successfully'); + } catch (error) { + log('Failed to initialize OpenCode server mode, using spawn fallback', { + error: String(error) + }); + } + } else { + log('OpenCode server mode disabled, using spawn mode'); + } +} + +// Call during server startup +async function main() { + // ... existing initialization ... + + await initializeOpencodeServer(); + + // ... start HTTP server ... +} +``` + +#### 2.3 Cleanup on Shutdown + +**File:** `server.js` (in gracefulShutdown function) + +```javascript +async function gracefulShutdown(signal) { + if (isShuttingDown) return; + isShuttingDown = true; + + log(`Received ${signal}, starting graceful shutdown...`); + + // Stop periodic tasks + stopAutoSave(); + stopMemoryCleanup(); + + // Stop OpenCode server + log('Stopping OpenCode server...'); + await opencodeManager.stop(); + + // ... rest of existing shutdown logic ... +} +``` + +### Phase 3: Configuration Options + +#### Environment Variables + +```bash +# Enable/disable server mode (default: enabled) +OPENCODE_SERVER_MODE=true + +# Server port (default: 4096) +OPENCODE_SERVER_PORT=4096 + +# Server hostname (default: 127.0.0.1) +OPENCODE_SERVER_HOST=127.0.0.1 + +# Basic auth password (recommended for production) +OPENCODE_SERVER_PASSWORD=your-secure-password + +# Basic auth username (default: opencode) +OPENCODE_SERVER_USERNAME=opencode + +# Auto-restart on failure (default: true) +OPENCODE_SERVER_AUTO_RESTART=true + +# Maximum connection attempts before fallback (default: 3) +OPENCODE_SERVER_MAX_RETRIES=3 +``` + +#### Feature Flag Integration + +```javascript +// In configuration section +const OPENCODE_SERVER_MODE = process.env.OPENCODE_SERVER_MODE !== 'false'; +const OPENCODE_SERVER_PORT = Number(process.env.OPENCODE_SERVER_PORT || 4096); +const OPENCODE_SERVER_HOST = process.env.OPENCODE_SERVER_HOST || '127.0.0.1'; +const OPENCODE_SERVER_PASSWORD = process.env.OPENCODE_SERVER_PASSWORD || ''; +const OPENCODE_SERVER_USERNAME = process.env.OPENCODE_SERVER_USERNAME || 'opencode'; +const OPENCODE_SERVER_AUTO_RESTART = process.env.OPENCODE_SERVER_AUTO_RESTART !== 'false'; +const OPENCODE_SERVER_MAX_RETRIES = Number(process.env.OPENCODE_SERVER_MAX_RETRIES || 3); +``` + +### Phase 4: Error Handling and Fallback + +#### 4.1 Automatic Fallback Strategy + +```javascript +class OpencodeProcessManager { + // ... existing code ... + + async executeWithFallback(sessionId, workspaceDir, operation, fallbackFn) { + // Try server mode first + if (this.isReady) { + try { + return await operation(); + } catch (error) { + // Check if error is recoverable + if (this.isRecoverableError(error)) { + log('Recoverable error in server mode, retrying', { error: String(error) }); + await this.restart(); + return await operation(); + } + + // Fall back to spawn mode + log('Server mode failed, falling back to spawn', { error: String(error) }); + return await fallbackFn(); + } + } + + // Server not ready, use spawn mode + return await fallbackFn(); + } + + isRecoverableError(error) { + // Connection errors, timeouts, and 5xx errors are recoverable + if (error.code === 'ECONNREFUSED') return true; + if (error.code === 'ECONNRESET') return true; + if (error.code === 'ETIMEDOUT') return true; + if (error.status >= 500 && error.status < 600) return true; + return false; + } + + async restart() { + log('Restarting OpenCode server...'); + await this.stop(); + await new Promise(resolve => setTimeout(resolve, 1000)); + await this.start(); + } +} +``` + +#### 4.2 Graceful Degradation + +```javascript +// In sendToOpencodeWithFallback +async function sendToOpencodeWithFallback({ session, model, content, message, cli, streamCallback, opencodeSessionId, plan }) { + const useServerMode = opencodeManager.isReady; + + if (useServerMode) { + try { + return await sendToOpencode({ session, model, content, message, cli, streamCallback, opencodeSessionId }); + } catch (error) { + log('Server mode failed after retries, using spawn fallback', { + error: String(error), + sessionId: session.id + }); + } + } + + // Original spawn-based implementation + return sendToOpencodeViaSpawn({ session, model, content, message, cli, streamCallback, opencodeSessionId, plan }); +} +``` + +### Phase 5: Monitoring and Observability + +#### 5.1 Health Endpoint + +```javascript +// Add to HTTP server routes +app.get('/api/opencode/status', async (req, res) => { + const stats = opencodeManager.getStats(); + res.json({ + serverMode: { + enabled: OPENCODE_SERVER_MODE, + ...stats + }, + fallbackMode: !opencodeManager.isReady + }); +}); + +app.post('/api/opencode/restart', async (req, res) => { + try { + await opencodeManager.restart(); + res.json({ success: true, stats: opencodeManager.getStats() }); + } catch (error) { + res.status(500).json({ error: String(error) }); + } +}); +``` + +#### 5.2 Metrics Logging + +```javascript +class OpencodeProcessManager { + // ... existing code ... + + logMetrics() { + const stats = this.getStats(); + const memUsage = process.memoryUsage(); + + log('OpenCode server metrics', { + ...stats, + memory: { + heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024) + 'MB', + heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024) + 'MB', + rss: Math.round(memUsage.rss / 1024 / 1024) + 'MB' + } + }); + } +} + +// Log metrics periodically +setInterval(() => { + if (opencodeManager.isReady) { + opencodeManager.logMetrics(); + } +}, 60000); // Every minute +``` + +## Testing Strategy + +### Unit Tests + +```javascript +// test/opencode-server.test.js + +describe('OpencodeProcessManager', () => { + let manager; + + beforeEach(() => { + manager = new OpencodeProcessManager(); + }); + + afterEach(async () => { + await manager.stop(); + }); + + test('should start server successfully', async () => { + await manager.start(); + expect(manager.isReady).toBe(true); + expect(manager.process).toBeDefined(); + }); + + test('should handle server crash and restart', async () => { + await manager.start(); + manager.process.kill('SIGKILL'); + await new Promise(r => setTimeout(r, 5000)); + expect(manager.isReady).toBe(true); + }); + + test('should create and track sessions', async () => { + await manager.start(); + const session = await manager.createSession('/tmp/test'); + expect(session.id).toBeDefined(); + expect(manager.sessionWorkspaces.has(session.id)).toBe(true); + }); + + test('should handle concurrent requests', async () => { + await manager.start(); + const session = await manager.createSession('/tmp/test'); + + const requests = Array(10).fill(null).map(() => + manager.sendMessage(session.id, '/tmp/test', { content: 'test' }) + ); + + const results = await Promise.all(requests); + expect(results).toHaveLength(10); + }); +}); +``` + +### Integration Tests + +```javascript +describe('OpenCode Server Integration', () => { + test('should fallback to spawn mode on server failure', async () => { + // Force server failure + await opencodeManager.stop(); + opencodeManager.isReady = false; + + // Send message - should use spawn fallback + const result = await sendToOpencodeWithFallback({ + session: { id: 'test', workspaceDir: '/tmp' }, + model: 'test-model', + content: 'Hello' + }); + + expect(result).toBeDefined(); + }); + + test('should handle SSE event streaming', async () => { + await opencodeManager.start(); + + const events = []; + const unregister = opencodeManager.registerEventHandler('test-session', (event) => { + events.push(event); + }); + + // Trigger an event + opencodeManager.handleServerEvent({ + type: 'message.part', + properties: { sessionID: 'test-session', part: { text: 'test' } } + }); + + expect(events).toHaveLength(1); + unregister(); + }); +}); +``` + +## Migration Path + +### Step 1: Add Feature Flag (Low Risk) + +1. Add environment variable `OPENCODE_SERVER_MODE=false` (default disabled) +2. Add OpencodeProcessManager class enhancements +3. Test in development + +### Step 2: Enable for Testing (Medium Risk) + +1. Set `OPENCODE_SERVER_MODE=true` in staging +2. Monitor metrics and error rates +3. Compare resource usage with spawn mode + +### Step 3: Gradual Rollout (Low Risk) + +1. Enable for subset of users/sessions +2. Use canary deployment pattern +3. Monitor for issues + +### Step 4: Full Rollout (Production) + +1. Enable for all traffic +2. Keep fallback mode available +3. Remove spawn mode code in future version (optional) + +## Performance Comparison + +### Expected Improvements + +| Metric | Spawn Mode | Server Mode | Improvement | +|--------|------------|-------------|-------------| +| Memory per session | ~300MB | Shared ~400MB | 33% reduction (2 sessions) | +| Startup latency | 1-2s | 10-50ms | 95% reduction | +| CPU overhead | High (process spawn) | Low (HTTP calls) | Significant | +| Concurrent sessions | Limited by memory | Higher capacity | Better scalability | + +### Benchmarking Script + +```bash +#!/bin/bash +# benchmark-opencode.sh + +echo "Benchmarking OpenCode modes..." + +# Spawn mode +OPENCODE_SERVER_MODE=false node server.js & +PID1=$! +sleep 5 +ab -n 100 -c 10 http://localhost:4000/api/test +kill $PID1 + +# Server mode +OPENCODE_SERVER_MODE=true node server.js & +PID2=$! +sleep 5 +ab -n 100 -c 10 http://localhost:4000/api/test +kill $PID2 +``` + +## Troubleshooting + +### Common Issues + +1. **Port already in use**: Change `OPENCODE_SERVER_PORT` or stop conflicting service +2. **Connection refused**: Check if OpenCode CLI is properly installed +3. **Auth failures**: Verify `OPENCODE_SERVER_PASSWORD` matches +4. **Memory issues**: Monitor with `/api/opencode/status` endpoint + +### Debug Mode + +```bash +# Enable verbose logging +DEBUG=opencode:* OPENCODE_SERVER_MODE=true node server.js + +# Check server status +curl http://localhost:4000/api/opencode/status + +# Manual restart +curl -X POST http://localhost:4000/api/opencode/restart +``` + +## Future Enhancements + +1. **Connection pooling**: Reuse HTTP connections for better performance +2. **Request batching**: Combine multiple operations into single API calls +3. **Multi-server support**: Load balance across multiple OpenCode instances +4. **Circuit breaker**: Automatic mode switching based on error rates +5. **Prometheus metrics**: Export metrics for monitoring systems