chat: add OpenCode as a second harness — live turn (Phase 1)

Introduces an OpenCode chat harness alongside Claude, driven over HTTP + SSE against
a persistent `opencode serve`, emitting the same ChatEvent contract so the entire
chat UI and createEventHandler pipeline are unchanged.

New servers/api/chat/opencode/:
- server-manager.ts — one warm `opencode serve` per cwd (free port, health-gated,
  respawn on exit; HOME set so it reads the user's ~/.local/share/opencode auth).
  Binary pinned via OPENCODE_BIN (installed is 1.17.9; the 1.18.4 upgrade never landed).
- client.ts — per-server HTTP calls (/session create, /message, /abort) + a single
  reconnecting `/event` SSE stream demuxed to per-session listeners.
- event-mapper.ts — SSE → ChatEvent. Verified live against 1.17.9: message.part.delta
  → delta, tool parts → tool:start/tool:result, message.updated → cost, session.idle
  → result. Crucially, deltas are gated on partID being a `text` part (declared before
  its deltas) so the model's reasoning — which also streams as field:'text' — is
  dropped, matching the Claude harness hiding thinking.
- state.ts — sessionKey ↔ opencode ses_ id map for resume.

channels/send-opencode.ts — the OpenCode analog of send-claude-code: ensure serve,
create/reuse session, subscribe, post the message, forward mapped events; kill = abort.

websocket.ts — replaces the Claude-only coercion with harness routing:
provider 'claude-code' → Claude sidecar, everything else → handleOpenCodeChat.
handleStop aborts the right harness.

Verified end-to-end (streaming text, tool call/result, cost, abort) against a
throwaway serve using the free deepseek model — no prod restart involved. UI-level
model selection + session history follow in Phases 2–3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 16:29:55 +00:00
co-authored by Claude Opus 4.8
parent 669692355d
commit ad32c7516e
6 changed files with 587 additions and 11 deletions
+102 -11
View File
@@ -3,6 +3,7 @@ import { randomUUID } from 'crypto';
import type { ClientMessage, ServerMessage, Message, ChatEvent } from './types';
import { sessionManager } from './session-manager';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import { ensureClaudeSessionsCwd } from './claude-sessions';
import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
@@ -13,6 +14,10 @@ import { logger } from './logger';
// Default model when no user preference is set
const DEFAULT_MODEL = 'claude-code';
// Harness selection: the `claude-code` provider runs through the Claude sidecar; every other provider
// (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server.
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
async function getUserDefaultModel(userId: number): Promise<string | null> {
try {
const settings = await getUserSettings(userId);
@@ -275,18 +280,14 @@ async function handleChat(
: msg.prompt;
// Use provided model, or fall back to user default, or the system default.
let model = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL;
// Claude-only: coerce any legacy/non-Claude model preference to the Claude default so old saved
// settings (Pi/opencode/openrouter model ids) don't break chat.
if (!model.startsWith('claude-code')) {
logger.info('Coercing non-Claude model to Claude default', { sessionId, requested: model });
model = DEFAULT_MODEL;
}
const model = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL;
logger.info('Model selected for chat', { sessionId, model, clientModel: msg.model || null });
return handleClaudeCodeChat(ws, sessionId, model, msg, prompt);
// Route by harness: claude-code → Claude sidecar; anything else → OpenCode server.
return isClaudeModel(model)
? handleClaudeCodeChat(ws, sessionId, model, msg, prompt)
: handleOpenCodeChat(ws, sessionId, model, msg, prompt);
}
async function handleClaudeCodeChat(
@@ -377,6 +378,91 @@ async function handleClaudeCodeChat(
}
}
async function handleOpenCodeChat(
ws: ServerWebSocket<WSData>,
sessionId: string,
model: string,
msg: {
prompt: string;
displayText?: string;
groupSlug?: string;
context?: string;
contextId?: string;
cwd?: string;
cwdRoot?: string;
sandboxed?: boolean;
resumeSessionId?: string;
},
effectivePrompt: string,
): Promise<void> {
const { email, username, userId } = ws.data;
// Same cwd resolution as the Claude path: /chat runs from a chosen pwd or the default dir.
const cwd =
msg.context === 'chat'
? msg.cwd?.trim()
? resolveCwd(email, ws.data.role, msg.cwd)
: ensureClaudeSessionsCwd(email)
: resolveCwd(email, ws.data.role, msg.cwd);
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
session.userId = userId;
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
sendToClient(ws, {
type: 'session:init',
sessionId,
model,
cwd,
context: session.meta.context,
contextId: session.meta.contextId,
});
const userMsg: Message = {
id: randomUUID(),
timestamp: Date.now(),
role: 'user',
text: msg.prompt,
};
session.messages.push(userMsg);
session.meta.messageCount += 1;
session.meta.updatedAt = Date.now();
if (!session.meta.title) {
session.meta.title = (msg.displayText ?? msg.prompt).slice(0, 100);
}
session.isGenerating = true;
const onEvent = createEventHandler(sessionId, model, cwd);
try {
const handle = await sendOpenCodeStreaming({
userId,
email,
username,
prompt: effectivePrompt,
sessionKey: sessionId,
cwd,
model,
role: ws.data.role,
resumeSessionId: msg.resumeSessionId,
onEvent,
});
// Store the abort handle so handleStop can end the turn (OpenCode is aborted via this handle).
session.piProcess = sessionId as any;
session._claudeKill = handle.kill;
} catch (err) {
logger.error('Failed to start OpenCode streaming', { sessionId, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start OpenCode' });
session.isGenerating = false;
}
}
async function handleResume(
ws: ServerWebSocket<WSData>,
msg: { sessionId: string; cwd?: string; cwdRoot?: string },
@@ -429,8 +515,13 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
if (session?.piProcess) {
try {
sidecar.killClaude(sessionId, session.email);
logger.info('Killed Claude Code process via sidecar', { sessionId });
if (isClaudeModel(session.model)) {
sidecar.killClaude(sessionId, session.email);
logger.info('Killed Claude Code process via sidecar', { sessionId });
} else {
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
logger.info('Aborted OpenCode turn', { sessionId });
}
session.isGenerating = false;
} catch (err) {
logger.error('Failed to stop process', { sessionId, error: String(err) });