import type { OpenCodeClient as OpencodeClient } from "@opencode/client"; import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js"; import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js"; import { existsSync, readFileSync } from "node:fs"; import { createServer } from "node:net"; import { resolve } from "node:path"; import { realpath, rm } from "node:fs/promises"; import { createConversationWorkspace, resolveConversationWorkspace } from "./conversationWorkspace.js"; import { serviceOpencodeConfig } from "./opencode2-config.js"; import { mcpReadonlyTools, mcpRuntimeReportSchema } from "./mcp-config.js"; 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 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; const isTcpPortAvailable = (hostname: string, port: number) => new Promise((resolve, reject) => { const server = createServer(); server.unref(); server.once("error", (error: NodeJS.ErrnoException) => { if (error.code === "EADDRINUSE") { resolve(false); return; } reject(error); }); server.listen({ host: hostname, port, exclusive: true }, () => { server.close((error) => (error ? reject(error) : resolve(true))); }); }); export const resolveEmbeddedOpencodePort = async ( hostname: string, configuredPort: number, ) => { if (configuredPort === 0) { return 0; } return (await isTcpPortAvailable(hostname, configuredPort)) ? configuredPort : 0; }; export class OpencodeRuntimeAdapter { private clientPromise: Promise | null = null; private closeServer: (() => void) | null = null; private workspaceBindings = new Map>(); 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 info = await client.server.info(); return { healthy: true, version: info.version }; } mcpReadonlyTools(): string[] { return mcpReadonlyTools(buildOpencodeConfig()); } async mcpStatus(sessionId: string) { const client = await this.ensureClient(); const session = await client.session.get({ sessionID: sessionId }); const directory = resolve(session.location.directory) === process.cwd() ? process.cwd() : await resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT); return this.readMcpReport(client, directory); } private async readMcpReport(client: OpencodeClient, directory: string) { const response = await client.rpc.call({ rpcID: "tjwater.tools", method: "mcp", input: {}, location: { directory }, }, { signal: AbortSignal.timeout(15_000) }); return mcpRuntimeReportSchema.parse(response.output); } async resolveAvailableModel(preferredModelIds: string[]): Promise { const client = await this.ensureClient(); // V2 2.0.24 model.list alone does not await a cold Location's plugin setup. await client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory: process.cwd() } }); const models = await client.model.list({ location: { directory: process.cwd() } }); const available = new Set(models.data.filter((model) => model.enabled && model.capabilities.tools) .map((model) => `${model.providerID}/${model.id}`)); const selected = preferredModelIds.find((modelId) => available.has(modelId)); if (!selected) throw new Error("no configured operational task model is available from OpenCode"); return selected; } async warmup(): Promise { const client = await this.ensureClient(); const health = await this.health(); if (!health.healthy || !health.version.startsWith("2.")) { throw new Error(`OpenCode 2 is required; received ${health.version}`); } const session = await this.createSession("tjwater-agent-warmup"); try { const response = await client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory: session.location.directory }, }); const tools = response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined; const required = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"]; const missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct)); if (missing.length) throw new Error(`OpenCode 2 business tools missing: ${missing.join(", ")}`); if (!tools?.some((tool) => tool.id === "shell" && tool.sandboxed)) throw new Error("OpenCode shell sandbox replacement is missing"); const actualRoot = tools.find((tool) => tool.id === "shell")?.workspaceRoot; if (!actualRoot || await realpath(actualRoot) !== await realpath(config.CONVERSATION_WORKSPACE_ROOT)) throw new Error("OpenCode and Agent conversation workspace roots do not match"); const mcp = await this.readMcpReport(client, session.location.directory); if (mcp.configuration_errors.length) throw new Error("MCP configuration invalid"); const models = await client.model.list({ location: { directory: session.location.directory } }); const model = models.data.find((model) => `${model.providerID}/${model.id}` === config.OPENCODE_MODEL); if (!model?.enabled || !model.capabilities.tools) { throw new Error(`OpenCode 2 model unavailable or does not support tools: ${config.OPENCODE_MODEL}`); } logger.info({ version: health.version, model: config.OPENCODE_MODEL, toolCount: tools?.length }, "opencode2 warmup tools verified"); } finally { await client.session.remove({ sessionID: session.id }); await rm(await resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT), { recursive: true }); } } async createSession(title?: string) { const client = await this.ensureClient(); const [providerID, id] = config.OPENCODE_MODEL.split("/"); const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT); try { return await client.session.create({ title, agent: "instruction", model: { providerID, id }, location: { directory } }); } catch (error) { await rm(directory, { recursive: true }); throw error; } } async ensureSessionWorkspace(sessionId: string): Promise { this.workspaceBindings ??= new Map(); const pending = this.workspaceBindings.get(sessionId); if (pending) return pending; const operation = (async () => { const client = await this.ensureClient(); const session = await client.session.get({ sessionID: sessionId }); if (resolve(session.location.directory) !== process.cwd()) { return resolveConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT); } // Old sessions used the service checkout. Moving an idle session binds a // fresh directory without copying application data, credentials or logs. const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT); try { await client.session.move({ sessionID: sessionId, directory }); } catch (error) { await rm(directory, { recursive: true }); throw error; } return directory; })(); this.workspaceBindings.set(sessionId, operation); try { return await operation; } finally { this.workspaceBindings.delete(sessionId); } } async sendPrompt(sessionId: string, text: string) { await this.prompt(sessionId, text); // 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts, // 所以这里紧跟一次 messages() 回读,给上层路由统一消费。 return this.messages(sessionId); } async approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string; }, signal: AbortSignal) { const client = await this.ensureClient(); const controller = new AbortController(); const combined = AbortSignal.any([signal, controller.signal, AbortSignal.timeout(5 * 60_000)]); const iterator = client.event.subscribe({ signal: combined })[Symbol.asyncIterator](); let requestId: string | undefined; let replied = false; try { // Subscribe first: an immediate client reply must never be lost. await iterator.next(); const permission = await client.permission.create({ sessionID: input.sessionId, action: "shell", resources: [input.command], save: [], metadata: { command: input.command, description: input.description ?? "" }, source: { type: "tool", messageID: input.messageId, id: input.callId }, }, { signal: combined }); requestId = permission.id; if (permission.effect === "deny") throw Object.assign(new Error("shell permission denied"), { status: 403 }); if (permission.effect === "allow") throw Object.assign(new Error("sandbox shell requires interactive approval"), { status: 403 }); while (true) { const next = await iterator.next(); if (next.done) throw new Error("permission event stream ended before approval"); const event = next.value; if (event.type !== "permission.replied" || event.data.sessionID !== input.sessionId || event.data.requestID !== requestId) continue; replied = true; if (event.data.reply === "reject") throw Object.assign(new Error("shell permission rejected"), { status: 403 }); return; } } finally { controller.abort(); await iterator.return?.(); if (requestId && !replied) await client.permission.reply({ sessionID: input.sessionId, requestID: requestId, decision: "reject" }).catch(() => {}); } } 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, }, ); if (model) await client.session.switchModel({ sessionID: sessionId, model: { providerID: model.providerID, id: model.modelID } }); await client.session.prompt({ sessionID: sessionId, 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.message.list({ sessionID: sessionId, limit, order: "desc" }); return messages.data.reverse().map((message) => normalizeRuntimeMessage(message, sessionId)); } async revertMessage(sessionId: string, messageId: string) { const client = await this.ensureClient(); return client.session.revert.stage({ sessionID: sessionId, messageID: messageId, files: false }); } async removeMessage(sessionId: string, messageId: string) { const client = await this.ensureClient(); // V2 commits the staged message boundary atomically; individual deletion was removed. await client.session.revert.commit({ sessionID: sessionId }); } 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); await this.revertMessage(sessionId, targetMessageId); await this.removeMessage(sessionId, targetMessageId); } async abortSession(sessionId: string) { const client = await this.ensureClient(); return client.session.interrupt({ sessionID: sessionId }); } async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) { const client = await this.ensureClient(); const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const active = await client.session.active(); if (!active[sessionId]) { 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 controller = new AbortController(); const iterator = client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator](); // Await the connected event before dispatching a prompt so its first events cannot be lost. const first = await iterator.next(); const normalizer = new Opencode2EventNormalizer(); const stream = (async function* () { try { let next = first; while (!next.done) { for (const event of normalizer.normalize(next.value)) yield event; next = await iterator.next(); } } catch (error) { if (!controller.signal.aborted) throw error; } finally { controller.abort(); await iterator.return?.(); } })(); return { [Symbol.asyncIterator]() { return { next: () => stream.next(), return: async () => { // Cancel pending next() before returning; async generator return alone cannot interrupt it. controller.abort(); return stream.return(undefined); }, }; }, }; } async replyPermission(options: { requestId: string; sessionId?: string; reply: PermissionReply; message?: string }) { if (!options.sessionId) throw new Error("OpenCode 2 permission replies require sessionId"); const client = await this.ensureClient(); return client.permission.reply({ sessionID: options.sessionId, requestID: options.requestId, decision: options.reply, message: options.message }); } async replyQuestion(options: { requestId: string; sessionId?: string; answers: QuestionAnswers }) { if (!options.sessionId) throw new Error("OpenCode 2 form replies require sessionId"); const client = await this.ensureClient(); const form = await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId }); return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(form, options.answers) }); } async rejectQuestion(options: { requestId: string; sessionId?: string }) { if (!options.sessionId) throw new Error("OpenCode 2 form cancellation requires sessionId"); const client = await this.ensureClient(); return client.session.form.cancel({ sessionID: options.sessionId, formID: options.requestId }); } 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 opencode2 server in client mode", ); return createRuntimeClient(config.OPENCODE_CLIENT_BASE_URL!, config.OPENCODE_CLIENT_PASSWORD); } // embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里, // 这样 .opencode/tools 下的自定义工具可以回调本服务。 process.env.TJWATER_AGENT_WORKSPACE_ROOT = resolve(config.CONVERSATION_WORKSPACE_ROOT); 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 ?? ""; const port = await resolveEmbeddedOpencodePort( config.OPENCODE_HOSTNAME, config.OPENCODE_PORT, ); if (port !== config.OPENCODE_PORT) { logger.warn( { hostname: config.OPENCODE_HOSTNAME, configuredPort: config.OPENCODE_PORT, }, "configured opencode port is occupied; falling back to a dynamic port", ); } logger.info( { hostname: config.OPENCODE_HOSTNAME, port, model: config.OPENCODE_MODEL, mode: config.OPENCODE_MODE, }, "starting opencode2 server in embedded mode", ); const startedAt = Date.now(); let runtime; try { runtime = await startOpencode2({ command: config.OPENCODE_COMMAND, hostname: config.OPENCODE_HOSTNAME, port, timeout: config.OPENCODE_TIMEOUT_MS, config: buildOpencodeConfig(), }); } catch (error) { if (isMissingOpencodeCli(error)) { throw new Error( "embedded mode requires the opencode2 CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL", ); } throw error; } logger.info( { elapsedMs: Math.max(0, Date.now() - startedAt), hostname: config.OPENCODE_HOSTNAME, baseUrl: runtime.url, mode: config.OPENCODE_MODE, }, "opencode2 server started in embedded mode", ); this.closeServer = () => { runtime.close(); }; return runtime.client; } } export const opencodeRuntime = new OpencodeRuntimeAdapter(); function buildOpencodeConfig(): Record { return serviceOpencodeConfig(deepMerge( deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), { model: config.OPENCODE_MODEL, }, )); } 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 delay(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); }); }