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:
@@ -0,0 +1,75 @@
|
||||
import type { ChatEvent } from '@@/api/chat/types';
|
||||
import { logger } from '@@/api/chat/logger';
|
||||
import { ensureServer } from '@@/api/chat/opencode/server-manager';
|
||||
import { getConnection } from '@@/api/chat/opencode/client';
|
||||
import { createEventMapper } from '@@/api/chat/opencode/event-mapper';
|
||||
import { getOpenCodeSession, setOpenCodeSession } from '@@/api/chat/opencode/state';
|
||||
import { getHomeDirForRole } from '../data-path';
|
||||
|
||||
// The OpenCode analog of send-claude-code.ts's streaming path. Drives a turn against a warm
|
||||
// `opencode serve` over HTTP + SSE, mapping events to the shared ChatEvent contract.
|
||||
|
||||
type OpenCodeStreamingParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
sessionKey: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
role?: string;
|
||||
resumeSessionId?: string;
|
||||
onEvent: (event: ChatEvent) => void;
|
||||
};
|
||||
|
||||
type OpenCodeStreamingHandle = {
|
||||
kill: () => void;
|
||||
};
|
||||
|
||||
/** Split an OpenCode model id (`providerID/modelID`, e.g. `opencode/claude-opus-4-8`). */
|
||||
function splitModel(model: string): { providerID: string; modelID: string } {
|
||||
const slash = model.indexOf('/');
|
||||
if (slash <= 0) return { providerID: 'opencode', modelID: model };
|
||||
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
|
||||
}
|
||||
|
||||
export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise<OpenCodeStreamingHandle> {
|
||||
const home = getHomeDirForRole(params.email, params.role ?? '');
|
||||
const cwd = params.cwd || home;
|
||||
|
||||
logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model });
|
||||
|
||||
const { baseUrl } = await ensureServer(cwd, home);
|
||||
const conn = getConnection(baseUrl);
|
||||
|
||||
// Reuse the OpenCode session for this live sessionKey, else create one bound to the cwd.
|
||||
let opencodeSessionId = getOpenCodeSession(params.sessionKey) ?? params.resumeSessionId;
|
||||
if (!opencodeSessionId) {
|
||||
opencodeSessionId = await conn.createSession(cwd);
|
||||
setOpenCodeSession(params.sessionKey, opencodeSessionId);
|
||||
}
|
||||
const sessionId = opencodeSessionId;
|
||||
|
||||
let unsub = () => {};
|
||||
const mapper = createEventMapper((event: ChatEvent) => {
|
||||
params.onEvent(event);
|
||||
if (event.type === 'result' || event.type === 'error') unsub();
|
||||
});
|
||||
unsub = conn.subscribe(sessionId, mapper);
|
||||
|
||||
const { providerID, modelID } = splitModel(params.model ?? '');
|
||||
|
||||
// Fire the turn; assistant tokens + tool calls stream back over the SSE subscription above.
|
||||
conn.postMessage(sessionId, providerID, modelID, params.prompt).catch((err) => {
|
||||
logger.error('OpenCode postMessage failed', { sessionKey: params.sessionKey, error: String(err) });
|
||||
params.onEvent({ type: 'error', message: 'Failed to send message to OpenCode' });
|
||||
unsub();
|
||||
});
|
||||
|
||||
return {
|
||||
kill: () => {
|
||||
void conn.abort(sessionId);
|
||||
unsub();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user