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
@@ -0,0 +1,118 @@
import type { Subprocess } from 'bun';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync } from 'fs';
import { logger } from '../logger';
// The `opencode` binary. Pinned (like CLAUDE_BIN) rather than resolved from PATH; override with
// OPENCODE_BIN. NOTE: the installed binary is 1.17.9 — the 1.18.4 upgrade never landed on disk.
const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode');
const HEALTH_TIMEOUT_MS = 20_000;
const HEALTH_POLL_MS = 200;
const START_ATTEMPTS = 3;
type OpenCodeServer = {
baseUrl: string;
proc: Subprocess;
port: number;
};
// One warm `opencode serve` per working directory (sessions bind to a directory at creation).
const servers = new Map<string, OpenCodeServer>();
const starting = new Map<string, Promise<OpenCodeServer>>();
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const port = probe.port;
probe.stop(true);
if (port == null) throw new Error('failed to acquire a free port');
return port;
}
async function isHealthy(baseUrl: string): Promise<boolean> {
try {
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(2000) });
return res.ok;
} catch {
return false;
}
}
async function waitHealthy(baseUrl: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isHealthy(baseUrl)) return true;
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
}
return false;
}
async function startServer(cwd: string, home: string): Promise<OpenCodeServer> {
mkdirSync(cwd, { recursive: true });
for (let attempt = 1; attempt <= START_ATTEMPTS; attempt += 1) {
const port = getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
// Loopback-only + no OPENCODE_SERVER_PASSWORD → the server is open on 127.0.0.1 (single-user box).
// HOME is set to the caller's home so `opencode` reads that user's ~/.local/share/opencode auth.
const proc = Bun.spawn([OPENCODE_BIN, 'serve', '--port', String(port), '--hostname', '127.0.0.1'], {
cwd,
env: { ...process.env, HOME: home },
stdout: 'ignore',
stderr: 'ignore',
});
if (await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS)) {
logger.info('opencode serve started', { cwd, baseUrl });
proc.exited.then((code) => {
// Drop the cached entry on exit so the next turn respawns.
if (servers.get(cwd)?.proc === proc) servers.delete(cwd);
logger.warn('opencode serve exited', { cwd, code });
});
return { baseUrl, proc, port };
}
logger.warn('opencode serve failed health check, retrying', { cwd, baseUrl, attempt });
try {
proc.kill();
} catch {
/* already gone */
}
}
throw new Error(`opencode serve failed to start for cwd ${cwd}`);
}
/** Ensure a healthy `opencode serve` for `cwd`, returning its base URL. Dedupes concurrent starts. */
export async function ensureServer(cwd: string, home: string): Promise<{ baseUrl: string }> {
const existing = servers.get(cwd);
if (existing && (await isHealthy(existing.baseUrl))) {
return { baseUrl: existing.baseUrl };
}
if (existing) {
try {
existing.proc.kill();
} catch {
/* already gone */
}
servers.delete(cwd);
}
const inflight = starting.get(cwd);
if (inflight) {
const s = await inflight;
return { baseUrl: s.baseUrl };
}
const promise = startServer(cwd, home);
starting.set(cwd, promise);
try {
const server = await promise;
servers.set(cwd, server);
return { baseUrl: server.baseUrl };
} finally {
starting.delete(cwd);
}
}