/** * OpenCode Agent Client * * Client for communicating with OpenCode server running in headless mode. * OpenCode is used as an AI agent that can execute MCP tools. */ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe' import { fetchWithTimeout, resolveTimeoutMs } from '@open-mercato/shared/lib/http/fetchWithTimeout' const DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS = 30_000 const DEFAULT_OPENCODE_SSE_CONNECT_TIMEOUT_MS = 15_000 function resolveOpencodeTimeoutMs(envVar: string, fallback: number): number { const raw = process.env[envVar] const parsed = raw ? Number.parseInt(raw, 10) : undefined return resolveTimeoutMs(parsed, fallback) } export type OpenCodeClientConfig = { baseUrl: string password?: string } export type OpenCodeSession = { id: string slug: string version: string projectID: string directory: string title: string time: { created: number updated: number } } export type OpenCodeMessagePart = { type: 'text' text: string } export type OpenCodeMessageInfo = { id: string sessionID: string role: 'user' | 'assistant' time: { created: number completed?: number } modelID?: string providerID?: string tokens?: { input: number output: number } error?: { name: string data: Record } } export type OpenCodeMessage = { info: OpenCodeMessageInfo parts: Array<{ id: string type: string text?: string [key: string]: unknown }> } export type OpenCodeHealth = { healthy: boolean version: string } export type OpenCodeMcpStatus = Record< string, { status: 'connected' | 'failed' | 'connecting' error?: string } > export type OpenCodeQuestionOption = { label: string description: string } export type OpenCodeQuestion = { id: string sessionID: string questions: Array<{ question: string header: string options: OpenCodeQuestionOption[] }> tool: { messageID: string callID: string } } /** * SSE Event from OpenCode event stream. */ export type OpenCodeSSEEvent = { type: string properties: Record } /** * Callback for SSE events. */ export type OpenCodeSSECallback = (event: OpenCodeSSEEvent) => void /** * Client for OpenCode server API. */ export class OpenCodeClient { private baseUrl: string private headers: Record constructor(config: OpenCodeClientConfig) { this.baseUrl = config.baseUrl.replace(/\/$/, '') this.headers = { 'Content-Type': 'application/json', } if (config.password) { const credentials = Buffer.from(`opencode:${config.password}`).toString('base64') this.headers['Authorization'] = `Basic ${credentials}` } } /** * Subscribe to SSE event stream for real-time updates. * Returns an abort function to stop the stream. */ subscribeToEvents( onEvent: OpenCodeSSECallback, onError?: (error: Error) => void ): () => void { const controller = new AbortController() const connectTimeoutMs = resolveOpencodeTimeoutMs( 'OPENCODE_SSE_CONNECT_TIMEOUT_MS', DEFAULT_OPENCODE_SSE_CONNECT_TIMEOUT_MS, ) const connectTimeoutReason = new Error( `OpenCode SSE connection timed out after ${connectTimeoutMs}ms`, ) let connectTimer: ReturnType | null = setTimeout(() => { controller.abort(connectTimeoutReason) }, connectTimeoutMs) const clearConnectTimer = () => { if (connectTimer !== null) { clearTimeout(connectTimer) connectTimer = null } } const connect = async () => { try { const res = await fetch(`${this.baseUrl}/event`, { headers: { ...this.headers, Accept: 'text/event-stream', }, signal: controller.signal, }) clearConnectTimer() if (!res.ok || !res.body) { throw new Error(`SSE connection failed: ${res.status}`) } const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // Process complete SSE messages const lines = buffer.split('\n') buffer = lines.pop() || '' // Keep incomplete line in buffer for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)) onEvent(data) } catch { // Ignore parse errors } } } } } catch (error) { clearConnectTimer() if ((error as Error).name === 'AbortError') { const reason = (controller.signal as AbortSignal & { reason?: unknown }).reason if (reason === connectTimeoutReason) { onError?.(connectTimeoutReason) } return } onError?.(error as Error) } } connect() return () => { clearConnectTimer() controller.abort() } } /** * Check OpenCode server health. */ async health(): Promise { const res = await fetchWithTimeout(`${this.baseUrl}/global/health`, { headers: this.headers, timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) if (!res.ok) { throw new Error(`Health check failed: ${res.status}`) } const data = await readJsonSafe(res, null) if (!data) throw new Error('Health check returned invalid JSON response') return data } /** * Get MCP server connection status. */ async mcpStatus(): Promise { const res = await fetchWithTimeout(`${this.baseUrl}/mcp`, { headers: this.headers, timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) if (!res.ok) { throw new Error(`MCP status check failed: ${res.status}`) } const data = await readJsonSafe(res, null) if (!data) throw new Error('MCP status check returned invalid JSON response') return data } /** * Create a new conversation session. */ async createSession(): Promise { const res = await fetchWithTimeout(`${this.baseUrl}/session`, { method: 'POST', headers: this.headers, body: JSON.stringify({}), timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) if (!res.ok) { const error = await res.text() throw new Error(`Failed to create session: ${error}`) } const data = await readJsonSafe(res, null) if (!data) throw new Error('Create session returned invalid JSON response') return data } /** * Get an existing session by ID. */ async getSession(sessionId: string): Promise { const res = await fetchWithTimeout(`${this.baseUrl}/session/${sessionId}`, { headers: this.headers, timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) if (!res.ok) { throw new Error(`Failed to get session: ${res.status}`) } const data = await readJsonSafe(res, null) if (!data) throw new Error('Get session returned invalid JSON response') return data } /** * Send a message to a session and wait for response. */ async sendMessage( sessionId: string, message: string, options?: { model?: { providerID: string; modelID: string } } ): Promise { const body: Record = { parts: [{ type: 'text', text: message }], } if (options?.model) { body.model = options.model } const res = await fetchWithTimeout(`${this.baseUrl}/session/${sessionId}/message`, { method: 'POST', headers: this.headers, body: JSON.stringify(body), timeoutMs: resolveOpencodeTimeoutMs( 'OPENCODE_SEND_MESSAGE_TIMEOUT_MS', resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), ), }) if (!res.ok) { const error = await res.text() throw new Error(`Failed to send message: ${error}`) } const data = await readJsonSafe(res, null) if (!data) throw new Error('Send message returned invalid JSON response') return data } /** * Set authentication credentials for a provider. */ async setAuth(providerId: string, apiKey: string): Promise { const res = await fetchWithTimeout(`${this.baseUrl}/auth/${providerId}`, { method: 'PUT', headers: this.headers, body: JSON.stringify({ type: 'api', key: apiKey }), timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) if (!res.ok) { throw new Error(`Failed to set auth: ${res.status}`) } } /** * Get current configuration. */ async getConfig(): Promise> { const res = await fetchWithTimeout(`${this.baseUrl}/config`, { headers: this.headers, timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) if (!res.ok) { throw new Error(`Failed to get config: ${res.status}`) } const data = await readJsonSafe>(res, null) if (!data) throw new Error('Get config returned invalid JSON response') return data } /** * Get pending questions that need user response. */ async getPendingQuestions(): Promise { const res = await fetchWithTimeout(`${this.baseUrl}/question`, { headers: this.headers, timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) if (!res.ok) { throw new Error(`Failed to get questions: ${res.status}`) } const data = await readJsonSafe(res, null) if (!data) throw new Error('Get questions returned invalid JSON response') return data } /** * Answer a pending question. * OpenCode expects: POST /question/{requestID}/reply with { answers: [["label"]] } * Each answer is an array of selected option labels (for multi-select support). */ async answerQuestion(questionId: string, answerIndex: number): Promise { // First get the question to find the selected option label const questions = await this.getPendingQuestions() const question = questions.find((q) => q.id === questionId) if (!question) { throw new Error(`Question ${questionId} not found`) } // Build answers array - each question's answer is an array of selected labels const answers: string[][] = [] for (const q of question.questions) { const selectedOption = q.options[answerIndex] if (selectedOption) { // Each answer is an array of selected labels (supports multi-select) answers.push([selectedOption.label]) } } const body = { answers } console.log('[OpenCode Client] Answering question', questionId, 'with body:', JSON.stringify(body)) const res = await fetchWithTimeout(`${this.baseUrl}/question/${questionId}/reply`, { method: 'POST', headers: this.headers, body: JSON.stringify(body), timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) const responseText = await res.text() console.log('[OpenCode Client] Answer response:', res.status, responseText.substring(0, 200)) if (!res.ok) { throw new Error(`Failed to answer question: ${res.status} - ${responseText}`) } } /** * Reject a pending question. */ async rejectQuestion(questionId: string): Promise { console.log('[OpenCode Client] Rejecting question', questionId) const res = await fetchWithTimeout(`${this.baseUrl}/question/${questionId}/reject`, { method: 'POST', headers: this.headers, timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) if (!res.ok) { const responseText = await res.text() throw new Error(`Failed to reject question: ${res.status} - ${responseText}`) } } /** * Get session status (idle, busy, waiting for question). * Falls back to inferring status from pending questions if endpoint doesn't exist. */ async getSessionStatus(sessionId: string): Promise<{ status: string; questionId?: string }> { try { const res = await fetchWithTimeout(`${this.baseUrl}/session/${sessionId}/status`, { headers: this.headers, timeoutMs: resolveOpencodeTimeoutMs('OPENCODE_REQUEST_TIMEOUT_MS', DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS), }) if (res.ok) { const contentType = res.headers.get('content-type') if (contentType && contentType.includes('application/json')) { const data = await readJsonSafe<{ status: string; questionId?: string }>(res, null) if (data) return data } } } catch { // Endpoint doesn't exist or network error - fall through to inference } // Fall back to inferring status from pending questions // Note: We can't tell if OpenCode is busy without the status endpoint // Return 'unknown' to let SSE events determine actual state const questions = await this.getPendingQuestions() const sessionQuestion = questions.find((q) => q.sessionID === sessionId) if (sessionQuestion) { return { status: 'waiting', questionId: sessionQuestion.id } } // Don't assume idle - we can't know without SSE events return { status: 'unknown' } } } /** * Create an OpenCode client with default configuration from environment. */ export function createOpenCodeClient(config?: Partial): OpenCodeClient { return new OpenCodeClient({ baseUrl: config?.baseUrl ?? process.env.OPENCODE_URL ?? 'http://localhost:4096', password: config?.password ?? process.env.OPENCODE_PASSWORD, }) }