Files
shopify-ai-backup/chat/OPENCODE_SERVER_INTEGRATION.md
2026-02-19 19:50:02 +00:00

32 KiB
Raw Blame History

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

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

// 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

// 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<MessagePart>
}

Event Stream (SSE)

// 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

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

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

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)

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

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

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

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

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

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)

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)

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)

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

# 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

// 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

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

// 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

// 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

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

// 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

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

#!/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

# 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