import { createOpencode, createOpencodeClient, type OpencodeClient, } from "@opencode-ai/sdk/v2"; import { config } from "../config.js"; import { logger } from "../logger.js"; const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const logDevelopmentDebug = ( message: string, metadata: Record, ) => { if (!isDevelopmentDebugLoggingEnabled) { return; } logger.info(metadata, message); }; export type RuntimeHealth = { healthy: boolean; version: string; }; type RuntimeModelOverride = { providerID: string; modelID: string; }; export class OpencodeRuntimeAdapter { private clientPromise: Promise | null = null; private closeServer: (() => void) | null = null; async ensureClient(): Promise { if (!this.clientPromise) { this.clientPromise = this.bootstrapClient(); } return this.clientPromise; } async health(): Promise { const client = await this.ensureClient(); const response = await client.global.health(); return requireData(response.data, "global.health"); } async createSession(title?: string) { const client = await this.ensureClient(); const response = await client.session.create({ title, }); return requireData(response.data, "session.create"); } async getSession(id: string) { const client = await this.ensureClient(); const response = await client.session.get({ sessionID: id, }); return requireData(response.data, "session.get"); } async sendPrompt(sessionId: string, text: string) { await this.prompt(sessionId, text); // 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts, // 所以这里紧跟一次 messages() 回读,给上层路由统一消费。 return this.messages(sessionId); } async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) { const client = await this.ensureClient(); const startedAt = Date.now(); logDevelopmentDebug( "dispatching opencode session.prompt", { sessionId, model: model ?? null, textChars: text.length, }, ); await client.session.prompt({ sessionID: sessionId, model, parts: [{ type: "text", text }], }); logDevelopmentDebug( "opencode session.prompt returned", { sessionId, elapsedMs: Math.max(0, Date.now() - startedAt), }, ); } async messages(sessionId: string, limit = 20) { const client = await this.ensureClient(); const messages = await client.session.messages({ sessionID: sessionId, limit, }); return requireData(messages.data, "session.messages"); } async forkSession(sessionId: string, messageId?: string) { const client = await this.ensureClient(); const response = await client.session.fork({ sessionID: sessionId, messageID: messageId, }); return requireData(response.data, "session.fork"); } async abortSession(sessionId: string) { const client = await this.ensureClient(); const response = await client.session.abort({ sessionID: sessionId, }); return requireData(response.data, "session.abort"); } async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) { const client = await this.ensureClient(); const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const response = await client.session.status({}); const statuses = requireData(response.data, "session.status"); const status = statuses[sessionId]; if (!status || status.type === "idle") { return; } await delay(100); } logger.warn( { sessionId, timeoutMs }, "timed out waiting for opencode session to become idle", ); } async subscribeEvents() { const client = await this.ensureClient(); const response = await client.event.subscribe(); return response.stream; } async dispose(): Promise { this.closeServer?.(); this.closeServer = null; this.clientPromise = null; } private async bootstrapClient(): Promise { if (config.OPENCODE_MODE === "client") { logger.info( { baseUrl: config.OPENCODE_CLIENT_BASE_URL, mode: config.OPENCODE_MODE, }, "connecting to opencode server in client mode", ); return createOpencodeClient({ baseUrl: config.OPENCODE_CLIENT_BASE_URL, }); } // embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里, // 这样 .opencode/tools 下的自定义工具可以回调本服务。 process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`; process.env.TJWATER_AGENT_INTERNAL_TOKEN = config.AGENT_INTERNAL_TOKEN ?? process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; logger.info( { hostname: config.OPENCODE_HOSTNAME, port: config.OPENCODE_PORT, model: config.OPENCODE_MODEL, mode: config.OPENCODE_MODE, }, "starting opencode server in embedded mode", ); let runtime; try { runtime = await createOpencode({ hostname: config.OPENCODE_HOSTNAME, port: config.OPENCODE_PORT, timeout: config.OPENCODE_TIMEOUT_MS, config: { model: config.OPENCODE_MODEL, }, }); } catch (error) { if (isMissingOpencodeCli(error)) { throw new Error( "embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL", ); } throw error; } this.closeServer = () => { runtime.server.close(); }; return runtime.client; } } export const opencodeRuntime = new OpencodeRuntimeAdapter(); function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException { return ( typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" ); } function requireData(data: T | undefined, operation: string): T { if (data === undefined) { throw new Error(`${operation} returned no data`); } return data; } function delay(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); }); }