import { isSessionNotFoundError, type CommandInfo, type ModelInfo, type ModelRef, type OpenCodeClient, type SessionInfo, type SessionMessageInfo, type SkillInfo, } from "@opencode-ai/client/promise" import type { AgentSideConnection, AuthenticateRequest, AuthenticateResponse, AuthMethod, CancelNotification, CloseSessionRequest, CloseSessionResponse, DeleteSessionRequest, DeleteSessionResponse, ForkSessionRequest, ForkSessionResponse, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpServer, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, ResumeSessionRequest, ResumeSessionResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, } from "@agentclientprotocol/sdk" import { OPENCODE_VERSION } from "../version" import { SessionMessage } from "@opencode-ai/schema/session-message" import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option" import { promptContentToParts } from "./content" import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event" import { ACPError } from "./error" export const AuthMethodID = "opencode-login" type Connection = Pick & Partial> type Catalog = { readonly providers: ConfigOptionProvider[] readonly models: ModelInfo[] readonly defaultModel: ModelRef readonly modes: Array<{ id: string; name: string; description?: string }> readonly defaultModeID: string readonly commands: CommandInfo[] readonly skills: SkillInfo[] } type Attached = { readonly id: string readonly cwd: string catalog: Catalog model: ModelRef modeID: string } type PreparedPrompt = { readonly start: TurnStart readonly text: string readonly files: Array<{ readonly uri: string; readonly name?: string }> readonly synthetic: ReadonlyArray readonly slash?: { readonly name: string; readonly args: string } readonly command?: CommandInfo readonly skill?: SkillInfo } export interface Interface { initialize(input: InitializeRequest): Promise authenticate(input: AuthenticateRequest): Promise newSession(input: NewSessionRequest): Promise loadSession(input: LoadSessionRequest): Promise listSessions(input: ListSessionsRequest): Promise deleteSession(input: DeleteSessionRequest): Promise resumeSession(input: ResumeSessionRequest): Promise closeSession(input: CloseSessionRequest): Promise forkSession(input: ForkSessionRequest): Promise setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise setSessionMode(input: SetSessionModeRequest): Promise prompt(input: PromptRequest): Promise cancel(input: CancelNotification): Promise } export function make(input: { readonly client: OpenCodeClient; readonly connection: Connection }): Interface { const sessions = new Map() const catalogs = new Map>() const registeredMcp = new Map>() const active = new Map() const capabilities = { writeTextFile: false } const catalog = (cwd: string) => { const cached = catalogs.get(cwd) if (cached) return cached const loaded = loadCatalog(input.client, cwd).catch((error) => { catalogs.delete(cwd) throw error }) catalogs.set(cwd, loaded) return loaded } const requireSession = async (sessionID: string) => { const current = sessions.get(sessionID) if (current) return current throw new ACPError.SessionNotFoundError({ sessionId: sessionID }) } const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => { const currentCatalog = await catalog(cwd) const state: Attached = { id: session.id, cwd, catalog: currentCatalog, model: session.model ?? currentCatalog.defaultModel, modeID: session.agent ?? currentCatalog.defaultModeID, } sessions.set(session.id, state) await registerMcpServers(input.client, registeredMcp, state, mcpServers) await input.connection.sessionUpdate({ sessionId: state.id, update: { sessionUpdate: "available_commands_update", availableCommands: [ ...state.catalog.commands, ...state.catalog.skills.filter( (skill) => !state.catalog.commands.some((command) => command.name === skill.name), ), ].map((command) => ({ name: command.name, description: command.description ?? "" })), }, }) return state } const replay = async (state: Attached) => { await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id)) } const configOptions = (state: Attached) => buildConfigOptions({ providers: state.catalog.providers, currentModel: { providerID: state.model.providerID, modelID: state.model.id }, currentVariant: state.model.variant, modes: state.catalog.modes, currentModeId: state.modeID, }) return { initialize: async (params) => { capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", id: AuthMethodID, } if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { authMethod._meta = { "terminal-auth": { command: "opencode", args: ["auth", "login"], label: "OpenCode Login" }, } } return { protocolVersion: 1, agentCapabilities: { loadSession: true, mcpCapabilities: { http: true, sse: false }, promptCapabilities: { embeddedContext: true, image: true }, sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} }, }, authMethods: [authMethod], agentInfo: { name: "OpenCode", version: OPENCODE_VERSION }, } }, authenticate: async (params) => { if (params.methodId !== AuthMethodID) throw new ACPError.UnknownAuthMethodError({ methodId: params.methodId }) return {} }, newSession: async (params) => { const currentCatalog = await catalog(params.cwd) const created = await input.client.session.create({ location: { directory: params.cwd }, agent: currentCatalog.defaultModeID, model: currentCatalog.defaultModel, }) const state = await attach(created, params.cwd, params.mcpServers) return { sessionId: state.id, configOptions: configOptions(state) } }, loadSession: async (params) => { const session = await getSession(input.client, params.sessionId) const state = await attach(session, session.location.directory, params.mcpServers) await replay(state) return { configOptions: configOptions(state) } }, listSessions: async (params) => { const page = await input.client.session.list({ ...(params.cwd ? { directory: params.cwd } : {}), order: "desc", limit: 100, ...(params.cursor ? { cursor: params.cursor } : {}), }) return { sessions: page.data.map((session) => ({ sessionId: session.id, cwd: session.location.directory, title: session.title, updatedAt: new Date(session.time.updated).toISOString(), })), ...(page.cursor.next ? { nextCursor: page.cursor.next } : {}), } }, deleteSession: async (params) => { await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => { if (!isSessionNotFoundError(error)) throw error }) sessions.delete(params.sessionId) registeredMcp.delete(params.sessionId) return {} }, resumeSession: async (params) => { const session = await getSession(input.client, params.sessionId) const state = await attach(session, session.location.directory, params.mcpServers ?? []) return { configOptions: configOptions(state) } }, closeSession: async (params) => { sessions.delete(params.sessionId) registeredMcp.delete(params.sessionId) const turn = active.get(params.sessionId) if (turn) { turn.cancelled = true turn.admission.abort() } await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {}) return {} }, forkSession: async (params) => { const forked = await input.client.session.fork({ sessionID: params.sessionId }) const state = await attach(forked, forked.location.directory, params.mcpServers ?? []) await replay(state) return { sessionId: state.id, configOptions: configOptions(state) } }, setSessionConfigOption: async (params) => { const state = await requireSession(params.sessionId) if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId }) switch (params.configId) { case "model": { const selected = requireModel(state.catalog, params.value) state.model = selected await input.client.session.switchModel({ sessionID: state.id, model: selected }) break } case "effort": { const model = state.catalog.models.find( (item) => item.providerID === state.model.providerID && item.id === state.model.id, ) if (!model?.variants.some((variant) => variant.id === params.value)) throw new ACPError.InvalidEffortError({ effort: params.value }) state.model = { ...state.model, variant: params.value } await input.client.session.switchModel({ sessionID: state.id, model: state.model }) break } case "mode": await selectMode(input.client, state, params.value) break default: throw new ACPError.InvalidConfigOptionError({ configId: params.configId }) } return { configOptions: configOptions(state) } }, setSessionMode: async (params) => { await selectMode(input.client, await requireSession(params.sessionId), params.modeId) return {} }, prompt: async (params) => { const state = await requireSession(params.sessionId) if (active.has(state.id)) { throw new ACPError.ServiceFailureError({ safeMessage: `Session already has an active ACP prompt: ${state.id}`, service: "session", }) } const messageID = SessionMessage.ID.create() const prepared = preparePrompt(state.catalog, params.prompt, messageID) const control: TurnControl = { cancelled: false, admission: new AbortController() } active.set(state.id, control) const response = await streamTurn({ client: input.client, connection: input.connection, sessionID: state.id, cwd: state.cwd, start: prepared.start, writeTextFile: capabilities.writeTextFile, control, submit: (signal) => submitPrompt(input.client, state, prepared, signal), }).finally(() => { if (active.get(state.id) === control) active.delete(state.id) }) await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {}) return response }, cancel: async (params) => { const current = active.get(params.sessionId) if (current) { current.cancelled = true current.admission.abort() } await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {}) }, } } function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt { const parts = promptContentToParts(prompt) const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored)) const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : [])) const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : [])) const slash = detectSlashCommand(text) const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined const skill = slash ? catalog.skills.find((item) => item.name === slash.name) : undefined const start = turnStart(messageID, slash, skill) return { start, text, files, synthetic, slash, command, skill } } async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) { if (prompt.synthetic.length > 0) { await client.session.synthetic({ sessionID: session.id, text: prompt.synthetic.join("\n\n"), description: "ACP embedded context", delivery: "steer", resume: false, }) } if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id }) if (prompt.skill) return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id }) if (prompt.command) { return client.session.command( { sessionID: session.id, id: prompt.start.id, command: prompt.command.name, arguments: prompt.slash?.args, files: prompt.files, delivery: "steer", }, { signal }, ) } return client.session.prompt( { sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" }, { signal }, ) } function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: SkillInfo | undefined): TurnStart { if (slash?.name === "compact") return { type: "compaction", id: messageID } if (skill) return { type: "skill", id: messageID } return { type: "input", id: messageID } } async function loadCatalog(client: OpenCodeClient, cwd: string): Promise { const location = { directory: cwd } // Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog. const deadline = Date.now() + 5_000 let missing = "No models are available" while (Date.now() < deadline) { const [modelResult, defaultResult, agentResult, commandResult, skillResult] = await Promise.all([ client.model.list({ location }), client.model.default({ location }), client.agent.list({ location }), client.command.list({ location }), client.skill.list({ location }), ]) const models = modelResult.data.filter((model) => model.enabled) const defaultModel = defaultResult.data ?? models[0] const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden) const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0] if (defaultModel && defaultAgent) { return { providers: providers(models), models, defaultModel: { providerID: defaultModel.providerID, id: defaultModel.id, variant: defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id, }, modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })), defaultModeID: defaultAgent.id, commands: commandResult.data, skills: skillResult.data.filter((skill) => skill.slash !== false), } } missing = defaultModel ? "No primary agents are available" : "No models are available" await Bun.sleep(25) } throw new Error(missing) } function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] { return Array.from(new Set(models.map((model) => model.providerID))) .toSorted() .map((providerID) => ({ id: providerID, name: providerID, models: models .filter((model) => model.providerID === providerID) .map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })), })) } function requireModel(catalog: Catalog, modelID: string): ModelRef { const selected = parseModelSelection(modelID, catalog.providers) const model = catalog.models.find( (item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID, ) if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID }) if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant)) throw new ACPError.InvalidEffortError({ effort: selected.variant }) return { providerID: model.providerID, id: model.id, variant: selected.variant } } async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) { if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID }) state.modeID = modeID await client.session.switchAgent({ sessionID: state.id, agent: modeID }) } async function getSession(client: OpenCodeClient, sessionID: string) { return client.session.get({ sessionID }).catch((error) => { if (isSessionNotFoundError(error)) throw new ACPError.SessionNotFoundError({ sessionId: sessionID }) throw error }) } async function messages(client: OpenCodeClient, sessionID: string) { const result: SessionMessageInfo[] = [] let cursor: string | undefined do { const page = cursor ? await client.message.list({ sessionID, limit: 200, cursor }) : await client.message.list({ sessionID, limit: 200, order: "asc" }) result.push(...page.data) cursor = page.cursor.next ?? undefined } while (cursor) return result } async function registerMcpServers( client: OpenCodeClient, registered: Map>, session: Attached, servers: readonly McpServer[], ) { const current = registered.get(session.id) ?? new Set() registered.set(session.id, current) await Promise.all( servers.flatMap((server) => { const config = mcpConfig(server) const key = `${server.name}:${stableStringify(config)}` if (current.has(key)) return [] current.add(key) return [ client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => { current.delete(key) throw error }), ] }), ) } function mcpConfig(server: McpServer) { if ("type" in server) { if (server.type === "acp") throw new Error("MCP-over-ACP is not supported") return { type: "remote" as const, url: server.url, headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])), oauth: false as const, } } return { type: "local" as const, command: [server.command, ...server.args], environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])), } } function stableStringify(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` if (!value || typeof value !== "object") return JSON.stringify(value) return `{${Object.entries(value) .toSorted(([a], [b]) => a.localeCompare(b)) .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) .join(",")}}` } async function sendUsageUpdate(client: OpenCodeClient, connection: Connection, session: Attached, used?: number) { if (!used) return const model = session.catalog.models.find( (item) => item.providerID === session.model.providerID && item.id === session.model.id, ) if (!model?.limit.context) return const info = await client.session.get({ sessionID: session.id }) await connection.sessionUpdate({ sessionId: session.id, update: { sessionUpdate: "usage_update", used, size: model.limit.context, cost: { amount: info.cost, currency: "USD" }, }, }) } function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined { const value = text.trim() if (!value.startsWith("/")) return undefined const [name, ...rest] = value.slice(1).split(/\s+/) if (!name) return undefined return { name, args: rest.join(" ").trim() } } export * as ACPService from "./service"