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,137 @@
|
||||
import type { OpenCodeEvent } from './event-mapper';
|
||||
import { logger } from '../logger';
|
||||
|
||||
// One connection per `opencode serve` base URL: a single shared SSE subscription on `GET /event`
|
||||
// demultiplexed to per-session listeners, plus the REST calls a turn needs. The SDK-style
|
||||
// `/session/*` route family is used (feature-complete, incl. DELETE).
|
||||
|
||||
type Listener = (event: OpenCodeEvent) => void;
|
||||
|
||||
class ServerConnection {
|
||||
private listeners = new Map<string, Set<Listener>>();
|
||||
private sseRunning = false;
|
||||
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
|
||||
subscribe(sessionId: string, listener: Listener): () => void {
|
||||
let set = this.listeners.get(sessionId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(sessionId, set);
|
||||
}
|
||||
set.add(listener);
|
||||
void this.ensureSse();
|
||||
|
||||
return () => {
|
||||
const current = this.listeners.get(sessionId);
|
||||
if (!current) return;
|
||||
current.delete(listener);
|
||||
if (current.size === 0) this.listeners.delete(sessionId);
|
||||
};
|
||||
}
|
||||
|
||||
private dispatch(event: OpenCodeEvent): void {
|
||||
const sessionId = event.properties?.sessionID;
|
||||
if (typeof sessionId !== 'string') return;
|
||||
const set = this.listeners.get(sessionId);
|
||||
if (!set) return;
|
||||
for (const listener of set) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (err) {
|
||||
logger.error('opencode SSE listener threw', { error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep a single `/event` SSE stream open while any session has listeners; reconnect on drop. */
|
||||
private async ensureSse(): Promise<void> {
|
||||
if (this.sseRunning) return;
|
||||
this.sseRunning = true;
|
||||
|
||||
void (async () => {
|
||||
while (this.listeners.size > 0) {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/event`, { headers: { accept: 'text/event-stream' } });
|
||||
if (!res.body) throw new Error('no SSE body');
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// SSE frames are separated by a blank line.
|
||||
let sep = buffer.indexOf('\n\n');
|
||||
while (sep !== -1) {
|
||||
const frame = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
this.handleFrame(frame);
|
||||
sep = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('opencode SSE stream error; will reconnect', { baseUrl: this.baseUrl, error: String(err) });
|
||||
}
|
||||
if (this.listeners.size > 0) await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
this.sseRunning = false;
|
||||
})();
|
||||
}
|
||||
|
||||
private handleFrame(frame: string): void {
|
||||
const data = frame
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trim())
|
||||
.join('');
|
||||
if (!data) return;
|
||||
try {
|
||||
this.dispatch(JSON.parse(data) as OpenCodeEvent);
|
||||
} catch {
|
||||
/* non-JSON keep-alive or partial frame */
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`opencode POST ${path} → ${res.status} ${await res.text().catch(() => '')}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async createSession(directory: string, title?: string): Promise<string> {
|
||||
const session = await this.postJson<{ id?: string }>('/session', { directory, title });
|
||||
if (!session.id) throw new Error('opencode POST /session returned no id');
|
||||
return session.id;
|
||||
}
|
||||
|
||||
async postMessage(sessionId: string, providerID: string, modelID: string, text: string): Promise<void> {
|
||||
await this.postJson(`/session/${sessionId}/message`, {
|
||||
model: { providerID, modelID },
|
||||
parts: [{ type: 'text', text }],
|
||||
});
|
||||
}
|
||||
|
||||
async abort(sessionId: string): Promise<void> {
|
||||
await this.postJson(`/session/${sessionId}/abort`, {}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const connections = new Map<string, ServerConnection>();
|
||||
|
||||
export function getConnection(baseUrl: string): ServerConnection {
|
||||
let conn = connections.get(baseUrl);
|
||||
if (!conn) {
|
||||
conn = new ServerConnection(baseUrl);
|
||||
connections.set(baseUrl, conn);
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
export type { ServerConnection };
|
||||
Reference in New Issue
Block a user