1251 lines
37 KiB
TypeScript
1251 lines
37 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
|
import net from "node:net";
|
|
import type { Logger } from "pino";
|
|
import { z } from "zod";
|
|
|
|
import type {
|
|
AgentCapabilityFlags,
|
|
AgentClient,
|
|
AgentMetadata,
|
|
AgentMode,
|
|
AgentModelDefinition,
|
|
AgentPermissionRequest,
|
|
AgentPermissionResponse,
|
|
AgentPersistenceHandle,
|
|
AgentPromptInput,
|
|
AgentRunOptions,
|
|
AgentRunResult,
|
|
AgentRuntimeInfo,
|
|
AgentSession,
|
|
AgentSessionConfig,
|
|
AgentStreamEvent,
|
|
AgentTimelineItem,
|
|
AgentUsage,
|
|
ListModelsOptions,
|
|
ListPersistedAgentsOptions,
|
|
McpServerConfig,
|
|
PersistedAgentDescriptor,
|
|
} from "../agent-sdk-types.js";
|
|
import {
|
|
applyProviderEnv,
|
|
findExecutable,
|
|
resolveProviderCommandPrefix,
|
|
type ProviderRuntimeSettings,
|
|
} from "../provider-launch-config.js";
|
|
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
|
|
|
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
};
|
|
|
|
const DEFAULT_MODES: AgentMode[] = [
|
|
{
|
|
id: "build",
|
|
label: "Build",
|
|
description: "Allows edits and tool execution for implementation work",
|
|
},
|
|
{
|
|
id: "plan",
|
|
label: "Plan",
|
|
description: "Read-only planning mode that avoids file edits",
|
|
},
|
|
];
|
|
|
|
const OPENCODE_MODE_IDS = new Set(DEFAULT_MODES.map((mode) => mode.id));
|
|
|
|
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
|
|
type OpenCodeMessageRole = "user" | "assistant";
|
|
|
|
type OpenCodeMcpConfig =
|
|
| {
|
|
type: "local";
|
|
command: string[];
|
|
environment?: Record<string, string>;
|
|
enabled?: boolean;
|
|
}
|
|
| {
|
|
type: "remote";
|
|
url: string;
|
|
headers?: Record<string, string>;
|
|
enabled?: boolean;
|
|
};
|
|
|
|
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
|
|
|
|
const OpencodeToolStateSchema = z
|
|
.object({
|
|
status: z.string().optional(),
|
|
input: z.unknown().optional(),
|
|
output: z.unknown().optional(),
|
|
error: z.unknown().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const OpencodeToolPartBaseSchema = z
|
|
.object({
|
|
tool: z.string().trim().min(1),
|
|
state: OpencodeToolStateSchema.optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const OpencodeToolPartWithCallIdSchema = OpencodeToolPartBaseSchema.extend({
|
|
callID: z.string().trim().min(1),
|
|
id: z.string().optional(),
|
|
}).transform((part) => ({
|
|
toolName: part.tool,
|
|
callId: part.callID,
|
|
status: part.state?.status,
|
|
input: part.state?.input,
|
|
output: part.state?.output,
|
|
error: part.state?.error,
|
|
}));
|
|
|
|
const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({
|
|
id: z.string().trim().min(1),
|
|
callID: z.string().optional(),
|
|
}).transform((part) => ({
|
|
toolName: part.tool,
|
|
callId: part.id,
|
|
status: part.state?.status,
|
|
input: part.state?.input,
|
|
output: part.state?.output,
|
|
error: part.state?.error,
|
|
}));
|
|
|
|
const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({
|
|
id: z.string().optional(),
|
|
callID: z.string().optional(),
|
|
}).transform((part) => ({
|
|
toolName: part.tool,
|
|
callId: undefined,
|
|
status: part.state?.status,
|
|
input: part.state?.input,
|
|
output: part.state?.output,
|
|
error: part.state?.error,
|
|
}));
|
|
|
|
const OpencodeToolPartSchema = z.union([
|
|
OpencodeToolPartWithCallIdSchema,
|
|
OpencodeToolPartWithIdSchema,
|
|
OpencodeToolPartWithoutIdSchema,
|
|
]);
|
|
|
|
const OpencodeToolPartTimelineEnvelopeSchema = OpencodeToolPartSchema.transform((part) => ({
|
|
toolName: part.toolName,
|
|
callId: part.callId,
|
|
status: part.status,
|
|
input: part.input,
|
|
output: part.output,
|
|
error: part.error,
|
|
}));
|
|
|
|
const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform(
|
|
(part) =>
|
|
mapOpencodeToolCall({
|
|
toolName: part.toolName,
|
|
callId: part.callId,
|
|
status: part.status,
|
|
input: part.input,
|
|
output: part.output,
|
|
error: part.error,
|
|
}),
|
|
);
|
|
|
|
function resolveOpenCodeBinary(): string {
|
|
const found = findExecutable("opencode");
|
|
if (found) {
|
|
return found;
|
|
}
|
|
throw new Error(
|
|
"OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.",
|
|
);
|
|
}
|
|
|
|
function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig {
|
|
if (config.type === "stdio") {
|
|
return {
|
|
type: "local",
|
|
command: [config.command, ...(config.args ?? [])],
|
|
...(config.env ? { environment: config.env } : {}),
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: "remote",
|
|
url: config.url,
|
|
...(config.headers ? { headers: config.headers } : {}),
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
function stringifyUnknownError(error: unknown): string {
|
|
if (typeof error === "string") {
|
|
return error;
|
|
}
|
|
try {
|
|
return JSON.stringify(error);
|
|
} catch {
|
|
return String(error);
|
|
}
|
|
}
|
|
|
|
function isAlreadyPresentMcpError(error: unknown): boolean {
|
|
const normalized = stringifyUnknownError(error).toLowerCase();
|
|
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
|
}
|
|
|
|
async function findAvailablePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.listen(0, () => {
|
|
const address = server.address();
|
|
if (address && typeof address === "object") {
|
|
const port = address.port;
|
|
server.close(() => resolve(port));
|
|
} else {
|
|
server.close(() => reject(new Error("Failed to get port")));
|
|
}
|
|
});
|
|
server.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function resolvePartDedupeKey(part: AgentMetadata, partType: "text" | "reasoning"): string | null {
|
|
const partId = part.id;
|
|
if (typeof partId === "string" && partId.trim().length > 0) {
|
|
return `${partType}:${partId}`;
|
|
}
|
|
const messageId = part.messageID;
|
|
if (typeof messageId === "string" && messageId.trim().length > 0) {
|
|
return `${partType}:message:${messageId}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeOpenCodeModeId(modeId: string | null | undefined): string {
|
|
const trimmed = typeof modeId === "string" ? modeId.trim() : "";
|
|
if (!trimmed || trimmed === "default") {
|
|
return "build";
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
|
|
const order = new Map(DEFAULT_MODES.map((mode, index) => [mode.id, index]));
|
|
return [...modes].sort((left, right) => {
|
|
const leftOrder = order.get(left.id) ?? Number.MAX_SAFE_INTEGER;
|
|
const rightOrder = order.get(right.id) ?? Number.MAX_SAFE_INTEGER;
|
|
if (leftOrder !== rightOrder) {
|
|
return leftOrder - rightOrder;
|
|
}
|
|
return left.label.localeCompare(right.label);
|
|
});
|
|
}
|
|
|
|
export class OpenCodeServerManager {
|
|
private static instance: OpenCodeServerManager | null = null;
|
|
private static exitHandlerRegistered = false;
|
|
private server: ChildProcess | null = null;
|
|
private port: number | null = null;
|
|
private startPromise: Promise<{ port: number; url: string }> | null = null;
|
|
private readonly logger: Logger;
|
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
|
private readonly runtimeSettingsKey: string;
|
|
|
|
private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
|
|
this.logger = logger;
|
|
this.runtimeSettings = runtimeSettings;
|
|
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
|
|
}
|
|
|
|
static getInstance(
|
|
logger: Logger,
|
|
runtimeSettings?: ProviderRuntimeSettings,
|
|
): OpenCodeServerManager {
|
|
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
|
|
if (!OpenCodeServerManager.instance) {
|
|
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
|
|
OpenCodeServerManager.registerExitHandler();
|
|
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
|
|
logger.warn(
|
|
{
|
|
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
|
|
requestedRuntimeSettings: nextSettingsKey,
|
|
},
|
|
"OpenCode server manager already initialized with different runtime settings",
|
|
);
|
|
}
|
|
return OpenCodeServerManager.instance;
|
|
}
|
|
|
|
private static registerExitHandler(): void {
|
|
if (OpenCodeServerManager.exitHandlerRegistered) {
|
|
return;
|
|
}
|
|
OpenCodeServerManager.exitHandlerRegistered = true;
|
|
|
|
const cleanup = () => {
|
|
const instance = OpenCodeServerManager.instance;
|
|
if (instance?.server && !instance.server.killed) {
|
|
instance.server.kill("SIGTERM");
|
|
}
|
|
};
|
|
|
|
process.on("exit", cleanup);
|
|
process.on("SIGTERM", cleanup);
|
|
process.on("SIGINT", cleanup);
|
|
}
|
|
|
|
async ensureRunning(): Promise<{ port: number; url: string }> {
|
|
if (this.startPromise) {
|
|
return this.startPromise;
|
|
}
|
|
|
|
if (this.server && this.port && !this.server.killed) {
|
|
return { port: this.port, url: `http://127.0.0.1:${this.port}` };
|
|
}
|
|
|
|
this.startPromise = this.startServer();
|
|
try {
|
|
const result = await this.startPromise;
|
|
return result;
|
|
} finally {
|
|
this.startPromise = null;
|
|
}
|
|
}
|
|
|
|
private async startServer(): Promise<{ port: number; url: string }> {
|
|
this.port = await findAvailablePort();
|
|
const url = `http://127.0.0.1:${this.port}`;
|
|
const launchPrefix = resolveProviderCommandPrefix(
|
|
this.runtimeSettings?.command,
|
|
resolveOpenCodeBinary,
|
|
);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this.server = spawn(
|
|
launchPrefix.command,
|
|
[...launchPrefix.args, "serve", "--port", String(this.port)],
|
|
{
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: applyProviderEnv(process.env, this.runtimeSettings),
|
|
},
|
|
);
|
|
|
|
let started = false;
|
|
const timeout = setTimeout(() => {
|
|
if (!started) {
|
|
reject(new Error("OpenCode server startup timeout"));
|
|
}
|
|
}, 30_000);
|
|
|
|
this.server.stdout?.on("data", (data: Buffer) => {
|
|
const output = data.toString();
|
|
if (output.includes("listening on") && !started) {
|
|
started = true;
|
|
clearTimeout(timeout);
|
|
resolve({ port: this.port!, url });
|
|
}
|
|
});
|
|
|
|
this.server.stderr?.on("data", (data: Buffer) => {
|
|
this.logger.error({ stderr: data.toString().trim() }, "OpenCode server stderr");
|
|
});
|
|
|
|
this.server.on("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
|
|
this.server.on("exit", (code) => {
|
|
if (!started) {
|
|
clearTimeout(timeout);
|
|
reject(new Error(`OpenCode server exited with code ${code}`));
|
|
}
|
|
this.server = null;
|
|
this.port = null;
|
|
});
|
|
});
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
if (this.server && !this.server.killed) {
|
|
this.server.kill("SIGTERM");
|
|
await new Promise<void>((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
this.server?.kill("SIGKILL");
|
|
resolve();
|
|
}, 5000);
|
|
this.server?.on("exit", () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
this.server = null;
|
|
this.port = null;
|
|
}
|
|
}
|
|
|
|
export class OpenCodeAgentClient implements AgentClient {
|
|
readonly provider: "opencode" = "opencode";
|
|
readonly capabilities = OPENCODE_CAPABILITIES;
|
|
|
|
private readonly serverManager: OpenCodeServerManager;
|
|
private readonly logger: Logger;
|
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
|
|
|
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
|
|
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
|
this.runtimeSettings = runtimeSettings;
|
|
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
|
|
}
|
|
|
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
const openCodeConfig = this.assertConfig(config);
|
|
const { url } = await this.serverManager.ensureRunning();
|
|
const client = createOpencodeClient({
|
|
baseUrl: url,
|
|
directory: openCodeConfig.cwd,
|
|
});
|
|
|
|
// Set a timeout for session creation to fail fast
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error("OpenCode session.create timed out after 10s")), 10_000);
|
|
});
|
|
|
|
const response = await Promise.race([
|
|
client.session.create({ directory: openCodeConfig.cwd }),
|
|
timeoutPromise,
|
|
]);
|
|
|
|
if (response.error) {
|
|
throw new Error(`Failed to create OpenCode session: ${JSON.stringify(response.error)}`);
|
|
}
|
|
|
|
const session = response.data;
|
|
if (!session) {
|
|
throw new Error("OpenCode session creation returned no data");
|
|
}
|
|
|
|
return new OpenCodeAgentSession(openCodeConfig, client, session.id);
|
|
}
|
|
|
|
async resumeSession(
|
|
handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
|
|
if (!cwd) {
|
|
throw new Error("OpenCode resume requires the original working directory");
|
|
}
|
|
|
|
const config: AgentSessionConfig = {
|
|
provider: "opencode",
|
|
cwd,
|
|
...overrides,
|
|
};
|
|
const openCodeConfig = this.assertConfig(config);
|
|
const { url } = await this.serverManager.ensureRunning();
|
|
const client = createOpencodeClient({
|
|
baseUrl: url,
|
|
directory: openCodeConfig.cwd,
|
|
});
|
|
|
|
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId);
|
|
}
|
|
|
|
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
|
const { url } = await this.serverManager.ensureRunning();
|
|
const client = createOpencodeClient({
|
|
baseUrl: url,
|
|
directory: options?.cwd ?? process.cwd(),
|
|
});
|
|
|
|
// Set a timeout for the API call to fail fast if OpenCode isn't responding
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
setTimeout(
|
|
() =>
|
|
reject(
|
|
new Error(
|
|
"OpenCode provider.list timed out after 10s - server may not be authenticated or connected to any providers",
|
|
),
|
|
),
|
|
10_000,
|
|
);
|
|
});
|
|
|
|
const response = await Promise.race([
|
|
client.provider.list({ directory: options?.cwd ?? process.cwd() }),
|
|
timeoutPromise,
|
|
]);
|
|
|
|
if (response.error) {
|
|
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
|
|
}
|
|
|
|
const providers = response.data;
|
|
if (!providers) {
|
|
return [];
|
|
}
|
|
|
|
// Only include models from connected providers (ones that are actually available)
|
|
const connectedProviderIds = new Set(providers.connected);
|
|
|
|
// Fail fast if no providers are connected
|
|
if (connectedProviderIds.size === 0) {
|
|
throw new Error(
|
|
"OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).",
|
|
);
|
|
}
|
|
|
|
const models: AgentModelDefinition[] = [];
|
|
for (const provider of providers.all) {
|
|
// Skip providers that aren't connected/configured
|
|
if (!connectedProviderIds.has(provider.id)) {
|
|
continue;
|
|
}
|
|
|
|
for (const [modelId, model] of Object.entries(provider.models)) {
|
|
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
|
const thinkingOptions = [
|
|
{ id: "default", label: "Model default", isDefault: true },
|
|
...rawVariants.map((id) => ({ id, label: id })),
|
|
];
|
|
|
|
models.push({
|
|
provider: "opencode",
|
|
id: `${provider.id}/${modelId}`,
|
|
label: model.name,
|
|
description: `${provider.name} - ${model.family ?? ""}`.trim(),
|
|
thinkingOptions: thinkingOptions.length > 1 ? thinkingOptions : undefined,
|
|
defaultThinkingOptionId: "default",
|
|
metadata: {
|
|
providerId: provider.id,
|
|
providerName: provider.name,
|
|
modelId,
|
|
family: model.family,
|
|
releaseDate: model.release_date,
|
|
supportsAttachments: model.attachment,
|
|
supportsReasoning: model.reasoning,
|
|
supportsToolCall: model.tool_call,
|
|
cost: model.cost,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return models;
|
|
}
|
|
|
|
async listPersistedAgents(
|
|
_options?: ListPersistedAgentsOptions,
|
|
): Promise<PersistedAgentDescriptor[]> {
|
|
// TODO: Implement by listing sessions from OpenCode
|
|
return [];
|
|
}
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
const command = this.runtimeSettings?.command;
|
|
if (command?.mode === "replace") {
|
|
return existsSync(command.argv[0]);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig {
|
|
if (config.provider !== "opencode") {
|
|
throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`);
|
|
}
|
|
return { ...config, provider: "opencode" };
|
|
}
|
|
}
|
|
|
|
export type OpenCodeEventTranslationState = {
|
|
sessionId: string;
|
|
messageRoles: Map<string, OpenCodeMessageRole>;
|
|
accumulatedUsage: AgentUsage;
|
|
streamedPartKeys: Set<string>;
|
|
emittedStructuredMessageIds: Set<string>;
|
|
};
|
|
|
|
function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
|
if (value === undefined) {
|
|
return null;
|
|
}
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function translateOpenCodeEvent(
|
|
event: unknown,
|
|
state: OpenCodeEventTranslationState,
|
|
): AgentStreamEvent[] {
|
|
const events: AgentStreamEvent[] = [];
|
|
|
|
if (!event || typeof event !== "object") {
|
|
return events;
|
|
}
|
|
|
|
const e = event as { type?: string; properties?: AgentMetadata };
|
|
const type = e.type;
|
|
const props = e.properties ?? {};
|
|
|
|
switch (type) {
|
|
case "session.created":
|
|
case "session.updated": {
|
|
const sessionId = props.id as string | undefined;
|
|
if (sessionId === state.sessionId) {
|
|
events.push({
|
|
type: "thread_started",
|
|
sessionId: state.sessionId,
|
|
provider: "opencode",
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "message.updated": {
|
|
const info = props.info as AgentMetadata | undefined;
|
|
if (!info) {
|
|
break;
|
|
}
|
|
const messageId = info.id as string | undefined;
|
|
const messageSessionId = info.sessionID as string | undefined;
|
|
const role = info.role as OpenCodeMessageRole | undefined;
|
|
|
|
if (messageId && messageSessionId === state.sessionId && role) {
|
|
state.messageRoles.set(messageId, role);
|
|
if (
|
|
role === "assistant" &&
|
|
!state.emittedStructuredMessageIds.has(messageId) &&
|
|
typeof info.time === "object" &&
|
|
info.time !== null &&
|
|
"completed" in info.time
|
|
) {
|
|
const text = stringifyStructuredAssistantMessage(info.structured);
|
|
if (text) {
|
|
state.emittedStructuredMessageIds.add(messageId);
|
|
events.push({
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: { type: "assistant_message", text },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "message.part.updated": {
|
|
const part = props.part as AgentMetadata | undefined;
|
|
const delta = props.delta as string | undefined;
|
|
if (!part) {
|
|
break;
|
|
}
|
|
|
|
const partSessionId = part.sessionID as string | undefined;
|
|
if (partSessionId !== state.sessionId) {
|
|
break;
|
|
}
|
|
|
|
const messageId = part.messageID as string | undefined;
|
|
const messageRole = messageId ? state.messageRoles.get(messageId) : undefined;
|
|
const partType = part.type as string | undefined;
|
|
const partTime = part.time as { start?: number; end?: number } | undefined;
|
|
|
|
if (partType === "text") {
|
|
const partKey = resolvePartDedupeKey(part, "text");
|
|
if (messageRole === "user") {
|
|
break;
|
|
}
|
|
if (!messageRole && !delta) {
|
|
break;
|
|
}
|
|
if (delta) {
|
|
if (partKey) {
|
|
state.streamedPartKeys.add(partKey);
|
|
}
|
|
events.push({
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: { type: "assistant_message", text: delta },
|
|
});
|
|
} else if (partTime?.end) {
|
|
if (partKey && state.streamedPartKeys.delete(partKey)) {
|
|
break;
|
|
}
|
|
const text = part.text as string | undefined;
|
|
if (text) {
|
|
events.push({
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: { type: "assistant_message", text },
|
|
});
|
|
}
|
|
}
|
|
} else if (partType === "reasoning") {
|
|
const partKey = resolvePartDedupeKey(part, "reasoning");
|
|
if (delta) {
|
|
if (partKey) {
|
|
state.streamedPartKeys.add(partKey);
|
|
}
|
|
events.push({
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: { type: "reasoning", text: delta },
|
|
});
|
|
} else if (partTime?.end) {
|
|
if (partKey && state.streamedPartKeys.delete(partKey)) {
|
|
break;
|
|
}
|
|
const text = part.text as string | undefined;
|
|
if (text) {
|
|
events.push({
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: { type: "reasoning", text },
|
|
});
|
|
}
|
|
}
|
|
} else if (partType === "tool") {
|
|
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
|
if (parsedToolPart.success && parsedToolPart.data) {
|
|
events.push({
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: parsedToolPart.data,
|
|
});
|
|
}
|
|
} else if (partType === "step-finish") {
|
|
const tokens = part.tokens as
|
|
| { input?: number; output?: number; reasoning?: number }
|
|
| undefined;
|
|
const cost = part.cost as number | undefined;
|
|
|
|
if (tokens) {
|
|
state.accumulatedUsage.inputTokens =
|
|
(state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
|
|
state.accumulatedUsage.outputTokens =
|
|
(state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
|
|
}
|
|
if (cost !== undefined) {
|
|
state.accumulatedUsage.totalCostUsd = (state.accumulatedUsage.totalCostUsd ?? 0) + cost;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "permission.asked": {
|
|
const sessionId = props.sessionID as string | undefined;
|
|
if (sessionId !== state.sessionId) {
|
|
break;
|
|
}
|
|
|
|
const requestId = props.id as string;
|
|
const permission = props.permission as string;
|
|
const metadata = props.metadata as AgentMetadata | undefined;
|
|
const patterns = props.patterns as string[] | undefined;
|
|
|
|
const permRequest: AgentPermissionRequest = {
|
|
id: requestId,
|
|
provider: "opencode",
|
|
name: permission,
|
|
kind: "tool",
|
|
title: permission,
|
|
description: patterns?.join(", "),
|
|
input: metadata,
|
|
};
|
|
|
|
events.push({
|
|
type: "permission_requested",
|
|
provider: "opencode",
|
|
request: permRequest,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "session.idle": {
|
|
const sessionId = props.sessionID as string | undefined;
|
|
if (sessionId === state.sessionId) {
|
|
state.streamedPartKeys.clear();
|
|
events.push({
|
|
type: "turn_completed",
|
|
provider: "opencode",
|
|
usage: undefined,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "session.error": {
|
|
const sessionId = props.sessionID as string | undefined;
|
|
if (sessionId === state.sessionId) {
|
|
state.streamedPartKeys.clear();
|
|
const error = props.error as string | undefined;
|
|
events.push({
|
|
type: "turn_failed",
|
|
provider: "opencode",
|
|
error: error ?? "Unknown error",
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
class OpenCodeAgentSession implements AgentSession {
|
|
readonly provider: "opencode" = "opencode";
|
|
readonly capabilities = OPENCODE_CAPABILITIES;
|
|
|
|
private readonly config: OpenCodeAgentConfig;
|
|
private readonly client: OpencodeClient;
|
|
private readonly sessionId: string;
|
|
private currentMode: string = "default";
|
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
|
private abortController: AbortController | null = null;
|
|
private accumulatedUsage: AgentUsage = {};
|
|
private mcpConfigured = false;
|
|
private mcpSetupPromise: Promise<void> | null = null;
|
|
/** Tracks the role of each message by ID to distinguish user from assistant messages */
|
|
private messageRoles = new Map<string, OpenCodeMessageRole>();
|
|
/** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */
|
|
private streamedPartKeys = new Set<string>();
|
|
/** Tracks assistant messages already emitted from structured payloads. */
|
|
private emittedStructuredMessageIds = new Set<string>();
|
|
private availableModesCache: AgentMode[] | null = null;
|
|
|
|
constructor(config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string) {
|
|
this.config = config;
|
|
this.client = client;
|
|
this.sessionId = sessionId;
|
|
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
|
}
|
|
|
|
get id(): string | null {
|
|
return this.sessionId;
|
|
}
|
|
|
|
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
|
return {
|
|
provider: "opencode",
|
|
sessionId: this.sessionId,
|
|
model: this.config.model ?? null,
|
|
modeId: this.currentMode,
|
|
};
|
|
}
|
|
|
|
async setModel(modelId: string | null): Promise<void> {
|
|
const normalizedModelId =
|
|
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
|
this.config.model = normalizedModelId ?? undefined;
|
|
}
|
|
|
|
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
|
const normalizedThinkingOptionId =
|
|
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
|
? thinkingOptionId
|
|
: null;
|
|
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
|
}
|
|
|
|
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
|
const events = this.stream(prompt, options);
|
|
const timeline: AgentTimelineItem[] = [];
|
|
let finalText = "";
|
|
let usage: AgentUsage | undefined;
|
|
|
|
for await (const event of events) {
|
|
if (event.type === "timeline") {
|
|
timeline.push(event.item);
|
|
if (event.item.type === "assistant_message") {
|
|
finalText = event.item.text;
|
|
}
|
|
} else if (event.type === "turn_completed") {
|
|
usage = event.usage;
|
|
} else if (event.type === "turn_failed") {
|
|
throw new Error(event.error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
sessionId: this.sessionId,
|
|
finalText,
|
|
usage,
|
|
timeline,
|
|
};
|
|
}
|
|
|
|
async *stream(
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): AsyncGenerator<AgentStreamEvent> {
|
|
this.abortController = new AbortController();
|
|
await this.ensureMcpServersConfigured();
|
|
|
|
const parts = this.buildPromptParts(prompt);
|
|
const model = this.parseModel(this.config.model);
|
|
const thinkingOptionId = this.config.thinkingOptionId;
|
|
const effectiveVariant =
|
|
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
|
|
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
|
|
|
|
// Send prompt asynchronously
|
|
const promptResponse = await this.client.session.promptAsync({
|
|
sessionID: this.sessionId,
|
|
directory: this.config.cwd,
|
|
parts,
|
|
...(options?.outputSchema
|
|
? {
|
|
format: {
|
|
type: "json_schema" as const,
|
|
schema: options.outputSchema as Record<string, unknown>,
|
|
},
|
|
}
|
|
: {}),
|
|
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
|
...(model ? { model } : {}),
|
|
...(effectiveMode ? { agent: effectiveMode } : {}),
|
|
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
|
});
|
|
|
|
if (promptResponse.error) {
|
|
yield {
|
|
type: "turn_failed",
|
|
provider: "opencode",
|
|
error: JSON.stringify(promptResponse.error),
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Subscribe to events
|
|
const eventsResult = await this.client.event.subscribe({
|
|
directory: this.config.cwd,
|
|
});
|
|
|
|
try {
|
|
for await (const event of eventsResult.stream) {
|
|
if (this.abortController.signal.aborted) {
|
|
break;
|
|
}
|
|
|
|
const translated = this.translateEvent(event);
|
|
for (const e of translated) {
|
|
yield e;
|
|
if (e.type === "turn_completed" || e.type === "turn_failed") {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!this.abortController.signal.aborted) {
|
|
yield {
|
|
type: "turn_failed",
|
|
provider: "opencode",
|
|
error: error instanceof Error ? error.message : "Stream error",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
async interrupt(): Promise<void> {
|
|
this.abortController?.abort();
|
|
await this.client.session.abort({
|
|
sessionID: this.sessionId,
|
|
directory: this.config.cwd,
|
|
});
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
|
const response = await this.client.session.messages({
|
|
sessionID: this.sessionId,
|
|
directory: this.config.cwd,
|
|
});
|
|
|
|
if (response.error || !response.data) {
|
|
return;
|
|
}
|
|
|
|
const messages = response.data;
|
|
|
|
for (const message of messages) {
|
|
const { info, parts } = message;
|
|
const role = info.role as "user" | "assistant";
|
|
|
|
if (role === "user") {
|
|
// Extract user message text from parts
|
|
const textParts = parts.filter((p) => (p as { type?: string }).type === "text");
|
|
const text = textParts.map((p) => (p as { text?: string }).text ?? "").join("");
|
|
|
|
if (text) {
|
|
yield {
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: { type: "user_message", text },
|
|
};
|
|
}
|
|
} else if (role === "assistant") {
|
|
let emittedAssistantText = false;
|
|
// Process each part
|
|
for (const part of parts) {
|
|
const partType = (part as { type?: string }).type;
|
|
|
|
if (partType === "text") {
|
|
const text = (part as { text?: string }).text;
|
|
if (text) {
|
|
emittedAssistantText = true;
|
|
yield {
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: { type: "assistant_message", text },
|
|
};
|
|
}
|
|
} else if (partType === "reasoning") {
|
|
const text = (part as { text?: string }).text;
|
|
if (text) {
|
|
yield {
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: { type: "reasoning", text },
|
|
};
|
|
}
|
|
} else if (partType === "tool") {
|
|
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
|
if (parsedToolPart.success) {
|
|
if (parsedToolPart.data) {
|
|
yield {
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: parsedToolPart.data,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!emittedAssistantText) {
|
|
const text = stringifyStructuredAssistantMessage(
|
|
(info as { structured?: unknown }).structured,
|
|
);
|
|
if (text) {
|
|
yield {
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: { type: "assistant_message", text },
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async getAvailableModes(): Promise<AgentMode[]> {
|
|
if (this.availableModesCache) {
|
|
return this.availableModesCache;
|
|
}
|
|
|
|
const response = await this.client.app.agents({
|
|
directory: this.config.cwd,
|
|
});
|
|
|
|
const discoveredModes =
|
|
response.error || !response.data
|
|
? []
|
|
: response.data
|
|
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
|
|
.filter((agent) => OPENCODE_MODE_IDS.has(agent.name))
|
|
.map((agent) => ({
|
|
id: agent.name,
|
|
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
|
|
description:
|
|
typeof agent.description === "string" && agent.description.trim().length > 0
|
|
? agent.description.trim()
|
|
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
|
}));
|
|
|
|
this.availableModesCache =
|
|
discoveredModes.length > 0 ? sortOpenCodeModes(discoveredModes) : DEFAULT_MODES;
|
|
return this.availableModesCache;
|
|
}
|
|
|
|
async getCurrentMode(): Promise<string | null> {
|
|
return this.currentMode;
|
|
}
|
|
|
|
async setMode(modeId: string): Promise<void> {
|
|
this.currentMode = normalizeOpenCodeModeId(modeId);
|
|
}
|
|
|
|
getPendingPermissions(): AgentPermissionRequest[] {
|
|
return Array.from(this.pendingPermissions.values());
|
|
}
|
|
|
|
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
|
|
const pending = this.pendingPermissions.get(requestId);
|
|
if (!pending) {
|
|
throw new Error(`No pending permission request with id '${requestId}'`);
|
|
}
|
|
|
|
const reply = response.behavior === "allow" ? "once" : "reject";
|
|
await this.client.permission.reply({
|
|
requestID: requestId,
|
|
directory: this.config.cwd,
|
|
reply,
|
|
message: response.behavior === "deny" ? response.message : undefined,
|
|
});
|
|
|
|
this.pendingPermissions.delete(requestId);
|
|
}
|
|
|
|
describePersistence(): AgentPersistenceHandle | null {
|
|
return {
|
|
provider: "opencode",
|
|
sessionId: this.sessionId,
|
|
nativeHandle: this.sessionId,
|
|
metadata: {
|
|
cwd: this.config.cwd,
|
|
},
|
|
};
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.abortController?.abort();
|
|
}
|
|
|
|
private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> {
|
|
if (typeof prompt === "string") {
|
|
return [{ type: "text", text: prompt }];
|
|
}
|
|
return prompt
|
|
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
.map((p) => ({ type: "text", text: p.text }));
|
|
}
|
|
|
|
private parseModel(model?: string): { providerID: string; modelID: string } | undefined {
|
|
if (!model) {
|
|
return undefined;
|
|
}
|
|
const parts = model.split("/");
|
|
if (parts.length >= 2) {
|
|
return { providerID: parts[0], modelID: parts.slice(1).join("/") };
|
|
}
|
|
return { providerID: "opencode", modelID: model };
|
|
}
|
|
|
|
private async ensureMcpServersConfigured(): Promise<void> {
|
|
if (this.mcpConfigured) {
|
|
return;
|
|
}
|
|
|
|
const mcpServers = this.config.mcpServers;
|
|
if (!mcpServers || Object.keys(mcpServers).length === 0) {
|
|
this.mcpConfigured = true;
|
|
return;
|
|
}
|
|
|
|
if (!this.mcpSetupPromise) {
|
|
this.mcpSetupPromise = this.configureMcpServers(mcpServers);
|
|
}
|
|
|
|
try {
|
|
await this.mcpSetupPromise;
|
|
this.mcpConfigured = true;
|
|
} catch (error) {
|
|
this.mcpSetupPromise = null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async configureMcpServers(mcpServers: Record<string, McpServerConfig>): Promise<void> {
|
|
for (const [name, serverConfig] of Object.entries(mcpServers)) {
|
|
const mappedConfig = toOpenCodeMcpConfig(serverConfig);
|
|
await this.registerMcpServer(name, mappedConfig);
|
|
}
|
|
}
|
|
|
|
private async registerMcpServer(name: string, config: OpenCodeMcpConfig): Promise<void> {
|
|
await this.runMcpOperation("add", name, () =>
|
|
this.client.mcp.add({
|
|
directory: this.config.cwd,
|
|
name,
|
|
config,
|
|
}),
|
|
);
|
|
await this.runMcpOperation("connect", name, () =>
|
|
this.client.mcp.connect({
|
|
directory: this.config.cwd,
|
|
name,
|
|
}),
|
|
);
|
|
}
|
|
|
|
private async runMcpOperation(
|
|
operation: "add" | "connect",
|
|
name: string,
|
|
run: () => Promise<{ error?: unknown }>,
|
|
): Promise<void> {
|
|
const response = await run();
|
|
const error = response.error;
|
|
if (!error) {
|
|
return;
|
|
}
|
|
|
|
if (isAlreadyPresentMcpError(error)) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
`Failed to ${operation} OpenCode MCP server '${name}': ${stringifyUnknownError(error)}`,
|
|
);
|
|
}
|
|
|
|
private translateEvent(event: unknown): AgentStreamEvent[] {
|
|
const translated = translateOpenCodeEvent(event, {
|
|
sessionId: this.sessionId,
|
|
messageRoles: this.messageRoles,
|
|
accumulatedUsage: this.accumulatedUsage,
|
|
streamedPartKeys: this.streamedPartKeys,
|
|
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
|
|
});
|
|
|
|
for (const translatedEvent of translated) {
|
|
if (translatedEvent.type === "permission_requested") {
|
|
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
|
|
}
|
|
if (translatedEvent.type === "turn_completed") {
|
|
translatedEvent.usage = this.extractAndResetUsage();
|
|
}
|
|
}
|
|
|
|
return translated;
|
|
}
|
|
|
|
private extractAndResetUsage(): AgentUsage | undefined {
|
|
const usage = this.accumulatedUsage;
|
|
this.accumulatedUsage = {};
|
|
|
|
if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) {
|
|
return undefined;
|
|
}
|
|
|
|
return usage;
|
|
}
|
|
}
|