Files
goodbuddy/src/main/agent/opencode-runtime.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

3509 lines
116 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 { runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist';
import type {
createOpencodeClient,
AssistantMessage,
OpencodeClient,
PermissionRequest,
PermissionRuleset,
QuestionRequest,
} from "@opencode-ai/sdk/v2";
import spawn from "cross-spawn";
import { createHash, randomBytes } from "node:crypto";
import {
cp,
mkdir,
mkdtemp,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { createRuntimeTemporaryDirectory, recordRuntimeTemporaryChild, removeRuntimeTemporaryDirectory } from "../runtime-temporary-directory";
import { dirname, join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import type {
AgentQuestionAnswer,
AgentRuntimeStatus,
SubagentEvent,
} from "../../shared/contracts";
import {
boundedRuntimeIdentifierSchema,
runtimeNativeInventoryLimits,
type RuntimeConversationCompactInput,
type RuntimeCustomizationSettings,
type RuntimeNativeSnapshot,
type RuntimeNativeTool,
} from "../../shared/runtime-customization-contracts";
import { createAnthropicApiBaseUrl } from "./anthropic-endpoint";
import { createOpenAIApiBaseUrl } from "./openai-endpoint";
import type {
AgentExecutionRequest,
AgentRuntime,
RuntimeConversationCompactOutcome,
RuntimeEvent,
RuntimeModelUsageEvent,
} from "./runtime";
import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway";
import { detectRuntimeBinary } from "./runtime-discovery";
import { getAvailableLoopbackPort } from "./loopback-port";
import type { ResolvedModelProfile } from "../runtime-settings-store";
import {
applyLaunchEnvironmentPath,
buildExplicitProfileRuntimeEnvironment,
buildRuntimeEnvironment,
runtimePrivacyEnvironment,
} from "./process-environment";
import type { LaunchEnvironmentProvider } from "../local-tool-environment/launch-environment-provider";
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from "../desktop-diagnostics";
import { boundedToolDetail, redactSensitiveText, safeToolErrorDetail } from "./approval-summary";
import type {
ResolvedMcpServer,
RuntimeSkillPackage,
} from "../capabilities/capability-service";
import { stageRuntimeSkillPackages } from "./runtime-skill-packages";
import {
requestProcessTreeTermination,
waitForProcessExit,
} from "./child-process-termination";
import {
OpenCodeSubagentProgress,
toOpenCodeSubagentEvent,
} from "./opencode-subagent";
import { promptWithUntrustedConversationHistory } from "./runtime-conversation-history";
const opencodeInterruptionNotice =
"[The user interrupted the previous turn before it finished. Its request may be incomplete; answer the new message below and do not resume or re-answer the interrupted request unless asked.]";
const STARTUP_TIMEOUT_MS = 30_000;
const STARTUP_POLL_INTERVAL_MS = 100;
const STARTUP_PROBE_TIMEOUT_MS = 500;
const CONTROL_REQUEST_TIMEOUT_MS = 30_000;
const MAX_PERMISSION_NAME_LENGTH = 128;
const MAX_PERMISSION_PATTERNS = 32;
const MAX_PERMISSION_PATTERN_LENGTH = 1_024;
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024;
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024;
const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024;
const MAX_NATIVE_AGENTS = runtimeNativeInventoryLimits.agents;
const MAX_NATIVE_TOOLS = runtimeNativeInventoryLimits.tools;
const MAX_NATIVE_COMMANDS = runtimeNativeInventoryLimits.commands;
const MAX_NATIVE_LSP = runtimeNativeInventoryLimits.lsp;
const MAX_NATIVE_FORMATTERS = runtimeNativeInventoryLimits.formatters;
const MAX_NATIVE_MCP_SERVERS = runtimeNativeInventoryLimits.mcpServers;
const MAX_NATIVE_SKILLS = runtimeNativeInventoryLimits.skills;
const MAX_NATIVE_RESOURCES = runtimeNativeInventoryLimits.resources;
const COMPACTION_USAGE_EVENT_GRACE_MS = 1_000;
const EMBEDDED_SERVER_USERNAME = "goodbuddy";
const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
// Keep temporary MCP names short: OpenCode exposes tools as
// "<MCP name>_<tool name>" and common provider APIs cap tool names at 64.
const KNOWLEDGE_MCP_PREFIX = "gbd-";
const CUSTOM_MCP_PREFIX = "gbc-";
const TEMPORARY_MCP_PREFIXES = [
KNOWLEDGE_MCP_PREFIX,
CUSTOM_MCP_PREFIX,
] as const;
const TEMPORARY_MCP_CONVERSATION_HASH_LENGTH = 6;
const MAX_TEMPORARY_MCP_SLOTS = 9;
const temporaryMcpToolOverrides = Object.fromEntries(
TEMPORARY_MCP_PREFIXES.map((prefix) => [`${prefix}*`, false]),
);
const temporaryMcpDenyRules: PermissionRuleset = TEMPORARY_MCP_PREFIXES.map(
(prefix) => ({
permission: `${prefix}*`,
pattern: "*",
action: "deny",
}),
);
const OPENCODE_INTERNAL_TOOL_IDS = new Set(["invalid"]);
const OPENCODE_BUILTIN_TOOL_KINDS: Readonly<
Partial<Record<string, RuntimeNativeTool["kind"]>>
> = {
apply_patch: "write",
bash: "shell",
edit: "write",
glob: "read",
grep: "read",
question: "interaction",
read: "read",
skill: "agent",
task: "agent",
todowrite: "agent",
webfetch: "network",
websearch: "network",
write: "write",
};
type SpawnedProcess = ReturnType<typeof spawn>;
type OpenCodeProviderConfig = {
model: string;
provider: Record<
string,
{
name: string;
npm: string;
options: {
apiKey?: string;
baseURL: string;
headers?: Record<string, string>;
};
models: Record<
string,
{
name: string;
attachment: boolean;
modalities: {
input: Array<"text" | "image">;
output: ["text"];
};
provider: {
npm: string;
};
}
>;
}
>;
};
type OpenCodeProviderDescriptor = {
id: string;
npm: string;
baseURL: string;
};
type OpenCodeServer = {
url: string;
authorization: string;
signal: AbortSignal;
activeRequests: number;
close: () => Promise<void>;
};
type OpenCodeSkillRegistration = {
root: string;
configDirectory: string;
skillsRoot: string;
};
const sharedSkillPreparations = new Map<string, Promise<string>>();
const sharedConfigPreparations = new Map<string, Promise<void>>();
function resolveOpenCodeProvider(
profile: ResolvedModelProfile,
): OpenCodeProviderDescriptor {
if (profile.protocol === "openai-images-generations") {
throw new Error("OpenCode 独立模型连接不支持图像生成协议");
}
return profile.protocol === "anthropic-messages"
? {
id: "goodbuddy-anthropic",
npm: "@ai-sdk/anthropic",
baseURL: createAnthropicApiBaseUrl(profile.baseUrl),
}
: profile.protocol === "openai-chat-completions"
? {
id: "goodbuddy-openai-chat",
npm: "@ai-sdk/openai-compatible",
baseURL: createOpenAIApiBaseUrl(profile.baseUrl),
}
: {
id: "goodbuddy-openai-responses",
npm: "@ai-sdk/openai",
baseURL: createOpenAIApiBaseUrl(profile.baseUrl),
};
}
function createOpenCodeProviderConfig(
profile: ResolvedModelProfile,
): OpenCodeProviderConfig {
const provider = resolveOpenCodeProvider(profile);
const options: {
apiKey?: string;
baseURL: string;
headers?: Record<string, string>;
} = {
baseURL: provider.baseURL,
...(profile.requestHeaders && Object.keys(profile.requestHeaders).length > 0
? { headers: profile.requestHeaders }
: {}),
};
if (profile.authentication === "api-key" && profile.apiKey) {
options.apiKey = profile.apiKey;
}
return {
model: `${provider.id}/${profile.modelName}`,
provider: {
[provider.id]: {
name: profile.name,
npm: provider.npm,
options,
models: {
[profile.modelName]: {
name: profile.name,
attachment: profile.supportsImageInput === true,
modalities: {
input:
profile.supportsImageInput === true
? ["text", "image"]
: ["text"],
output: ["text"],
},
provider: {
npm: provider.npm,
},
},
},
},
},
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function opencodeErrorMessage(value: unknown, fallback: string): string {
return safeToolErrorDetail(value, 1_000) ?? fallback;
}
function byteLengthWithin(value: string, maximum: number): boolean {
return Buffer.byteLength(value) <= maximum;
}
function createPublicQuestionId(
requestId: string,
sessionId: string,
upstreamQuestionId: string,
): string {
return `opencode-${createHash("sha256")
.update(`${requestId}\0${sessionId}\0${upstreamQuestionId}`)
.digest("hex")
.slice(0, 48)}`;
}
function executionDeadlineLabel(milliseconds: number): string {
return milliseconds % 60_000 === 0
? `${milliseconds / 60_000} 分钟`
: `${milliseconds} 毫秒`;
}
function eventSubscriptionOptions(signal: AbortSignal) {
return {
signal,
sseSleepFn: (milliseconds: number) =>
delay(milliseconds, undefined, { signal }),
};
}
async function* consumeEventSubscription<T>(
stream: AsyncGenerator<T, void, unknown>,
controller: AbortController,
): AsyncGenerator<T, void, unknown> {
try {
while (true) {
const next = await stream.next();
if (next.done) return;
yield next.value;
}
} finally {
// The SDK removes its abort listener on iterator return without cancelling
// the reader. Abort first, while that listener can still close the body.
controller.abort();
await stream.return();
}
}
function awaitWithAbort<T>(
operation: Promise<T>,
signal: AbortSignal,
): Promise<T> {
signal.throwIfAborted();
return new Promise<T>((resolveOperation, rejectOperation) => {
const abort = (): void => rejectOperation(signal.reason);
signal.addEventListener("abort", abort, { once: true });
operation.then(
(value) => {
signal.removeEventListener("abort", abort);
resolveOperation(value);
},
(error: unknown) => {
signal.removeEventListener("abort", abort);
rejectOperation(error);
},
);
});
}
function areBoundedPatterns(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.length <= MAX_PERMISSION_PATTERNS &&
value.every(
(pattern) =>
typeof pattern === "string" &&
pattern.length <= MAX_PERMISSION_PATTERN_LENGTH,
) &&
byteLengthWithin(value.join("\0"), MAX_PERMISSION_PATTERNS_BYTES)
);
}
function parsePermissionRequest(
properties: unknown,
sessionId: string,
): PermissionRequest | undefined {
if (!isRecord(properties) || properties.sessionID !== sessionId) {
return undefined;
}
const { id, permission, patterns, metadata, always, tool } = properties;
if (
typeof id !== "string" ||
id.length === 0 ||
id.length > MAX_PERMISSION_NAME_LENGTH ||
typeof permission !== "string" ||
permission.length === 0 ||
permission.length > MAX_PERMISSION_NAME_LENGTH ||
!areBoundedPatterns(patterns) ||
!isRecord(metadata) ||
!areBoundedPatterns(always) ||
(tool !== undefined &&
(!isRecord(tool) ||
typeof tool.messageID !== "string" ||
tool.messageID.length === 0 ||
tool.messageID.length > 256 ||
typeof tool.callID !== "string" ||
tool.callID.length === 0 ||
tool.callID.length > 256))
) {
throw new Error("OpenCode 权限请求格式无效");
}
let serializedMetadata: string;
try {
serializedMetadata = JSON.stringify(metadata);
} catch {
throw new Error("OpenCode 权限请求元数据无效");
}
if (!byteLengthWithin(serializedMetadata, MAX_PERMISSION_METADATA_BYTES)) {
throw new Error("OpenCode 权限请求元数据超过安全限制");
}
return properties as PermissionRequest;
}
function parseQuestionRequest(
properties: unknown,
sessionId: string,
): QuestionRequest | undefined {
if (!isRecord(properties) || properties.sessionID !== sessionId) {
return undefined;
}
const { id, questions, tool } = properties;
if (
typeof id !== "string" ||
id.length === 0 ||
!Array.isArray(questions) ||
questions.length === 0 ||
!questions.every(
(question) =>
isRecord(question) &&
typeof question.question === "string" &&
question.question.trim().length > 0 &&
typeof question.header === "string" &&
question.header.trim().length > 0 &&
Array.isArray(question.options) &&
question.options.every(
(option) =>
isRecord(option) &&
typeof option.label === "string" &&
option.label.trim().length > 0 &&
typeof option.description === "string",
) &&
(question.multiple === undefined ||
typeof question.multiple === "boolean") &&
(question.custom === undefined || typeof question.custom === "boolean"),
) ||
(tool !== undefined &&
(!isRecord(tool) ||
typeof tool.messageID !== "string" ||
tool.messageID.length === 0 ||
typeof tool.callID !== "string" ||
tool.callID.length === 0))
) {
throw new Error("OpenCode 提问请求格式无效");
}
return properties as QuestionRequest;
}
function isSafeTokenCount(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0;
}
function createUsageEvent(
requestId: string,
message: AssistantMessage,
): RuntimeModelUsageEvent | undefined {
const { tokens } = message;
if (
message.time.completed === undefined ||
!isSafeTokenCount(tokens.input) ||
!isSafeTokenCount(tokens.output) ||
!isSafeTokenCount(tokens.cache.read) ||
!isSafeTokenCount(tokens.cache.write) ||
(tokens.total !== undefined && !isSafeTokenCount(tokens.total))
) {
return undefined;
}
return {
requestId,
type: "model-usage",
callId: message.id.slice(0, 256),
runtime: "opencode",
provider: message.providerID.slice(0, 100),
model: message.modelID.slice(0, 500),
inputTokens: tokens.input,
outputTokens: tokens.output,
cacheReadTokens: tokens.cache.read,
cacheWriteTokens: tokens.cache.write,
...(tokens.total === undefined
? {}
: { reportedTotalTokens: tokens.total }),
};
}
export type OpenCodeRuntimeDependencies = {
spawn: typeof spawn;
detectBinary: (
runtime: "opencode",
configuredPath: string,
bundledPath?: string,
) => Promise<{ path?: string; detail: string }>;
validateBinary: (
runtime: "opencode",
configuredPath: string,
bundledPath?: string,
) => Promise<{ path?: string; detail: string }>;
createClient: typeof createOpencodeClient;
checkServerHealth: (
url: string,
authorization: string,
signal: AbortSignal,
) => Promise<boolean>;
platform: NodeJS.Platform;
startupTimeoutMs: number;
controlRequestTimeoutMs: number;
executionTimeoutMs?: number;
};
export type OpenCodeRuntimeOptions = {
observeFailure?: DesktopDiagnosticFailureObserver;
baseUrl?: string;
embedded: boolean;
binaryPath: string;
bundledBinaryPath?: string;
bundledConfigPath?: string;
configPath: string;
defaultWorkspace: string;
modelProfile?: ResolvedModelProfile;
skillInstructions?: string;
skillPackages?: RuntimeSkillPackage[];
sharedCacheRoot?: string;
knowledgeGateway?: KnowledgeMcpGateway;
mcpServers?: ResolvedMcpServer[];
customization?: RuntimeCustomizationSettings["opencode"];
launchEnvironmentProvider?: LaunchEnvironmentProvider;
};
function boundedNativeIdentifier(value: unknown): string | undefined {
const parsed = boundedRuntimeIdentifierSchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
}
function boundedNativeText(
value: unknown,
maximum: number,
): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
return normalized ? normalized.slice(0, maximum) : undefined;
}
function isTemporaryMcpName(value: string): boolean {
return TEMPORARY_MCP_PREFIXES.some((prefix) => value.startsWith(prefix));
}
function temporaryMcpConversationKey(conversationId: string): string {
return createHash("sha256")
.update(conversationId)
.digest("hex")
.slice(0, TEMPORARY_MCP_CONVERSATION_HASH_LENGTH);
}
export function createTemporaryMcpName(
prefix: (typeof TEMPORARY_MCP_PREFIXES)[number],
conversationKey: string,
slot: number,
generation = 0,
): string {
if (generation > 0) {
// Eight base-36 digits keep the registration within the 12-character budget.
if (generation >= 36 ** 8) throw new Error("OpenCode MCP name generations exhausted");
return `${prefix}${generation.toString(36).padStart(8, "0")}`;
}
return `${prefix}${conversationKey}${slot === 1 ? "" : `-${slot}`}`;
}
function isTemporaryMcpInventoryItem(
...values: Array<string | undefined>
): boolean {
return values.some(
(value) => value !== undefined && isTemporaryMcpName(value),
);
}
function mapOpenCodeNativeTools(
values: readonly string[],
): RuntimeNativeTool[] {
const seen = new Set<string>();
const tools: RuntimeNativeTool[] = [];
for (const value of values) {
if (tools.length >= MAX_NATIVE_TOOLS) {
break;
}
const id = boundedNativeIdentifier(value);
if (
!id ||
seen.has(id) ||
OPENCODE_INTERNAL_TOOL_IDS.has(id) ||
isTemporaryMcpName(id)
) {
continue;
}
seen.add(id);
const builtinKind = OPENCODE_BUILTIN_TOOL_KINDS[id];
tools.push({
id,
name: id.slice(0, 200),
kind: builtinKind ?? "other",
source: builtinKind ? "runtime" : "unknown",
});
}
return tools;
}
function isLocalPathLikeResourceUri(value: string): boolean {
return (
/^file:/iu.test(value) ||
/^[a-z]:[\\/]/iu.test(value) ||
/^(?:[\\/]{1,2}|\.\.?[\\/]|~[\\/])/u.test(value)
);
}
function boundedResourceUri(value: unknown): string | undefined {
const bounded = boundedNativeText(value, 2_048);
if (
!bounded ||
isLocalPathLikeResourceUri(bounded) ||
/^data:/iu.test(bounded)
) {
return undefined;
}
try {
const url = new URL(bounded);
if (url.protocol === "file:" || url.protocol === "data:") {
return undefined;
}
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString().slice(0, 2_048);
} catch {
return bounded.includes("?") || bounded.includes("#") ? undefined : bounded;
}
}
function createSkillPermissionRules(
skillIds: readonly string[],
): PermissionRuleset {
if (skillIds.length === 0) {
return [];
}
return Object.entries(createSkillPermissionConfig(skillIds)).map(
([pattern, action]) => ({
permission: "skill",
pattern,
action,
}),
);
}
function createSkillPermissionConfig(
skillIds: readonly string[],
): Record<string, "allow" | "deny"> {
return Object.fromEntries([
["*", "deny" as const],
...skillIds.map((skillId) => [skillId, "allow" as const]),
]);
}
function createOpenCodeSkillConfig(
registration: OpenCodeSkillRegistration,
skillIds: readonly string[],
): {
skills: { paths: string[]; urls: never[] };
permission: {
"*": "allow";
skill: Record<string, "allow" | "deny">;
};
} {
return {
skills: {
paths: [registration.skillsRoot],
urls: [],
},
permission: {
"*": "allow",
skill: createSkillPermissionConfig(skillIds),
},
};
}
async function normalizeOpenCodeSkillManifest(
skillDirectory: string,
skillId: string,
): Promise<void> {
const manifestPath = join(skillDirectory, "SKILL.md");
const content = await readFile(manifestPath, "utf8");
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(content);
if (!match?.[1] || !match[2]?.trim()) {
throw new Error("OpenCode Skill 清单格式无效");
}
const metadata = parseYaml(match[1]);
if (
typeof metadata !== "object" ||
metadata === null ||
Array.isArray(metadata)
) {
throw new Error("OpenCode Skill 清单元数据无效");
}
const normalizedMetadata: Record<string, unknown> = {
...metadata,
name: skillId,
};
delete normalizedMetadata.id;
await writeFile(
manifestPath,
["---", stringifyYaml(normalizedMetadata).trimEnd(), "---", match[2]].join(
"\n",
),
"utf8",
);
}
async function defaultDetectBinary(
runtime: "opencode",
configuredPath: string,
bundledPath?: string,
): Promise<{ path?: string; detail: string }> {
return detectRuntimeBinary({
binaryPath: configuredPath,
bundledPath,
validation: "path-only",
binaryNames: [runtime],
label: "OpenCode CLI",
});
}
async function defaultValidateBinary(
runtime: "opencode",
configuredPath: string,
): Promise<{ path?: string; detail: string }> {
return detectRuntimeBinary({
binaryPath: configuredPath,
validation: "canonical-file",
allowAutomaticDiscovery: false,
binaryNames: [runtime],
label: "OpenCode CLI",
});
}
type OpencodeSdk = typeof import("@opencode-ai/sdk/v2");
let opencodeSdk: OpencodeSdk | undefined;
// The SDK is loaded when an OpenCode client is first created, not at Main
// startup (P6). initializeClient awaits this before any default factory runs.
async function loadOpencodeSdk(): Promise<OpencodeSdk> {
opencodeSdk ??= await import("@opencode-ai/sdk/v2");
return opencodeSdk;
}
const defaultCreateClient: typeof createOpencodeClient = (config) => {
if (!opencodeSdk) throw new Error("OpenCode SDK is not loaded");
return opencodeSdk.createOpencodeClient(config);
};
async function defaultCheckServerHealth(
url: string,
authorization: string,
signal: AbortSignal,
): Promise<boolean> {
const { createOpencodeClient } = await loadOpencodeSdk();
const client = createOpencodeClient({
baseUrl: url,
headers: { Authorization: authorization },
});
const response = await client.global.health({ signal });
return response.error === undefined && response.data?.healthy === true;
}
export class OpenCodeRuntime implements AgentRuntime {
readonly runtimeId = "opencode";
readonly requiresToolApproval = false;
readonly supportsToolExecution = true;
// OpenCode applies the prompt `system` field to the current turn only.
readonly consumesTrustedInstructions = true;
private client?: OpencodeClient;
private clientInitialization?: Promise<OpencodeClient>;
private server?: OpenCodeServer;
private startingChild?: SpawnedProcess;
// `interrupted` marks a reused session whose last turn the user cancelled.
private readonly sessions = new Map<
string,
{ id: string; directory: string; interrupted?: boolean }
>();
private initializationController?: AbortController;
private initializationWaiters = 0;
private readonly sessionInitializations = new Map<string, Promise<string>>();
private readonly pendingQuestions = new Map<
string,
{
client: OpencodeClient;
directory: string;
questionCount: number;
upstreamQuestionId: string;
}
>();
private readonly conversationRunTails = new Map<string, Promise<void>>();
private mcpMutationTail: Promise<void> = Promise.resolve();
// Slots bound active requests; generations retire uncertain names without
// retaining a slot forever. Replacement names are unique across this client.
private readonly temporaryMcpSlots = new WeakMap<
OpencodeClient,
{ generation: number; conversations: Map<string, Map<number, { active: boolean; generation: number }>> }
>();
private readonly dependencies: OpenCodeRuntimeDependencies;
constructor(
private readonly options: OpenCodeRuntimeOptions,
dependencies: Partial<OpenCodeRuntimeDependencies> = {},
) {
this.dependencies = {
spawn,
detectBinary: defaultDetectBinary,
validateBinary: defaultValidateBinary,
createClient: defaultCreateClient,
checkServerHealth: defaultCheckServerHealth,
platform: process.platform,
startupTimeoutMs: STARTUP_TIMEOUT_MS,
controlRequestTimeoutMs: CONTROL_REQUEST_TIMEOUT_MS,
...dependencies,
};
}
get hasRetainedSessions(): boolean {
return this.sessions.size > 0 || this.sessionInitializations.size > 0;
}
private usesEmbeddedPermissionMediation(): boolean {
return this.options.embedded && !this.options.baseUrl;
}
private supportsNativeCustomization(): boolean {
return this.usesEmbeddedPermissionMediation();
}
get supportsScopedDataTools(): boolean {
return this.usesEmbeddedPermissionMediation();
}
private async acquireConversationRun(
conversationId: string,
signal: AbortSignal,
): Promise<() => void> {
signal.throwIfAborted();
const previous =
this.conversationRunTails.get(conversationId) ?? Promise.resolve();
let releaseGate!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
const tail = previous.then(
() => gate,
() => gate,
);
this.conversationRunTails.set(conversationId, tail);
let abort!: () => void;
const aborted = new Promise<never>((_resolve, reject) => {
abort = () => reject(signal.reason);
});
signal.addEventListener("abort", abort, { once: true });
try {
await Promise.race([previous, aborted]);
signal.throwIfAborted();
return () => {
releaseGate();
if (this.conversationRunTails.get(conversationId) === tail) {
this.conversationRunTails.delete(conversationId);
}
};
} catch (error) {
releaseGate();
if (this.conversationRunTails.get(conversationId) === tail) {
this.conversationRunTails.delete(conversationId);
}
throw error;
} finally {
signal.removeEventListener("abort", abort);
}
}
private acquireTemporaryMcpSlot(
client: OpencodeClient,
conversationKey: string,
): number {
let state = this.temporaryMcpSlots.get(client);
if (!state) {
state = { generation: 0, conversations: new Map() };
this.temporaryMcpSlots.set(client, state);
}
let occupied = state.conversations.get(conversationKey);
if (!occupied) {
occupied = new Map();
state.conversations.set(conversationKey, occupied);
}
for (let slot = 1; slot <= MAX_TEMPORARY_MCP_SLOTS; slot += 1) {
const entry = occupied.get(slot);
if (!entry?.active) {
occupied.set(slot, { active: true, generation: entry?.generation ?? 0 });
return slot;
}
}
throw new Error(
`同一会话中并行的 OpenCode 请求过多(最多 ${MAX_TEMPORARY_MCP_SLOTS} 个),请等待其他请求结束后重试`,
);
}
private releaseTemporaryMcpSlot(
client: OpencodeClient,
conversationKey: string,
slot: number,
): void {
const byConversation = this.temporaryMcpSlots.get(client)?.conversations;
const occupied = byConversation?.get(conversationKey);
if (!byConversation || !occupied) {
return;
}
const entry = occupied.get(slot);
if (entry?.generation) entry.active = false;
else occupied.delete(slot);
if (occupied.size === 0) {
byConversation.delete(conversationKey);
}
}
private terminate(child: SpawnedProcess): void {
requestProcessTreeTermination(child, {
platform: this.dependencies.platform,
spawn: this.dependencies.spawn,
});
}
private waitForExit(child: SpawnedProcess): Promise<void> {
return waitForProcessExit(child);
}
private getNativeSkillIds(): string[] {
if (!this.usesEmbeddedPermissionMediation()) {
return [];
}
const ids = (this.options.skillPackages ?? []).map((skill) => skill.id);
if (
new Set(ids).size !== ids.length ||
ids.some((id) => id.length > 64 || !OPENCODE_SKILL_NAME_PATTERN.test(id))
) {
throw new Error("OpenCode Skill 注册信息无效");
}
return ids;
}
private async normalizeSkillPackages(skillsRoot: string): Promise<void> {
for (const skill of this.options.skillPackages ?? []) {
await normalizeOpenCodeSkillManifest(
join(skillsRoot, skill.id),
skill.id,
);
}
}
private async mutateMcp<T>(
signal: AbortSignal,
operation: () => Promise<T>,
): Promise<T> {
signal.throwIfAborted();
const previous = this.mcpMutationTail;
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
this.mcpMutationTail = previous.then(
() => current,
() => current,
);
try {
await awaitWithAbort(previous, signal);
signal.throwIfAborted();
return await operation();
} finally {
release();
}
}
private async prepareSharedSkills(sharedRoot: string): Promise<string> {
const packages = this.options.skillPackages ?? [];
const fingerprint = createHash("sha256")
.update(
packages
.map((skill) => ({
id: skill.id,
digest: skill.digest ?? resolve(skill.directory),
}))
.sort((left, right) => left.id.localeCompare(right.id))
.map((skill) => `${skill.id}\0${skill.digest}`)
.join("\0"),
)
.digest("hex");
const cacheRoot = join(sharedRoot, "skills");
const target = join(cacheRoot, fingerprint);
const existing = sharedSkillPreparations.get(target);
if (existing) {
return existing;
}
const preparation = (async () => {
try {
if ((await stat(target)).isDirectory()) {
return target;
}
} catch {
// The immutable Skill snapshot has not been prepared yet.
}
await mkdir(cacheRoot, { recursive: true, mode: 0o700 });
const stagingRoot = await mkdtemp(join(cacheRoot, ".staging-"));
try {
const staged = await stageRuntimeSkillPackages(
stagingRoot,
packages,
"OpenCode",
);
await this.normalizeSkillPackages(staged);
try {
await rename(staged, target);
} catch (error) {
try {
if ((await stat(target)).isDirectory()) {
return target;
}
} catch {
// Preserve the original rename failure below.
}
throw error;
}
return target;
} finally {
await rm(stagingRoot, { recursive: true, force: true });
}
})();
sharedSkillPreparations.set(target, preparation);
try {
return await preparation;
} catch (error) {
sharedSkillPreparations.delete(target);
throw error;
}
}
private async createSkillRegistration(): Promise<OpenCodeSkillRegistration> {
const root = await createRuntimeTemporaryDirectory(this.options.sharedCacheRoot?.trim() || tmpdir(), "goodbuddy-opencode-");
try {
const sharedRoot = this.options.sharedCacheRoot?.trim();
if (sharedRoot) {
const configDirectory = join(resolve(sharedRoot), "config", "opencode");
await this.prepareSharedConfig(configDirectory);
const skillsRoot = await this.prepareSharedSkills(resolve(sharedRoot));
return { root, configDirectory, skillsRoot };
}
const configDirectory = join(root, "config");
const skillsRoot = await stageRuntimeSkillPackages(
configDirectory,
this.options.skillPackages ?? [],
"OpenCode",
);
await this.normalizeSkillPackages(skillsRoot);
return { root, configDirectory, skillsRoot };
} catch (error) {
await removeRuntimeTemporaryDirectory(root);
throw error;
}
}
private async prepareSharedConfig(configDirectory: string): Promise<void> {
const bundledConfigPath = this.options.bundledConfigPath?.trim();
if (!bundledConfigPath) {
await mkdir(configDirectory, { recursive: true, mode: 0o700 });
return;
}
const target = resolve(configDirectory);
const existing = sharedConfigPreparations.get(target);
if (existing) {
return existing;
}
const preparation = (async () => {
const source = resolve(bundledConfigPath);
const packagePath = join(
"node_modules",
"@opencode-ai",
"plugin",
"package.json",
);
const sourcePackage = JSON.parse(
await readFile(join(source, packagePath), "utf8"),
) as { version: string };
try {
const installed = JSON.parse(
await readFile(join(target, packagePath), "utf8"),
) as { version?: string };
if (installed.version === sourcePackage.version) {
return;
}
} catch {
// Install the bundled dependency tree below.
}
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
const staging = `${target}.staging-${process.pid}-${randomBytes(6).toString("hex")}`;
await rm(staging, { recursive: true, force: true });
try {
await cp(source, staging, {
recursive: true,
errorOnExist: true,
force: false,
});
await rm(target, { recursive: true, force: true });
await rename(staging, target);
} finally {
await rm(staging, { recursive: true, force: true });
}
})();
sharedConfigPreparations.set(target, preparation);
try {
await preparation;
} catch (error) {
sharedConfigPreparations.delete(target);
throw error;
}
}
private async inspectEmbeddedConfiguration(validatePath = false): Promise<{
path?: string;
detail: string;
}> {
if (
this.options.modelProfile?.authentication === "api-key" &&
!this.options.modelProfile.apiKey
) {
throw new Error("OpenCode 独立模型连接尚未配置 API Key");
}
const detection = await this.dependencies.detectBinary(
"opencode",
this.options.binaryPath,
this.options.bundledBinaryPath,
);
if (!validatePath || !detection.path) {
return detection;
}
return this.dependencies.validateBinary("opencode", detection.path);
}
private async launchEmbedded(signal?: AbortSignal): Promise<OpenCodeServer> {
if (signal?.aborted) {
throw new Error("OpenCode Server 启动已取消");
}
const detection = await this.inspectEmbeddedConfiguration();
const binaryPath = detection.path;
if (!binaryPath) {
throw new Error(detection.detail);
}
if (signal?.aborted) {
throw new Error("OpenCode Server 启动已取消");
}
const port = await getAvailableLoopbackPort();
if (signal?.aborted) {
throw new Error("OpenCode Server 启动已取消");
}
const skillIds = this.getNativeSkillIds();
const registration = await this.createSkillRegistration();
let launchedChild: SpawnedProcess | undefined;
try {
if (signal?.aborted) {
throw new Error("OpenCode Server 启动已取消");
}
const profile = this.options.modelProfile;
let env = profile
? buildExplicitProfileRuntimeEnvironment(
runtimePrivacyEnvironment,
profile.authentication === "api-key" && profile.apiKey
? {
name:
profile.protocol === "anthropic-messages"
? "ANTHROPIC_API_KEY"
: "OPENAI_API_KEY",
value: profile.apiKey,
}
: undefined,
)
: buildRuntimeEnvironment(runtimePrivacyEnvironment);
env = applyLaunchEnvironmentPath(
env,
this.options.launchEnvironmentProvider,
);
delete env.OPENCODE_CONFIG;
delete env.OPENCODE_CONFIG_CONTENT;
delete env.OPENCODE_CONFIG_DIR;
delete env.OPENCODE_SERVER_PASSWORD;
delete env.OPENCODE_SERVER_USERNAME;
const serverPassword = randomBytes(32).toString("base64url");
const authorization = `Basic ${Buffer.from(
`${EMBEDDED_SERVER_USERNAME}:${serverPassword}`,
).toString("base64")}`;
env.OPENCODE_SERVER_USERNAME = EMBEDDED_SERVER_USERNAME;
env.OPENCODE_SERVER_PASSWORD = serverPassword;
env.OPENCODE_CONFIG_DIR = registration.configDirectory;
env.OPENCODE_DISABLE_AUTOUPDATE = "1";
env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = "1";
env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "1";
env.OPENCODE_DISABLE_EXTERNAL_SKILLS = "1";
env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "1";
env.OPENCODE_DISABLE_LSP_DOWNLOAD = "1";
env.OPENCODE_DISABLE_MODELS_FETCH = "1";
env.OPENCODE_DISABLE_PROJECT_CONFIG = "1";
env.OPENCODE_DISABLE_SHARE = "1";
env.XDG_CACHE_HOME = this.options.sharedCacheRoot?.trim()
? join(resolve(this.options.sharedCacheRoot), "cache")
: join(registration.root, "xdg-cache");
env.XDG_CONFIG_HOME = this.options.sharedCacheRoot?.trim()
? dirname(registration.configDirectory)
: join(registration.root, "xdg-config");
env.XDG_DATA_HOME = join(registration.root, "xdg-data");
env.XDG_STATE_HOME = join(registration.root, "xdg-state");
const skillConfig = createOpenCodeSkillConfig(registration, skillIds);
// GoodBuddy does not use native Git snapshots or their synchronous diffs.
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
...(profile ? createOpenCodeProviderConfig(profile) : {}),
...skillConfig,
snapshot: false,
});
if (!profile && this.options.configPath.trim()) {
env.OPENCODE_CONFIG = resolve(this.options.configPath);
}
const serverArgs = ["serve", "--hostname=127.0.0.1", `--port=${port}`];
return await new Promise<OpenCodeServer>((resolveServer, reject) => {
const child = this.dependencies.spawn(binaryPath, serverArgs, {
cwd: this.options.defaultWorkspace,
env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
this.startingChild = child;
launchedChild = child;
const ownership = recordRuntimeTemporaryChild(registration.root, child.pid);
const { stdout, stderr } = child;
let settled = false;
let pollTimer: NodeJS.Timeout | undefined;
const probeController = new AbortController();
const probeSignal = signal
? AbortSignal.any([signal, probeController.signal])
: probeController.signal;
const url = `http://127.0.0.1:${port}`;
const cleanupStartupListeners = (): void => {
clearTimeout(timeout);
if (pollTimer) {
clearTimeout(pollTimer);
}
probeController.abort();
signal?.removeEventListener("abort", abort);
child.removeListener("error", onError);
child.removeListener("close", onClose);
};
const fail = (message: string): void => {
if (settled) {
return;
}
settled = true;
cleanupStartupListeners();
const clearStartingChild = (): void => {
if (this.startingChild === child) {
this.startingChild = undefined;
}
};
const exited = this.waitForExit(child);
this.terminate(child);
void exited.finally(() => {
if (child.exitCode !== null) {
clearStartingChild();
}
reject(new Error(message.slice(0, 1_000)));
});
};
const succeed = (url: string): void => {
if (settled) {
return;
}
settled = true;
cleanupStartupListeners();
if (this.startingChild === child) {
this.startingChild = undefined;
}
stdout?.resume();
stderr?.resume();
const lifetime = new AbortController();
child.once("close", (code: number | null) => {
lifetime.abort(new Error(
`OpenCode 服务已退出(code ${code ?? "unknown"}),请重试当前请求`,
));
});
child.once("error", () => {
lifetime.abort(new Error(
"OpenCode 服务发生错误并已关闭,请重试当前请求",
));
});
resolveServer({
url,
authorization,
signal: lifetime.signal,
activeRequests: 0,
close: async () => {
lifetime.abort(new Error("OpenCode 服务已关闭,请重试当前请求"));
try {
const exited = this.waitForExit(child);
this.terminate(child);
await exited;
} finally {
if (child.exitCode !== null || child.signalCode != null) await removeRuntimeTemporaryDirectory(registration.root);
}
},
});
};
const onError = (): void => {
fail("OpenCode Server 启动失败");
};
const onClose = (code: number | null): void => {
fail(`OpenCode Server 启动前退出(code ${code ?? "unknown"})`);
};
const abort = (): void => {
fail("OpenCode Server 启动已取消");
};
const probe = async (): Promise<void> => {
try {
await ownership;
const attemptSignal = AbortSignal.any([
probeSignal,
AbortSignal.timeout(STARTUP_PROBE_TIMEOUT_MS),
]);
if (
await this.dependencies.checkServerHealth(
url,
authorization,
attemptSignal,
)
) {
succeed(url);
return;
}
} catch {
// The server is not ready yet.
}
if (!settled) {
pollTimer = setTimeout(
() => void probe(),
STARTUP_POLL_INTERVAL_MS,
);
}
};
const timeout = setTimeout(() => {
fail("OpenCode Server 启动超时(30 秒)");
}, this.dependencies.startupTimeoutMs);
void ownership.catch(() => fail("OpenCode launch ownership could not be recorded"));
if (!stdout || !stderr) {
fail("OpenCode Server 管道初始化失败");
return;
}
stdout.resume();
stderr.resume();
child.once("error", onError);
child.once("close", onClose);
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) {
abort();
return;
}
void probe();
});
} catch (error) {
if (!launchedChild || launchedChild.exitCode !== null || launchedChild.signalCode != null || !launchedChild.pid) await removeRuntimeTemporaryDirectory(registration.root).catch(() => undefined);
throw error;
}
}
private async getClient(signal?: AbortSignal): Promise<OpencodeClient> {
signal?.throwIfAborted();
if (this.client && !this.server) {
return this.client;
}
const cached = Boolean(this.client && this.server);
this.initializationWaiters += 1;
if (!this.clientInitialization) {
const controller = this.initializationController = new AbortController();
const initialization = this.initializeClient(
cached ? undefined : controller.signal,
).finally(() => {
if (this.clientInitialization === initialization) {
this.clientInitialization = undefined;
this.initializationController = undefined;
}
});
this.clientInitialization = initialization;
}
try {
return signal
? await awaitWithAbort(this.clientInitialization, signal)
: await this.clientInitialization;
} catch (error) {
if (signal?.aborted && !cached && !this.client) {
throw new Error("OpenCode Server 启动已取消", { cause: error });
}
throw error;
} finally {
this.initializationWaiters -= 1;
if (this.initializationWaiters === 0 && !this.client) {
this.initializationController?.abort(
new Error("OpenCode 初始化已无等待请求"),
);
await this.clientInitialization?.catch(() => undefined);
}
}
}
private async controlRequest<T>(
label: string,
operation: (signal: AbortSignal) => Promise<T>,
callerSignal?: AbortSignal,
onAbortedResult?: (value: T) => void | Promise<void>,
): Promise<T> {
const timeoutController = new AbortController();
const timeout = setTimeout(
() => {
timeoutController.abort(new Error(`OpenCode ${label}超时`));
},
Math.max(1, this.dependencies.controlRequestTimeoutMs),
);
timeout.unref?.();
const controlSignal = callerSignal
? AbortSignal.any([callerSignal, timeoutController.signal])
: timeoutController.signal;
try {
controlSignal.throwIfAborted();
const request = operation(controlSignal);
if (onAbortedResult) {
void request
.then(async (value) => {
if (controlSignal.aborted) {
await onAbortedResult(value);
}
})
.catch(() => undefined);
}
return await awaitWithAbort(request, controlSignal);
} catch (error) {
if (timeoutController.signal.aborted && !callerSignal?.aborted) {
throw timeoutController.signal.reason;
}
throw error;
} finally {
clearTimeout(timeout);
}
}
private async initializeClient(
signal?: AbortSignal,
): Promise<OpencodeClient> {
if (this.client && this.server) {
// A short probe must not replace a server still serving another request.
if (this.server.activeRequests > 0 && !this.server.signal.aborted) {
return this.client;
}
const probeSignal = AbortSignal.any([
...(signal ? [signal] : []),
AbortSignal.timeout(STARTUP_PROBE_TIMEOUT_MS),
]);
let healthy = false;
try {
healthy = await awaitWithAbort(
this.dependencies.checkServerHealth(
this.server.url,
this.server.authorization,
probeSignal,
),
probeSignal,
);
} catch {
// A dead or unresponsive cached server must not poison later requests.
}
signal?.throwIfAborted();
if (healthy) return this.client;
const server = this.server;
this.server = undefined;
this.client = undefined;
this.sessions.clear();
this.sessionInitializations.clear();
this.pendingQuestions.clear();
await server.close();
}
let baseUrl = this.options.baseUrl;
if (baseUrl && this.options.modelProfile) {
throw new Error("OpenCode 独立模型连接仅支持由 GoodBuddy 启动的本机服务");
}
await loadOpencodeSdk();
if (!baseUrl && this.options.embedded) {
this.server = await this.launchEmbedded(signal);
baseUrl = this.server.url;
}
if (!baseUrl) {
throw new Error("未配置 OpenCode Server");
}
this.client = this.dependencies.createClient({
baseUrl,
directory: this.options.defaultWorkspace,
...(this.server
? {
headers: {
Authorization: this.server.authorization,
},
}
: {}),
});
return this.client;
}
async getStatus(): Promise<AgentRuntimeStatus> {
if (this.usesEmbeddedPermissionMediation() && !this.client) {
try {
const detection = await this.inspectEmbeddedConfiguration(true);
return {
id: "opencode",
label: "OpenCode",
available: Boolean(detection.path),
supportsToolExecution: this.supportsToolExecution,
detail: detection.path
? "OpenCode 已配置,将在首次使用时启动"
: detection.detail,
};
} catch (error) {
return {
id: "opencode",
label: "OpenCode",
available: false,
supportsToolExecution: this.supportsToolExecution,
detail: error instanceof Error ? error.message : "OpenCode 不可用",
};
}
}
return this.checkConnection();
}
async testConnection(): Promise<AgentRuntimeStatus> {
return this.checkConnection();
}
private async checkConnection(): Promise<AgentRuntimeStatus> {
try {
const client = await this.getClient();
const response = await this.controlRequest("连接检查", (signal) =>
client.session.list(
{ directory: this.options.defaultWorkspace },
{ signal },
),
);
if (response.error) {
throw new Error("OpenCode Server 返回错误");
}
return {
id: "opencode",
label: "OpenCode",
available: true,
supportsToolExecution: this.supportsToolExecution,
detail: this.server
? "由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程"
: `已连接 ${this.options.baseUrl}`,
};
} catch (error) {
return {
id: "opencode",
label: "OpenCode",
available: false,
supportsToolExecution: this.supportsToolExecution,
detail: error instanceof Error ? error.message : "OpenCode 不可用",
};
}
}
private async discoverAgents(
client: OpencodeClient,
signal?: AbortSignal,
directory = this.options.defaultWorkspace,
): Promise<
Array<{
id: string;
name: string;
description?: string;
mode: "primary" | "subagent" | "all";
native: boolean;
hidden: boolean;
}>
> {
const response = await this.controlRequest(
"读取 Agent 清单",
(controlSignal) =>
client.app.agents(
{
directory,
},
{ signal: controlSignal },
),
signal,
);
if (response.error || !response.data) {
throw new Error("OpenCode Agent 清单不可用");
}
return response.data
.flatMap((agent) => {
const id = boundedNativeIdentifier(agent.name);
if (!id) {
return [];
}
const description = boundedNativeText(agent.description, 2_000);
return [
{
id,
name: id.slice(0, 200),
...(description ? { description } : {}),
mode: agent.mode,
native: agent.native === true,
hidden: agent.hidden === true,
},
];
})
.slice(0, MAX_NATIVE_AGENTS);
}
private async resolveSelectedAgent(
client: OpencodeClient,
request: AgentExecutionRequest,
signal: AbortSignal,
): Promise<string | undefined> {
const control =
request.runtimeControl?.provider === "opencode"
? request.runtimeControl
: undefined;
const selected = control?.agent ?? this.options.customization?.defaultAgent;
if (!selected) {
return undefined;
}
if (!this.supportsNativeCustomization()) {
throw new Error("外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent");
}
const agents = await this.discoverAgents(
client, signal, request.executionWorkspace ?? this.options.defaultWorkspace,
);
if (
!agents.some(
(agent) =>
agent.id === selected &&
!agent.hidden &&
(agent.mode === "primary" || agent.mode === "all"),
)
) {
throw new Error(
`OpenCode Agent 不存在、已隐藏或不可作为主 Agent:${selected.slice(0, 128)}`,
);
}
return selected;
}
async getNativeSnapshot(
directory = this.options.defaultWorkspace,
): Promise<RuntimeNativeSnapshot> {
const controlled = this.supportsNativeCustomization();
const empty: RuntimeNativeSnapshot = {
provider: "opencode",
available: false,
inventoryStatus: controlled ? "unavailable" : "connection-only",
detail: controlled
? "OpenCode 原生能力不可用"
: "外部 OpenCode Server 仅支持连接状态;GoodBuddy 不支持读取或控制其原生自定义能力",
agents: [],
tools: [],
toolsSupported: controlled,
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [],
prompts: [],
resources: [],
resourcesSupported: false,
context: {
strategy: controlled ? "native" : "unsupported",
manualCompact: controlled,
detail: controlled
? "由 OpenCode 原生上下文与手动 Compact 管理"
: "外部 OpenCode Server 不支持由 GoodBuddy 管理上下文",
},
};
if (!controlled) {
try {
const client = await this.getClient();
const response = await this.controlRequest("连接检查", (signal) =>
client.session.list(
{
directory,
limit: 1,
},
{ signal },
),
);
if (response.error || !response.data) {
throw new Error("connection check failed");
}
return { ...empty, available: true };
} catch {
return {
...empty,
inventoryStatus: "unavailable",
detail:
"外部 OpenCode Server 无法连接,且不支持由 GoodBuddy 读取或控制原生自定义能力",
};
}
}
let client: OpencodeClient;
try {
client = await this.getClient();
} catch {
return {
...empty,
detail: "OpenCode Server 无法连接",
};
}
const assignedSkillIds = new Set(
(this.options.skillPackages ?? []).map((skill) => skill.id),
);
const [
availabilityResult,
agentsResult,
toolsResult,
commandsResult,
lspResult,
formattersResult,
mcpResult,
skillsResult,
resourcesResult,
] = await Promise.allSettled([
this.controlRequest("连接检查", (signal) =>
client.session.list(
{
directory,
limit: 1,
},
{ signal },
),
),
this.discoverAgents(client, undefined, directory),
this.controlRequest("读取工具清单", (signal) =>
client.tool.ids({ directory }, { signal }),
),
this.controlRequest("读取命令清单", (signal) =>
client.command.list({ directory }, { signal }),
),
this.controlRequest("读取 LSP 状态", (signal) =>
client.lsp.status({ directory }, { signal }),
),
this.controlRequest("读取格式化器状态", (signal) =>
client.formatter.status({ directory }, { signal }),
),
this.controlRequest("读取 MCP 状态", (signal) =>
client.mcp.status({ directory }, { signal }),
),
this.controlRequest("读取技能清单", (signal) =>
client.app.skills({ directory }, { signal }),
),
this.controlRequest("读取资源清单", (signal) =>
client.experimental.resource.list({ directory }, { signal }),
),
]);
if (
availabilityResult.status === "rejected" ||
availabilityResult.value.error ||
!availabilityResult.value.data
) {
return {
...empty,
detail: "OpenCode Server 无法连接",
};
}
const unavailable: string[] = [];
const agents =
agentsResult.status === "fulfilled"
? agentsResult.value
: (unavailable.push("Agent"), []);
const toolIds =
toolsResult.status === "fulfilled" &&
!toolsResult.value.error &&
toolsResult.value.data
? toolsResult.value.data
: (unavailable.push("工具"), []);
const tools = mapOpenCodeNativeTools(toolIds);
const commandData =
commandsResult.status === "fulfilled" &&
!commandsResult.value.error &&
commandsResult.value.data
? commandsResult.value.data
: (unavailable.push("命令"), []);
const nativeCommandData = commandData
.filter((command) => {
const name = boundedNativeIdentifier(command.name);
return (
name !== undefined &&
!isTemporaryMcpInventoryItem(name) &&
!assignedSkillIds.has(name)
);
})
.slice(0, MAX_NATIVE_COMMANDS);
const commands = nativeCommandData.flatMap((command) => {
const id = boundedNativeIdentifier(command.name);
if (!id) {
return [];
}
const description = boundedNativeText(command.description, 2_000);
const agent = boundedNativeIdentifier(command.agent);
return [
{
id,
name: id.slice(0, 200),
...(description ? { description } : {}),
source: command.source ?? ("command" as const),
...(agent ? { agent } : {}),
},
];
});
const prompts = nativeCommandData.flatMap((command) => {
if (command.source !== "mcp") {
return [];
}
const id = boundedNativeIdentifier(command.name);
const prompt = boundedNativeText(command.template, 20_000);
if (!id || !prompt) {
return [];
}
const description = boundedNativeText(command.description, 2_000);
return [
{
id,
name: id.slice(0, 200),
...(description ? { description } : {}),
prompt,
source: "mcp" as const,
},
];
});
const lspData =
lspResult.status === "fulfilled" &&
!lspResult.value.error &&
lspResult.value.data
? lspResult.value.data
: (unavailable.push("LSP"), []);
const lsp = lspData
.flatMap((server) => {
const id = boundedNativeIdentifier(server.id);
const name = boundedNativeText(server.name, 200);
if (!id || !name) {
return [];
}
return [
{
id,
name,
status: server.status,
},
];
})
.slice(0, MAX_NATIVE_LSP);
const formatterData =
formattersResult.status === "fulfilled" &&
!formattersResult.value.error &&
formattersResult.value.data
? formattersResult.value.data
: (unavailable.push("Formatter"), []);
const formatters = formatterData
.flatMap((formatter) => {
const id = boundedNativeIdentifier(formatter.name);
if (!id) {
return [];
}
return [
{
id,
name: id.slice(0, 200),
enabled: formatter.enabled,
extensions: formatter.extensions
.flatMap((extension) => {
const value = boundedNativeText(extension, 32);
return value ? [value] : [];
})
.slice(0, 100),
},
];
})
.slice(0, MAX_NATIVE_FORMATTERS);
const mcpData =
mcpResult.status === "fulfilled" &&
!mcpResult.value.error &&
mcpResult.value.data
? mcpResult.value.data
: (unavailable.push("MCP"), {});
const mcpServers = Object.entries(mcpData)
.flatMap(([rawName, server]) => {
const id = boundedNativeIdentifier(rawName);
if (!id || isTemporaryMcpName(id)) {
return [];
}
const status =
server.status === "needs_auth"
? ("needs-auth" as const)
: server.status === "needs_client_registration"
? ("unsupported" as const)
: server.status;
return [
{
id,
name: id.slice(0, 200),
status,
},
];
})
.slice(0, MAX_NATIVE_MCP_SERVERS);
const skillData =
skillsResult.status === "fulfilled" &&
!skillsResult.value.error &&
skillsResult.value.data
? skillsResult.value.data
: (unavailable.push("Skill"), []);
const skills = skillData
.flatMap((skill) => {
const id = boundedNativeIdentifier(skill.name);
if (!id || assignedSkillIds.has(id)) {
return [];
}
const description = boundedNativeText(skill.description, 2_000);
return [
{
id,
name: id.slice(0, 200),
...(description ? { description } : {}),
source: "unknown" as const,
},
];
})
.slice(0, MAX_NATIVE_SKILLS);
const resourceData =
resourcesResult.status === "fulfilled" &&
!resourcesResult.value.error &&
resourcesResult.value.data
? resourcesResult.value.data
: (unavailable.push("资源"), {});
const resources = Object.entries(resourceData)
.flatMap(([rawId, resource]) => {
const id =
boundedNativeIdentifier(rawId) ??
boundedNativeIdentifier(resource.name);
const name = boundedNativeText(resource.name, 200);
const uri = boundedResourceUri(resource.uri);
const server = boundedNativeText(resource.client, 200);
if (
!id ||
!name ||
!uri ||
isTemporaryMcpInventoryItem(id, name, server)
) {
return [];
}
const description = boundedNativeText(resource.description, 2_000);
const mimeType = boundedNativeText(resource.mimeType, 200);
return [
{
id,
name,
uri,
...(description ? { description } : {}),
...(mimeType ? { mimeType } : {}),
...(server ? { server } : {}),
},
];
})
.slice(0, MAX_NATIVE_RESOURCES);
return {
provider: "opencode",
available: true,
inventoryStatus: unavailable.length === 0 ? "available" : "partial",
detail:
unavailable.length === 0
? "OpenCode 原生能力已就绪"
: `OpenCode 已连接;部分原生清单暂不可用:${unavailable.join("、")}`.slice(
0,
1_000,
),
agents,
tools,
toolsSupported:
toolsResult.status === "fulfilled" &&
!toolsResult.value.error &&
toolsResult.value.data !== undefined,
commands,
lsp,
formatters,
mcpServers,
skills,
rules: [],
prompts,
resources,
resourcesSupported:
resourcesResult.status === "fulfilled" &&
!resourcesResult.value.error &&
resourcesResult.value.data !== undefined,
context: {
strategy: "native",
manualCompact: true,
detail: "由 OpenCode 原生上下文与手动 Compact 管理",
},
};
}
private async getSessionId(
client: OpencodeClient,
request: AgentExecutionRequest,
directory: string,
signal: AbortSignal,
agent?: string,
permission?: PermissionRuleset,
): Promise<{ id: string; created: boolean }> {
const current = this.sessions.get(request.conversationId);
if (current?.directory === directory) {
return { id: current.id, created: false };
}
if (current) {
await this.releaseConversation(request.conversationId);
}
const pending = this.sessionInitializations.get(request.conversationId);
if (pending) {
return {
id: await awaitWithAbort(pending, signal),
created: false,
};
}
const creation: Promise<string> = this.controlRequest(
"创建会话",
(controlSignal) =>
client.session.create(
{
title: "GoodBuddy 对话",
directory,
...(agent ? { agent } : {}),
...(permission ? { permission } : {}),
},
{ signal: controlSignal },
),
signal,
async (response) => {
const sessionId = response.data?.id;
if (sessionId) {
await client.session
.delete(
{
sessionID: sessionId,
directory,
},
{ signal: AbortSignal.timeout(1_000) },
)
.catch(() => undefined);
}
},
).then((response) => {
if (!response.data) {
throw new Error("OpenCode 会话创建失败");
}
const sessionId = response.data.id;
const stillCurrent =
this.sessionInitializations.get(request.conversationId) === creation;
if (signal.aborted || !stillCurrent) {
if (this.sessions.get(request.conversationId)?.id !== sessionId) {
void client.session
.delete(
{
sessionID: sessionId,
directory,
},
{ signal: AbortSignal.timeout(1_000) },
)
.catch(() => undefined);
}
if (signal.aborted) {
throw signal.reason;
}
throw new Error("OpenCode 会话初始化已失效");
}
this.sessions.set(request.conversationId, { id: sessionId, directory });
return sessionId;
});
this.sessionInitializations.set(request.conversationId, creation);
try {
return {
id: await awaitWithAbort(creation, signal),
created: true,
};
} finally {
if (
this.sessionInitializations.get(request.conversationId) === creation
) {
this.sessionInitializations.delete(request.conversationId);
}
}
}
async *run(
request: AgentExecutionRequest,
signal: AbortSignal,
): AsyncGenerator<RuntimeEvent, void, void> {
const configuredTimeout = this.dependencies.executionTimeoutMs;
const deadline =
configuredTimeout === undefined ? undefined : new AbortController();
const deadlineTimer =
configuredTimeout === undefined || deadline === undefined
? undefined
: setTimeout(
() =>
deadline.abort(
new Error(
`OpenCode 执行超过 ${executionDeadlineLabel(
configuredTimeout,
)}总时限`,
),
),
configuredTimeout,
);
deadlineTimer?.unref?.();
const executionSignal = deadline
? AbortSignal.any([signal, deadline.signal])
: signal;
let releaseConversation: (() => void) | undefined;
try {
releaseConversation = await this.acquireConversationRun(
request.conversationId,
executionSignal,
);
yield* this.runUnlocked(request, executionSignal);
} catch (error) {
if (deadline?.signal.aborted && !signal.aborted) {
throw deadline.signal.reason;
}
throw error;
} finally {
if (deadlineTimer) {
clearTimeout(deadlineTimer);
}
releaseConversation?.();
}
}
private async *runUnlocked(
request: AgentExecutionRequest,
signal: AbortSignal,
): AsyncGenerator<RuntimeEvent, void, void> {
signal.throwIfAborted();
if (
request.images?.length &&
this.options.modelProfile &&
this.options.modelProfile.supportsImageInput !== true
) {
throw new Error("当前模型连接未启用图像输入");
}
const client = await this.getClient(signal);
const server = this.server;
const requestSignal = server
? AbortSignal.any([signal, server.signal])
: signal;
if (server) server.activeRequests += 1;
try {
requestSignal.throwIfAborted();
yield* this.runWithClient(request, requestSignal, client);
} catch (error) {
requestSignal.throwIfAborted();
throw error;
} finally {
if (server) server.activeRequests -= 1;
}
}
private async *runWithClient(
request: AgentExecutionRequest,
signal: AbortSignal,
client: OpencodeClient,
): AsyncGenerator<RuntimeEvent, void, void> {
const directory = resolve(request.executionWorkspace ?? this.options.defaultWorkspace);
const runtimeControl =
request.runtimeControl?.provider === "opencode"
? request.runtimeControl
: undefined;
if (runtimeControl?.command && !this.supportsNativeCustomization()) {
throw new Error("外部 OpenCode Server 不支持由 GoodBuddy 执行原生命令");
}
const selectedAgent = await this.resolveSelectedAgent(
client,
request,
signal,
);
let selectedCommand:
| {
name: string;
arguments: string;
}
| undefined;
if (runtimeControl?.command) {
const commandResponse = await this.controlRequest(
"验证原生命令",
(controlSignal) =>
client.command.list({ directory }, { signal: controlSignal }),
signal,
);
if (commandResponse.error || !commandResponse.data) {
throw new Error("OpenCode 无法验证原生命令");
}
const assignedSkillIds = new Set(
(this.options.skillPackages ?? []).map((skill) => skill.id),
);
const command = commandResponse.data.find((candidate) => {
const name = boundedNativeIdentifier(candidate.name);
return (
name !== undefined &&
name === runtimeControl.command?.name &&
!isTemporaryMcpName(name) &&
!assignedSkillIds.has(name)
);
});
if (!command) {
throw new Error(
`OpenCode 原生命令不存在或已失效:${runtimeControl.command.name.slice(0, 128)}`,
);
}
selectedCommand = runtimeControl.command;
}
const nativeSkillIds = this.getNativeSkillIds();
const nativeSkillPermissionRules =
createSkillPermissionRules(nativeSkillIds);
let knowledgeMcpName: string | undefined;
let knowledgeToolIds: string[] = [];
let customMcpName: string | undefined;
let customMcpToken: string | undefined;
type McpAttempt = Pick<DesktopMcpDiagnosticMetadata, "correlationId" | "kind" | "attempt">;
const attemptedMcpNames = new Map<string, McpAttempt>();
const mcpConversationKey = temporaryMcpConversationKey(
request.conversationId,
);
let mcpSlot: number | undefined;
const temporaryMcpName = (
prefix: (typeof TEMPORARY_MCP_PREFIXES)[number],
): string => {
mcpSlot ??= this.acquireTemporaryMcpSlot(client, mcpConversationKey);
const entry = this.temporaryMcpSlots.get(client)!.conversations.get(mcpConversationKey)!.get(mcpSlot)!;
return createTemporaryMcpName(prefix, mcpConversationKey, mcpSlot, entry.generation);
};
const retireMcpName = (name: string): void => {
const state = this.temporaryMcpSlots.get(client)!;
const entry = state.conversations.get(mcpConversationKey)!.get(mcpSlot!)!;
const prefix = name.startsWith(KNOWLEDGE_MCP_PREFIX) ? KNOWLEDGE_MCP_PREFIX : CUSTOM_MCP_PREFIX;
// A late cleanup of an already retired name must not retire its successor.
if (createTemporaryMcpName(prefix, mcpConversationKey, mcpSlot!, entry.generation) === name) {
entry.generation = ++state.generation;
}
};
const subscriptionController = new AbortController();
const imageCapabilityToken = request.imageToolBinding
? this.options.knowledgeGateway?.bindImageTool(request.imageToolBinding, signal, request.knowledgeCapabilityToken)
: undefined;
const scopedCapabilityToken = imageCapabilityToken ?? request.knowledgeCapabilityToken;
const observeMcpFailure = (mcp: DesktopMcpDiagnosticMetadata): void => {
try {
this.options.observeFailure?.({
component: "runtime", stage: mcp.phase, code: "runtime.mcp.failed",
error: new Error("Local MCP operation failed"), mcp,
});
} catch {
// Diagnostics must not affect retry, cancellation, or cleanup.
}
};
const disconnectMcp = async (name: string, metadata: McpAttempt): Promise<boolean> => {
// Called under the mutation lock; queue wait does not consume this budget.
const cleanupSignal = AbortSignal.timeout(1_000);
const startedAt = performance.now();
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let status: number | undefined;
try {
const result = await awaitWithAbort(
client.mcp.disconnect({ name, directory }, { signal: cleanupSignal }),
cleanupSignal,
);
if (result.data === true && !result.error) return true;
status = result.response?.status;
category = result.error && !result.response ? "transport-failure"
: status && status >= 400 ? "http-rejection" : "cleanup-failure";
} catch {
// The name is retired below; the active request can still release its slot.
}
retireMcpName(name);
observeMcpFailure({ ...metadata, phase: "disconnect", category, status,
elapsedMs: Math.round(performance.now() - startedAt) });
return false;
};
const addMcp = (prefix: (typeof TEMPORARY_MCP_PREFIXES)[number], url: string, token: string, label: string) =>
this.mutateMcp(signal, async () => {
let name = temporaryMcpName(prefix);
for (let attempt = 0; ; attempt++) {
signal.throwIfAborted();
const startedAt = performance.now();
const metadata: McpAttempt = {
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
kind: name.startsWith(KNOWLEDGE_MCP_PREFIX) ? "builtin" : "custom",
attempt: attempt === 0 ? 1 : 2,
};
let failure: unknown;
let retryableStatus = true;
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let httpStatus: number | undefined;
let attempted = false;
let confirmed = false;
try {
const added = await this.controlRequest(label, (controlSignal) => {
attemptedMcpNames.set(name, metadata);
attempted = true;
return client.mcp.add({
directory, name,
config: {
type: "remote", url, enabled: true,
headers: { Authorization: `Bearer ${token}` },
oauth: false,
},
}, { signal: controlSignal });
}, signal);
confirmed = !!added.response;
const status = added.data?.[name];
httpStatus = added.response?.status;
const httpRejected = httpStatus !== undefined && httpStatus >= 400;
if (!httpRejected && !added.error && status?.status === "connected") return name;
// With throwOnError:false the SDK resolves fetch failures without a response.
const transportFailure = !!added.error && !added.response;
category = transportFailure ? "transport-failure"
: httpRejected ? "http-rejection" : "initialization-failure";
retryableStatus = transportFailure || (!httpRejected && !added.error && status?.status === "failed");
failure = added.error ?? (status && "error" in status
? `${status.status}: ${status.error}`
: status?.status ?? "unknown");
} catch (error) {
failure = error;
} finally {
// Aborting the HTTP client does not cancel OpenCode's pending add.
if (attempted && !confirmed) retireMcpName(name);
}
observeMcpFailure({ ...metadata, phase: "connect",
category: signal.aborted ? "cancelled" : category, status: httpStatus,
elapsedMs: Math.round(performance.now() - startedAt) });
signal.throwIfAborted();
const failureDetail = safeToolErrorDetail(failure) ?? "unknown";
const detail = redactSensitiveText(
failureDetail.split(token).join("[REDACTED]"),
).replace(/https?:\/\/[^\s<>"']+/giu, "[URL]");
const error = new Error(`OpenCode ${label}失败(${httpStatus && httpStatus >= 400 ? `HTTP ${httpStatus}: ` : ""}${detail})`);
// Only explicit transport failures during initialization may be retried.
const transient = /\b(?:ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE)\b|\b(?:connection closed|socket hang up|request timed out)\b/iu.test(detail)
&& !/\b(?:auth(?:entication|orization)?\s+(?:failed|required|error|denied)|oauth|credentials|unauthorized|forbidden|401|403|protocol|invalid|parse|json|method)\b/iu.test(failureDetail);
if (attempt > 0 || !retryableStatus || !transient) throw error;
if (!await disconnectMcp(name, metadata)) throw error;
attemptedMcpNames.delete(name);
name = temporaryMcpName(prefix);
}
});
try {
if (
scopedCapabilityToken &&
this.usesEmbeddedPermissionMediation() &&
this.options.knowledgeGateway?.getEndpoint()
) {
const knowledgeEndpoint = this.options.knowledgeGateway.getEndpoint()!;
knowledgeMcpName = await addMcp(KNOWLEDGE_MCP_PREFIX, knowledgeEndpoint, scopedCapabilityToken, "连接内置只读工具");
// OpenCode 1.18.x does not include dynamically added MCP tools in
// experimental/tool/ids. Its model tool namespace is deterministic:
// "<MCP server name>_<declared tool name>".
knowledgeToolIds = this.options.knowledgeGateway
.getAvailableToolNames(scopedCapabilityToken)
.map((toolName) => `${knowledgeMcpName}_${toolName}`);
}
if (
this.usesEmbeddedPermissionMediation() &&
this.options.knowledgeGateway?.getEndpoint() &&
this.options.mcpServers?.length
) {
const customMcpEndpoint = this.options.knowledgeGateway.getEndpoint()!;
customMcpToken = this.options.knowledgeGateway.grantCustomMcp(
request.requestId,
this.options.mcpServers,
signal,
);
if (customMcpToken) {
const tools =
await this.options.knowledgeGateway.prepareCustomMcpTools(
customMcpToken,
signal,
);
customMcpName = await addMcp(CUSTOM_MCP_PREFIX, customMcpEndpoint, customMcpToken, "连接自定义 MCP 工具");
knowledgeToolIds.push(
...tools.map((tool) => `${customMcpName}_${tool.name}`),
);
}
}
const permission = [
{ permission: "*", pattern: "*", action: "allow" as const },
...(this.usesEmbeddedPermissionMediation()
? temporaryMcpDenyRules
: []),
...nativeSkillPermissionRules,
...knowledgeToolIds.map((toolId) => ({
permission: toolId,
pattern: "*",
action: "allow" as const,
})),
];
let toolOverrides: Record<string, boolean> | undefined;
if (this.usesEmbeddedPermissionMediation()) {
toolOverrides = {
...temporaryMcpToolOverrides,
...Object.fromEntries(
knowledgeToolIds.map((toolId) => [toolId, true]),
),
};
}
const session = await this.getSessionId(
client,
request,
directory,
signal,
selectedAgent,
permission,
);
const sessionId = session.id;
if (!session.created) {
const update = await this.controlRequest(
"更新会话权限",
(controlSignal) =>
client.session.update(
{
sessionID: sessionId,
directory,
permission,
},
{ signal: controlSignal },
),
signal,
);
if (update.error || !update.data) {
throw new Error("OpenCode 会话权限配置失败");
}
}
yield {
requestId: request.requestId,
type: "status",
message: "OpenCode 正在处理请求",
};
const subscription = await this.controlRequest(
"订阅事件流",
(controlSignal) =>
client.event.subscribe(
{ directory },
eventSubscriptionOptions(controlSignal),
),
AbortSignal.any([signal, subscriptionController.signal]),
);
let sessionAbort: Promise<void> | undefined;
const abortSession = (): void => {
sessionAbort ??= this.controlRequest(
"取消会话",
(controlSignal) => client.session.abort(
{ sessionID: sessionId, directory },
{ signal: controlSignal },
),
)
.then((response) => {
if (response.error || response.data !== true) {
throw new Error("OpenCode 会话取消失败");
}
})
.catch(() => {
// An unconfirmed abort must never target a reused session.
if (this.sessions.get(request.conversationId)?.id === sessionId) {
this.sessions.delete(request.conversationId);
}
});
};
signal.addEventListener("abort", abortSession, { once: true });
const toolStates = new Map<
string,
{
name: string;
summary: string;
state: "pending" | "running" | "completed" | "failed";
input?: string;
output?: string;
error?: string;
subagent?: SubagentEvent;
}
>();
const reasoningPartIds = new Set<string>();
let lastReasoningPartId: string | undefined;
const subagentProgress = new OpenCodeSubagentProgress(
request.requestId,
sessionId,
);
const reportedQuestionIds = new Map<string, string>();
let aggregateOutputBytes = 0;
let outputTruncated = false;
const retainOutputDelta = (
delta: string,
): { delta: string; truncated: boolean } => {
if (outputTruncated) {
return { delta: "", truncated: false };
}
const bytes = Buffer.from(delta);
const remaining = MAX_EXECUTION_OUTPUT_BYTES - aggregateOutputBytes;
if (bytes.byteLength <= remaining) {
aggregateOutputBytes += bytes.byteLength;
return { delta, truncated: false };
}
outputTruncated = true;
aggregateOutputBytes = MAX_EXECUTION_OUTPUT_BYTES;
return {
delta: bytes
.subarray(0, Math.max(0, remaining))
.toString("utf8")
.replace(/\uFFFD+$/u, ""),
truncated: true,
};
};
let hasResponseTextAfterFailure = false;
// Match OC's sortable 48-bit timestamp prefix so reused-session history stays ordered.
const messageTime = ((BigInt(Date.now()) << 12n) & 0xffffffffffffn).toString(16).padStart(12, '0');
const promptMessageId = `msg_${messageTime}${crypto.randomUUID().replaceAll('-', '').slice(0, 14)}`;
const checklistMessageIds = new Set<string>();
const checklistCalls = new Map<string, unknown>();
const submittedChecklistCalls = new Set<string>();
const pendingChecklistUpdates: RuntimeChecklist[] = [];
let runFailed = false;
let submissionFailure: { error: unknown } | undefined;
try {
const promptText = promptWithUntrustedConversationHistory(
request,
session.created,
);
const sessionState = this.sessions.get(request.conversationId);
// The native transcript keeps a cancelled turn; tell the model it was
// interrupted so it answers the new message instead of merging both.
const interruptionNotice =
!session.created && sessionState?.id === sessionId && sessionState.interrupted
? opencodeInterruptionNotice
: undefined;
if (sessionState?.id === sessionId) sessionState.interrupted = false;
const imageParts = (request.images ?? []).map((image) => ({
type: "file" as const,
mime: image.mediaType,
filename: image.name,
url: `data:${image.mediaType};base64,${image.data}`,
}));
const prompt = selectedCommand
? client.session.command(
{
sessionID: sessionId,
directory,
command: selectedCommand.name,
messageID: promptMessageId,
arguments: selectedCommand.arguments,
...(selectedAgent ? { agent: selectedAgent } : {}),
...(this.options.modelProfile
? {
model: `${
resolveOpenCodeProvider(this.options.modelProfile).id
}/${this.options.modelProfile.modelName}`,
}
: {}),
...(imageParts.length > 0 ? { parts: imageParts } : {}),
},
{ signal },
)
: this.controlRequest(
"提交请求",
(controlSignal) =>
client.session.promptAsync(
{
sessionID: sessionId,
directory,
messageID: promptMessageId,
model: this.options.modelProfile
? {
providerID: resolveOpenCodeProvider(
this.options.modelProfile,
).id,
modelID: this.options.modelProfile.modelName,
}
: undefined,
...(selectedAgent ? { agent: selectedAgent } : {}),
system:
[
nativeSkillIds.length > 0
? undefined
: this.options.skillInstructions,
request.trustedInstructions,
]
.filter(Boolean)
.join("\n\n") || undefined,
...(toolOverrides ? { tools: toolOverrides } : {}),
parts: [
...(interruptionNotice
? [
{
type: "text" as const,
text: interruptionNotice,
synthetic: true,
},
]
: []),
{ type: "text" as const, text: promptText },
...imageParts,
],
},
{ signal: controlSignal },
),
signal,
);
void prompt.then((result) => {
if (result.error) {
throw new Error(opencodeErrorMessage(result.error, "OpenCode 提交请求失败"));
}
}).catch((error: unknown) => {
submissionFailure = { error };
// Wake a silent SSE reader without cancelling any peer subscription.
subscriptionController.abort(error);
});
const repliedPermissionIds = new Set<string>();
const ownedSessions = new Set([sessionId]);
const ownsSession = async (candidate: string): Promise<boolean> => {
const visited = new Set<string>();
let current: string | undefined = candidate;
while (current && !visited.has(current)) {
if (ownedSessions.has(current)) {
for (const id of visited) ownedSessions.add(id);
return true;
}
visited.add(current);
const result = await this.controlRequest(
"确认子会话权限归属",
(controlSignal) => client.session.get(
{ sessionID: current!, directory },
{ signal: controlSignal },
),
signal,
);
if (result.error || !result.data) return false;
current = result.data.parentID;
}
return false;
};
const reportedMessageIds = new Set<string>();
const childTaskIdsBySession = new Map<string, string>();
let waitingForRetry = false;
for await (const event of consumeEventSubscription(
subscription.stream,
subscriptionController,
)) {
if (signal.aborted) break;
if (event.type === 'message.updated' &&
event.properties.info.sessionID === sessionId &&
event.properties.info.role === 'assistant' &&
event.properties.info.parentID === promptMessageId) {
checklistMessageIds.add(event.properties.info.id);
}
if (event.type === 'message.part.updated') {
const part = event.properties.part;
if (part.sessionID === sessionId && part.type === 'tool' &&
part.tool === 'todowrite' && checklistMessageIds.has(part.messageID)) {
if (part.state.status === 'pending' && !submittedChecklistCalls.has(part.callID)) {
checklistCalls.set(part.callID, undefined);
} else if (part.state.status === 'running' && !submittedChecklistCalls.has(part.callID)) {
checklistCalls.set(part.callID, part.state.input.todos);
} else {
checklistCalls.delete(part.callID);
}
}
}
if (event.type === 'todo.updated' && event.properties.sessionID === sessionId) {
const parsed = runtimeChecklistSchema.safeParse({ source: 'opencode', items: event.properties.todos });
if (parsed.success && checklistCalls.size > 0) pendingChecklistUpdates.push(parsed.data);
}
// OC 1.18.29 publishes todo.updated before the tool's running input frame.
for (const [callId, items] of checklistCalls) {
const candidate = runtimeChecklistSchema.safeParse({ source: 'opencode', items });
if (!candidate.success) continue;
const index = pendingChecklistUpdates.findIndex(update => JSON.stringify(update) === JSON.stringify(candidate.data));
if (index >= 0) {
const checklist = pendingChecklistUpdates.splice(index, 1)[0]!;
submittedChecklistCalls.add(callId);
checklistCalls.delete(callId);
yield { type: 'checklist', requestId: request.requestId, checklist };
}
}
if (checklistCalls.size === 0) pendingChecklistUpdates.length = 0;
const childProgress = subagentProgress.update(event);
if (
childProgress &&
"sessionID" in event.properties &&
event.properties.sessionID !== sessionId
) {
const task = childProgress.runtimeCallId
? toolStates.get(childProgress.runtimeCallId)
: undefined;
if (task) task.subagent = childProgress;
yield childProgress;
}
if (
event.type === "session.status" &&
event.properties.sessionID === sessionId
) {
const status = event.properties.status;
if (status.type === "retry") {
waitingForRetry = true;
const scheduled = Number.isSafeInteger(status.next) && status.next > 0
? `(计划 ${new Date(status.next).toLocaleTimeString("zh-CN", { hour12: false })})`
: "";
yield {
requestId: request.requestId,
type: "status",
message: `OpenCode 等待第 ${status.attempt} 次重试${scheduled}:${
safeToolErrorDetail(status.message, 1_000) || "模型请求失败"
}`,
};
} else if (status.type === "busy" && waitingForRetry) {
waitingForRetry = false;
yield {
requestId: request.requestId,
type: "status",
message: "OpenCode 已开始重试,等待后续进展",
};
}
}
if (
event.type === "message.updated" &&
event.properties.sessionID === sessionId &&
event.properties.info.sessionID === sessionId &&
event.properties.info.role === "assistant" &&
!reportedMessageIds.has(event.properties.info.id)
) {
const usage = createUsageEvent(
request.requestId,
event.properties.info,
);
if (usage) {
reportedMessageIds.add(event.properties.info.id);
yield usage;
}
}
if (
event.type === "message.part.delta" &&
event.properties.sessionID === sessionId &&
event.properties.delta
) {
const reasoning =
reasoningPartIds.has(event.properties.partID) ||
[
"reasoning",
"reasoning_content",
"reasoning_details",
"thinking",
].includes(event.properties.field);
if (reasoning || event.properties.field === "text") {
if (
!reasoning &&
/\S/u.test(event.properties.delta) &&
[...toolStates.values()].some((tool) => tool.state === "failed")
) {
hasResponseTextAfterFailure = true;
}
const delta =
reasoning &&
lastReasoningPartId !== undefined &&
lastReasoningPartId !== event.properties.partID
? `\n\n${event.properties.delta}`
: event.properties.delta;
if (reasoning) {
lastReasoningPartId = event.properties.partID;
}
const retained = retainOutputDelta(delta);
if (retained.delta) {
yield {
requestId: request.requestId,
type: reasoning ? "reasoning" : "text",
delta: retained.delta,
};
}
if (retained.truncated) {
yield {
requestId: request.requestId,
type: "status",
message:
"OpenCode 输出较长,已停止继续展示文本;任务仍在继续",
};
}
}
}
if (
event.type === "message.part.updated" &&
event.properties.sessionID === sessionId
) {
const { part } = event.properties;
if (part.type === "reasoning") {
reasoningPartIds.add(part.id);
} else if (part.type === "tool") {
const callId = part.callID || part.id;
if (!callId || callId.length > 256) {
throw new Error("OpenCode 工具调用 ID 格式无效");
}
const toolName = part.tool.slice(0, 200);
const title = "title" in part.state ? part.state.title : undefined;
const summary =
(typeof title === "string" ? title.trim().slice(0, 240) : "") ||
toolStates.get(callId)?.summary ||
`OpenCode 工具:${toolName}`;
const nativeControlTool = toolName.trim().toLowerCase() === "question";
const state =
part.state.status === "error" ? "failed" : part.state.status;
if (state === "failed") {
hasResponseTextAfterFailure = false;
}
const error =
part.state.status === "error"
? safeToolErrorDetail(part.state.error)
: undefined;
const input = isRecord(part.state.input)
? boundedToolDetail(part.state.input, 4_000)
: undefined;
const output =
part.state.status === "completed" &&
typeof part.state.output === "string"
? part.state.output.slice(0, 16_000)
: undefined;
const subagent =
toolName.trim().toLowerCase() === "task"
? toOpenCodeSubagentEvent({
requestId: request.requestId,
callId,
state: part.state.status,
input: part.state.input,
output,
error,
})
: undefined;
toolStates.set(callId, {
name: toolName,
summary,
state,
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {}),
...(subagent ? { subagent } : {}),
});
if (subagent) {
const metadata = "metadata" in part.state ? part.state.metadata : undefined;
if (isRecord(metadata) && typeof metadata.sessionId === "string") {
childTaskIdsBySession.set(metadata.sessionId, subagent.childTaskId);
}
const retained = subagentProgress.retain(subagent);
toolStates.get(callId)!.subagent = retained;
yield retained;
} else if (!nativeControlTool) {
yield {
requestId: request.requestId,
type: "tool",
callId,
name: toolName,
state,
summary,
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {}),
};
}
}
}
if (
event.type === "session.next.reasoning.delta" &&
event.properties.sessionID === sessionId &&
event.properties.delta
) {
const retained = retainOutputDelta(event.properties.delta);
if (retained.delta) {
yield {
requestId: request.requestId,
type: "reasoning",
delta: retained.delta,
};
}
if (retained.truncated) {
yield {
requestId: request.requestId,
type: "status",
message: "OpenCode 输出较长,已停止继续展示文本;任务仍在继续",
};
}
}
if (
event.type === "question.asked" &&
await ownsSession(event.properties.sessionID)
) {
const questionRequest = parseQuestionRequest(
event.properties,
event.properties.sessionID,
);
if (
questionRequest &&
!reportedQuestionIds.has(questionRequest.id)
) {
const publicQuestionId = createPublicQuestionId(
request.requestId,
event.properties.sessionID,
questionRequest.id,
);
if (this.pendingQuestions.has(publicQuestionId)) {
throw new Error("OpenCode 提问公开 ID 与另一活动请求冲突");
}
reportedQuestionIds.set(questionRequest.id, publicQuestionId);
this.pendingQuestions.set(publicQuestionId, {
client,
directory,
questionCount: questionRequest.questions.length,
upstreamQuestionId: questionRequest.id,
});
yield {
requestId: request.requestId,
type: "question",
questionId: publicQuestionId,
childTaskId: childTaskIdsBySession.get(event.properties.sessionID),
questions: questionRequest.questions.map((question) => ({
header: question.header,
question: question.question,
options: question.options.map((option) => ({
label: option.label,
description: option.description,
})),
multiple: question.multiple ?? false,
custom: question.custom ?? true,
})),
};
}
}
if (
(event.type === "question.replied" ||
event.type === "question.rejected") &&
reportedQuestionIds.has(event.properties.requestID)
) {
const publicQuestionId = reportedQuestionIds.get(
event.properties.requestID,
);
if (publicQuestionId) {
this.pendingQuestions.delete(publicQuestionId);
}
}
if (
this.usesEmbeddedPermissionMediation() &&
event.type === "permission.asked"
) {
const properties = event.properties as unknown;
const permissionSessionId = isRecord(properties) &&
typeof properties.sessionID === "string"
? properties.sessionID
: sessionId;
if (
!await ownsSession(permissionSessionId)
) {
continue;
}
let permissionRequest: PermissionRequest;
try {
const parsed = parsePermissionRequest(properties, permissionSessionId);
if (!parsed) {
throw new Error("OpenCode 权限请求格式无效");
}
permissionRequest = parsed;
} catch (error) {
if (
isRecord(properties) &&
typeof properties.id === "string" &&
properties.id.length > 0 &&
properties.id.length <= MAX_PERMISSION_NAME_LENGTH &&
!repliedPermissionIds.has(properties.id)
) {
const permissionId = properties.id;
repliedPermissionIds.add(properties.id);
const rejection = await this.controlRequest(
"回复权限请求",
(controlSignal) =>
client.permission.reply(
{
requestID: permissionId,
directory,
reply: "reject",
},
{ signal: controlSignal },
),
signal,
);
if (rejection.error || rejection.data !== true) {
throw new Error("OpenCode 权限拒绝回复失败", {
cause: error,
});
}
continue;
}
throw error;
}
if (repliedPermissionIds.has(permissionRequest.id)) {
continue;
}
repliedPermissionIds.add(permissionRequest.id);
const callId =
permissionRequest.tool?.callID ?? permissionRequest.id;
const toolName = permissionRequest.permission.slice(0, 200);
if (permissionSessionId === sessionId) {
const summary = toolStates.get(callId)?.summary ?? `OpenCode 工具:${toolName}`;
toolStates.set(callId, { name: toolName, summary, state: "pending" });
yield {
requestId: request.requestId,
type: "tool",
callId,
name: toolName,
state: "pending",
summary,
};
}
const response = await this.controlRequest(
"回复权限请求",
(controlSignal) =>
client.permission.reply(
{
requestID: permissionRequest.id,
directory,
reply: "once",
},
{ signal: controlSignal },
),
signal,
);
if (response.error || response.data !== true) {
throw new Error("OpenCode 权限回复失败");
}
}
if (
event.type === "session.error" &&
event.properties.sessionID === sessionId
) {
const error = event.properties.error;
throw new Error(opencodeErrorMessage(error, "OpenCode 执行失败"));
}
if (
event.type === "session.idle" &&
event.properties.sessionID === sessionId
) {
const promptResult = await prompt;
if (promptResult.error) {
throw new Error(
opencodeErrorMessage(
promptResult.error,
"OpenCode 提交请求失败",
),
);
}
const incompleteTool = [...toolStates.entries()].find(
([, tool]) =>
tool.state === "pending" || tool.state === "running",
);
if (incompleteTool) {
const [callId] = incompleteTool;
throw new Error(`OpenCode 工具未完成(${callId.slice(0, 128)})`);
}
const failedTools = [...toolStates.entries()].filter(
([, tool]) => tool.state === "failed",
);
if (failedTools.length > 0 && !hasResponseTextAfterFailure) {
const [callId, tool] = failedTools[0]!;
throw new Error(
`OpenCode 工具执行失败(${callId.slice(0, 128)})${tool.error ? `:${tool.error}` : ""}`,
);
}
for (const [callId, tool] of failedTools) {
if (tool.subagent) {
continue;
}
yield {
requestId: request.requestId,
type: "tool",
callId,
name: tool.name,
state: "recoverable",
summary: `OpenCode 已在后续响应中处理工具失败:${tool.name}`,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {}),
};
}
yield {
requestId: request.requestId,
type: "done",
sessionId,
};
return;
}
}
const promptResult = await prompt;
if (promptResult.error) {
throw new Error(
opencodeErrorMessage(promptResult.error, "OpenCode 提交请求失败"),
);
}
signal.throwIfAborted();
throw new Error("OpenCode 事件流意外结束");
} catch (error) {
runFailed = true;
abortSession();
for (const [callId, tool] of toolStates) {
if (tool.state === "pending" || tool.state === "running") {
if (tool.subagent) {
yield {
...tool.subagent,
state: signal.aborted ? "cancelled" : "failed",
...(signal.aborted
? { reason: tool.subagent.reason ?? "父请求已取消" }
: {
error:
tool.subagent.error ??
"OpenCode 子 Agent 未返回完成状态",
}),
};
} else {
yield {
requestId: request.requestId,
type: "tool",
callId,
name: tool.name,
state: "failed",
summary: tool.summary,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {}),
};
}
}
}
throw submissionFailure?.error ?? error;
} finally {
signal.removeEventListener("abort", abortSession);
// Keep the conversation locked until its session-wide abort settles.
await sessionAbort;
const currentSession = this.sessions.get(request.conversationId);
if (runFailed && signal.aborted && currentSession?.id === sessionId) {
// Keep the cancelled turn visible to the model, as it is to the user;
// the next prompt carries an interruption notice instead.
currentSession.interrupted = true;
} else if (
// A failed prompt is dropped from a reused session so it is not
// merged into the next turn.
runFailed &&
!selectedCommand &&
checklistMessageIds.size === 0 &&
this.sessions.get(request.conversationId)?.id === sessionId
) {
await Promise.resolve()
.then(() =>
client.session.deleteMessage(
{ sessionID: sessionId, messageID: promptMessageId, directory },
{ signal: AbortSignal.timeout(1_000) },
),
)
.catch(() => undefined);
}
for (const questionId of reportedQuestionIds.values()) {
this.pendingQuestions.delete(questionId);
}
}
} finally {
subscriptionController.abort();
if (imageCapabilityToken && imageCapabilityToken !== request.knowledgeCapabilityToken) this.options.knowledgeGateway?.revoke(imageCapabilityToken);
for (const [name, metadata] of attemptedMcpNames) {
await this.mutateMcp(
new AbortController().signal,
() => disconnectMcp(name, metadata),
);
}
if (mcpSlot !== undefined) {
this.releaseTemporaryMcpSlot(client, mcpConversationKey, mcpSlot);
}
if (customMcpToken) {
this.options.knowledgeGateway?.revoke(customMcpToken);
}
}
}
async respondToQuestion(
questionId: string,
answers?: AgentQuestionAnswer[],
): Promise<void> {
const pending = this.pendingQuestions.get(questionId);
if (!pending) {
throw new Error("OpenCode 提问已失效或不存在");
}
const response = answers
? answers.length === pending.questionCount
? await this.controlRequest("提交提问回答", (signal) =>
pending.client.question.reply(
{
requestID: pending.upstreamQuestionId,
directory: pending.directory,
answers,
},
{ signal },
),
)
: undefined
: await this.controlRequest("取消提问", (signal) =>
pending.client.question.reject(
{
requestID: pending.upstreamQuestionId,
directory: pending.directory,
},
{ signal },
),
);
if (!response) {
throw new Error("OpenCode 提问回答数量不匹配");
}
if (response.error || response.data !== true) {
throw new Error(
answers ? "OpenCode 提交回答失败" : "OpenCode 取消提问失败",
);
}
this.pendingQuestions.delete(questionId);
}
async compactConversation(
request: RuntimeConversationCompactInput,
signal: AbortSignal,
): Promise<RuntimeConversationCompactOutcome> {
signal.throwIfAborted();
if (!this.supportsNativeCustomization()) {
throw new Error(
"外部 OpenCode Server 不支持由 GoodBuddy 执行原生 Compact",
);
}
const configuredTimeout = this.dependencies.executionTimeoutMs;
const deadline =
configuredTimeout === undefined ? undefined : new AbortController();
const deadlineTimer =
configuredTimeout === undefined || deadline === undefined
? undefined
: setTimeout(
() => deadline.abort(new Error("OpenCode 原生 Compact 执行超时")),
configuredTimeout,
);
deadlineTimer?.unref?.();
let executionSignal = deadline
? AbortSignal.any([signal, deadline.signal])
: signal;
let releaseConversation: (() => void) | undefined;
let server: OpenCodeServer | undefined;
try {
releaseConversation = await this.acquireConversationRun(
request.conversationId,
executionSignal,
);
const client = this.sessions.has(request.conversationId)
? await this.getClient(executionSignal)
: undefined;
if (client) {
server = this.server;
if (server) {
server.activeRequests += 1;
executionSignal = AbortSignal.any([executionSignal, server.signal]);
}
}
const session = this.sessions.get(request.conversationId);
const sessionId = session?.id;
if (!sessionId || !client) {
return {
result: {
provider: "opencode",
strategy: "native",
compacted: false,
detail: "当前 GoodBuddy 对话尚无可压缩的 OpenCode 会话",
},
};
}
const directory = session!.directory;
const context = await this.controlRequest(
"读取原生上下文",
(controlSignal) =>
client.v2.session.context(
{ sessionID: sessionId },
{ signal: controlSignal },
),
executionSignal,
);
if (context.error || !context.data) {
throw new Error("OpenCode 原生上下文不可用,无法执行 Compact");
}
const latestAssistant = [...context.data.data]
.reverse()
.find((message) => message.type === "assistant");
const configuredModel = this.options.modelProfile
? {
providerID: resolveOpenCodeProvider(this.options.modelProfile).id,
modelID: this.options.modelProfile.modelName,
}
: latestAssistant?.type === "assistant"
? {
providerID: latestAssistant.model.providerID,
modelID: latestAssistant.model.id,
}
: undefined;
if (!configuredModel) {
return {
result: {
provider: "opencode",
strategy: "native",
compacted: false,
detail: "当前 OpenCode 会话尚无可用于压缩的模型记录",
},
};
}
executionSignal.throwIfAborted();
const subscriptionController = new AbortController();
const subscriptionSignal = AbortSignal.any([
executionSignal,
subscriptionController.signal,
]);
const subscription = await this.controlRequest(
"订阅 Compact 事件流",
(controlSignal) =>
client.event.subscribe(
{ directory },
eventSubscriptionOptions(controlSignal),
),
subscriptionSignal,
);
const usageEvents: RuntimeModelUsageEvent[] = [];
const reportedMessageIds = new Set<string>();
const usageCapture = (async () => {
for await (const event of consumeEventSubscription(
subscription.stream,
subscriptionController,
)) {
if (
event.type === "message.updated" &&
event.properties.sessionID === sessionId &&
event.properties.info.sessionID === sessionId &&
event.properties.info.role === "assistant" &&
!reportedMessageIds.has(event.properties.info.id)
) {
const usage = createUsageEvent(
request.requestId,
event.properties.info,
);
if (usage) {
reportedMessageIds.add(event.properties.info.id);
usageEvents.push(usage);
}
}
if (
event.type === "session.idle" &&
event.properties.sessionID === sessionId
) {
return;
}
}
})();
try {
const compact = await client.session.summarize(
{
sessionID: sessionId,
directory,
providerID: configuredModel.providerID,
modelID: configuredModel.modelID,
auto: false,
},
{ signal: executionSignal },
);
if (compact.error || compact.data !== true) {
throw new Error(
opencodeErrorMessage(compact.error, "OpenCode 原生 Compact 失败"),
);
}
let graceTimer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
usageCapture,
new Promise<void>((resolveGrace) => {
graceTimer = setTimeout(
resolveGrace,
COMPACTION_USAGE_EVENT_GRACE_MS,
);
graceTimer.unref?.();
}),
]);
} finally {
if (graceTimer) {
clearTimeout(graceTimer);
}
}
return {
result: {
provider: "opencode",
strategy: "native",
compacted: true,
detail: "OpenCode 已完成原生上下文压缩",
},
...(usageEvents.length > 0 ? { usageEvents } : {}),
};
} finally {
subscriptionController.abort();
await usageCapture.catch(() => undefined);
}
} catch (error) {
if (deadline?.signal.aborted && !signal.aborted) {
throw deadline.signal.reason;
}
executionSignal.throwIfAborted();
throw error;
} finally {
if (deadlineTimer) {
clearTimeout(deadlineTimer);
}
if (server) server.activeRequests -= 1;
releaseConversation?.();
}
}
async dispose(): Promise<void> {
this.initializationController?.abort(new Error("OpenCode Runtime 已关闭"));
this.pendingQuestions.clear();
const startingChild = this.startingChild;
this.startingChild = undefined;
if (startingChild) {
this.terminate(startingChild);
await this.waitForExit(startingChild);
}
const server = this.server;
this.server = undefined;
this.client = undefined;
this.clientInitialization = undefined;
this.sessions.clear();
this.sessionInitializations.clear();
this.conversationRunTails.clear();
await server?.close();
}
async releaseConversation(conversationId: string): Promise<void> {
const session = this.sessions.get(conversationId);
this.sessions.delete(conversationId);
if (!session || !this.client) {
return;
}
await this.controlRequest("释放会话", (signal) =>
this.client!.session.delete(
{
sessionID: session.id,
directory: session.directory,
},
{ signal },
),
).catch(() => undefined);
}
}