import { createOpencode, type OpencodeClient, } from "@opencode-ai/sdk/v2"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { chmod, mkdir, rmdir } from "node:fs/promises"; import { resolve } from "node:path"; import { config } from "../config.js"; import { logger } from "../logger.js"; import { ensureDirectory } from "../utils/fileStore.js"; import { setSandboxOwnership } from "./conversationWorkspace.js"; import { cleanupExpiredToolOutputs, resolveOpencodeToolOutputDirectory, } from "./opencodeToolOutputCleanup.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 type PermissionReply = "once" | "always" | "reject"; export type QuestionAnswers = string[][]; type RuntimeMessage = { info: { id: string; role: string; }; }; const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role; const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id; export class OpencodeRuntimeAdapter { private clientPromise: Promise | null = null; private closeServer: (() => void) | null = null; private toolOutputCleanupTimer: ReturnType | null = null; async ensureClient(): Promise { if (!this.clientPromise) { this.clientPromise = this.bootstrapClient().catch((error) => { this.clientPromise = null; throw error; }); } return this.clientPromise; } async health(): Promise { const client = await this.ensureClient(); const response = await client.global.health(); return requireData(response.data, "global.health"); } async warmup(): Promise { const client = await this.ensureClient(); const healthStartedAt = Date.now(); const healthResponse = await client.global.health(); const health = requireData(healthResponse.data, "global.health"); logDevelopmentDebug("opencode warmup health check completed", { elapsedMs: Math.max(0, Date.now() - healthStartedAt), healthy: health.healthy, version: health.version, }); const sessionStartedAt = Date.now(); const sessionResponse = await client.session.create({ title: "tjwater-agent-warmup", }); const session = requireData(sessionResponse.data, "session.create"); logDevelopmentDebug("opencode warmup session created", { elapsedMs: Math.max(0, Date.now() - sessionStartedAt), sessionId: session.id, }); try { const [provider, model] = config.OPENCODE_MODEL.split("/"); if (!provider || !model) { throw new Error( `invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`, ); } const toolsStartedAt = Date.now(); const toolsResponse = await client.tool.list({ provider, model }); const tools = requireData(toolsResponse.data, "tool.list"); logDevelopmentDebug("opencode warmup tools loaded", { elapsedMs: Math.max(0, Date.now() - toolsStartedAt), model: config.OPENCODE_MODEL, sessionId: session.id, toolCount: tools.length, }); } finally { const cleanupStartedAt = Date.now(); let cleanupSucceeded = true; await client.session.delete( { sessionID: session.id }, { throwOnError: true }, ).catch((error) => { cleanupSucceeded = false; logger.warn( { err: error, sessionId: session.id }, "failed to remove opencode warmup session", ); }); logDevelopmentDebug("opencode warmup session cleanup completed", { elapsedMs: Math.max(0, Date.now() - cleanupStartedAt), sessionId: session.id, succeeded: cleanupSucceeded, }); } } async createSession( title?: string, options: { conversationWorkspace?: boolean; workspaceRoot?: string; } = {}, ) { const client = await this.ensureClient(); if (!options.conversationWorkspace) { const response = await client.session.create({ title }); return requireData(response.data, "session.create"); } const workspaceRoot = resolve( options.workspaceRoot ?? config.RESULT_REF_IMPORT_DIR, ); const directory = resolve(workspaceRoot, `conversation-${randomUUID()}`); await ensureDirectory(workspaceRoot); await chmod(workspaceRoot, 0o700); await mkdir(directory, { mode: 0o700 }); await setSandboxOwnership(directory); try { const response = await client.session.create({ directory, title, permission: [ { permission: "read", pattern: `${directory}/**`, action: "allow" }, { permission: "edit", pattern: `${directory}/**`, action: "ask" }, ], }); return requireData(response.data, "session.create"); } catch (error) { await rmdir(directory).catch(() => undefined); throw error; } } async getSession(sessionId: string) { const client = await this.ensureClient(); const response = await client.session.get({ sessionID: sessionId }); 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 revertMessage(sessionId: string, messageId: string) { const client = await this.ensureClient(); const response = await client.session.revert({ sessionID: sessionId, messageID: messageId, }); return response.data; } async removeMessage(sessionId: string, messageId: string) { const client = await this.ensureClient(); const response = await client.session.deleteMessage({ sessionID: sessionId, messageID: messageId, }); return response.data; } async revertToUserMessage(sessionId: string, options: { userOrdinal: number }) { const messages = await this.messages(sessionId, 80); const userMessages = messages.filter( (message) => getRuntimeMessageRole(message) === "user", ); const targetUserMessage = userMessages[options.userOrdinal - 1]; if (!targetUserMessage) { if (messages.length === 0 && options.userOrdinal === 1) { logger.warn( { sessionId, userOrdinal: options.userOrdinal }, "skipping opencode revert because runtime session has no messages", ); return; } throw new Error("target user message not found to revert"); } const targetMessageId = getRuntimeMessageId(targetUserMessage); const targetIndex = messages.findIndex( (message) => getRuntimeMessageId(message) === targetMessageId, ); const messagesToRemove = targetIndex >= 0 ? messages.slice(targetIndex) : [targetUserMessage]; await this.revertMessage(sessionId, targetMessageId); for (const message of messagesToRemove.reverse()) { const messageId = getRuntimeMessageId(message); try { await this.removeMessage(sessionId, messageId); } catch (error) { logger.warn( { err: error, sessionId, messageId }, "failed to remove reverted opencode message", ); } } } 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(directory?: string) { const client = await this.ensureClient(); const response = await client.event.subscribe( directory ? { directory } : undefined, ); return response.stream; } async replyPermission(options: { requestId: string; sessionId?: string; directory?: string; reply: PermissionReply; message?: string; }) { const client = await this.ensureClient(); const directory = await this.resolveInteractionDirectory(options); if ("permission" in client && client.permission?.reply) { const response = await client.permission.reply({ requestID: options.requestId, directory, reply: options.reply, message: options.message, }); return requireData(response.data, "permission.reply"); } if ("permission" in client && client.permission?.respond && options.sessionId) { const response = await client.permission.respond({ sessionID: options.sessionId, permissionID: options.requestId, directory, response: options.reply, }); return requireData(response.data, "permission.respond"); } throw new Error("opencode permission reply API is unavailable"); } async replyQuestion(options: { requestId: string; sessionId?: string; directory?: string; answers: QuestionAnswers; }) { const client = await this.ensureClient(); const directory = await this.resolveInteractionDirectory(options); if ("question" in client && client.question?.reply) { try { const response = await client.question.reply({ requestID: options.requestId, directory, answers: options.answers, }); return requireData(response.data, "question.reply"); } catch (error) { if (!options.sessionId) { throw error; } } } const v2Question = (client as unknown as { v2?: { session?: { question?: { reply?: (parameters: { sessionID: string; requestID: string; directory?: string; questionV2Reply: { answers: QuestionAnswers }; }) => Promise<{ data: unknown }>; }; }; }; }).v2?.session?.question; if (v2Question?.reply && options.sessionId) { const response = await v2Question.reply({ sessionID: options.sessionId, requestID: options.requestId, directory, questionV2Reply: { answers: options.answers, }, }); return requireData(response.data, "question.v2.reply"); } throw new Error("opencode question reply API is unavailable"); } async rejectQuestion(options: { requestId: string; sessionId?: string; directory?: string; }) { const client = await this.ensureClient(); const directory = await this.resolveInteractionDirectory(options); if ("question" in client && client.question?.reject) { try { const response = await client.question.reject({ requestID: options.requestId, directory, }); return requireData(response.data, "question.reject"); } catch (error) { if (!options.sessionId) { throw error; } } } const v2Question = (client as unknown as { v2?: { session?: { question?: { reject?: (parameters: { sessionID: string; requestID: string; directory?: string; }) => Promise<{ data: unknown }>; }; }; }; }).v2?.session?.question; if (v2Question?.reject && options.sessionId) { const response = await v2Question.reject({ sessionID: options.sessionId, requestID: options.requestId, directory, }); return requireData(response.data, "question.v2.reject"); } throw new Error("opencode question reject API is unavailable"); } private async resolveInteractionDirectory(options: { sessionId?: string; directory?: string; }): Promise { const directory = options.directory?.trim(); if (directory) { return directory; } if (!options.sessionId) { return undefined; } const session = await this.getSession(options.sessionId); return session.directory; } async dispose(): Promise { if (this.toolOutputCleanupTimer) { clearInterval(this.toolOutputCleanupTimer); this.toolOutputCleanupTimer = null; } this.closeServer?.(); this.closeServer = null; this.clientPromise = null; } private async bootstrapClient(): Promise { await this.cleanupToolOutputs(); // 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 ?? ""; process.env.RESULT_REF_IMPORT_DIR = config.RESULT_REF_IMPORT_DIR; logger.info( { hostname: config.OPENCODE_HOSTNAME, port: config.OPENCODE_PORT, model: config.OPENCODE_MODEL, mode: config.OPENCODE_MODE, }, "starting opencode server in embedded mode", ); const startedAt = Date.now(); let runtime; try { runtime = await createOpencode({ hostname: config.OPENCODE_HOSTNAME, port: config.OPENCODE_PORT, timeout: config.OPENCODE_TIMEOUT_MS, config: buildOpencodeConfig(), }); } catch (error) { if (isMissingOpencodeCli(error)) { throw new Error( "embedded mode requires the opencode CLI to be installed and available in PATH", ); } throw error; } logger.info( { elapsedMs: Math.max(0, Date.now() - startedAt), hostname: config.OPENCODE_HOSTNAME, port: config.OPENCODE_PORT, mode: config.OPENCODE_MODE, }, "opencode server started in embedded mode", ); this.closeServer = () => { runtime.server.close(); }; this.startToolOutputCleanupLoop(); return runtime.client; } private async cleanupToolOutputs(): Promise { const directory = resolveOpencodeToolOutputDirectory(); const ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000; try { const result = await cleanupExpiredToolOutputs(directory, ttlMs); if (result.removed > 0) { logger.info( { directory, ...result }, "removed expired opencode tool output files", ); } } catch (error) { logger.warn( { err: error, directory }, "failed to clean expired opencode tool output files", ); } } private startToolOutputCleanupLoop(): void { if (this.toolOutputCleanupTimer) { return; } this.toolOutputCleanupTimer = setInterval(() => { void this.cleanupToolOutputs(); }, config.RESULT_REF_CLEANUP_INTERVAL_MS); this.toolOutputCleanupTimer.unref(); } } export const opencodeRuntime = new OpencodeRuntimeAdapter(); function buildOpencodeConfig(): Record { return deepMerge( deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), { model: config.OPENCODE_MODEL, tool_output: { max_bytes: config.MAX_INLINE_RESULT_BYTES, max_lines: 2000, }, }, ); } function readProjectOpencodeConfig(): Record { const path = resolve(process.cwd(), "opencode.json"); if (!existsSync(path)) { return {}; } return parseConfigJson(readFileSync(path, "utf8"), path); } function readEnvOpencodeConfig(): Record { const content = process.env.OPENCODE_CONFIG_CONTENT; if (!content?.trim()) { return {}; } return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT"); } function parseConfigJson(content: string, source: string): Record { const parsed = JSON.parse(content) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error(`${source} must contain a JSON object`); } return parsed as Record; } function deepMerge( left: Record, right: Record, ): Record { const next = { ...left }; for (const [key, value] of Object.entries(right)) { const existing = next[key]; if (isPlainObject(existing) && isPlainObject(value)) { next[key] = deepMerge(existing, value); } else { next[key] = value; } } return next; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } 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); }); }