diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index 217712fbb7..304e606eec 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -108,7 +108,7 @@ const reportModelResolverLog = logger.scope("report-model-resolver"); container.bind(REPORT_MODEL_RESOLVER).toConstantValue({ async resolveDefaultModel( apiHost: string, - adapter: "claude" | "codex", + adapter: "claude" | "codex" | "opencode", preferredModel?: string | null, ): Promise { try { diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index cd399b109d..25d7be3095 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -415,7 +415,7 @@ export interface RunTaskInCloudOptions { pendingUserMessage?: string; mode?: "interactive" | "background"; /** Adapter to use on the cloud runner. Currently only "claude" on mobile. */ - runtimeAdapter?: "claude" | "codex"; + runtimeAdapter?: "claude" | "codex" | "opencode"; /** Gateway model ID, e.g. "claude-opus-4-8". */ model?: string; /** Reasoning effort: "low" | "medium" | "high" (model-dependent). */ diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index f86b057914..8fb2287459 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -11,8 +11,10 @@ import { ClaudeAcpAgent } from "./claude/claude-agent"; import type { GatewayEnv } from "./claude/session/options"; import { CodexAcpAgent } from "./codex/codex-agent"; import type { CodexProcessOptions } from "./codex/spawn"; +import { OpencodeAcpAgent } from "./opencode/opencode-agent"; +import type { OpencodeProcessOptions } from "./opencode/spawn"; -type AgentAdapter = "claude" | "codex"; +type AgentAdapter = "claude" | "codex" | "opencode"; export type AcpConnectionConfig = { adapter?: AgentAdapter; @@ -24,6 +26,7 @@ export type AcpConnectionConfig = { logger?: Logger; processCallbacks?: ProcessSpawnedCallback; codexOptions?: CodexProcessOptions; + opencodeOptions?: OpencodeProcessOptions; allowedModelIds?: Set; /** Callback invoked when the agent calls the create_output tool for structured output */ onStructuredOutput?: (output: Record) => Promise; @@ -58,6 +61,10 @@ export function createAcpConnection( return createCodexConnection(config); } + if (adapterType === "opencode") { + return createOpencodeConnection(config); + } + return createClaudeConnection(config); } @@ -241,3 +248,83 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { }, }; } + +/** + * Creates an ACP connection to the `opencode acp` subprocess via an in-process + * proxy agent. opencode speaks ACP natively, so OpencodeAcpAgent forwards over a + * ClientSideConnection — the same shape as the codex connection. + */ +function createOpencodeConnection(config: AcpConnectionConfig): AcpConnection { + const logger = + config.logger?.child("OpencodeConnection") ?? + new Logger({ debug: true, prefix: "[OpencodeConnection]" }); + + const { logWriter } = config; + const streams = createBidirectionalStreams(); + + let agentWritable = streams.agent.writable; + let clientWritable = streams.client.writable; + + if (config.taskRunId && logWriter) { + if (!logWriter.isRegistered(config.taskRunId)) { + logWriter.register(config.taskRunId, { + taskId: config.taskId ?? config.taskRunId, + runId: config.taskRunId, + deviceType: config.deviceType, + }); + } + + const taskRunId = config.taskRunId; + agentWritable = createTappedWritableStream(streams.agent.writable, { + onMessage: (line) => { + logWriter.appendRawLine(taskRunId, line); + }, + logger, + }); + + clientWritable = createTappedWritableStream(streams.client.writable, { + onMessage: (line) => { + logWriter.appendRawLine(taskRunId, line); + }, + logger, + }); + } + + const agentStream = ndJsonStream(agentWritable, streams.agent.readable); + + let agent: OpencodeAcpAgent | null = null; + const agentConnection = new AgentSideConnection((client) => { + agent = new OpencodeAcpAgent(client, { + opencodeProcessOptions: config.opencodeOptions ?? {}, + processCallbacks: config.processCallbacks, + logger: config.logger?.child("OpencodeAcpAgent"), + }); + return agent; + }, agentStream); + + return { + agentConnection, + clientStreams: { + readable: streams.client.readable, + writable: clientWritable, + }, + cleanup: async () => { + logger.info("Cleaning up Opencode connection"); + + if (agent) { + await agent.closeSession(); + } + + try { + await streams.client.writable.close(); + } catch { + // Stream may already be closed + } + try { + await streams.agent.writable.close(); + } catch { + // Stream may already be closed + } + }, + }; +} diff --git a/packages/agent/src/adapters/opencode/models.test.ts b/packages/agent/src/adapters/opencode/models.test.ts new file mode 100644 index 0000000000..3f44ea72a6 --- /dev/null +++ b/packages/agent/src/adapters/opencode/models.test.ts @@ -0,0 +1,106 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { + formatOpencodeModelName, + modelIdFromConfigOptions, + normalizeOpencodeConfigOptions, +} from "./models"; + +const modelSelect = ( + options: unknown, + currentValue = "posthog/@cf/zai-org/glm-5.2", +): SessionConfigOption => + ({ + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue, + options, + }) as unknown as SessionConfigOption; + +describe("formatOpencodeModelName", () => { + it("strips the posthog/ provider prefix and takes the final path segment", () => { + expect(formatOpencodeModelName("posthog/@cf/zai-org/glm-5.2")).toBe( + "glm-5.2", + ); + expect(formatOpencodeModelName("@cf/zai-org/glm-5.2")).toBe("glm-5.2"); + }); +}); + +describe("modelIdFromConfigOptions", () => { + it("returns the model option's currentValue", () => { + expect(modelIdFromConfigOptions([modelSelect([])])).toBe( + "posthog/@cf/zai-org/glm-5.2", + ); + }); + + it("returns undefined when there is no model option", () => { + expect(modelIdFromConfigOptions([])).toBeUndefined(); + expect(modelIdFromConfigOptions(undefined)).toBeUndefined(); + }); +}); + +describe("normalizeOpencodeConfigOptions", () => { + it("keeps only posthog/* models and cleans their names (flat)", () => { + const result = normalizeOpencodeConfigOptions([ + modelSelect([ + { value: "openai/gpt-5", name: "OpenAI/GPT-5" }, + { + value: "anthropic/claude-opus-4-8", + name: "Anthropic/Claude Opus 4.8", + }, + { + value: "posthog/@cf/zai-org/glm-5.2", + name: "PostHog Gateway/GLM 5.2", + }, + ]), + ]); + const model = result?.find((o) => o.category === "model"); + expect((model as { options: unknown }).options).toEqual([ + { value: "posthog/@cf/zai-org/glm-5.2", name: "glm-5.2" }, + ]); + }); + + it("filters grouped options and drops empty groups", () => { + const result = normalizeOpencodeConfigOptions([ + modelSelect([ + { + group: "openai", + name: "OpenAI", + options: [{ value: "openai/gpt-5", name: "GPT-5" }], + }, + { + group: "posthog", + name: "PostHog", + options: [{ value: "posthog/@cf/zai-org/glm-5.2", name: "GLM 5.2" }], + }, + ]), + ]); + const model = result?.find((o) => o.category === "model"); + expect((model as { options: unknown }).options).toEqual([ + { + group: "posthog", + name: "PostHog", + options: [{ value: "posthog/@cf/zai-org/glm-5.2", name: "glm-5.2" }], + }, + ]); + }); + + it("leaves non-model options untouched", () => { + const modeOption = { + id: "mode", + name: "Mode", + type: "select", + category: "mode", + currentValue: "auto", + options: [{ value: "auto", name: "Auto" }], + } as unknown as SessionConfigOption; + expect(normalizeOpencodeConfigOptions([modeOption])).toEqual([modeOption]); + }); + + it("returns null/undefined input unchanged", () => { + expect(normalizeOpencodeConfigOptions(null)).toBeNull(); + expect(normalizeOpencodeConfigOptions(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/agent/src/adapters/opencode/models.ts b/packages/agent/src/adapters/opencode/models.ts new file mode 100644 index 0000000000..bdc1c265d3 --- /dev/null +++ b/packages/agent/src/adapters/opencode/models.ts @@ -0,0 +1,73 @@ +import type { + SessionConfigOption, + SessionConfigSelectGroup, + SessionConfigSelectOption, +} from "@agentclientprotocol/sdk"; + +// All gateway models are registered in the generated opencode.json under this +// provider key, so opencode surfaces them as `posthog/` in its config +// options. We keep only those — opencode otherwise lists its ~80 built-in models +// (gpt-*, claude-*, embeddings, image) which we don't want in the Code picker. +export const OPENCODE_PROVIDER_PREFIX = "posthog/"; + +export function formatOpencodeModelName(value: string): string { + const withoutProvider = value.startsWith(OPENCODE_PROVIDER_PREFIX) + ? value.slice(OPENCODE_PROVIDER_PREFIX.length) + : value; + // GLM ids are slash-paths ("@cf/zai-org/glm-5.2") — take the final segment. + return (withoutProvider.split("/").pop() ?? withoutProvider).toLowerCase(); +} + +export function modelIdFromConfigOptions( + configOptions: SessionConfigOption[] | null | undefined, +): string | undefined { + const modelOption = configOptions?.find((o) => o.category === "model"); + return typeof modelOption?.currentValue === "string" + ? modelOption.currentValue + : undefined; +} + +function isPosthogModel(opt: SessionConfigSelectOption): boolean { + return opt.value.startsWith(OPENCODE_PROVIDER_PREFIX); +} + +/** + * Restrict the model picker to our gateway provider and give each entry a clean + * label. Filters opencode's full built-in catalogue down to `posthog/*`. + */ +export function normalizeOpencodeConfigOptions( + configOptions: SessionConfigOption[] | null | undefined, +): SessionConfigOption[] | null | undefined { + if (!configOptions) return configOptions; + + const formatOption = ( + opt: SessionConfigSelectOption, + ): SessionConfigSelectOption => ({ + ...opt, + name: formatOpencodeModelName(opt.value), + }); + + return configOptions.map((option) => { + if (option.category !== "model" || option.type !== "select") return option; + const options = option.options; + if (options.length === 0) return option; + const isGroup = "group" in options[0]; + + if (isGroup) { + const groups = (options as SessionConfigSelectGroup[]) + .map((group) => ({ + ...group, + options: group.options.filter(isPosthogModel).map(formatOption), + })) + .filter((group) => group.options.length > 0); + return { ...option, options: groups } as SessionConfigOption; + } + + return { + ...option, + options: (options as SessionConfigSelectOption[]) + .filter(isPosthogModel) + .map(formatOption), + } as SessionConfigOption; + }); +} diff --git a/packages/agent/src/adapters/opencode/opencode-agent.ts b/packages/agent/src/adapters/opencode/opencode-agent.ts new file mode 100644 index 0000000000..4a9f282953 --- /dev/null +++ b/packages/agent/src/adapters/opencode/opencode-agent.ts @@ -0,0 +1,271 @@ +/** + * In-process ACP proxy agent for opencode. + * + * Implements the ACP Agent interface and delegates to the `opencode acp` + * subprocess over a ClientSideConnection. opencode already speaks ACP, so most + * methods are near-transparent forwards; the interception points are + * PostHog-specific notifications (sdk_session, turn_complete), model-picker + * normalization, and permission-mode tracking. + * + * v1 deliberately defers (vs. the codex adapter): session resume/fork/refresh, + * structured-output and local-tools MCP injection, broadcastUserMessage, PR + * context, and context-breakdown telemetry. + */ + +import { + type AgentSideConnection, + ClientSideConnection, + type InitializeRequest, + type InitializeResponse, + type NewSessionRequest, + type NewSessionResponse, + ndJsonStream, + type PromptRequest, + type PromptResponse, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModeRequest, + type SetSessionModeResponse, +} from "@agentclientprotocol/sdk"; +import packageJson from "../../../package.json" with { type: "json" }; +import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions"; +import { isCodeExecutionMode, type PermissionMode } from "../../execution-mode"; +import type { ProcessSpawnedCallback } from "../../types"; +import { Logger } from "../../utils/logger"; +import { + nodeReadableToWebReadable, + nodeWritableToWebWritable, +} from "../../utils/streams"; +import { BaseAcpAgent, type BaseSession } from "../base-acp-agent"; +import { resolveTaskId } from "../session-meta"; +import { + modelIdFromConfigOptions, + normalizeOpencodeConfigOptions, +} from "./models"; +import { createOpencodeClient } from "./opencode-client"; +import { + createSessionState, + type OpencodeSessionState, + resetSessionState, + resetUsage, +} from "./session-state"; +import { OpencodeSettingsManager } from "./settings"; +import { + type OpencodeProcess, + type OpencodeProcessOptions, + spawnOpencodeProcess, +} from "./spawn"; + +interface OpencodeNewSessionMeta { + taskRunId?: string; + taskId?: string; + permissionMode?: string; + persistence?: { taskId?: string; runId?: string }; +} + +function toPermissionMode(mode?: string): PermissionMode { + if (mode && isCodeExecutionMode(mode)) return mode; + return "auto"; +} + +type OpencodeSession = BaseSession & { + settingsManager: OpencodeSettingsManager; + promptRunning: boolean; +}; + +export interface OpencodeAcpAgentOptions { + opencodeProcessOptions: OpencodeProcessOptions; + processCallbacks?: ProcessSpawnedCallback; + logger?: Logger; +} + +export class OpencodeAcpAgent extends BaseAcpAgent { + readonly adapterName = "opencode"; + declare session: OpencodeSession; + private opencodeProcess: OpencodeProcess; + private connection: ClientSideConnection; + private sessionState: OpencodeSessionState; + // Serialize prompt() so the per-turn usage accumulator stays single-owner. + private promptMutex: Promise = Promise.resolve(); + + constructor(client: AgentSideConnection, options: OpencodeAcpAgentOptions) { + super(client); + this.logger = + options.logger ?? + new Logger({ debug: true, prefix: "[OpencodeAcpAgent]" }); + + const cwd = options.opencodeProcessOptions.cwd ?? process.cwd(); + const settingsManager = new OpencodeSettingsManager(cwd); + + this.opencodeProcess = spawnOpencodeProcess({ + ...options.opencodeProcessOptions, + logger: this.logger, + processCallbacks: options.processCallbacks, + }); + + const readable = nodeReadableToWebReadable(this.opencodeProcess.stdout); + const writable = nodeWritableToWebWritable(this.opencodeProcess.stdin); + const stream = ndJsonStream(writable, readable); + + this.session = { + abortController: new AbortController(), + settingsManager, + notificationHistory: [], + cancelled: false, + promptRunning: false, + }; + + this.sessionState = createSessionState("", cwd); + + this.connection = new ClientSideConnection( + () => createOpencodeClient(this.client, this.logger, this.sessionState), + stream, + ); + } + + async initialize(request: InitializeRequest): Promise { + await this.session.settingsManager.initialize(); + const response = await this.connection.initialize(request); + + // v1 doesn't implement loadSession/resumeSession/fork, so don't advertise + // them upward — PostHog Code would otherwise attempt resume and fail. + const { + resume: _resume, + fork: _fork, + ...sessionCapabilities + } = response.agentCapabilities?.sessionCapabilities ?? {}; + + return { + ...response, + agentCapabilities: { + ...response.agentCapabilities, + sessionCapabilities, + }, + agentInfo: { + name: packageJson.name, + title: "OpenCode Agent", + version: packageJson.version, + }, + _meta: { + ...(response as { _meta?: Record })._meta, + posthog: { steering: "interrupt-resend" }, + }, + }; + } + + async newSession(params: NewSessionRequest): Promise { + const meta = params._meta as OpencodeNewSessionMeta | undefined; + const permissionMode = toPermissionMode(meta?.permissionMode); + + const response = await this.connection.newSession(params); + response.configOptions = normalizeOpencodeConfigOptions( + response.configOptions, + ); + + resetSessionState(this.sessionState, response.sessionId, params.cwd, { + taskRunId: meta?.taskRunId, + taskId: resolveTaskId(meta), + modelId: modelIdFromConfigOptions(response.configOptions), + permissionMode, + }); + this.sessionId = response.sessionId; + this.sessionState.configOptions = response.configOptions ?? []; + + if (meta?.taskRunId) { + await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, { + taskRunId: meta.taskRunId, + sessionId: response.sessionId, + adapter: "opencode", + }); + } + + this.logger.info("opencode session created", { + sessionId: response.sessionId, + taskRunId: meta?.taskRunId, + }); + return response; + } + + async prompt(params: PromptRequest): Promise { + const previous = this.promptMutex; + const next = previous.catch(() => {}).then(() => this.runPrompt(params)); + this.promptMutex = next; + return next; + } + + private async runPrompt(params: PromptRequest): Promise { + this.session.cancelled = false; + this.session.interruptReason = undefined; + resetUsage(this.sessionState); + + this.session.promptRunning = true; + let response: PromptResponse; + try { + response = await this.connection.prompt(params); + } finally { + this.session.promptRunning = false; + } + + if (this.sessionState.taskRunId) { + const usage = this.sessionState.accumulatedUsage; + await this.client.extNotification(POSTHOG_NOTIFICATIONS.TURN_COMPLETE, { + sessionId: params.sessionId, + stopReason: response.stopReason ?? "end_turn", + usage: { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedReadTokens: usage.cachedReadTokens, + cachedWriteTokens: usage.cachedWriteTokens, + totalTokens: + usage.inputTokens + + usage.outputTokens + + usage.cachedReadTokens + + usage.cachedWriteTokens, + }, + }); + } + + return response; + } + + protected async interrupt(): Promise { + await this.connection.cancel({ sessionId: this.sessionId }); + } + + async setSessionMode( + params: SetSessionModeRequest, + ): Promise { + // Permissions are enforced client-side via the auto-approve table, so we + // only track the requested mode locally for v1. + this.sessionState.permissionMode = toPermissionMode(params.modeId); + return {}; + } + + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const response = await this.connection.setSessionConfigOption(params); + if (response.configOptions) { + response.configOptions = normalizeOpencodeConfigOptions( + response.configOptions, + ) as typeof response.configOptions; + this.sessionState.configOptions = response.configOptions ?? []; + } + return response; + } + + async authenticate(): Promise { + // Auth is handled externally (gateway token injected at spawn). + } + + async closeSession(): Promise { + this.logger.info("Closing opencode session", { sessionId: this.sessionId }); + this.session.abortController.abort(); + this.session.settingsManager.dispose(); + try { + this.opencodeProcess.kill(); + } catch (err) { + this.logger.warn("Failed to kill opencode process", { error: err }); + } + } +} diff --git a/packages/agent/src/adapters/opencode/opencode-client.ts b/packages/agent/src/adapters/opencode/opencode-client.ts new file mode 100644 index 0000000000..eaf403eccb --- /dev/null +++ b/packages/agent/src/adapters/opencode/opencode-client.ts @@ -0,0 +1,216 @@ +/** + * ACP Client for the opencode subprocess. + * + * Acts as the "client" from opencode's perspective: it receives permission + * requests, session updates, file I/O and terminal operations from opencode and + * forwards them to the upstream PostHog Code client. Mostly transparent — the + * only interception points are permission auto-approval (by mode) and + * best-effort usage capture. + */ + +import type { + AgentSideConnection, + Client, + CreateTerminalRequest, + CreateTerminalResponse, + KillTerminalRequest, + KillTerminalResponse, + ReadTextFileRequest, + ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + TerminalHandle, + TerminalOutputRequest, + TerminalOutputResponse, + ToolKind, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from "@agentclientprotocol/sdk"; +import type { PermissionMode } from "../../execution-mode"; +import type { Logger } from "../../utils/logger"; +import type { OpencodeSessionState } from "./session-state"; + +const AUTO_APPROVED_KINDS: Record> = { + default: new Set(["read", "search", "fetch", "think"]), + acceptEdits: new Set(["read", "edit", "search", "fetch", "think"]), + plan: new Set(["read", "search", "fetch", "think"]), + bypassPermissions: new Set([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", + ]), + auto: new Set(["read", "search", "fetch", "think"]), + "read-only": new Set(["read", "search", "fetch", "think"]), + "full-access": new Set([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", + ]), +}; + +function shouldAutoApprove( + mode: PermissionMode, + kind: ToolKind | null | undefined, +): boolean { + if (mode === "bypassPermissions" || mode === "full-access") return true; + if (!kind) return false; + return AUTO_APPROVED_KINDS[mode]?.has(kind) ?? false; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +/** + * Best-effort token/context capture from a session update. opencode's exact + * usage-update shape is still being pinned down (the spike couldn't complete a + * billed turn), so every field is read defensively and a miss is a no-op rather + * than a crash. + */ +function captureUsage( + update: Record, + state: OpencodeSessionState, +): void { + const used = asNumber(update.used) ?? asNumber(update.contextUsed); + if (used !== undefined) state.contextUsed = used; + + const input = asNumber(update.inputTokens); + const output = asNumber(update.outputTokens); + const cachedRead = asNumber(update.cachedReadTokens); + const cachedWrite = asNumber(update.cachedWriteTokens); + if (input !== undefined) state.accumulatedUsage.inputTokens += input; + if (output !== undefined) state.accumulatedUsage.outputTokens += output; + if (cachedRead !== undefined) { + state.accumulatedUsage.cachedReadTokens += cachedRead; + } + if (cachedWrite !== undefined) { + state.accumulatedUsage.cachedWriteTokens += cachedWrite; + } +} + +export function createOpencodeClient( + upstreamClient: AgentSideConnection, + logger: Logger, + sessionState: OpencodeSessionState, +): Client { + const terminalHandles = new Map(); + + return { + async requestPermission( + params: RequestPermissionRequest, + ): Promise { + const kind = params.toolCall?.kind as ToolKind | null | undefined; + if (shouldAutoApprove(sessionState.permissionMode, kind)) { + const allowOption = params.options?.find( + (o) => o.kind === "allow_once" || o.kind === "allow_always", + ); + logger.debug("Auto-approving permission", { + mode: sessionState.permissionMode, + kind, + }); + return { + outcome: { + outcome: "selected", + optionId: allowOption?.optionId ?? "allow", + }, + }; + } + return upstreamClient.requestPermission(params); + }, + + async sessionUpdate(params: SessionNotification): Promise { + const update = params.update as Record | undefined; + if (update) captureUsage(update, sessionState); + await upstreamClient.sessionUpdate(params); + }, + + async readTextFile( + params: ReadTextFileRequest, + ): Promise { + return upstreamClient.readTextFile(params); + }, + + async writeTextFile( + params: WriteTextFileRequest, + ): Promise { + return upstreamClient.writeTextFile(params); + }, + + async createTerminal( + params: CreateTerminalRequest, + ): Promise { + const handle = await upstreamClient.createTerminal(params); + terminalHandles.set(handle.id, handle); + return { terminalId: handle.id }; + }, + + async terminalOutput( + params: TerminalOutputRequest, + ): Promise { + const handle = terminalHandles.get(params.terminalId); + if (!handle) return { output: "", truncated: false }; + return handle.currentOutput(); + }, + + async releaseTerminal( + params: ReleaseTerminalRequest, + ): Promise { + const handle = terminalHandles.get(params.terminalId); + if (handle) { + terminalHandles.delete(params.terminalId); + const result = await handle.release(); + return result ?? undefined; + } + }, + + async waitForTerminalExit( + params: WaitForTerminalExitRequest, + ): Promise { + const handle = terminalHandles.get(params.terminalId); + if (!handle) return { exitCode: 1 }; + return handle.waitForExit(); + }, + + async killTerminal( + params: KillTerminalRequest, + ): Promise { + const handle = terminalHandles.get(params.terminalId); + if (handle) return handle.kill(); + }, + + async extMethod( + method: string, + params: Record, + ): Promise> { + return upstreamClient.extMethod(method, params); + }, + + async extNotification( + method: string, + params: Record, + ): Promise { + return upstreamClient.extNotification(method, params); + }, + }; +} diff --git a/packages/agent/src/adapters/opencode/session-state.ts b/packages/agent/src/adapters/opencode/session-state.ts new file mode 100644 index 0000000000..4e121b2646 --- /dev/null +++ b/packages/agent/src/adapters/opencode/session-state.ts @@ -0,0 +1,72 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { PermissionMode } from "../../execution-mode"; + +export interface OpencodeUsage { + inputTokens: number; + outputTokens: number; + cachedReadTokens: number; + cachedWriteTokens: number; +} + +export interface OpencodeSessionState { + sessionId: string; + cwd: string; + modelId?: string; + configOptions: SessionConfigOption[]; + accumulatedUsage: OpencodeUsage; + contextUsed?: number; + permissionMode: PermissionMode; + taskRunId?: string; + taskId?: string; +} + +function emptyUsage(): OpencodeUsage { + return { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }; +} + +export function createSessionState( + sessionId: string, + cwd: string, +): OpencodeSessionState { + return { + sessionId, + cwd, + configOptions: [], + accumulatedUsage: emptyUsage(), + permissionMode: "auto", + }; +} + +// opencode-client closure-captures the original sessionState reference, so we +// mutate in place across newSession — reassigning would orphan it and break +// usage propagation. (Mirrors the codex adapter's single-owner discipline.) +export function resetSessionState( + state: OpencodeSessionState, + sessionId: string, + cwd: string, + opts?: { + taskRunId?: string; + taskId?: string; + modelId?: string; + permissionMode?: PermissionMode; + }, +): void { + state.sessionId = sessionId; + state.cwd = cwd; + state.modelId = opts?.modelId; + state.configOptions = []; + state.accumulatedUsage = emptyUsage(); + state.contextUsed = undefined; + state.permissionMode = opts?.permissionMode ?? "auto"; + state.taskRunId = opts?.taskRunId; + state.taskId = opts?.taskId; +} + +export function resetUsage(state: OpencodeSessionState): void { + state.accumulatedUsage = emptyUsage(); +} diff --git a/packages/agent/src/adapters/opencode/settings.ts b/packages/agent/src/adapters/opencode/settings.ts new file mode 100644 index 0000000000..599fe201ce --- /dev/null +++ b/packages/agent/src/adapters/opencode/settings.ts @@ -0,0 +1,31 @@ +import type { BaseSettingsManager } from "../base-acp-agent"; + +/** + * SettingsManager for opencode sessions. Unlike codex (which scans + * ~/.codex/config.toml to disable user MCPs), opencode runs against the + * config file we generate in a run-private dir, so there's nothing to parse — + * this just tracks cwd to satisfy the BaseSettingsManager interface. + */ +export class OpencodeSettingsManager implements BaseSettingsManager { + private cwd: string; + + constructor(cwd: string) { + this.cwd = cwd; + } + + async initialize(): Promise { + // No-op. Kept async to satisfy the BaseSettingsManager interface. + } + + getCwd(): string { + return this.cwd; + } + + async setCwd(cwd: string): Promise { + this.cwd = cwd; + } + + dispose(): void { + // No-op: no resources to release. + } +} diff --git a/packages/agent/src/adapters/opencode/spawn.test.ts b/packages/agent/src/adapters/opencode/spawn.test.ts new file mode 100644 index 0000000000..b9a543e0c6 --- /dev/null +++ b/packages/agent/src/adapters/opencode/spawn.test.ts @@ -0,0 +1,108 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { Logger } from "../../utils/logger"; + +const spawnMock = vi.hoisted(() => vi.fn()); +vi.mock("node:child_process", () => ({ spawn: spawnMock })); + +const { spawnOpencodeProcess } = await import("./spawn"); + +function makeFakeChild() { + return { + stdin: { destroy: vi.fn() }, + stdout: { on: vi.fn(), destroy: vi.fn() }, + stderr: { on: vi.fn(), destroy: vi.fn() }, + on: vi.fn(), + kill: vi.fn(), + pid: 4321, + }; +} + +function setup() { + const configDir = mkdtempSync(join(tmpdir(), "opencode-spawn-test-")); + const binaryPath = join(configDir, "opencode-bin"); + writeFileSync(binaryPath, "#!/bin/sh\n"); + return { configDir, binaryPath }; +} + +describe("spawnOpencodeProcess opencode.json", () => { + it("registers the gateway as a Chat-Completions provider with an env-injected token", () => { + spawnMock.mockClear(); + spawnMock.mockReturnValue(makeFakeChild()); + const { configDir, binaryPath } = setup(); + + spawnOpencodeProcess({ + logger: new Logger({ debug: false }), + configDir, + binaryPath, + apiBaseUrl: "https://gateway.us.posthog.com/posthog_code/v1", + apiKey: "phx_secret", + model: "@cf/zai-org/glm-5.2", + }); + + const config = JSON.parse( + readFileSync(join(configDir, "opencode.json"), "utf8"), + ); + expect(config.provider.posthog.npm).toBe("@ai-sdk/openai-compatible"); + expect(config.provider.posthog.options.baseURL).toBe( + "https://gateway.us.posthog.com/posthog_code/v1", + ); + expect(config.provider.posthog.options.apiKey).toBe( + "{env:POSTHOG_GATEWAY_API_KEY}", + ); + expect(config.provider.posthog.models["@cf/zai-org/glm-5.2"]).toEqual({ + name: "glm-5.2", + }); + expect(config.model).toBe("posthog/@cf/zai-org/glm-5.2"); + // The real token is never inlined into the config file. + expect(JSON.stringify(config)).not.toContain("phx_secret"); + }); +}); + +describe("spawnOpencodeProcess env + command", () => { + it("isolates XDG state under the config dir and injects the token + config path", () => { + spawnMock.mockClear(); + spawnMock.mockReturnValue(makeFakeChild()); + const { configDir, binaryPath } = setup(); + + spawnOpencodeProcess({ + logger: new Logger({ debug: false }), + configDir, + binaryPath, + apiKey: "phx_secret", + }); + + const [command, args, options] = spawnMock.mock.calls[0]; + expect(command).toBe(binaryPath); + expect(args).toEqual(["acp"]); + const env = options.env; + expect(env.POSTHOG_GATEWAY_API_KEY).toBe("phx_secret"); + expect(env.OPENCODE_CONFIG).toBe(join(configDir, "opencode.json")); + expect(env.XDG_DATA_HOME).toBe(join(configDir, "xdg", "data")); + expect(env.XDG_STATE_HOME).toBe(join(configDir, "xdg", "state")); + expect(env.XDG_CACHE_HOME).toBe(join(configDir, "xdg", "cache")); + expect(env.XDG_CONFIG_HOME).toBe(join(configDir, "xdg", "config")); + }); +}); + +describe("spawnOpencodeProcess binary resolution", () => { + it("throws a clear error when no binary is available", () => { + spawnMock.mockClear(); + const { configDir } = setup(); + const prev = process.env.OPENCODE_BIN; + process.env.OPENCODE_BIN = ""; + try { + expect(() => + spawnOpencodeProcess({ + logger: new Logger({ debug: false }), + configDir, + }), + ).toThrow(/opencode binary/); + } finally { + if (prev === undefined) delete process.env.OPENCODE_BIN; + else process.env.OPENCODE_BIN = prev; + } + }); +}); diff --git a/packages/agent/src/adapters/opencode/spawn.ts b/packages/agent/src/adapters/opencode/spawn.ts new file mode 100644 index 0000000000..8c80595c3f --- /dev/null +++ b/packages/agent/src/adapters/opencode/spawn.ts @@ -0,0 +1,207 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; +import type { Readable, Writable } from "node:stream"; +import type { ProcessSpawnedCallback } from "../../types"; +import { Logger } from "../../utils/logger"; + +/** + * Env var the generated opencode.json references via `{env:...}` so the gateway + * token is never written to disk in the config file. + */ +const GATEWAY_TOKEN_ENV = "POSTHOG_GATEWAY_API_KEY"; + +/** opencode provider key under which the gateway is registered in opencode.json. */ +const PROVIDER_KEY = "posthog"; + +export interface OpencodeProcessOptions { + cwd?: string; + /** Gateway base URL ending in `/v1` (the OpenAI-compatible Chat Completions surface). */ + apiBaseUrl?: string; + apiKey?: string; + /** Bare gateway model id, e.g. "@cf/zai-org/glm-5.2". */ + model?: string; + /** + * Run-private directory that holds the generated opencode.json and all of + * opencode's XDG state (db, cache, sessions). Isolating it keeps the spike/ + * adapter from touching the user's global ~/.local/share/opencode db — sharing + * it triggers schema-mismatch crashes and risks corrupting real sessions. + */ + configDir?: string; + /** Appended to opencode's instructions (parity with codex `developerInstructions`). */ + developerInstructions?: string; + /** + * Path to the native `opencode--/bin/opencode` binary. The + * `opencode-ai` npm launcher mangles ACP-over-stdio when spawned as a + * subprocess, so the native binary must be invoked directly (no npx fallback). + */ + binaryPath?: string; + logger?: Logger; + processCallbacks?: ProcessSpawnedCallback; + /** Extra writable roots (parity with codex; opencode reads them from config). */ + additionalDirectories?: string[]; +} + +export interface OpencodeProcess { + process: ChildProcess; + stdin: Writable; + stdout: Readable; + kill: () => void; +} + +/** + * Build the opencode.json that registers the PostHog gateway as a custom + * `@ai-sdk/openai-compatible` provider (Chat Completions wire format — GLM's + * native surface) and pins the default model. The token is injected via + * `{env:...}` rather than inlined. + */ +function buildOpencodeConfig( + options: OpencodeProcessOptions, +): Record { + const model = options.model; + const models: Record = {}; + if (model) { + models[model] = { name: model.split("/").pop() ?? model }; + } + + const config: Record = { + $schema: "https://opencode.ai/config.json", + provider: { + [PROVIDER_KEY]: { + npm: "@ai-sdk/openai-compatible", + name: "PostHog Gateway", + options: { + baseURL: options.apiBaseUrl, + apiKey: `{env:${GATEWAY_TOKEN_ENV}}`, + }, + models, + }, + }, + }; + + if (model) { + config.model = `${PROVIDER_KEY}/${model}`; + } + if (options.developerInstructions) { + config.instructions = [options.developerInstructions]; + } + + return config; +} + +function resolveConfigDir(options: OpencodeProcessOptions): string { + const dir = options.configDir; + if (!dir) { + throw new Error( + "opencode requires a run-private configDir to hold opencode.json and isolated XDG state", + ); + } + mkdirSync(dir, { recursive: true }); + return dir; +} + +function findOpencodeBinary(options: OpencodeProcessOptions): string { + const bin = options.binaryPath ?? process.env.OPENCODE_BIN; + if (bin && existsSync(bin)) { + return bin; + } + if (bin) { + throw new Error( + `opencode binary not found at ${bin}. Run "node apps/code/scripts/download-binaries.mjs" to download it.`, + ); + } + throw new Error( + "opencode binary path not provided. Set OpencodeProcessOptions.binaryPath or the OPENCODE_BIN env var — the npx `opencode-ai` launcher does not work for ACP over stdio.", + ); +} + +export function spawnOpencodeProcess( + options: OpencodeProcessOptions, +): OpencodeProcess { + const logger = + options.logger ?? new Logger({ debug: true, prefix: "[OpencodeSpawn]" }); + + const configDir = resolveConfigDir(options); + const configPath = join(configDir, "opencode.json"); + writeFileSync( + configPath, + JSON.stringify(buildOpencodeConfig(options), null, 2), + ); + + const command = findOpencodeBinary(options); + + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + delete env.ELECTRON_NO_ASAR; + + if (options.apiKey) { + env[GATEWAY_TOKEN_ENV] = options.apiKey; + } + + // Point opencode at our generated config and isolate ALL of its state into the + // run-private dir so it never reads/writes the user's global opencode db. + env.OPENCODE_CONFIG = configPath; + env.XDG_DATA_HOME = join(configDir, "xdg", "data"); + env.XDG_STATE_HOME = join(configDir, "xdg", "state"); + env.XDG_CACHE_HOME = join(configDir, "xdg", "cache"); + env.XDG_CONFIG_HOME = join(configDir, "xdg", "config"); + + if (options.binaryPath && existsSync(options.binaryPath)) { + const binDir = dirname(options.binaryPath); + env.PATH = `${binDir}${delimiter}${env.PATH ?? ""}`; + } + + const args = ["acp"]; + + logger.info("Spawning opencode acp process", { + command, + cwd: options.cwd, + configDir, + hasApiBaseUrl: !!options.apiBaseUrl, + hasApiKey: !!options.apiKey, + model: options.model, + }); + + const child = spawn(command, args, { + cwd: options.cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + + child.stderr?.on("data", (data: Buffer) => { + logger.warn("opencode stderr:", data.toString()); + }); + + child.on("error", (err) => { + logger.error("opencode process error:", err); + }); + + child.on("exit", (code, signal) => { + logger.info("opencode process exited", { code, signal }); + if (child.pid && options.processCallbacks?.onProcessExited) { + options.processCallbacks.onProcessExited(child.pid); + } + }); + + if (!child.stdin || !child.stdout) { + throw new Error("Failed to get stdio streams from opencode process"); + } + + if (child.pid && options.processCallbacks?.onProcessSpawned) { + options.processCallbacks.onProcessSpawned({ pid: child.pid, command }); + } + + return { + process: child, + stdin: child.stdin, + stdout: child.stdout, + kill: () => { + logger.info("Killing opencode process", { pid: child.pid }); + child.stdin?.destroy(); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.kill("SIGTERM"); + }, + }; +} diff --git a/packages/agent/src/adapters/reasoning-effort.ts b/packages/agent/src/adapters/reasoning-effort.ts index 2c031d024a..c228fb481f 100644 --- a/packages/agent/src/adapters/reasoning-effort.ts +++ b/packages/agent/src/adapters/reasoning-effort.ts @@ -1,7 +1,7 @@ import { getEffortOptions as getClaudeEffortOptions } from "./claude/session/models"; import { getReasoningEffortOptions as getCodexReasoningEffortOptions } from "./codex/models"; -export type RuntimeAdapter = "claude" | "codex"; +export type RuntimeAdapter = "claude" | "codex" | "opencode"; export type SupportedReasoningEffort = | "low" @@ -19,6 +19,9 @@ export function getReasoningEffortOptions( adapter: RuntimeAdapter, modelId: string, ): ReasoningEffortOption[] | null { + // opencode runs GLM, which has no reasoning/effort tiers. + if (adapter === "opencode") return null; + const options = adapter === "codex" ? getCodexReasoningEffortOptions(modelId) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 28d26d627c..d327f162a8 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -6,6 +6,7 @@ import type { GatewayEnv } from "./adapters/claude/session/options"; import { DEFAULT_CODEX_MODEL, DEFAULT_GATEWAY_MODEL, + DEFAULT_OPENCODE_MODEL, fetchModelsList, isBlockedModelId, } from "./gateway-models"; @@ -106,12 +107,28 @@ export class Agent { : codexModelIds[0]; } } - if (!sanitizedModel && options.adapter !== "codex") { + // opencode only serves GLM (no /v1/models discovery). Normalize away any + // `posthog/` provider prefix and ignore a non-GLM model id that leaked in + // from another adapter's session — always resolve to a bare GLM id. + if (options.adapter === "opencode") { + const candidate = options.model?.replace(/^posthog\//, ""); + const isGlm = + !!candidate && + (candidate.startsWith("@cf/") || candidate.includes("zai")); + sanitizedModel = isGlm ? candidate : DEFAULT_OPENCODE_MODEL; + } + if ( + !sanitizedModel && + options.adapter !== "codex" && + options.adapter !== "opencode" + ) { sanitizedModel = DEFAULT_GATEWAY_MODEL; } const claudeGatewayEnv: GatewayEnv | undefined = - options.adapter !== "codex" && gatewayConfig + options.adapter !== "codex" && + options.adapter !== "opencode" && + gatewayConfig ? { anthropicBaseUrl: gatewayConfig.gatewayUrl, anthropicAuthToken: gatewayConfig.apiKey, @@ -147,6 +164,19 @@ export class Agent { additionalDirectories: options.additionalDirectories, } : undefined, + opencodeOptions: + options.adapter === "opencode" && gatewayConfig + ? { + cwd: options.repositoryPath, + apiBaseUrl: `${gatewayConfig.gatewayUrl}/v1`, + apiKey: gatewayConfig.apiKey, + model: sanitizedModel, + binaryPath: options.opencodeBinaryPath, + configDir: options.opencodeConfigDir, + developerInstructions: options.developerInstructions, + additionalDirectories: options.additionalDirectories, + } + : undefined, }); return this.acpConnection; diff --git a/packages/agent/src/gateway-models.test.ts b/packages/agent/src/gateway-models.test.ts index ee83e138b5..853db200d6 100644 --- a/packages/agent/src/gateway-models.test.ts +++ b/packages/agent/src/gateway-models.test.ts @@ -1,12 +1,29 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + DEFAULT_OPENCODE_MODEL, fetchGatewayModels, fetchModelsList, formatGatewayModelName, + formatGlmModelName, + type GatewayModel, getClaudeModelRecency, + getProviderName, isBlockedModelId, + isGlmModel, } from "./gateway-models"; +function model( + partial: Partial & Pick, +): GatewayModel { + return { + owned_by: "", + context_window: 200000, + supports_streaming: true, + supports_vision: false, + ...partial, + }; +} + describe("formatGatewayModelName", () => { it("keeps Claude models in friendly title case", () => { expect( @@ -62,6 +79,46 @@ describe("formatGatewayModelName", () => { }); }); +describe("GLM / opencode models", () => { + it("detects GLM by owned_by=cloudflare", () => { + expect( + isGlmModel(model({ id: "@cf/zai-org/glm-5.2", owned_by: "cloudflare" })), + ).toBe(true); + }); + + it("detects GLM by id prefix/contents when owned_by is absent", () => { + expect(isGlmModel(model({ id: "@cf/zai-org/glm-5.2" }))).toBe(true); + expect(isGlmModel(model({ id: "zai/glm-5.2" }))).toBe(true); + }); + + it("does not treat Anthropic/OpenAI models as GLM", () => { + expect( + isGlmModel(model({ id: "claude-opus-4-8", owned_by: "anthropic" })), + ).toBe(false); + expect(isGlmModel(model({ id: "gpt-5.5", owned_by: "openai" }))).toBe( + false, + ); + }); + + it("formats the slash-path GLM id to its final segment", () => { + expect(formatGlmModelName("@cf/zai-org/glm-5.2")).toBe("glm-5.2"); + expect( + formatGatewayModelName( + model({ id: "@cf/zai-org/glm-5.2", owned_by: "cloudflare" }), + ), + ).toBe("glm-5.2"); + }); + + it("names the cloudflare provider 'GLM'", () => { + expect(getProviderName("cloudflare")).toBe("GLM"); + }); + + it("default opencode model is GLM and is not blocked", () => { + expect(DEFAULT_OPENCODE_MODEL).toBe("@cf/zai-org/glm-5.2"); + expect(isBlockedModelId(DEFAULT_OPENCODE_MODEL)).toBe(false); + }); +}); + describe("getClaudeModelRecency", () => { it.each([ ["claude-haiku-4-5", 4005], diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index cacc55e51c..4e17dc2359 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -19,6 +19,10 @@ export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8"; export const DEFAULT_CODEX_MODEL = "gpt-5.5"; +// GLM (open-source, Cloudflare Workers AI) is served Chat-Completions-native and +// runs on the opencode adapter. Currently a single model id. +export const DEFAULT_OPENCODE_MODEL = "@cf/zai-org/glm-5.2"; + const BLOCKED_MODELS = new Set([ "gpt-5-mini", "openai/gpt-5-mini", @@ -117,6 +121,16 @@ export function isOpenAIModel(model: GatewayModel): boolean { return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); } +// GLM/Cloudflare models surface from the gateway with owned_by="cloudflare" and +// ids like "@cf/zai-org/glm-5.2". Kept provider-specific (not "everything not +// anthropic/openai") so a future provider doesn't leak into the opencode picker. +export function isGlmModel(model: GatewayModel): boolean { + if (model.owned_by) { + return model.owned_by === "cloudflare"; + } + return model.id.startsWith("@cf/") || model.id.includes("zai"); +} + export interface ModelInfo { id: string; owned_by?: string; @@ -178,6 +192,7 @@ const PROVIDER_NAMES: Record = { anthropic: "Anthropic", openai: "OpenAI", "google-vertex": "Gemini", + cloudflare: "GLM", }; export function getProviderName(ownedBy: string): string { @@ -205,6 +220,10 @@ export function getClaudeModelRecency(modelId: string): number { const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"]; export function formatGatewayModelName(model: GatewayModel): string { + if (isGlmModel(model)) { + return formatGlmModelName(model.id); + } + if (isOpenAIModel(model)) { return stripProviderPrefix(model.id).toLowerCase(); } @@ -212,6 +231,12 @@ export function formatGatewayModelName(model: GatewayModel): string { return formatModelId(model.id); } +// GLM/Cloudflare ids are slash-paths ("@cf/zai-org/glm-5.2") that the generic +// formatter's "-"/"_" word-splitter mangles; take the final path segment. +export function formatGlmModelName(modelId: string): string { + return (modelId.split("/").pop() ?? modelId).toLowerCase(); +} + function stripProviderPrefix(modelId: string): string { for (const prefix of PROVIDER_PREFIXES) { if (modelId.startsWith(prefix)) { diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 8124e8d594..c569ba79d2 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -224,9 +224,9 @@ interface TestableServer { inboxReportUrl?: string | null, ): string | { append: string }; buildCodexInstructions(systemPrompt: string | { append: string }): string; - getRuntimeAdapter(): "claude" | "codex"; + getRuntimeAdapter(): "claude" | "codex" | "opencode"; buildClaudeCodeSessionMeta( - runtimeAdapter: "claude" | "codex", + runtimeAdapter: "claude" | "codex" | "opencode", ): { claudeCode: { options: Record } } | undefined; } @@ -236,7 +236,7 @@ interface NativeResumeTestServer { payload: JwtPayload, posthogAPI: PostHogAPIClient, preTaskRun: TaskRun | null, - runtimeAdapter: "claude" | "codex", + runtimeAdapter: "claude" | "codex" | "opencode", cwd: string, permissionMode: PermissionMode, ): Promise<{ sessionId: string; warm: boolean } | null>; diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 22bf913294..70c45a4808 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -344,7 +344,7 @@ export class AgentServer { this.app = this.createApp(); } - private getRuntimeAdapter(): "claude" | "codex" { + private getRuntimeAdapter(): "claude" | "codex" | "opencode" { return this.config.runtimeAdapter ?? "claude"; } @@ -609,7 +609,7 @@ export class AgentServer { payload: JwtPayload, posthogAPI: PostHogAPIClient, preTaskRun: TaskRun | null, - runtimeAdapter: "claude" | "codex", + runtimeAdapter: "claude" | "codex" | "opencode", cwd: string, permissionMode: PermissionMode, ): Promise<{ sessionId: string; warm: boolean } | null> { @@ -1925,7 +1925,7 @@ export class AgentServer { * it cannot sit behind a plugins guard. */ private buildClaudeCodeSessionMeta( - runtimeAdapter: "claude" | "codex", + runtimeAdapter: "claude" | "codex" | "opencode", ): { claudeCode: { options: Record } } | undefined { const plugins = this.config.claudeCode?.plugins; const effort = diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index c70368ae9a..0c562fb2f7 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -27,7 +27,9 @@ const envSchema = z.object({ }) .regex(/^\d+$/, "POSTHOG_PROJECT_ID must be a numeric string") .transform((val) => parseInt(val, 10)), - POSTHOG_CODE_RUNTIME_ADAPTER: z.enum(["claude", "codex"]).optional(), + POSTHOG_CODE_RUNTIME_ADAPTER: z + .enum(["claude", "codex", "opencode"]) + .optional(), POSTHOG_CODE_MODEL: z.string().optional(), POSTHOG_CODE_REASONING_EFFORT: z .enum(["low", "medium", "high", "xhigh", "max"]) diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index d11cb7748c..fefcbb1ec9 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -26,7 +26,7 @@ export interface AgentServerConfig { baseBranch?: string; claudeCode?: ClaudeCodeConfig; allowedDomains?: string[]; - runtimeAdapter?: "claude" | "codex"; + runtimeAdapter?: "claude" | "codex" | "opencode"; model?: string; reasoningEffort?: "low" | "medium" | "high" | "xhigh" | "max"; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 0056590678..91d301053d 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -49,15 +49,19 @@ export interface ProcessSpawnedCallback { export interface TaskExecutionOptions { repositoryPath?: string; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; model?: string; gatewayUrl?: string; codexBinaryPath?: string; codexHome?: string; + /** opencode-only: path to the native `opencode--/bin/opencode` binary. */ + opencodeBinaryPath?: string; + /** opencode-only: run-private dir holding the generated opencode.json + isolated XDG state. */ + opencodeConfigDir?: string; reasoningEffort?: EffortLevel; /** - * Codex-only. Appended on top of the model's base prompt via the Codex - * `developer_instructions` config key, preserving Codex's native base prompt. + * Codex/opencode. Appended on top of the model's base prompt via the harness's + * developer-instructions config, preserving the native base prompt. */ developerInstructions?: string; processCallbacks?: ProcessSpawnedCallback; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 05bec74cdd..e80c6315b1 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -463,7 +463,7 @@ export interface FinalizedTaskArtifactUpload { uploaded_at?: string; } -type CloudRuntimeAdapter = "claude" | "codex"; +type CloudRuntimeAdapter = "claude" | "codex" | "opencode"; interface CloudRunOptions { adapter?: CloudRuntimeAdapter; diff --git a/packages/core/src/handoff/handoff-saga.ts b/packages/core/src/handoff/handoff-saga.ts index dcf9d6bc13..2b13161e06 100644 --- a/packages/core/src/handoff/handoff-saga.ts +++ b/packages/core/src/handoff/handoff-saga.ts @@ -41,7 +41,7 @@ export interface HandoffSagaDeps extends HandoffBaseDeps { projectId: number; logUrl: string; sessionId?: string; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; }): Promise<{ sessionId: string } | null>; closeCloudRun( taskId: string, diff --git a/packages/core/src/handoff/schemas.ts b/packages/core/src/handoff/schemas.ts index b384ed27a4..7314a8ec93 100644 --- a/packages/core/src/handoff/schemas.ts +++ b/packages/core/src/handoff/schemas.ts @@ -63,7 +63,7 @@ export type HandoffPreflightResult = z.infer; export const handoffExecuteInput = handoffApiInput.extend({ sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), + adapter: z.enum(["claude", "codex", "opencode"]).optional(), localGitState: handoffLocalGitStateSchema.optional(), }); diff --git a/packages/core/src/handoff/types.ts b/packages/core/src/handoff/types.ts index 8e781a4e69..611244e3c4 100644 --- a/packages/core/src/handoff/types.ts +++ b/packages/core/src/handoff/types.ts @@ -17,7 +17,7 @@ export interface HandoffSagaInput { apiHost: string; teamId: number; sessionId?: string; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; localGitState?: HandoffLocalGitState; } diff --git a/packages/core/src/inbox/identifiers.ts b/packages/core/src/inbox/identifiers.ts index 31aba20b2d..34616594e8 100644 --- a/packages/core/src/inbox/identifiers.ts +++ b/packages/core/src/inbox/identifiers.ts @@ -25,7 +25,7 @@ export interface ReportModelResolver { */ resolveDefaultModel( apiHost: string, - adapter: "claude" | "codex", + adapter: "claude" | "codex" | "opencode", preferredModel?: string | null, ): Promise; } diff --git a/packages/core/src/inbox/reportTaskCreation.ts b/packages/core/src/inbox/reportTaskCreation.ts index 453196710b..9db9d094a8 100644 --- a/packages/core/src/inbox/reportTaskCreation.ts +++ b/packages/core/src/inbox/reportTaskCreation.ts @@ -72,7 +72,7 @@ export interface BuildSignalReportTaskInput { reportId: string; cloudRepository: string; githubUserIntegrationId: string; - adapter: "claude" | "codex"; + adapter: "claude" | "codex" | "opencode"; model: string; reasoningLevel?: string; baseBranch?: string | null; diff --git a/packages/core/src/inbox/signalReportTaskService.ts b/packages/core/src/inbox/signalReportTaskService.ts index 1921398d8a..22b267f5cc 100644 --- a/packages/core/src/inbox/signalReportTaskService.ts +++ b/packages/core/src/inbox/signalReportTaskService.ts @@ -27,7 +27,7 @@ export interface CreateSignalReportTaskInput { githubUserIntegrationId: string | null; cloudRegion: CloudRegion | null; projectId?: number | null; - adapter: "claude" | "codex"; + adapter: "claude" | "codex" | "opencode"; modelOverride?: string | null; reasoningLevel?: string; question?: string; diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts index cc6c92811f..7e082f7d40 100644 --- a/packages/core/src/sessions/cloudSessionConfig.ts +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -54,7 +54,8 @@ export function buildCloudDefaultConfigOptions( ): SessionConfigOption[] { const modes = adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const fallbackMode = adapter === "codex" ? "auto" : "plan"; + const fallbackMode = + adapter === "codex" || adapter === "opencode" ? "auto" : "plan"; const currentMode = typeof initialMode === "string" && modes.some((mode) => mode.id === initialMode) diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 2bc4f987d2..f2fad9dac8 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -286,7 +286,7 @@ export interface ConnectParams { repoPath: string; initialPrompt?: ContentBlock[]; executionMode?: ExecutionMode; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; model?: string; reasoningLevel?: string; /** @@ -1199,7 +1199,7 @@ export class SessionService { auth: AuthCredentials, initialPrompt?: ContentBlock[], executionMode?: ExecutionMode, - adapter?: "claude" | "codex", + adapter?: "claude" | "codex" | "opencode", model?: string, reasoningLevel?: string, importedSessionId?: string, diff --git a/packages/core/src/task-detail/previewConfig.ts b/packages/core/src/task-detail/previewConfig.ts index 90ad092d4e..0b5cd4cbd0 100644 --- a/packages/core/src/task-detail/previewConfig.ts +++ b/packages/core/src/task-detail/previewConfig.ts @@ -1,7 +1,7 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { flattenConfigValues } from "@posthog/core/task-detail/configOptions"; -export type PreviewAdapter = "claude" | "codex"; +export type PreviewAdapter = "claude" | "codex" | "opencode"; export interface PreviewSettingsSnapshot { defaultInitialTaskMode: string; @@ -72,7 +72,8 @@ export function deriveInitialConfig( ) { initialMode = lastUsedInitialTaskMode; } else { - const fallbackDefault = adapter === "codex" ? "auto" : "plan"; + const fallbackDefault = + adapter === "codex" || adapter === "opencode" ? "auto" : "plan"; initialMode = typeof serverDefault === "string" && availableValues.includes(serverDefault) diff --git a/packages/core/src/task-detail/taskCreationApiClient.ts b/packages/core/src/task-detail/taskCreationApiClient.ts index 3ef2f5ed56..76da42598d 100644 --- a/packages/core/src/task-detail/taskCreationApiClient.ts +++ b/packages/core/src/task-detail/taskCreationApiClient.ts @@ -5,7 +5,7 @@ export interface CreateTaskRunClientOptions { environment?: "local" | "cloud"; mode?: "interactive" | "background"; branch?: string | null; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; model?: string; reasoningLevel?: string; sandboxEnvironmentId?: string; diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 637d724047..8f449ee96c 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -326,7 +326,9 @@ export class TaskCreationSaga extends Saga< homeQuickAction: input.homeQuickActionLabel, initialPermissionMode: input.adapter ? (input.executionMode ?? - (input.adapter === "codex" ? "auto" : "plan")) + (input.adapter === "codex" || input.adapter === "opencode" + ? "auto" + : "plan")) : input.executionMode, }); if (!taskRun?.id) { diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 9ddb6b297c..84af34c5d8 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -12,7 +12,7 @@ export interface PrepareTaskInputOptions { allowRemoteBranchCheckout?: boolean; reuseExistingWorktree?: boolean; executionMode?: ExecutionMode; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; model?: string; reasoningLevel?: string; environmentId?: string | null; diff --git a/packages/core/src/workflow/schemas.ts b/packages/core/src/workflow/schemas.ts index 4488197cdb..c6fc1575f9 100644 --- a/packages/core/src/workflow/schemas.ts +++ b/packages/core/src/workflow/schemas.ts @@ -59,7 +59,7 @@ export const workflowAction = z label: z.string().min(1).max(120), skillId: z.string(), prompt: z.string().min(1).max(8_000), - adapter: z.enum(["claude", "codex"]).optional(), + adapter: z.enum(["claude", "codex", "opencode"]).optional(), model: z.string().min(1).optional(), }) .strict(); diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 1eaf429c35..59aa81080b 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -74,7 +74,7 @@ export interface TaskCreateProperties { uses_worktree_link?: boolean; /** Worktree mode: repo has a non-empty .worktreeinclude file */ uses_worktree_include?: boolean; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; } export interface TaskViewProperties { diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 9368b1c941..27f65722f6 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -88,7 +88,7 @@ export interface TaskRun { task: string; // Task ID team: number; branch: string | null; - runtime_adapter?: "claude" | "codex" | null; + runtime_adapter?: "claude" | "codex" | "opencode" | null; model?: string | null; reasoning_effort?: "low" | "medium" | "high" | "xhigh" | "max" | null; stage?: string | null; // Current stage (e.g., 'research', 'plan', 'build') diff --git a/packages/shared/src/handoff-host.ts b/packages/shared/src/handoff-host.ts index 0716a0b904..1416bff0a0 100644 --- a/packages/shared/src/handoff-host.ts +++ b/packages/shared/src/handoff-host.ts @@ -21,7 +21,7 @@ export interface HandoffReconnectParams { projectId: number; logUrl: string; sessionId?: string; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; } export interface HandoffResumeStateResult { diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index 1c7663ba6b..2bfa9cc60d 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -11,7 +11,7 @@ import type { ExecutionMode } from "./exec-types"; import type { AcpMessage } from "./session-events"; import type { TaskRunStatus } from "./task"; -export type Adapter = "claude" | "codex"; +export type Adapter = "claude" | "codex" | "opencode"; export type PermissionRequest = Omit & { taskRunId: string; diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 116da1dbb0..984fc903f5 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -29,7 +29,7 @@ export interface TaskCreationInput { githubIntegrationId?: number; githubUserIntegrationId?: string; executionMode?: ExecutionMode; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; model?: string; reasoningLevel?: string; environmentId?: string; diff --git a/packages/ui/src/features/inbox/hooks/resolveDefaultModel.ts b/packages/ui/src/features/inbox/hooks/resolveDefaultModel.ts index 63d3993b6b..5226a5d4c3 100644 --- a/packages/ui/src/features/inbox/hooks/resolveDefaultModel.ts +++ b/packages/ui/src/features/inbox/hooks/resolveDefaultModel.ts @@ -19,7 +19,7 @@ const log = logger.scope("resolve-default-model"); export async function resolveDefaultModel( queryClient: QueryClient, apiHost: string, - adapter: "claude" | "codex", + adapter: "claude" | "codex" | "opencode", modelResolver: ReportModelResolver, preferredModel?: string | null, ): Promise { diff --git a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts index 962c143afe..fef4e2905b 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts @@ -52,7 +52,7 @@ export interface InboxCloudTaskInputContext { reportTitle?: string | null; cloudRepository: string; githubUserIntegrationId: string; - adapter: "claude" | "codex"; + adapter: "claude" | "codex" | "opencode"; model: string; reasoningLevel?: string; } diff --git a/packages/ui/src/features/message-editor/components/AdapterIndicator.tsx b/packages/ui/src/features/message-editor/components/AdapterIndicator.tsx index cfd914f1d5..42520af356 100644 --- a/packages/ui/src/features/message-editor/components/AdapterIndicator.tsx +++ b/packages/ui/src/features/message-editor/components/AdapterIndicator.tsx @@ -2,7 +2,7 @@ import { Robot } from "@phosphor-icons/react"; import { Flex, Text } from "@radix-ui/themes"; interface AdapterIndicatorProps { - adapter: "claude" | "codex"; + adapter: "claude" | "codex" | "opencode"; } export function AdapterIndicator({ adapter }: AdapterIndicatorProps) { diff --git a/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts b/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts index b931cc55fa..6b720d300b 100644 --- a/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts +++ b/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts @@ -18,7 +18,7 @@ function seedSessionContext(taskId: string | undefined) { function seedSessionAvailableCommands( commands: { name: string; description: string }[], - adapter?: "claude" | "codex", + adapter?: "claude" | "codex" | "opencode", ) { const events: AcpMessage[] = [ { @@ -68,7 +68,7 @@ interface Scenario { name: string; contextTaskId?: string; sessionCommands?: { name: string; description: string }[]; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; draftCommands?: { name: string; description: string }[]; expectContains: string[]; expectNotContains?: string[]; diff --git a/packages/ui/src/features/sessions/components/ModelSelector.tsx b/packages/ui/src/features/sessions/components/ModelSelector.tsx index dbfe97ce14..60f9c49de2 100644 --- a/packages/ui/src/features/sessions/components/ModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/ModelSelector.tsx @@ -24,7 +24,7 @@ interface ModelSelectorProps { taskId?: string; disabled?: boolean; onModelChange?: (modelId: string) => void; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; } export function ModelSelector({ diff --git a/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx index a3250af886..9b2704048d 100644 --- a/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx +++ b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx @@ -14,7 +14,7 @@ import { flattenSelectOptions } from "../sessionStore"; interface ReasoningLevelSelectorProps { thoughtOption?: SessionConfigOption; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; onChange?: (value: string) => void; disabled?: boolean; } diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx index 6c4668a89d..4764109631 100644 --- a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx @@ -5,6 +5,7 @@ import type { import { ArrowsClockwise, CaretDown, + Code, Cpu, Robot, Spinner, @@ -20,23 +21,27 @@ import { DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { flattenSelectOptions } from "@posthog/ui/features/sessions/sessionStore"; import type { AgentAdapter } from "@posthog/ui/features/settings/settingsStore"; import { Fragment, useMemo, useRef, useState } from "react"; +/** Gates the opencode harness (GLM). Auto-on in dev; off for everyone else. */ +const OPENCODE_FLAG = "posthog-code-opencode-harness"; + const ADAPTER_ICONS: Record = { claude: , codex: , + opencode: , }; const ADAPTER_LABELS: Record = { claude: "Claude", codex: "Codex", + opencode: "OpenCode", }; -function getOtherAdapter(adapter: AgentAdapter): AgentAdapter { - return adapter === "claude" ? "codex" : "claude"; -} +const ALL_ADAPTERS: AgentAdapter[] = ["claude", "codex", "opencode"]; interface UnifiedModelSelectorProps { modelOption?: SessionConfigOption; @@ -73,7 +78,11 @@ export function UnifiedModelSelector({ const currentLabel = options.find((opt) => opt.value === currentValue)?.name ?? currentValue; - const otherAdapter = getOtherAdapter(adapter); + // opencode is gated behind a flag; never offer switching to it when off. + const opencodeEnabled = useFeatureFlag(OPENCODE_FLAG) || import.meta.env.DEV; + const switchableAdapters = ALL_ADAPTERS.filter( + (a) => a !== adapter && (a !== "opencode" || opencodeEnabled), + ); if (isConnecting) { return ( @@ -154,10 +163,12 @@ export function UnifiedModelSelector({ - onAdapterChange(otherAdapter)}> - - Switch to {ADAPTER_LABELS[otherAdapter]} - + {switchableAdapters.map((a) => ( + onAdapterChange(a)}> + + Switch to {ADAPTER_LABELS[a]} + + ))} ); diff --git a/packages/ui/src/features/sessions/sessionAdapterStore.ts b/packages/ui/src/features/sessions/sessionAdapterStore.ts index 3219aa73cd..60b1e523c0 100644 --- a/packages/ui/src/features/sessions/sessionAdapterStore.ts +++ b/packages/ui/src/features/sessions/sessionAdapterStore.ts @@ -2,7 +2,7 @@ import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -type AdapterType = "claude" | "codex"; +type AdapterType = "claude" | "codex" | "opencode"; interface SessionAdapterState { adaptersByRunId: Record; diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index f480c7fb45..4490ce902c 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -12,7 +12,7 @@ import { persist } from "zustand/middleware"; export type DefaultRunMode = "local" | "cloud" | "last_used"; export type LocalWorkspaceMode = "worktree" | "local"; -export type AgentAdapter = "claude" | "codex"; +export type AgentAdapter = "claude" | "codex" | "opencode"; export type DefaultInitialTaskMode = "plan" | "last_used"; export type DefaultMessagingMode = "queue" | "steer"; export type DefaultReasoningEffort = diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 9230596e62..b801f86f2c 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -567,7 +567,8 @@ export function TaskInput({ // Defaults ensure values are always passed even before the preview config loads. const currentModel = modelOption?.type === "select" ? modelOption.currentValue : undefined; - const adapterDefault = adapter === "codex" ? "auto" : "plan"; + const adapterDefault = + adapter === "codex" || adapter === "opencode" ? "auto" : "plan"; const modeFallback = defaultInitialTaskMode === "last_used" && lastUsedInitialTaskMode && diff --git a/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts index 599eb968ac..07f93c1079 100644 --- a/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts +++ b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts @@ -39,7 +39,7 @@ function getOptionByCategory( * Returns config options as local state with a setter for local updates. */ export function usePreviewConfig( - adapter: "claude" | "codex", + adapter: "claude" | "codex" | "opencode", ): PreviewConfigResult { const hostClient = useHostTRPCClient(); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 1727846e66..a6ab47f147 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -61,7 +61,7 @@ interface UseTaskCreationOptions { branch?: string | null; editorIsEmpty: boolean; executionMode?: ExecutionMode; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; model?: string; reasoningLevel?: string; environmentId?: string | null; diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 7d28a3d71b..9ee694ea31 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -85,6 +85,11 @@ import { AGENT_REPO_FILES, AGENT_SLEEP_COORDINATOR, } from "./identifiers"; +import { + cleanupOpencodeConfig, + prepareOpencodeConfig, + resolveOpencodeBinaryPath, +} from "./opencode-config"; import type { AgentLogger, AgentMcpApps, @@ -259,7 +264,7 @@ interface SessionConfig { logUrl?: string; /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ sessionId?: string; - adapter?: "claude" | "codex"; + adapter?: "claude" | "codex" | "opencode"; /** Permission mode to use for the session */ permissionMode?: string; /** Custom instructions injected into the system prompt */ @@ -411,6 +416,14 @@ export class AgentService extends TypedEventEmitter { return this.bundledResources.resolve(`.vite/build/codex-acp/${binary}`); } + private getOpencodeBinaryPath(): string | undefined { + const binary = process.platform === "win32" ? "opencode.exe" : "opencode"; + const bundled = this.bundledResources.resolve( + `.vite/build/opencode/${binary}`, + ); + return resolveOpencodeBinaryPath(bundled); + } + /** * Respond to a pending permission request from the UI. * This resolves the promise that the agent is waiting on. @@ -783,18 +796,40 @@ If a repository IS genuinely required, attach one in this priority order: } } + let opencodeConfigDir: string | undefined; + if (adapter === "opencode") { + try { + opencodeConfigDir = await prepareOpencodeConfig({ + appDataPath: this.storagePaths.appDataPath, + taskRunId, + }); + } catch (err) { + // A prep failure must not kill the session; the spawn step will + // surface a clear error if the dir is genuinely unusable. + this.log.warn("Failed to prepare opencode config dir", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const isCodexOrOpencode = adapter === "codex" || adapter === "opencode"; const acpConnection = await agent.run(taskId, taskRunId, { adapter, gatewayUrl: proxyUrl, codexBinaryPath: adapter === "codex" ? this.getCodexBinaryPath() : undefined, codexHome, + opencodeBinaryPath: + adapter === "opencode" ? this.getOpencodeBinaryPath() : undefined, + opencodeConfigDir, model, reasoningEffort: adapter === "codex" ? effort : undefined, - developerInstructions: - adapter === "codex" ? systemPrompt.append : undefined, - additionalDirectories: - adapter === "codex" ? additionalDirectories : undefined, + developerInstructions: isCodexOrOpencode + ? systemPrompt.append + : undefined, + additionalDirectories: isCodexOrOpencode + ? additionalDirectories + : undefined, onStructuredOutput: jsonSchema ? async (output) => { const posthogAPI = agent.getPosthogAPI(); @@ -1566,6 +1601,12 @@ For git operations while detached: await cleanupCodexHome(this.storagePaths.appDataPath, taskRunId).catch( () => this.log.debug("Codex home cleanup failed", { taskRunId }), ); + await cleanupOpencodeConfig( + this.storagePaths.appDataPath, + taskRunId, + ).catch(() => + this.log.debug("Opencode config cleanup failed", { taskRunId }), + ); this.sessions.delete(taskRunId); @@ -1814,7 +1855,7 @@ For git operations while detached: } = params as { taskRunId: string; sessionId: string; - adapter: "claude" | "codex"; + adapter: "claude" | "codex" | "opencode"; }; const session = this.sessions.get(notifTaskRunId); if (session) { @@ -2127,7 +2168,7 @@ For git operations while detached: async getPreviewConfigOptions( apiHost: string, - adapter: "claude" | "codex" = "claude", + adapter: "claude" | "codex" | "opencode" = "claude", ): Promise { const gatewayUrl = getLlmGatewayUrl(apiHost); const gatewayModels = await fetchGatewayModels({ gatewayUrl }); diff --git a/packages/workspace-server/src/services/agent/opencode-config.ts b/packages/workspace-server/src/services/agent/opencode-config.ts new file mode 100644 index 0000000000..78a0ed383f --- /dev/null +++ b/packages/workspace-server/src/services/agent/opencode-config.ts @@ -0,0 +1,90 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { isSafePathSegment } from "../skills/skill-discovery"; + +/** + * Resolves a task run's private opencode config/state directory. Each run gets + * its own so the generated opencode.json and isolated XDG state (db, cache, + * sessions) never touch the user's global ~/.local/share/opencode db — sharing + * it triggers schema-mismatch crashes and risks corrupting real sessions. + */ +export function getOpencodeConfigDir( + appDataPath: string, + taskRunId: string, +): string { + if (!isSafePathSegment(taskRunId)) { + throw new Error(`Unsafe taskRunId: ${JSON.stringify(taskRunId)}`); + } + return path.join(appDataPath, "opencode-config", taskRunId); +} + +/** + * Builds a fresh run-private config dir for opencode. The opencode.json itself + * is written by the agent's spawn step; this just guarantees a clean directory. + */ +export async function prepareOpencodeConfig(options: { + appDataPath: string; + taskRunId: string; +}): Promise { + const dir = getOpencodeConfigDir(options.appDataPath, options.taskRunId); + // A retried run reuses its taskRunId, so wipe stale config/state first. + await fs.promises.rm(dir, { recursive: true, force: true }); + await fs.promises.mkdir(dir, { recursive: true }); + return dir; +} + +/** Removes a run's private opencode dir. No-op when it was never created. */ +export async function cleanupOpencodeConfig( + appDataPath: string, + taskRunId: string, +): Promise { + await fs.promises.rm(getOpencodeConfigDir(appDataPath, taskRunId), { + recursive: true, + force: true, + }); +} + +function findNpxOpencodeBinaries(): string[] { + // Dev convenience: a developer who has run `npx opencode-ai` has the native + // binary cached here. Empty in a shipped app (no npx), where the bundled + // binary or OPENCODE_BIN is used instead. + const triple = `opencode-${process.platform}-${process.arch}`; + const npxRoot = path.join(os.homedir(), ".npm", "_npx"); + const found: string[] = []; + try { + for (const dir of fs.readdirSync(npxRoot)) { + const p = path.join( + npxRoot, + dir, + "node_modules", + triple, + "bin", + "opencode", + ); + if (fs.existsSync(p)) found.push(p); + } + } catch { + // npx cache absent — fine. + } + return found.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); +} + +/** + * Resolves the native `opencode` binary to spawn. The `opencode-ai` npm launcher + * mangles ACP-over-stdio when spawned as a subprocess, so the native binary must + * be invoked directly. Preference: bundled binary, then `OPENCODE_BIN`, then a + * dev fallback (freshest npx-cached binary, then a stable `~/.opencode` install). + * Returns undefined when none exists — the spawn then throws a clear error. + */ +export function resolveOpencodeBinaryPath( + bundledPath: string | undefined, +): string | undefined { + const candidates = [ + bundledPath, + process.env.OPENCODE_BIN, + ...findNpxOpencodeBinaries(), + path.join(os.homedir(), ".opencode", "bin", "opencode"), + ].filter((p): p is string => Boolean(p)); + return candidates.find((p) => fs.existsSync(p)); +} diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 493e79943e..fa8db4eae2 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -25,7 +25,7 @@ export const sessionConfigSchema = z.object({ logUrl: z.string().optional(), /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), + adapter: z.enum(["claude", "codex", "opencode"]).optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), /** Permission mode to use for the session (e.g. "default", "acceptEdits", "plan", "bypassPermissions") */ @@ -51,7 +51,7 @@ export const startSessionInput = z.object({ permissionMode: z.string().optional(), autoProgress: z.boolean().optional(), runMode: z.enum(["local", "cloud"]).optional(), - adapter: z.enum(["claude", "codex"]).optional(), + adapter: z.enum(["claude", "codex", "opencode"]).optional(), additionalDirectories: z.array(z.string()).optional(), customInstructions: z.string().max(2000).optional(), /** @@ -193,7 +193,7 @@ export const reconnectSessionInput = z.object({ projectId: z.number(), logUrl: z.string().optional(), sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), + adapter: z.enum(["claude", "codex", "opencode"]).optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), permissionMode: z.string().optional(), @@ -323,7 +323,7 @@ export const getGatewayModelsOutput = z.array(modelOptionSchema); export const getPreviewConfigOptionsInput = z.object({ apiHost: z.string(), - adapter: z.enum(["claude", "codex"]), + adapter: z.enum(["claude", "codex", "opencode"]), }); export const getPreviewConfigOptionsOutput = z.array(sessionConfigOptionSchema);