/** * v2.6 Phase 1 — OpenCodeServerBackend. * * Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP * server per BooCoder process; one opencode session per BooCode session (resumed * on switch-back); one SSE read loop PER session, each scoped to that session's * worktree directory so sessions in different directories stream concurrently * (P1.5-a — replaced the Phase-1 single-stream-last-directory model). * * Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic * `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them * to WS frames. No dispatcher/route references this file yet. * * Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2 / §2a. * SDK shapes verified by direct read of @opencode-ai/sdk@1.15.12 dist .d.ts: * - client methods take FLATTENED params (sessionID/directory/body all inline), * not {path,query,body}. create→{directory}, promptAsync→{sessionID,directory, * parts,model}, abort→{sessionID,directory}. model is {providerID,modelID}. * - client.event() resolves to { stream: AsyncGenerator }; the * real event is chunk.payload (discriminate on chunk.payload.type). * - promptAsync is fire-and-forget (204); the turn completes via a * 'session.idle' event for that opencode session id. */ import { spawn, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createServer } from 'node:net'; import type { FastifyBaseLogger } from 'fastify'; import { createOpencodeClient, type OpencodeClient, type Event, type Part, type ToolPart, type ToolState, type AssistantMessage, } from '@opencode-ai/sdk/v2/client'; import type { ToolCallStatus } from '@agentclientprotocol/sdk'; import type { Sql } from '../../db.js'; import type { AcpToolSnapshot } from '../acp-tool-snapshot.js'; import type { AgentBackend, AgentEvent, AgentSessionHandle, EnsureSessionOpts, PromptCtx, TurnResult, } from '../agent-backend.js'; const READY_TIMEOUT_MS = 30_000; const SSE_RECONNECT_DELAY_MS = 1_000; /** * No-activity backstop for an in-flight turn. opencode streams reasoning/text/tool * deltas continuously while working, so "zero events for this long" means the turn * is wedged or its terminal event (session.idle) was lost (see the reconnect race * below). Generous so a legitimately slow turn never trips it. */ const TURN_INACTIVITY_MS = 180_000; /** One in-flight turn's emitter + completion settler. */ interface TurnState { onEvent: (e: AgentEvent) => void; settle: (r: TurnResult) => void; } /** Per-(opencode session) demux state. dedup sets scoped here, cleared per turn. */ interface SessionState { boocodeSessionId: string; agentSessionId: string; /** Worktree directory for SDK `directory` routing; refreshed each turn from ctx. */ worktreePath: string; /** dedup gate: `${type}:${id}` added on delta, deleted-and-tested on updated. Cleared at turn end. */ streamedPartKeys: Set; /** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. Cleared at turn end. */ partTypeById: Map; activeTurn: TurnState | null; /** Inactivity backstop timer for the active turn; null when no turn in flight. */ watchdog: ReturnType | null; /** Per-session SSE subscription handle. Non-null while the loop is running; * aborting it tears down the underlying fetch and exits the loop. */ sseAbort: AbortController | null; } export interface OpenCodeServerBackendDeps { sql: Sql; log: FastifyBaseLogger; /** Absolute path to the opencode binary (resolved from available_agents at wiring time, Phase 1.7). */ opencodeBinary: string; } export class OpenCodeServerBackend implements AgentBackend { readonly backend = 'opencode_server' as const; private readonly sql: Sql; private readonly log: FastifyBaseLogger; private readonly opencodeBinary: string; private child: ChildProcess | null = null; private client: OpencodeClient | null = null; private port: number | null = null; private up = false; private serverStarting: Promise | null = null; /** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */ private readonly byOpencodeId = new Map(); constructor(deps: OpenCodeServerBackendDeps) { this.sql = deps.sql; this.log = deps.log; this.opencodeBinary = deps.opencodeBinary; } /** §2: liveness for the health endpoint + dispatcher fallback decision. */ health(): 'up' | 'down' { return this.up ? 'up' : 'down'; } // ─── Server lifecycle (1.2: spawn once + client + ready) ───────────────────── /** Lazy: start the single server on first use. Idempotent — one server per backend. */ private ensureServer(): Promise { if (!this.serverStarting) this.serverStarting = this.startServer(); return this.serverStarting; } private async startServer(): Promise { const port = await freePort(); // Phase 1: run unsecured on loopback (opencode's documented default — serve.ts // only WARNS when OPENCODE_SERVER_PASSWORD is unset). The real boundary is the // 127.0.0.1 bind. Defense-in-depth basic-auth is deferred: the hey-api client's // auth wiring + opencode's exact scheme must be confirmed against a live server // first, else every request 401s. Recon explicitly said "do NOT block on it". const child = spawn(this.opencodeBinary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, }); this.child = child; this.port = port; // Child lifetime is the backend's (the pool's), NOT a request's. We never tie // it to a per-turn abort signal. On unexpected exit we mark down + log; crash // recovery is Phase 3. child.on('exit', (code, signal) => { this.up = false; this.log.warn({ code, signal, port }, 'opencode-server: child exited (recovery is Phase 3)'); }); await waitForReady(child, READY_TIMEOUT_MS); this.client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` }); this.up = true; this.log.info({ port }, 'opencode-server: ready'); } // ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ─────────────────── /** Per-session SSE subscription, scoped to the session's worktree directory. * opencode scopes events by the `directory` query param (defaults to the * server's cwd if omitted), so two sessions in different worktrees each get * their own dir-scoped stream and never drop each other's events. Idempotent: * a no-op if this session's loop is already running. Started from ensureSession * (and defensively from prompt) once worktreePath is known. */ private startSessionEventLoop(state: SessionState): void { if (state.sseAbort) return; // already running const abort = new AbortController(); state.sseAbort = abort; void this.runSessionEventLoop(state, abort).finally(() => { // Only clear if this controller is still the live one (a later restart may // have already installed a new one). if (state.sseAbort === abort) state.sseAbort = null; }); } private async runSessionEventLoop(state: SessionState, abort: AbortController): Promise { const signal = abort.signal; while (this.up && this.client && !signal.aborted) { try { // Re-read worktreePath each (re)subscribe so a directory refresh is picked // up on reconnect. Passing `signal` lets close/dispose tear down a stream // that's parked in `for await` between events. const sub = await this.client.event.subscribe( { directory: state.worktreePath }, { signal }, ); for await (const ev of sub.stream) { if (signal.aborted) break; // Dir-scoped streams should only carry this session's events, but two // sessions sharing a worktree (possible post-P1.5-b) each receive BOTH // sessions' events — so drop anything that isn't ours, else the other // session's deltas get processed twice (once per loop). const sid = eventSessionId(ev); if (sid != null && sid !== state.agentSessionId) continue; this.dispatchEvent(ev); } if (this.up && !signal.aborted) { await this.reconcile(state); // recover an idle/error lost during the gap await sleep(SSE_RECONNECT_DELAY_MS); } } catch (err) { if (!this.up || signal.aborted) break; this.log.warn( { err: errMsg(err), agentSessionId: state.agentSessionId }, 'opencode-server: session event loop error; reconnecting', ); await this.reconcile(state); await sleep(SSE_RECONNECT_DELAY_MS); } } } /** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */ private dispatchEvent(ev: Event): void { switch (ev.type) { // ─── session.next.* — live streaming events (the primary path) ───────── case 'session.next.text.delta': { const p = ev.properties; const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); const cleaned = stripDcpTags(p.delta); if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned }); return; } case 'session.next.reasoning.delta': { const p = ev.properties; const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); st.activeTurn.onEvent({ type: 'reasoning', text: p.delta }); return; } case 'session.next.tool.called': { const p = ev.properties; const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); const snap: AcpToolSnapshot = { toolCallId: p.callID, title: p.tool, kind: null, status: 'in_progress', rawInput: p.input, rawOutput: undefined, }; st.activeTurn.onEvent({ type: 'tool_call', toolCall: snap }); return; } case 'session.next.tool.success': { const p = ev.properties; const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); const output = p.content?.map((c) => ('text' in c ? (c as { text: string }).text : '')).join('') ?? ''; const snap: AcpToolSnapshot = { toolCallId: p.callID, title: p.callID, kind: null, status: 'completed', rawInput: undefined, rawOutput: output, }; st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap }); return; } case 'session.next.tool.failed': { const p = ev.properties; const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); const snap: AcpToolSnapshot = { toolCallId: p.callID, title: p.callID, kind: null, status: 'failed', rawInput: undefined, rawOutput: errToString(p.error), }; st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap }); return; } // ─── message.part.* — terminal/post-hoc events (dedup gate) ──────────── case 'message.part.delta': { const p = ev.properties; const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); const isReasoning = p.field === 'reasoning' || st.partTypeById.get(p.partID) === 'reasoning'; if (isReasoning) { st.streamedPartKeys.add(`reasoning:${p.partID}`); st.activeTurn.onEvent({ type: 'reasoning', text: p.delta }); } else if (p.field === 'text') { st.streamedPartKeys.add(`text:${p.partID}`); const cleaned = stripDcpTags(p.delta); if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned }); } return; } case 'message.part.updated': { const part = ev.properties.part; const st = this.byOpencodeId.get(part.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); this.handleUpdatedPart(part, st); return; } // ─── lifecycle ───────────────────────────────────────────────────────── case 'session.idle': { this.byOpencodeId.get(ev.properties.sessionID)?.activeTurn?.settle({ ok: true }); return; } case 'session.error': { const sid = ev.properties.sessionID; if (!sid) return; this.byOpencodeId.get(sid)?.activeTurn?.settle({ ok: false, error: errToString(ev.properties.error) }); return; } default: return; } } /** Terminal part: dedup gate for text/reasoning; tool parts → tool_call/tool_update. */ private handleUpdatedPart(part: Part, st: SessionState): void { const turn = st.activeTurn; if (!turn) return; if (part.type === 'text' || part.type === 'reasoning') { st.partTypeById.set(part.id, part.type); const key = resolvePartDedupeKey(part, part.type); if (key && st.streamedPartKeys.delete(key)) return; // already streamed via delta const raw = part.text ?? ''; const text = part.type === 'text' ? stripDcpTags(raw) : raw; if (text && part.time?.end != null) { turn.onEvent({ type: part.type, text }); } return; } if (part.type === 'tool') { const snap = toolPartToSnapshot(part); const status = part.state?.status; // tool_call on start (pending/running), tool_update on terminal (completed/error). // The current ACP path merges both into one frame; the contract keeps them // distinct because opencode's SSE distinguishes start from result. const event: AgentEvent = status === 'completed' || status === 'error' ? { type: 'tool_update', toolCall: snap } : { type: 'tool_call', toolCall: snap }; turn.onEvent(event); return; } // NOTE: opencode's SSE payload union carries no available-commands event, so the // AgentEvent 'commands' arm is intentionally never emitted here (1.3). } // ─── turn-completion resilience (watchdog + reconnect reconcile) ───────────── /** Reset the inactivity backstop on any event routed to a session's active turn. */ private bumpActivity(st: SessionState): void { if (!st.activeTurn) return; if (st.watchdog) clearTimeout(st.watchdog); st.watchdog = setTimeout(() => { void this.onTurnStall(st); }, TURN_INACTIVITY_MS); st.watchdog.unref?.(); } /** Watchdog fired: reconcile once; if the server says still-running we can't tell, so fail closed. * Also mark the agent_sessions row crashed so a stale session isn't resumed next turn. */ private async onTurnStall(st: SessionState): Promise { const settled = await this.reconcile(st); if (!settled) { this.log.warn({ agentSessionId: st.agentSessionId }, 'opencode-server: turn stalled (no activity), failing + marking crashed'); await this.sql` UPDATE agent_sessions SET status = 'crashed' WHERE agent_session_id = ${st.agentSessionId} `.catch(() => {}); st.activeTurn?.settle({ ok: false, error: 'turn timed out (no activity)' }); } } /** * Ask the server whether this session's turn already finished — recovers a * session.idle/error lost during an SSE gap. Returns true if it settled the turn. * Inconclusive (still running / call failed) → false; the watchdog covers that. */ private async reconcile(st: SessionState): Promise { const turn = st.activeTurn; if (!turn || !this.client) return false; try { const res = await this.client.session.messages({ sessionID: st.agentSessionId, directory: st.worktreePath, }); if (res.error || !res.data) return false; let lastAssistant: AssistantMessage | undefined; for (let i = res.data.length - 1; i >= 0; i--) { const info = res.data[i]!.info; if (info.role === 'assistant') { lastAssistant = info; break; } } if (!lastAssistant) return false; if (lastAssistant.error != null) { turn.settle({ ok: false, error: errToString(lastAssistant.error) }); return true; } if (lastAssistant.time.completed != null) { turn.settle({ ok: true }); return true; } return false; // still running — the live stream will deliver session.idle } catch { return false; // inconclusive — watchdog backstop covers it } } // ─── ensureSession: create-or-resume against agent_sessions (1.5) ──────────── async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise { await this.ensureServer(); if (!this.client) throw new Error('opencode-server: client not ready after ensureServer'); const configHash = sessionConfigHash(opts.model); // P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the // context unit (two tabs in one session = two contexts sharing one worktree). // session_id + worktree_id are retained as informational (SET NULL) columns. const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>` SELECT agent_session_id, status, config_hash FROM agent_sessions WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent} `; let agentSessionId = row?.agent_session_id ?? null; // Don't resume crashed sessions or sessions whose config drifted (model change). const shouldResume = agentSessionId && row!.status !== 'crashed' && (row!.config_hash == null || row!.config_hash === configHash); if (!shouldResume) { if (agentSessionId) { this.log.info({ sessionId, oldStatus: row!.status, hashMatch: row!.config_hash === configHash }, 'opencode-server: not resuming stale session, creating fresh'); this.byOpencodeId.delete(agentSessionId); } const created = await this.client.session.create({ directory: opts.worktreePath }); if (created.error || !created.data) { throw new Error(`opencode-server: session.create failed: ${errToString(created.error)}`); } agentSessionId = created.data.id; await this.sql` INSERT INTO agent_sessions (chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash) VALUES (${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash}) ON CONFLICT (chat_id, agent) DO UPDATE SET session_id = EXCLUDED.session_id, worktree_id = EXCLUDED.worktree_id, backend = 'opencode_server', agent_session_id = EXCLUDED.agent_session_id, server_port = EXCLUDED.server_port, status = 'active', last_active_at = clock_timestamp(), config_hash = EXCLUDED.config_hash `; } else { await this.sql` UPDATE agent_sessions SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash} WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent} `; } // Both branches above guarantee agentSessionId is non-null. const ocSessionId = agentSessionId!; // Register / refresh the demux entry the SSE loop keys on. Preserve an existing // entry (and any in-flight turn) — just refresh the routing fields. let state = this.byOpencodeId.get(ocSessionId); if (state) { state.boocodeSessionId = sessionId; state.worktreePath = opts.worktreePath; } else { state = { boocodeSessionId: sessionId, agentSessionId: ocSessionId, worktreePath: opts.worktreePath, streamedPartKeys: new Set(), partTypeById: new Map(), activeTurn: null, watchdog: null, sseAbort: null, }; this.byOpencodeId.set(ocSessionId, state); } // Start this session's own SSE loop, scoped to its worktree directory. Both // fresh-create and resume reach here; idempotent, so a re-ensure (e.g. a // second turn) won't spawn a duplicate loop. this.startSessionEventLoop(state); return { sessionId, agent: opts.agent, backend: 'opencode_server', chatId: opts.chatId, worktreeId: opts.worktreeId, agentSessionId: ocSessionId, serverPort: this.port, }; } // ─── prompt: send one turn (1.6) ───────────────────────────────────────────── async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise { if (!this.client) throw new Error('opencode-server: client not ready'); const oc = handle.agentSessionId; if (!oc) throw new Error('opencode-server: handle has no agentSessionId'); let state = this.byOpencodeId.get(oc); if (!state) { state = { boocodeSessionId: handle.sessionId, agentSessionId: oc, worktreePath: ctx.worktreePath, streamedPartKeys: new Set(), partTypeById: new Map(), activeTurn: null, watchdog: null, sseAbort: null, }; this.byOpencodeId.set(oc, state); } const session = state; // Authoritative per-turn directory for SDK routing + reconcile. session.worktreePath = ctx.worktreePath; // Defensive: ensureSession normally starts the loop, but if prompt is reached // with a freshly-created state (no loop yet), start it so the turn streams. // Idempotent when ensureSession already started one. this.startSessionEventLoop(session); const client = this.client; return await new Promise((resolve) => { let settled = false; const cleanup = () => { session.activeTurn = null; if (session.watchdog) { clearTimeout(session.watchdog); session.watchdog = null; } session.streamedPartKeys.clear(); session.partTypeById.clear(); ctx.signal.removeEventListener('abort', onAbort); }; const settle = (r: TurnResult) => { if (settled) return; settled = true; cleanup(); resolve(r); }; const onAbort = () => { // Abort the turn only — never the server. client.session.abort({ sessionID: oc, directory: ctx.worktreePath }).catch(() => {}); settle({ ok: false, error: 'aborted' }); }; session.activeTurn = { onEvent: ctx.onEvent, settle }; this.bumpActivity(session); // arm the inactivity backstop if (ctx.signal.aborted) { onAbort(); return; } ctx.signal.addEventListener('abort', onAbort, { once: true }); const model = parseModel(ctx.model); client.session .promptAsync({ sessionID: oc, directory: ctx.worktreePath, parts: [{ type: 'text', text: input }], ...(model ? { model } : {}), }) .then((res) => { // promptAsync is fire-and-forget (204); the turn completes via session.idle. // Only a submission error settles here. if (res.error) settle({ ok: false, error: errToString(res.error) }); }) .catch((err) => settle({ ok: false, error: errMsg(err) })); }); } // ─── teardown ──────────────────────────────────────────────────────────────── async closeSession(handle: AgentSessionHandle): Promise { if (handle.agentSessionId) { // Stop this session's SSE loop before dropping its demux entry. this.byOpencodeId.get(handle.agentSessionId)?.sseAbort?.abort(); this.byOpencodeId.delete(handle.agentSessionId); } await this.sql` UPDATE agent_sessions SET status = 'closed' WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent} `.catch(() => {}); } async dispose(): Promise { this.up = false; // Abort every per-session SSE loop so none survive the teardown. for (const st of this.byOpencodeId.values()) st.sseAbort?.abort(); const child = this.child; this.child = null; this.client = null; this.byOpencodeId.clear(); if (child && !child.killed) { child.kill('SIGTERM'); const t = setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); }, 5_000); t.unref(); } } } // ─── helpers ────────────────────────────────────────────────────────────────── /** Extract the opencode sessionID an event belongs to, across event shapes. * Most carry `properties.sessionID`; `message.part.updated` nests it under * `properties.part.sessionID`. Returns null when the event has no session * (the per-session loop then leaves it to dispatchEvent, which drops it). */ function eventSessionId(ev: Event): string | null { const props = (ev as { properties?: unknown }).properties; if (!props || typeof props !== 'object') return null; if (ev.type === 'message.part.updated') { const part = (props as { part?: { sessionID?: string } }).part; return part?.sessionID ?? null; } return (props as { sessionID?: string }).sessionID ?? null; } /** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */ function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined { if (!model || !model.trim()) return undefined; const trimmed = model.trim(); const idx = trimmed.indexOf('/'); if (idx > 0 && idx < trimmed.length - 1) { return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) }; } // No slash but non-empty → infer llama-swap (the only configured provider). // Guard against bare '/' or trailing/leading slash. if (idx < 0 && trimmed.length > 0) { return { providerID: 'llama-swap', modelID: trimmed }; } return undefined; } /** Ported verbatim from Paseo opencode-agent.ts: id → message-id fallback → null. */ function resolvePartDedupeKey(part: { id: string; messageID: string }, type: string): string | null { if (part.id.trim().length > 0) return `${type}:${part.id}`; if (part.messageID.trim().length > 0) return `${type}:message:${part.messageID}`; return null; } /** opencode ToolPart → ACP-shaped snapshot (reuses the existing persist/render path). */ function toolPartToSnapshot(part: ToolPart): AcpToolSnapshot { const state = part.state; let rawInput: unknown; let rawOutput: unknown; let title: string | undefined; if (state) { if ('input' in state) rawInput = (state as { input?: unknown }).input; if ('output' in state) rawOutput = (state as { output?: unknown }).output; else if ('error' in state) rawOutput = (state as { error?: unknown }).error; if ('title' in state) title = (state as { title?: string }).title; } return { toolCallId: part.callID, title: title ?? part.tool, kind: null, status: mapToolStatus(state?.status), rawInput, rawOutput, }; } function mapToolStatus(s: ToolState['status'] | undefined): ToolCallStatus | null { switch (s) { case 'pending': return 'pending'; case 'running': return 'in_progress'; case 'completed': return 'completed'; case 'error': return 'failed'; default: return null; } } /** Bind-probe an ephemeral port on loopback. */ function freePort(): Promise { return new Promise((resolve, reject) => { const srv = createServer(); srv.unref(); srv.on('error', reject); srv.listen(0, '127.0.0.1', () => { const addr = srv.address(); if (addr && typeof addr === 'object') { const { port } = addr; srv.close(() => resolve(port)); } else { srv.close(() => reject(new Error('opencode-server: could not determine a free port'))); } }); }); } /** Resolve when the child prints the ready line; reject on timeout or early exit. */ function waitForReady(child: ChildProcess, timeoutMs: number): Promise { return new Promise((resolve, reject) => { let done = false; let stderrBuf = ''; const finish = (err?: Error) => { if (done) return; done = true; clearTimeout(timer); child.stdout?.off('data', onOut); child.stderr?.off('data', onErr); child.off('exit', onExit); if (err) reject(err); else resolve(); }; const onOut = (buf: Buffer) => { if (buf.toString().includes('opencode server listening on')) finish(); }; const onErr = (buf: Buffer) => { stderrBuf += buf.toString(); }; const onExit = (code: number | null) => finish(new Error(`opencode serve exited before ready (code ${code}); stderr: ${stderrBuf.slice(-2000)}`)); const timer = setTimeout( () => finish(new Error(`opencode serve not ready in ${timeoutMs}ms; stderr: ${stderrBuf.slice(-2000)}`)), timeoutMs, ); child.stdout?.on('data', onOut); child.stderr?.on('data', onErr); child.on('exit', onExit); }); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } /** Strip opencode-dcp plugin tags that render as literal text in the UI. */ function stripDcpTags(s: string): string { return s.replace(/[^<]*<\/dcp-message-id>/g, ''); } function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); } function errToString(e: unknown): string { if (e == null) return 'unknown error'; if (typeof e === 'string') return e; if (e instanceof Error) return e.message; try { return JSON.stringify(e); } catch { return String(e); } } /** Hash of stable config — detects model changes across sessions without * invalidating on ephemeral state like the random server port (which changes * every BooCoder restart). */ function sessionConfigHash(model: string): string { return createHash('sha256').update(`opencode_server|${model}`).digest('hex').slice(0, 16); }