diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml new file mode 100644 index 00000000..34af9621 --- /dev/null +++ b/packages/extensions/zed/extension.toml @@ -0,0 +1,36 @@ +id = "opencode" +name = "OpenCode" +description = "The AI coding agent built for the terminal" +version = "0.1.1" +schema_version = 1 +authors = ["Anomaly"] +repository = "https://github.com/sst/opencode" + +[agent_servers.opencode] +name = "OpenCode" +icon = "./icons/opencode.svg" + +[agent_servers.opencode.targets.darwin-aarch64] +archive = "https://github.com/sst/opencode/releases/latest/download/opencode-darwin-arm64.zip" +cmd = "./opencode" +args = ["acp"] + +[agent_servers.opencode.targets.darwin-x86_64] +archive = "https://github.com/sst/opencode/releases/latest/download/opencode-darwin-x64.zip" +cmd = "./opencode" +args = ["acp"] + +[agent_servers.opencode.targets.linux-aarch64] +archive = "https://github.com/sst/opencode/releases/latest/download/opencode-linux-arm64.zip" +cmd = "./opencode" +args = ["acp"] + +[agent_servers.opencode.targets.linux-x86_64] +archive = "https://github.com/sst/opencode/releases/latest/download/opencode-linux-x64.zip" +cmd = "./opencode" +args = ["acp"] + +[agent_servers.opencode.targets.windows-x86_64] +archive = "https://github.com/sst/opencode/releases/latest/download/opencode-windows-x64.zip" +cmd = "./opencode.exe" +args = ["acp"] diff --git a/packages/extensions/zed/icons/opencode.svg b/packages/extensions/zed/icons/opencode.svg new file mode 100644 index 00000000..fc001e49 --- /dev/null +++ b/packages/extensions/zed/icons/opencode.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 046c8262..b25b6688 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -20,299 +20,321 @@ import { } from "@agentclientprotocol/sdk" import { Log } from "../util/log" import { ACPSessionManager } from "./session" -import type { ACPConfig } from "./types" +import type { ACPConfig, ACPSessionState } from "./types" import { Provider } from "../provider/provider" -import { SessionPrompt } from "../session/prompt" import { Installation } from "@/installation" -import { SessionLock } from "@/session/lock" -import { Bus } from "@/bus" import { MessageV2 } from "@/session/message-v2" -import { Storage } from "@/storage/storage" -import { Command } from "@/command" -import { Agent as Agents } from "@/agent/agent" -import { Permission } from "@/permission" -import { SessionCompaction } from "@/session/compaction" import { Config } from "@/config/config" import { MCP } from "@/mcp" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" +import type { OpencodeClient } from "@opencode-ai/sdk" export namespace ACP { const log = Log.create({ service: "acp-agent" }) - export async function init() { - const model = await defaultModel({}) + export async function init({ sdk }: { sdk: OpencodeClient }) { + const model = await defaultModel({ sdk }) return { - create: (connection: AgentSideConnection, config: ACPConfig) => { - if (!config.defaultModel) { - config.defaultModel = model + create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { + if (!fullConfig.defaultModel) { + fullConfig.defaultModel = model } - return new Agent(connection, config) + return new Agent(connection, fullConfig) }, } } export class Agent implements ACPAgent { - private sessionManager = new ACPSessionManager() private connection: AgentSideConnection private config: ACPConfig + private sdk: OpencodeClient + private sessionManager - constructor(connection: AgentSideConnection, config: ACPConfig = {}) { + constructor(connection: AgentSideConnection, config: ACPConfig) { this.connection = connection this.config = config - this.setupEventSubscriptions() + this.sdk = config.sdk + this.sessionManager = new ACPSessionManager(this.sdk) } - private setupEventSubscriptions() { + private setupEventSubscriptions(session: ACPSessionState) { + const sessionId = session.id + const directory = session.cwd + const options: PermissionOption[] = [ { optionId: "once", kind: "allow_once", name: "Allow once" }, { optionId: "always", kind: "allow_always", name: "Always allow" }, { optionId: "reject", kind: "reject_once", name: "Reject" }, ] - Bus.subscribe(Permission.Event.Updated, async (event) => { - const acpSession = this.sessionManager.get(event.properties.sessionID) - if (!acpSession) return - try { - const permission = event.properties - const res = await this.connection - .requestPermission({ - sessionId: acpSession.id, - toolCall: { - toolCallId: permission.callID ?? permission.id, - status: "pending", - title: permission.title, - rawInput: permission.metadata, - kind: toToolKind(permission.type), - locations: toLocations(permission.type, permission.metadata), - }, - options, - }) - .catch((error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - Permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "reject", - }) - return - }) - if (!res) return - if (res.outcome.outcome !== "selected") { - Permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "reject", - }) - return - } - Permission.respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: res.outcome.optionId as "once" | "always" | "reject", - }) - } catch (err) { - if (!(err instanceof Permission.RejectedError)) { - log.error("unexpected error when handling permission", { error: err }) - throw err - } - } - }) - - Bus.subscribe(MessageV2.Event.PartUpdated, async (event) => { - const props = event.properties - const { part } = props - const acpSession = this.sessionManager.get(part.sessionID) - if (!acpSession) return - - const message = await Storage.read([ - "message", - part.sessionID, - part.messageID, - ]).catch(() => undefined) - if (!message || message.role !== "assistant") return - - if (part.type === "tool") { - switch (part.state.status) { - case "pending": - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((err) => { - log.error("failed to send tool pending to ACP", { error: err }) - }) - break - case "running": - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) - }) - break - case "completed": - const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } - - if (part.tool === "todowrite") { - const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) - if (parsedTodos.success) { - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "plan", - entries: parsedTodos.data.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" - ? "completed" - : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), + this.config.sdk.event.subscribe({ query: { directory } }).then(async (events) => { + for await (const event of events.stream) { + switch (event.type) { + case "permission.updated": + try { + const permission = event.properties + const res = await this.connection + .requestPermission({ + sessionId, + toolCall: { + toolCallId: permission.callID ?? permission.id, + status: "pending", + title: permission.title, + rawInput: permission.metadata, + kind: toToolKind(permission.type), + locations: toLocations(permission.type, permission.metadata), + }, + options, + }) + .catch(async (error) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, + }) + await this.config.sdk.postSessionIdPermissionsPermissionId({ + path: { id: permission.sessionID, permissionID: permission.id }, + body: { + response: "reject", }, + query: { directory }, }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.error }) + return + }) + if (!res) return + if (res.outcome.outcome !== "selected") { + await this.config.sdk.postSessionIdPermissionsPermissionId({ + path: { id: permission.sessionID, permissionID: permission.id }, + body: { + response: "reject", + }, + query: { directory }, + }) + return } + await this.config.sdk.postSessionIdPermissionsPermissionId({ + path: { id: permission.sessionID, permissionID: permission.id }, + body: { + response: res.outcome.optionId as "once" | "always" | "reject", + }, + query: { directory }, + }) + } catch (err) { + log.error("unexpected error when handling permission", { error: err }) + } finally { + break } - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, + case "message.part.updated": + log.info("message part updated", { event: event.properties }) + try { + const props = event.properties + const { part } = props + + const message = await this.config.sdk.session + .message({ + throwOnError: true, + path: { + id: part.sessionID, + messageID: part.messageID, }, - }, - }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) - }) - break - case "error": - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, + query: { directory }, + }) + .then((x) => x.data) + .catch((err) => { + log.error("unexpected error when fetching message", { error: err }) + return undefined + }) + + if (!message || message.info.role !== "assistant") return + + if (part.type === "tool") { + switch (part.state.status) { + case "pending": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: part.callID, + title: part.tool, + kind: toToolKind(part.tool), + status: "pending", + locations: [], + rawInput: {}, + }, + }) + .catch((err) => { + log.error("failed to send tool pending to ACP", { error: err }) + }) + break + case "running": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, + }, + }) + .catch((err) => { + log.error("failed to send tool in_progress to ACP", { error: err }) + }) + break + case "completed": + const kind = toToolKind(part.tool) + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, }, - }, - ], - rawOutput: { - error: part.state.error, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) - }) - break - } - } else if (part.type === "text") { - const delta = props.delta - if (delta && part.synthetic !== true) { - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: delta, - }, - }, - }) - .catch((err) => { - log.error("failed to send text to ACP", { error: err }) - }) - } - } else if (part.type === "reasoning") { - const delta = props.delta - if (delta) { - await this.connection - .sessionUpdate({ - sessionId: acpSession.id, - update: { - sessionUpdate: "agent_thought_chunk", - content: { - type: "text", - text: delta, - }, - }, - }) - .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) - }) + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = + typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = + typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + if (part.tool === "todowrite") { + const parsedTodos = z + .array(Todo.Info) + .safeParse(JSON.parse(part.state.output)) + if (parsedTodos.success) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: parsedTodos.data.map((todo) => { + const status: PlanEntry["status"] = + todo.status === "cancelled" + ? "completed" + : (todo.status as PlanEntry["status"]) + return { + priority: "medium", + status, + content: todo.content, + } + }), + }, + }) + .catch((err) => { + log.error("failed to send session update for todo", { error: err }) + }) + } else { + log.error("failed to parse todo output", { error: parsedTodos.error }) + } + } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "completed", + kind, + content, + title: part.state.title, + rawOutput: { + output: part.state.output, + metadata: part.state.metadata, + }, + }, + }) + .catch((err) => { + log.error("failed to send tool completed to ACP", { error: err }) + }) + break + case "error": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "failed", + content: [ + { + type: "content", + content: { + type: "text", + text: part.state.error, + }, + }, + ], + rawOutput: { + error: part.state.error, + }, + }, + }) + .catch((err) => { + log.error("failed to send tool error to ACP", { error: err }) + }) + break + } + } else if (part.type === "text") { + const delta = props.delta + if (delta && part.synthetic !== true) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((err) => { + log.error("failed to send text to ACP", { error: err }) + }) + } + } else if (part.type === "reasoning") { + const delta = props.delta + if (delta) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((err) => { + log.error("failed to send reasoning to ACP", { error: err }) + }) + } + } + } finally { + break + } } } }) @@ -364,19 +386,26 @@ export namespace ACP { } async newSession(params: NewSessionRequest) { + const directory = params.cwd try { - const model = await defaultModel(this.config) - const session = await this.sessionManager.create(params.cwd, params.mcpServers, model) + const model = await defaultModel(this.config, directory) + + // Store ACP session state + const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) + const sessionId = state.id + + log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) - log.info("creating_session", { mcpServers: params.mcpServers.length }) const load = await this.loadSession({ - cwd: params.cwd, + cwd: directory, mcpServers: params.mcpServers, - sessionId: session.id, + sessionId, }) + this.setupEventSubscriptions(state) + return { - sessionId: session.id, + sessionId, models: load.models, modes: load.modes, _meta: {}, @@ -393,26 +422,47 @@ export namespace ACP { } async loadSession(params: LoadSessionRequest) { - const model = await defaultModel(this.config) + const directory = params.cwd + const model = await defaultModel(this.config, directory) const sessionId = params.sessionId - const providers = await Provider.list() - const entries = Object.entries(providers).sort((a, b) => { - const nameA = a[1].info.name.toLowerCase() - const nameB = b[1].info.name.toLowerCase() + const providers = await this.sdk.config + .providers({ throwOnError: true, query: { directory } }) + .then((x) => x.data.providers) + const entries = providers.sort((a, b) => { + const nameA = a.name.toLowerCase() + const nameB = b.name.toLowerCase() if (nameA < nameB) return -1 if (nameA > nameB) return 1 return 0 }) - const availableModels = entries.flatMap(([providerID, provider]) => { - const models = Provider.sort(Object.values(provider.info.models)) + const availableModels = entries.flatMap((provider) => { + const models = Provider.sort(Object.values(provider.models)) return models.map((model) => ({ - modelId: `${providerID}/${model.id}`, - name: `${provider.info.name}/${model.name}`, + modelId: `${provider.id}/${model.id}`, + name: `${provider.name}/${model.name}`, })) }) - const availableCommands = (await Command.list()).map((command) => ({ + const agents = await this.config.sdk.app + .agents({ + throwOnError: true, + query: { + directory, + }, + }) + .then((resp) => resp.data) + + const commands = await this.config.sdk.command + .list({ + throwOnError: true, + query: { + directory, + }, + }) + .then((resp) => resp.data) + + const availableCommands = commands.map((command) => ({ name: command.name, description: command.description ?? "", })) @@ -423,7 +473,7 @@ export namespace ACP { description: "compact the session", }) - const availableModes = (await Agents.list()) + const availableModes = agents .filter((agent) => agent.mode !== "subagent") .map((agent) => ({ id: agent.name, @@ -459,7 +509,18 @@ export namespace ACP { await Promise.all( Object.entries(mcpServers).map(async ([key, mcp]) => { - await MCP.add(key, mcp) + await this.sdk.mcp + .add({ + throwOnError: true, + query: { directory }, + body: { + name: key, + config: mcp, + }, + }) + .catch((error) => { + log.error("failed to add mcp server", { name: key, error }) + }) }), ) @@ -489,12 +550,8 @@ export namespace ACP { async setSessionModel(params: SetSessionModelRequest) { const session = this.sessionManager.get(params.sessionId) - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`) - } - const parsed = Provider.parseModel(params.modelId) - const model = await Provider.getModel(parsed.providerID, parsed.modelID) + const model = Provider.parseModel(params.modelId) this.sessionManager.setModel(session.id, { providerID: model.providerID, @@ -507,31 +564,32 @@ export namespace ACP { } async setSessionMode(params: SetSessionModeRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`) - } - await Agents.get(params.modeId).then((agent) => { - if (!agent) throw new Error(`Agent not found: ${params.modeId}`) - }) + this.sessionManager.get(params.sessionId) + await this.config.sdk.app + .agents({ throwOnError: true }) + .then((x) => x.data) + .then((agent) => { + if (!agent) throw new Error(`Agent not found: ${params.modeId}`) + }) this.sessionManager.setMode(params.sessionId, params.modeId) } async prompt(params: PromptRequest) { const sessionID = params.sessionId - const acpSession = this.sessionManager.get(sessionID) - if (!acpSession) { - throw new Error(`Session not found: ${sessionID}`) - } + const session = this.sessionManager.get(sessionID) + const directory = session.cwd - const current = acpSession.model - const model = current ?? (await defaultModel(this.config)) + const current = session.model + const model = current ?? (await defaultModel(this.config, directory)) if (!current) { - this.sessionManager.setModel(acpSession.id, model) + this.sessionManager.setModel(session.id, model) } - const agent = acpSession.modeId ?? "build" + const agent = session.modeId ?? "build" - const parts: SessionPrompt.PromptInput["parts"] = [] + const parts: Array< + | { type: "text"; text: string } + | { type: "file"; url: string; filename: string; mime: string } + > = [] for (const part of params.prompt) { switch (part.type) { case "text": @@ -545,12 +603,14 @@ export namespace ACP { parts.push({ type: "file", url: `data:${part.mimeType};base64,${part.data}`, + filename: "image", mime: part.mimeType, }) } else if (part.uri && part.uri.startsWith("http:")) { parts.push({ type: "file", url: part.uri, + filename: "image", mime: part.mimeType, }) } @@ -581,7 +641,7 @@ export namespace ACP { const cmd = (() => { const text = parts - .filter((p) => p.type === "text") + .filter((p): p is { type: "text"; text: string } => p.type === "text") .map((p) => p.text) .join("") .trim() @@ -598,36 +658,50 @@ export namespace ACP { } if (!cmd) { - await SessionPrompt.prompt({ - sessionID, - model: { - providerID: model.providerID, - modelID: model.modelID, + await this.sdk.session.prompt({ + path: { id: sessionID }, + body: { + model: { + providerID: model.providerID, + modelID: model.modelID, + }, + parts, + agent, + }, + query: { + directory, }, - parts, - agent, }) return done } - const command = await Command.get(cmd.name) + const command = await this.config.sdk.command + .list({ throwOnError: true, query: { directory } }) + .then((x) => x.data.find((c) => c.name === cmd.name)) if (command) { - await SessionPrompt.command({ - sessionID, - command: command.name, - arguments: cmd.args, - model: model.providerID + "/" + model.modelID, - agent, + await this.sdk.session.command({ + path: { id: sessionID }, + body: { + command: command.name, + arguments: cmd.args, + model: model.providerID + "/" + model.modelID, + agent, + }, + query: { + directory, + }, }) return done } switch (cmd.name) { case "compact": - await SessionCompaction.run({ - sessionID, - providerID: model.providerID, - modelID: model.modelID, + await this.config.sdk.session.summarize({ + path: { id: sessionID }, + throwOnError: true, + query: { + directory, + }, }) break } @@ -636,7 +710,14 @@ export namespace ACP { } async cancel(params: CancelNotification) { - SessionLock.abort(params.sessionId) + const session = this.sessionManager.get(params.sessionId) + await this.config.sdk.session.abort({ + path: { id: params.sessionId }, + throwOnError: true, + query: { + directory: session.cwd, + }, + }) } } @@ -687,12 +768,15 @@ export namespace ACP { } } - async function defaultModel(config: ACPConfig) { + async function defaultModel(config: ACPConfig, cwd?: string) { + const sdk = config.sdk const configured = config.defaultModel if (configured) return configured - const model = await Config.get() - .then((cfg) => { + const model = await sdk.config + .get({ throwOnError: true, query: { directory: cwd } }) + .then((resp) => { + const cfg = resp.data if (!cfg.model) return undefined const parsed = Provider.parseModel(cfg.model) return { diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index d3ab73d2..eb9dd522 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,66 +1,74 @@ -import type { McpServer } from "@agentclientprotocol/sdk" -import { Session } from "../session" -import { Provider } from "../provider/provider" +import { RequestError, type McpServer } from "@agentclientprotocol/sdk" import type { ACPSessionState } from "./types" +import { Log } from "@/util/log" +import type { OpencodeClient } from "@opencode-ai/sdk" + +const log = Log.create({ service: "acp-session-manager" }) export class ACPSessionManager { private sessions = new Map() + private sdk: OpencodeClient + + constructor(sdk: OpencodeClient) { + this.sdk = sdk + } async create( cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"], ): Promise { - const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` }) + const session = await this.sdk.session + .create({ + body: { + title: `ACP Session ${crypto.randomUUID()}`, + }, + query: { + directory: cwd, + }, + throwOnError: true, + }) + .then((x) => x.data) + const sessionId = session.id - const resolvedModel = model ?? (await Provider.defaultModel()) + const resolvedModel = model const state: ACPSessionState = { id: sessionId, - parentId: session.parentID, cwd, mcpServers, createdAt: new Date(), model: resolvedModel, } + log.info("creating_session", { state }) this.sessions.set(sessionId, state) return state } - get(sessionId: string) { - return this.sessions.get(sessionId) - } - - async remove(sessionId: string) { - const state = this.sessions.get(sessionId) - if (!state) return - - await Session.remove(sessionId).catch(() => {}) - this.sessions.delete(sessionId) - } - - has(sessionId: string) { - return this.sessions.has(sessionId) + get(sessionId: string): ACPSessionState { + const session = this.sessions.get(sessionId) + if (!session) { + log.error("session not found", { sessionId }) + throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` })) + } + return session } getModel(sessionId: string) { - const session = this.sessions.get(sessionId) - if (!session) return + const session = this.get(sessionId) return session.model } setModel(sessionId: string, model: ACPSessionState["model"]) { - const session = this.sessions.get(sessionId) - if (!session) return + const session = this.get(sessionId) session.model = model this.sessions.set(sessionId, session) return session } setMode(sessionId: string, modeId: string) { - const session = this.sessions.get(sessionId) - if (!session) return + const session = this.get(sessionId) session.modeId = modeId this.sessions.set(sessionId, session) return session diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 119b335c..8507228e 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -1,12 +1,12 @@ import type { McpServer } from "@agentclientprotocol/sdk" +import type { OpencodeClient } from "@opencode-ai/sdk" export interface ACPSessionState { id: string - parentId?: string cwd: string mcpServers: McpServer[] createdAt: Date - model: { + model?: { providerID: string modelID: string } @@ -14,6 +14,7 @@ export interface ACPSessionState { } export interface ACPConfig { + sdk: OpencodeClient defaultModel?: { providerID: string modelID: string diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 77ef0c60..7d27f941 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -3,6 +3,8 @@ import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" +import { Server } from "@/server/server" +import { createOpencodeClient } from "@opencode-ai/sdk" const log = Log.create({ service: "acp-command" }) @@ -17,15 +19,34 @@ export const AcpCommand = cmd({ command: "acp", describe: "Start ACP (Agent Client Protocol) server", builder: (yargs) => { - return yargs.option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) + return yargs + .option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) + .option("port", { + type: "number", + describe: "port to listen on", + default: 0, + }) + .option("hostname", { + type: "string", + describe: "hostname to listen on", + default: "127.0.0.1", + }) }, - handler: async (opts) => { - if (opts.cwd) process.chdir(opts["cwd"]) + handler: async (args) => { await bootstrap(process.cwd(), async () => { + const server = Server.listen({ + port: args.port, + hostname: args.hostname, + }) + + const sdk = createOpencodeClient({ + baseUrl: `http://${server.hostname}:${server.port}`, + }) + const input = new WritableStream({ write(chunk) { return new Promise((resolve, reject) => { @@ -50,10 +71,10 @@ export const AcpCommand = cmd({ }) const stream = ndJsonStream(input, output) - const agent = await ACP.init() + const agent = await ACP.init({ sdk }) new AgentSideConnection((conn) => { - return agent.create(conn, {}) + return agent.create(conn, { sdk }) }, stream) log.info("setup connection") diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index fbc88703..eddd1924 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -92,13 +92,28 @@ export namespace MCP { export async function add(name: string, mcp: Config.Mcp) { const s = await state() const result = await create(name, mcp) - if (!result) return + if (!result) { + const status = { + status: "failed" as const, + error: "unknown error", + } + s.status[name] = status + return { + status, + } + } if (!result.mcpClient) { s.status[name] = result.status - return + return { + status: s.status, + } } s.clients[name] = result.mcpClient s.status[name] = result.status + + return { + status: s.status, + } } async function create(key: string, mcp: Config.Mcp) { @@ -207,8 +222,12 @@ export namespace MCP { } } - const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {}) + const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch((err) => { + log.error("create() failed to get tools from client", { key, error: err }) + return undefined + }) if (!result) { + log.info("create() tools() returned nothing, closing client", { key }) await mcpClient.close().catch((error) => { log.error("Failed to close MCP client", { error, @@ -227,6 +246,7 @@ export namespace MCP { } } + log.info("create() successfully created client", { key, toolCount: Object.keys(result).length }) return { mcpClient, status, @@ -238,13 +258,18 @@ export namespace MCP { } export async function clients() { - return state().then((state) => state.clients) + const s = await state() + log.info("clients() called", { clientCount: Object.keys(s.clients).length }) + return s.clients } export async function tools() { const result: Record = {} const s = await state() - for (const [clientName, client] of Object.entries(await clients())) { + log.info("tools() called", { clientCount: Object.keys(s.clients).length }) + const clientsSnapshot = await clients() + for (const [clientName, client] of Object.entries(clientsSnapshot)) { + log.info("tools() fetching tools for client", { clientName }) const tools = await client.tools().catch((e) => { log.error("failed to get tools", { clientName, error: e.message }) const failedStatus = { @@ -255,14 +280,17 @@ export namespace MCP { delete s.clients[clientName] }) if (!tools) { + log.info("tools() no tools returned for client", { clientName }) continue } + log.info("tools() got tools for client", { clientName, toolCount: Object.keys(tools).length }) for (const [toolName, tool] of Object.entries(tools)) { const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_") result[sanitizedClientName + "_" + sanitizedToolName] = tool } } + log.info("tools() final result", { toolCount: Object.keys(result).length }) return result } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 53d8b4dd..bfe804ae 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1359,6 +1359,36 @@ export namespace Server { return c.json(await MCP.status()) }, ) + .post( + "/mcp", + describeRoute({ + description: "Add MCP server dynamically", + operationId: "mcp.add", + responses: { + 200: { + description: "MCP server added successfully", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + name: z.string(), + config: Config.Mcp, + }), + ), + async (c) => { + const { name, config } = c.req.valid("json") + const result = await MCP.add(name, config) + return c.json(result.status) + }, + ) .get( "/lsp", describeRoute({ diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 1a54da8f..f902d91a 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -106,6 +106,9 @@ import type { AppAgentsResponses, McpStatusData, McpStatusResponses, + McpAddData, + McpAddResponses, + McpAddErrors, LspStatusData, LspStatusResponses, FormatterStatusData, @@ -764,6 +767,20 @@ class Mcp extends _HeyApiClient { ...options, }) } + + /** + * Add MCP server dynamically + */ + public add(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/mcp", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } } class Lsp extends _HeyApiClient { diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 5f565df5..2a0df302 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1979,9 +1979,9 @@ export type SessionMessagesData = { */ id: string } - query?: { + query: { directory?: string - limit?: number + limit: number } url: "/session/{id}/message" } @@ -2552,6 +2552,38 @@ export type McpStatusResponses = { export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] +export type McpAddData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } + path?: never + query?: { + directory?: string + } + url: "/mcp" +} + +export type McpAddErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpAddError = McpAddErrors[keyof McpAddErrors] + +export type McpAddResponses = { + /** + * MCP server added successfully + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpAddResponse = McpAddResponses[keyof McpAddResponses] + export type LspStatusData = { body?: never path?: never