/** * OpenCode SDK integration for agent interactions * * Uses @opencode-ai/sdk/v2 for native TypeScript integration. * Follows the same patterns as the Codex client. */ import { createOpencode } from '@opencode-ai/sdk/v2'; import { createServer } from 'node:net'; import type { AgentResponse } from '../../core/models/index.js'; import { createLogger, getErrorMessage, createStreamDiagnostics, parseStructuredOutput, type StreamDiagnostics } from '../../shared/utils/index.js'; import { parseProviderModel } from '../../shared/utils/providerModel.js'; import { buildOpenCodePermissionConfig, buildOpenCodePermissionRuleset, mapToOpenCodePermissionReply, mapToOpenCodeTools, type OpenCodeCallOptions, } from './types.js'; import { type OpenCodeStreamEvent, type OpenCodePart, type OpenCodeTextPart, createStreamTrackingState, emitInit, emitText, emitResult, handlePartUpdated, } from './OpenCodeStreamHandler.js'; export type { OpenCodeCallOptions } from './types.js'; const log = createLogger('opencode-sdk'); const OPENCODE_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000; const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted'; const OPENCODE_RETRY_MAX_ATTEMPTS = 3; const OPENCODE_RETRY_BASE_DELAY_MS = 250; const OPENCODE_INTERACTION_TIMEOUT_MS = 5000; const OPENCODE_SERVER_START_TIMEOUT_MS = 30000; const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'stream disconnected before completion', 'transport error', 'network error', 'error decoding response body', 'econnreset', 'etimedout', 'eai_again', 'fetch failed', 'failed to start server on port', ]; async function withTimeout( operation: (signal: AbortSignal) => Promise, timeoutMs: number, timeoutErrorMessage: string, ): Promise { const controller = new AbortController(); let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { controller.abort(); reject(new Error(timeoutErrorMessage)); }, timeoutMs); }); try { return await Promise.race([ operation(controller.signal), timeoutPromise, ]); } finally { if (timeoutId !== undefined) { clearTimeout(timeoutId); } } } function extractOpenCodeErrorMessage(error: unknown): string | undefined { if (!error || typeof error !== 'object') { return undefined; } const value = error as { message?: unknown; data?: { message?: unknown }; name?: unknown }; if (typeof value.message === 'string' && value.message.length > 0) { return value.message; } if (typeof value.data?.message === 'string' && value.data.message.length > 0) { return value.data.message; } if (typeof value.name === 'string' && value.name.length > 0) { return value.name; } return undefined; } function getCommonPrefixLength(a: string, b: string): number { const max = Math.min(a.length, b.length); let i = 0; while (i < max && a[i] === b[i]) { i += 1; } return i; } function stripPromptEcho( chunk: string, echoState: { remainingPrompt: string }, ): string { if (!chunk) return ''; if (!echoState.remainingPrompt) return chunk; const consumeLength = getCommonPrefixLength(chunk, echoState.remainingPrompt); if (consumeLength > 0) { echoState.remainingPrompt = echoState.remainingPrompt.slice(consumeLength); return chunk.slice(consumeLength); } return chunk; } type OpenCodeQuestionOption = { label: string; description: string; }; type OpenCodeQuestionInfo = { question: string; header: string; options: OpenCodeQuestionOption[]; multiple?: boolean; }; type OpenCodeQuestionAskedProperties = { id: string; sessionID: string; questions: OpenCodeQuestionInfo[]; }; function toQuestionInput(props: OpenCodeQuestionAskedProperties): { questions: Array<{ question: string; header?: string; options?: Array<{ label: string; description?: string; }>; multiSelect?: boolean; }>; } { return { questions: props.questions.map((item) => ({ question: item.question, header: item.header, options: item.options.map((opt) => ({ label: opt.label, description: opt.description, })), multiSelect: item.multiple, })), }; } function toQuestionAnswers( props: OpenCodeQuestionAskedProperties, answers: Record, ): Array> { return props.questions.map((item) => { const key = item.header || item.question; const value = answers[key]; if (!value) return []; return [value]; }); } async function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.unref(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const addr = server.address(); if (!addr || typeof addr === 'string') { server.close(() => reject(new Error('Failed to allocate free TCP port'))); return; } const port = addr.port; server.close((err) => { if (err) { reject(err); return; } resolve(port); }); }); }); } /** * Client for OpenCode SDK agent interactions. * * Handles session management, streaming event conversion, * permission auto-reply, and response processing. */ export class OpenCodeClient { private isRetriableError(message: string, aborted: boolean, abortCause?: 'timeout' | 'external'): boolean { if (aborted || abortCause) { return false; } const lower = message.toLowerCase(); return OPENCODE_RETRYABLE_ERROR_PATTERNS.some((pattern) => lower.includes(pattern)); } private async waitForRetryDelay(attempt: number, signal?: AbortSignal): Promise { const delayMs = OPENCODE_RETRY_BASE_DELAY_MS * (2 ** Math.max(0, attempt - 1)); await new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { if (signal) { signal.removeEventListener('abort', onAbort); } resolve(); }, delayMs); const onAbort = (): void => { clearTimeout(timeoutId); if (signal) { signal.removeEventListener('abort', onAbort); } reject(new Error(OPENCODE_STREAM_ABORTED_MESSAGE)); }; if (signal) { if (signal.aborted) { onAbort(); return; } signal.addEventListener('abort', onAbort, { once: true }); } }); } /** Build a prompt suffix that instructs the agent to return JSON matching the schema */ private buildStructuredOutputSuffix(schema: Record): string { return [ '', '---', 'IMPORTANT: You MUST respond with ONLY a valid JSON object matching this schema. No other text, no markdown code blocks, no explanation.', '```', JSON.stringify(schema, null, 2), '```', ].join('\n'); } /** Call OpenCode with an agent prompt */ async call( agentType: string, prompt: string, options: OpenCodeCallOptions, ): Promise { const basePrompt = options.systemPrompt ? `${options.systemPrompt}\n\n${prompt}` : prompt; // OpenCode SDK does not natively support structured output via outputFormat. // Inject JSON output instructions into the prompt to make the agent return JSON. const fullPrompt = options.outputSchema ? `${basePrompt}${this.buildStructuredOutputSuffix(options.outputSchema)}` : basePrompt; for (let attempt = 1; attempt <= OPENCODE_RETRY_MAX_ATTEMPTS; attempt++) { let idleTimeoutId: ReturnType | undefined; const streamAbortController = new AbortController(); const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; let abortCause: 'timeout' | 'external' | undefined; let diagRef: StreamDiagnostics | undefined; let serverClose: (() => void) | undefined; let opencodeApiClient: Awaited>['client'] | undefined; let sessionId: string | undefined = options.sessionId; const interactionTimeoutMs = options.interactionTimeoutMs ?? OPENCODE_INTERACTION_TIMEOUT_MS; const resetIdleTimeout = (): void => { if (idleTimeoutId !== undefined) { clearTimeout(idleTimeoutId); } idleTimeoutId = setTimeout(() => { diagRef?.onIdleTimeoutFired(); abortCause = 'timeout'; streamAbortController.abort(); }, OPENCODE_STREAM_IDLE_TIMEOUT_MS); }; const onExternalAbort = (): void => { abortCause = 'external'; streamAbortController.abort(); }; if (options.abortSignal) { if (options.abortSignal.aborted) { streamAbortController.abort(); } else { options.abortSignal.addEventListener('abort', onExternalAbort, { once: true }); } } try { log.debug('Starting OpenCode session', { agentType, model: options.model, hasSystemPrompt: !!options.systemPrompt, attempt, }); const diag = createStreamDiagnostics('opencode-sdk', { agentType, model: options.model, attempt }); diagRef = diag; const parsedModel = parseProviderModel(options.model, 'OpenCode model'); const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; const port = await getFreePort(); const permission = buildOpenCodePermissionConfig(options.permissionMode, options.networkAccess); const config = { model: fullModel, small_model: fullModel, permission, ...(options.opencodeApiKey ? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } : {}), }; const { client, server } = await createOpencode({ port, signal: streamAbortController.signal, config, timeout: OPENCODE_SERVER_START_TIMEOUT_MS, }); opencodeApiClient = client; serverClose = server.close; const sessionResult = sessionId ? { data: { id: sessionId } } : await client.session.create({ directory: options.cwd, permission: buildOpenCodePermissionRuleset(options.permissionMode, options.networkAccess), }); sessionId = sessionResult.data?.id; if (!sessionId) { throw new Error('Failed to create OpenCode session'); } const { stream } = await client.event.subscribe( { directory: options.cwd }, { signal: streamAbortController.signal }, ); resetIdleTimeout(); diag.onConnected(); const tools = mapToOpenCodeTools(options.allowedTools); const promptPayload: Record = { sessionID: sessionId, directory: options.cwd, model: parsedModel, ...(tools ? { tools } : {}), parts: [{ type: 'text' as const, text: fullPrompt }], }; if (options.outputSchema) { promptPayload.outputFormat = { type: 'json_schema', schema: options.outputSchema, }; } // OpenCode SDK types do not yet expose outputFormat even though runtime accepts it. const promptPayloadForSdk = promptPayload as unknown as Parameters[0]; await client.session.promptAsync(promptPayloadForSdk, { signal: streamAbortController.signal, }); emitInit(options.onStream, options.model, sessionId); let content = ''; let success = true; let failureMessage = ''; const state = createStreamTrackingState(); const echoState = { remainingPrompt: fullPrompt }; const textOffsets = new Map(); const textContentParts = new Map(); for await (const event of stream) { if (streamAbortController.signal.aborted) break; resetIdleTimeout(); const sseEvent = event as OpenCodeStreamEvent; diag.onFirstEvent(sseEvent.type); diag.onEvent(sseEvent.type); if (sseEvent.type === 'message.part.updated') { const props = sseEvent.properties as { part: OpenCodePart; delta?: string }; const part = props.part; const delta = props.delta; if (part.type === 'text') { const textPart = part as OpenCodeTextPart; const prev = textOffsets.get(textPart.id) ?? 0; const rawDelta = delta ?? (textPart.text.length > prev ? textPart.text.slice(prev) : ''); textOffsets.set(textPart.id, textPart.text.length); if (rawDelta) { const visibleDelta = stripPromptEcho(rawDelta, echoState); if (visibleDelta) { emitText(options.onStream, visibleDelta); const previous = textContentParts.get(textPart.id) ?? ''; textContentParts.set(textPart.id, `${previous}${visibleDelta}`); } } continue; } handlePartUpdated(part, delta, options.onStream, state); continue; } if (sseEvent.type === 'permission.asked') { const permProps = sseEvent.properties as { id: string; sessionID: string; }; if (permProps.sessionID === sessionId) { try { const reply = options.permissionMode ? mapToOpenCodePermissionReply(options.permissionMode) : 'once'; await withTimeout( (signal) => client.permission.reply({ requestID: permProps.id, directory: options.cwd, reply, }, { signal }), interactionTimeoutMs, 'OpenCode permission reply timed out', ); } catch (e) { success = false; failureMessage = getErrorMessage(e); break; } } continue; } if (sseEvent.type === 'question.asked') { const questionProps = sseEvent.properties as OpenCodeQuestionAskedProperties; if (questionProps.sessionID === sessionId) { if (!options.onAskUserQuestion) { try { await withTimeout( (signal) => client.question.reject({ requestID: questionProps.id, directory: options.cwd, }, { signal }), interactionTimeoutMs, 'OpenCode question reject timed out', ); } catch (e) { success = false; failureMessage = getErrorMessage(e); break; } continue; } try { const answers = await options.onAskUserQuestion(toQuestionInput(questionProps)); await withTimeout( (signal) => client.question.reply({ requestID: questionProps.id, directory: options.cwd, answers: toQuestionAnswers(questionProps, answers), }, { signal }), interactionTimeoutMs, 'OpenCode question reply timed out', ); } catch (e) { success = false; failureMessage = getErrorMessage(e); break; } } continue; } if (sseEvent.type === 'message.updated') { const messageProps = sseEvent.properties as { info?: { sessionID?: string; role?: 'assistant' | 'user'; time?: { completed?: number }; error?: unknown; }; }; const info = messageProps.info; const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; if (isCurrentAssistantMessage) { const streamError = extractOpenCodeErrorMessage(info?.error); if (streamError) { success = false; failureMessage = streamError; diag.onStreamError('message.updated', streamError); break; } } continue; } if (sseEvent.type === 'message.completed') { const completedProps = sseEvent.properties as { info?: { sessionID?: string; role?: 'assistant' | 'user'; error?: unknown; }; }; const info = completedProps.info; const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; if (isCurrentAssistantMessage) { const streamError = extractOpenCodeErrorMessage(info?.error); if (streamError) { success = false; failureMessage = streamError; diag.onStreamError('message.completed', streamError); break; } } continue; } if (sseEvent.type === 'message.failed') { const failedProps = sseEvent.properties as { info?: { sessionID?: string; role?: 'assistant' | 'user'; error?: unknown; }; }; const info = failedProps.info; const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; if (isCurrentAssistantMessage) { success = false; failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed'; diag.onStreamError('message.failed', failureMessage); break; } continue; } if (sseEvent.type === 'session.status') { const statusProps = sseEvent.properties as { sessionID?: string; status?: { type?: string }; }; if (statusProps.sessionID === sessionId && statusProps.status?.type === 'idle') { break; } continue; } if (sseEvent.type === 'session.idle') { const idleProps = sseEvent.properties as { sessionID: string }; if (idleProps.sessionID === sessionId) { break; } continue; } if (sseEvent.type === 'session.error') { const errorProps = sseEvent.properties as { sessionID?: string; error?: { name: string; data: { message: string } }; }; if (!errorProps.sessionID || errorProps.sessionID === sessionId) { success = false; failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error'; diag.onStreamError('session.error', failureMessage); break; } continue; } } content = [...textContentParts.values()].join('\n'); diag.onCompleted(success ? 'normal' : 'error', success ? undefined : failureMessage); if (!success) { const message = failureMessage || 'OpenCode execution failed'; const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause); if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) { log.info('Retrying OpenCode call after transient failure', { agentType, attempt, message }); await this.waitForRetryDelay(attempt, options.abortSignal); continue; } emitResult(options.onStream, false, message, sessionId); return { persona: agentType, status: 'error', content: message, timestamp: new Date(), sessionId, }; } const trimmed = content.trim(); const structuredOutput = parseStructuredOutput(trimmed, !!options.outputSchema); emitResult(options.onStream, true, trimmed, sessionId); return { persona: agentType, status: 'done', content: trimmed, timestamp: new Date(), sessionId, structuredOutput, }; } catch (error) { const message = getErrorMessage(error); const errorMessage = streamAbortController.signal.aborted ? abortCause === 'timeout' ? timeoutMessage : OPENCODE_STREAM_ABORTED_MESSAGE : message; diagRef?.onCompleted( abortCause === 'timeout' ? 'timeout' : streamAbortController.signal.aborted ? 'abort' : 'error', errorMessage, ); const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) { log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage }); await this.waitForRetryDelay(attempt, options.abortSignal); continue; } if (sessionId) { emitResult(options.onStream, false, errorMessage, sessionId); } return { persona: agentType, status: 'error', content: errorMessage, timestamp: new Date(), sessionId, }; } finally { if (idleTimeoutId !== undefined) { clearTimeout(idleTimeoutId); } if (options.abortSignal) { options.abortSignal.removeEventListener('abort', onExternalAbort); } if (opencodeApiClient) { const disposeAbortController = new AbortController(); const disposeTimeoutId = setTimeout(() => { disposeAbortController.abort(); }, 3000); try { await opencodeApiClient.instance.dispose( { directory: options.cwd }, { signal: disposeAbortController.signal }, ); } catch { // Ignore dispose errors during cleanup. } finally { clearTimeout(disposeTimeoutId); } } if (serverClose) { serverClose(); } if (!streamAbortController.signal.aborted) { streamAbortController.abort(); } } } throw new Error('Unreachable: OpenCode retry loop exhausted without returning'); } /** Call OpenCode with a custom agent configuration (system prompt + prompt) */ async callCustom( agentName: string, prompt: string, systemPrompt: string, options: OpenCodeCallOptions, ): Promise { return this.call(agentName, prompt, { ...options, systemPrompt, }); } } const defaultClient = new OpenCodeClient(); export async function callOpenCode( agentType: string, prompt: string, options: OpenCodeCallOptions, ): Promise { return defaultClient.call(agentType, prompt, options); } export async function callOpenCodeCustom( agentName: string, prompt: string, systemPrompt: string, options: OpenCodeCallOptions, ): Promise { return defaultClient.callCustom(agentName, prompt, systemPrompt, options); }