1276 lines
32 KiB
Markdown
1276 lines
32 KiB
Markdown
# 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<MessagePart>
|
||
}
|
||
```
|
||
|
||
#### 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
|