Files
next-tjwater-agent/src/runtime/opencode.ts
T
jiang 6a49d2209e feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
2026-10-19 18:46:58 +08:00

696 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS } from "./product-mcp-tools.js";
import { assertWebSearchReady } from "./web-search-check.js";
import { isMessageNotFoundError, isSessionNotFoundError, 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 { bindScopedConversationWorkspace, createConversationWorkspace, ensureSessionWorkspaceDirectory, isLegacyConversationWorkspace, isManagedConversationWorkspace, migrateConversationWorkspace, resolveConversationWorkspace, resolveStoredConversationWorkspace, sessionWorkspaceDirectory, type SessionWorkspaceScope } from "./conversationWorkspace.js";
import { getRuntimeSessionContext } from "./sessionContext.js";
import { TASK_AGENT } from "./task-agent-policy.js";
import { serviceOpencodeConfig } from "./opencode2-config.js";
import { mcpReadonlyTools, mcpRuntimeReportSchema } from "./mcp-config.js";
import { parseModelReference } from "../chat/modelConfig.js";
import type { ResponseInput, InputDelivery } from "../chat/response-inputs.js";
import { assertModelCheckPassed, modelCheckReportSchema, modelCheckTargets } from "./model-check.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const logDevelopmentDebug = (
message: string,
metadata: Record<string, unknown>,
) => {
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<boolean>((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<OpencodeClient> | null = null;
private closeServer: (() => void) | null = null;
private workspaceBindings = new Map<string, Promise<string>>();
async ensureClient(): Promise<OpencodeClient> {
if (!this.clientPromise) {
this.clientPromise = this.bootstrapClient().catch((error) => {
this.clientPromise = null;
throw error;
});
}
return this.clientPromise;
}
async health(): Promise<RuntimeHealth> {
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, scope?: SessionWorkspaceScope) {
const client = await this.ensureClient();
const session = await client.session.get({ sessionID: sessionId });
let directory: string;
try {
directory = resolve(session.location.directory) === process.cwd() ? process.cwd()
: await resolveStoredConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT);
} catch (error) {
if (!scope || (error as NodeJS.ErrnoException).code !== "ENOENT" || !isLegacyConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT)) throw error;
directory = await resolveConversationWorkspace(resolve(sessionWorkspaceDirectory(config.CONVERSATION_WORKSPACE_ROOT, { ...scope, sessionId }), "workspace"), 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<string> {
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 checkModels(directory = process.cwd()) {
const client = await this.ensureClient();
const response = await client.rpc.call({
rpcID: "tjwater.tools", method: "check_models", input: modelCheckTargets(config.MODEL_CONFIG), location: { directory },
}, { signal: AbortSignal.timeout(30_000) });
return modelCheckReportSchema.parse(response.output);
}
async warmup(): Promise<void> {
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 readTools = async () => {
const response = await client.rpc.call({
rpcID: "tjwater.tools", method: "list", input: {},
location: { directory: session.location.directory },
}, { signal: AbortSignal.timeout(10_000) });
return response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined;
};
let tools = await readTools();
const required = [...LOCAL_QUERY_TOOL_IDS, ...(config.TJWATER_MCP_ENABLED ? PRODUCT_MCP_TOOL_IDS : []), "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
let missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct));
const catalogDeadline = Date.now() + 10_000;
// Native MCP connection/catalog publication can finish after plugin setup.
// Wait only for mandatory query tools; lost plugin tools still fail fast.
while (missing.length && missing.every((name) => name.startsWith("mcp_")) && Date.now() < catalogDeadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
tools = await readTools();
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.startsWith("browser_") || ["web_search", "mcp_tjwater_web_search", "query_project_data", "query_operational_task_data", "get_operational_task_context"].includes(tool.id))) {
throw new Error("OpenCode retired tools are still registered; reload trusted configuration and plugins");
}
if (!tools?.some((tool) => tool.id === "websearch")) throw new Error("OpenCode websearch tool is missing");
await assertWebSearchReady(client, session.location.directory);
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");
assertModelCheckPassed(await this.checkModels(session.location.directory));
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, scope?: SessionWorkspaceScope) {
const client = await this.ensureClient();
const { providerID, modelID: id } = parseModelReference(config.OPENCODE_MODEL);
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
let createdSessionId: string | undefined;
try {
const session = await client.session.create({ title, agent: "instruction", model: { providerID, id }, location: { directory } });
createdSessionId = session.id;
if (scope) await this.ensureSessionWorkspace(session.id, scope);
return session;
} catch (error) {
if (createdSessionId) await client.session.remove({ sessionID: createdSessionId }).catch(() => {});
await rm(directory, { recursive: true, force: true });
if (scope && createdSessionId) {
await ensureSessionWorkspaceDirectory(config.CONVERSATION_WORKSPACE_ROOT, { ...scope, sessionId: createdSessionId })
.then((parent) => rm(resolve(parent, "workspace"), { recursive: true, force: true })).catch(() => {});
}
throw error;
}
}
async ensureSessionWorkspace(sessionId: string, scope?: SessionWorkspaceScope): Promise<string> {
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 });
const owner = scope ?? getRuntimeSessionContext(sessionId);
if (owner) {
let source = session.location.directory;
if (resolve(source) === process.cwd()) source = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
const directory = await bindScopedConversationWorkspace(source, config.CONVERSATION_WORKSPACE_ROOT, { ...owner, sessionId });
if (directory !== resolve(session.location.directory)) await client.session.move({ sessionID: sessionId, directory });
return directory;
}
if (resolve(session.location.directory) !== process.cwd()) {
const directory = await migrateConversationWorkspace(session.location.directory, config.CONVERSATION_WORKSPACE_ROOT);
if (directory !== resolve(session.location.directory)) await client.session.move({ sessionID: sessionId, directory });
return directory;
}
// 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 removeSession(sessionId: string, scope?: SessionWorkspaceScope) {
const client = await this.ensureClient();
let session;
try { session = await client.session.get({ sessionID: sessionId }); }
catch (error) { if (isSessionNotFoundError(error)) return; throw error; }
const root = await realpath(config.CONVERSATION_WORKSPACE_ROOT);
const directory = scope ? await this.ensureSessionWorkspace(sessionId, scope)
: await resolveStoredConversationWorkspace(session.location.directory, root);
if (!scope && !isLegacyConversationWorkspace(directory, root) && !isManagedConversationWorkspace(directory, root, sessionId)) {
throw new Error("cannot remove another session's workspace");
}
await client.session.interrupt({ sessionID: sessionId });
await this.waitForSessionIdle(sessionId);
if ((await client.session.active())[sessionId]) throw new Error("session is still active; cleanup can be retried");
try { await client.session.remove({ sessionID: sessionId }); }
catch (error) { if (!isSessionNotFoundError(error)) throw error; }
await rm(directory, { recursive: true, force: true });
}
async sendPrompt(sessionId: string, text: string) {
await this.prompt(sessionId, text);
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
// 所以这里紧跟一次 messages() 回读,给上层路由统一消费。
return this.messages(sessionId);
}
async generateText(prompt: string, model?: RuntimeModelOverride, signal?: AbortSignal) {
const client = await this.ensureClient();
const selected = model ?? parseModelReference(config.OPENCODE_MODEL);
const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
let sessionId: string | undefined;
try {
const session = await client.session.create({
title: "prompt-suggestions", agent: "instruction",
model: { providerID: selected.providerID, id: selected.modelID },
location: { directory },
}, { signal });
sessionId = session.id;
// Await the Location's configured model catalog before one-shot generation.
await client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } }, { signal });
return (await client.session.generate({ sessionID: sessionId, prompt }, { signal })).text;
} finally {
try {
if (sessionId) await client.session.remove({ sessionID: sessionId });
} finally {
await rm(directory, { recursive: true });
}
}
}
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 agentMessageState(sessionId: string, messageId: string): Promise<"missing" | "enqueued" | "delivered"> {
const client = await this.ensureClient();
const pending = await client.session.inbox.list({ sessionID: sessionId });
if (pending.some((item) => item.id === messageId)) return "enqueued";
try {
await client.session.message.get({ sessionID: sessionId, messageID: messageId });
return "delivered";
} catch (error) {
// Only a not-found response is safe to treat as absent. A transport
// failure must not cause a second injection.
if (error && typeof error === "object" && "status" in error && error.status === 404) return "missing";
if (isMessageNotFoundError(error)) return "missing";
throw error;
}
}
async enqueueAgentMessage(sessionId: string, messageId: string, text: string, metadata: Record<string, string>) {
const client = await this.ensureClient();
// resume:false is essential: a race with idle must never start an
// unowned background turn in a user's main chat.
await client.session.synthetic({ sessionID: sessionId, id: messageId, text,
description: "调查协作消息(非用户直接指令)", metadata, delivery: "steer", resume: false });
}
async enqueueUserInput(sessionId: string, input: ResponseInput) {
const client = await this.ensureClient();
await client.session.prompt({ sessionID: sessionId, id: input.id, text: input.text,
delivery: input.delivery, resume: true, metadata: { source: "tjwater_user_input" } });
}
async listPendingUserInputs(sessionId: string): Promise<ResponseInput[]> {
const client = await this.ensureClient();
return (await client.session.inbox.list({ sessionID: sessionId })).flatMap((item) =>
item.type === "user" && item.payload.metadata?.source === "tjwater_user_input"
? [{ id: item.id, text: item.payload.text, delivery: item.delivery, status: "pending" as const }] : []);
}
async updatePendingUserInput(sessionId: string, id: string, delivery: InputDelivery) {
const item = (await this.listPendingUserInputs(sessionId)).find((input) => input.id === id);
if (!item) throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
const client = await this.ensureClient();
try { await client.session.inbox.update({ sessionID: sessionId, inboxID: id, delivery }); }
catch (error) {
if (!(await this.listPendingUserInputs(sessionId)).some((input) => input.id === id)) {
throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
}
throw error;
}
return { ...item, delivery };
}
async cancelPendingUserInputs(sessionId: string) {
const client = await this.ensureClient();
for (const input of await this.listPendingUserInputs(sessionId)) {
try { await client.session.inbox.cancel({ sessionID: sessionId, inboxID: input.id }); }
catch (error) { if (!(error && typeof error === "object" && "status" in error && error.status === 404)) throw error; }
}
}
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,
},
);
const context = getRuntimeSessionContext(sessionId);
// The server owns purpose; a browser prompt or requested model cannot select
// the background coordinator profile. Re-evaluate for resumed sessions too.
const agent = context?.purpose === "task_execution" && context.taskId && context.projectId ? TASK_AGENT : "instruction";
await client.session.switchAgent({ sessionID: sessionId, agent });
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<void> {
this.closeServer?.();
this.closeServer = null;
this.clientPromise = null;
}
private async bootstrapClient(): Promise<OpencodeClient> {
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<string, unknown> {
return serviceOpencodeConfig(deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), config.MODEL_CONFIG, config.CONVERSATION_WORKSPACE_ROOT);
}
function readProjectOpencodeConfig(): Record<string, unknown> {
const path = resolve(process.cwd(), "opencode.json");
if (!existsSync(path)) {
return {};
}
return parseConfigJson(readFileSync(path, "utf8"), path);
}
function readEnvOpencodeConfig(): Record<string, unknown> {
const content = process.env.OPENCODE_CONFIG_CONTENT;
if (!content?.trim()) {
return {};
}
return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT");
}
function parseConfigJson(content: string, source: string): Record<string, unknown> {
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<string, unknown>;
}
function deepMerge(
left: Record<string, unknown>,
right: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown> {
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<void>((resolve) => {
setTimeout(resolve, ms);
});
}