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