Просмотр исходного кода

rework acp to compensate for changes in Zed IDE (#4050)

Aiden Cline 3 месяцев назад
Родитель
Сommit
73cd8a334c

+ 36 - 0
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"]

+ 3 - 0
packages/extensions/zed/icons/opencode.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M13 14H3V2H13V14ZM10.5 4.4H5.5V11.6H10.5V4.4Z" fill="#C4CAD4"/>
+</svg>

+ 401 - 317
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<MessageV2.Info>([
-          "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,
                     })
-                    .catch((err) => {
-                      log.error("failed to send session update for todo", { error: err })
+                    await this.config.sdk.postSessionIdPermissionsPermissionId({
+                      path: { id: permission.sessionID, permissionID: permission.id },
+                      body: {
+                        response: "reject",
+                      },
+                      query: { directory },
                     })
-                } 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<SetSessionModeResponse | void> {
-      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 {

+ 34 - 26
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<string, ACPSessionState>()
+  private sdk: OpencodeClient
+
+  constructor(sdk: OpencodeClient) {
+    this.sdk = sdk
+  }
 
   async create(
     cwd: string,
     mcpServers: McpServer[],
     model?: ACPSessionState["model"],
   ): Promise<ACPSessionState> {
-    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

+ 3 - 2
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

+ 30 - 9
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<Uint8Array>({
         write(chunk) {
           return new Promise<void>((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")

+ 33 - 5
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<string, Tool> = {}
     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
   }
 }

+ 30 - 0
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({

+ 17 - 0
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<ThrowOnError extends boolean = false>(options?: Options<McpAddData, ThrowOnError>) {
+    return (options?.client ?? this._client).post<McpAddResponses, McpAddErrors, ThrowOnError>({
+      url: "/mcp",
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+      },
+    })
+  }
 }
 
 class Lsp extends _HeyApiClient {

+ 34 - 2
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