acp: slash commands, agents, permissions, @ references, code cleanup (#3403)

Co-authored-by: yetone <yetoneful@gmail.com>
This commit is contained in:
Aiden Cline
2025-10-25 01:32:46 -05:00
committed by GitHub
parent 5fec5ff424
commit 98d51dde6a
10 changed files with 654 additions and 527 deletions

View File

@@ -1,204 +1,587 @@
import type {
Agent,
Agent as ACPAgent,
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
CancelNotification,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PermissionOption,
PromptRequest,
PromptResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
import { SessionPrompt } from "../session/prompt"
import { Identifier } from "../id/id"
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"
export class OpenCodeAgent implements Agent {
private log = Log.create({ service: "acp-agent" })
private sessionManager = new ACPSessionManager()
private connection: AgentSideConnection
private config: ACPConfig
export namespace ACP {
const log = Log.create({ service: "acp-agent" })
constructor(connection: AgentSideConnection, config: ACPConfig = {}) {
this.connection = connection
this.config = config
}
// TODO: mcp servers?
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
this.log.info("initialize", { protocolVersion: params.protocolVersion })
type ToolKind =
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other"
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: false,
},
_meta: {
opencode: {
version: await import("../installation").then((m) => m.Installation.VERSION),
export class Agent implements ACPAgent {
private sessionManager = new ACPSessionManager()
private connection: AgentSideConnection
private config: ACPConfig
constructor(connection: AgentSideConnection, config: ACPConfig = {}) {
this.connection = connection
this.config = config
this.setupEventSubscriptions()
}
private setupEventSubscriptions() {
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":
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
content: [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
],
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: acpSession.id,
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: 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 })
})
}
}
})
}
async initialize(params: InitializeRequest) {
log.info("initialize", { protocolVersion: params.protocolVersion })
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
// TODO: map acp mcp
// mcpCapabilities: {
// http: true,
// sse: true,
// },
},
},
authMethods: [
{
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
id: "opencode-login",
},
],
_meta: {
opencode: {
version: Installation.VERSION,
},
},
}
}
async authenticate(_params: AuthenticateRequest) {
throw new Error("Authentication not implemented")
}
async newSession(params: NewSessionRequest) {
const model = await defaultModel(this.config)
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
const load = await this.loadSession({
cwd: params.cwd,
mcpServers: params.mcpServers,
sessionId: session.id,
})
return {
sessionId: session.id,
models: load.models,
modes: load.modes,
_meta: {},
}
}
async loadSession(params: LoadSessionRequest) {
const model = await defaultModel(this.config)
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()
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))
return models.map((model) => ({
modelId: `${providerID}/${model.id}`,
name: `${provider.info.name}/${model.name}`,
}))
})
const availableCommands = (await Command.list()).map((command) => ({
name: command.name,
description: command.description ?? "",
}))
setTimeout(() => {
this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
})
}, 0)
const availableModes = (await Agents.list())
.filter((agent) => agent.mode !== "subagent")
.map((agent) => ({
id: agent.name,
name: agent.name,
description: agent.description,
}))
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
return {
sessionId,
models: {
currentModelId: `${model.providerID}/${model.modelID}`,
availableModels,
},
modes: {
availableModes,
currentModeId,
},
_meta: {},
}
}
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)
this.sessionManager.setModel(session.id, {
providerID: model.providerID,
modelID: model.modelID,
})
return {
_meta: {},
}
}
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.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 current = acpSession.model
const model = current ?? (await defaultModel(this.config))
if (!current) {
this.sessionManager.setModel(acpSession.id, model)
}
const agent = acpSession.modeId ?? "build"
const parts: SessionPrompt.PromptInput["parts"] = []
for (const part of params.prompt) {
switch (part.type) {
case "text":
parts.push({
type: "text" as const,
text: part.text,
})
break
case "image":
if (part.data) {
parts.push({
type: "file",
url: `data:${part.mimeType};base64,${part.data}`,
mime: part.mimeType,
})
} else if (part.uri && part.uri.startsWith("http:")) {
parts.push({
type: "file",
url: part.uri,
mime: part.mimeType,
})
}
break
case "resource_link":
const parsed = parseUri(part.uri)
parts.push(parsed)
break
case "resource":
const resource = part.resource
if ("text" in resource) {
parts.push({
type: "text",
text: resource.text,
})
}
break
default:
break
}
}
log.info("parts", { parts })
const cmd = await (async () => {
const text = parts.filter((part) => part.type === "text").join("")
const match = text.match(/^\/(\w+)\s*(.*)$/)
if (!match) return
const [c, args] = match.slice(1)
const command = await Command.get(c)
if (!command) return
return { command, args }
})()
if (cmd) {
await SessionPrompt.command({
sessionID,
command: cmd.command.name,
arguments: cmd.args,
agent,
})
} else {
await SessionPrompt.prompt({
sessionID,
model: {
providerID: model.providerID,
modelID: model.modelID,
},
parts,
agent,
})
}
return {
stopReason: "end_turn" as const,
_meta: {},
}
}
async cancel(params: CancelNotification) {
SessionLock.abort(params.sessionId)
}
}
async authenticate(params: AuthenticateRequest): Promise<void | AuthenticateResponse> {
this.log.info("authenticate", { methodId: params.methodId })
throw new Error("Authentication not yet implemented")
}
function toToolKind(toolName: string): ToolKind {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "bash":
return "execute"
case "webfetch":
return "fetch"
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
this.log.info("newSession", { cwd: params.cwd, mcpServers: params.mcpServers.length })
case "edit":
case "patch":
case "write":
return "edit"
const model = await this.defaultModel()
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
const availableModels = await this.availableModels()
case "grep":
case "glob":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
return {
sessionId: session.id,
models: {
currentModelId: `${model.providerID}/${model.modelID}`,
availableModels,
},
_meta: {},
case "list":
case "read":
return "read"
default:
return "other"
}
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
this.log.info("loadSession", { sessionId: params.sessionId, cwd: params.cwd })
const defaultModel = await this.defaultModel()
const session = await this.sessionManager.load(params.sessionId, params.cwd, params.mcpServers, defaultModel)
const availableModels = await this.availableModels()
return {
models: {
currentModelId: `${session.model.providerID}/${session.model.modelID}`,
availableModels,
},
_meta: {},
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "read":
case "edit":
case "write":
return input["filePath"] ? [{ path: input["filePath"] }] : []
case "glob":
case "grep":
return input["path"] ? [{ path: input["path"] }] : []
case "bash":
return []
case "list":
return input["path"] ? [{ path: input["path"] }] : []
default:
return []
}
}
async setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse> {
this.log.info("setSessionModel", { sessionId: params.sessionId, modelId: params.modelId })
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)
this.sessionManager.setModel(session.id, {
providerID: model.providerID,
modelID: model.modelID,
})
return {
_meta: {},
}
}
private async defaultModel() {
const configured = this.config.defaultModel
async function defaultModel(config: ACPConfig) {
const configured = config.defaultModel
if (configured) return configured
return Provider.defaultModel()
}
private async availableModels() {
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()
if (nameA < nameB) return -1
if (nameA > nameB) return 1
return 0
})
return entries.flatMap(([providerID, provider]) => {
const models = Provider.sort(Object.values(provider.info.models))
return models.map((model) => ({
modelId: `${providerID}/${model.id}`,
name: `${provider.info.name}/${model.name}`,
}))
})
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
this.log.info("prompt", {
sessionId: params.sessionId,
promptLength: params.prompt.length,
})
const acpSession = this.sessionManager.get(params.sessionId)
if (!acpSession) {
throw new Error(`Session not found: ${params.sessionId}`)
}
const current = acpSession.model
const model = current ?? (await this.defaultModel())
if (!current) {
this.sessionManager.setModel(acpSession.id, model)
}
const parts = params.prompt.map((content) => {
if (content.type === "text") {
function parseUri(
uri: string,
): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
try {
if (uri.startsWith("file://")) {
const path = uri.slice(7)
const name = path.split("/").pop() || path
return {
type: "text" as const,
text: content.text,
type: "file",
url: uri,
filename: name,
mime: "text/plain",
}
}
if (content.type === "resource") {
const resource = content.resource
let text = ""
if ("text" in resource && typeof resource.text === "string") {
text = resource.text
}
return {
type: "text" as const,
text,
if (uri.startsWith("zed://")) {
const url = new URL(uri)
const path = url.searchParams.get("path")
if (path) {
const name = path.split("/").pop() || path
return {
type: "file",
url: `file://${path}`,
filename: name,
mime: "text/plain",
}
}
}
return {
type: "text" as const,
text: JSON.stringify(content),
type: "text",
text: uri,
}
} catch {
return {
type: "text",
text: uri,
}
})
await SessionPrompt.prompt({
sessionID: acpSession.openCodeSessionId,
messageID: Identifier.ascending("message"),
model: {
providerID: model.providerID,
modelID: model.modelID,
},
parts,
acpConnection: {
connection: this.connection,
sessionId: params.sessionId,
},
})
this.log.debug("prompt response completed")
// Streaming notifications are now handled during prompt execution
// No need to send final text chunk here
return {
stopReason: "end_turn",
_meta: {},
}
}
async cancel(params: CancelNotification): Promise<void> {
this.log.info("cancel", { sessionId: params.sessionId })
}
}

View File

@@ -1,85 +0,0 @@
import type {
Client,
CreateTerminalRequest,
CreateTerminalResponse,
KillTerminalCommandRequest,
KillTerminalResponse,
ReadTextFileRequest,
ReadTextFileResponse,
ReleaseTerminalRequest,
ReleaseTerminalResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
TerminalOutputRequest,
TerminalOutputResponse,
WaitForTerminalExitRequest,
WaitForTerminalExitResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
export class ACPClient implements Client {
private log = Log.create({ service: "acp-client" })
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
this.log.debug("requestPermission", params)
const firstOption = params.options[0]
if (!firstOption) {
return { outcome: { outcome: "cancelled" } }
}
return {
outcome: {
outcome: "selected",
optionId: firstOption.optionId,
},
}
}
async sessionUpdate(params: SessionNotification): Promise<void> {
this.log.debug("sessionUpdate", { sessionId: params.sessionId })
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.log.debug("writeTextFile", { path: params.path })
await Bun.write(params.path, params.content)
return { _meta: {} }
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
this.log.debug("readTextFile", { path: params.path })
const file = Bun.file(params.path)
const exists = await file.exists()
if (!exists) {
throw new Error(`File not found: ${params.path}`)
}
const content = await file.text()
return { content, _meta: {} }
}
async createTerminal(params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
this.log.debug("createTerminal", params)
throw new Error("Terminal support not yet implemented")
}
async terminalOutput(params: TerminalOutputRequest): Promise<TerminalOutputResponse> {
this.log.debug("terminalOutput", params)
throw new Error("Terminal support not yet implemented")
}
async releaseTerminal(params: ReleaseTerminalRequest): Promise<void | ReleaseTerminalResponse> {
this.log.debug("releaseTerminal", params)
throw new Error("Terminal support not yet implemented")
}
async waitForTerminalExit(params: WaitForTerminalExitRequest): Promise<WaitForTerminalExitResponse> {
this.log.debug("waitForTerminalExit", params)
throw new Error("Terminal support not yet implemented")
}
async killTerminal(params: KillTerminalCommandRequest): Promise<void | KillTerminalResponse> {
this.log.debug("killTerminal", params)
throw new Error("Terminal support not yet implemented")
}
}

View File

@@ -1,53 +0,0 @@
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { OpenCodeAgent } from "./agent"
export namespace ACPServer {
const log = Log.create({ service: "acp-server" })
export async function start() {
await Instance.provide({
directory: process.cwd(),
fn: async () => {
log.info("starting ACP server", { cwd: process.cwd() })
const stdout = new WritableStream({
write(chunk) {
process.stdout.write(chunk)
},
})
const stdin = new ReadableStream({
start(controller) {
process.stdin.on("data", (chunk) => {
controller.enqueue(new Uint8Array(chunk))
})
process.stdin.on("end", () => {
controller.close()
})
},
})
const stream = ndJsonStream(stdout, stdin)
new AgentSideConnection((conn) => {
return new OpenCodeAgent(conn)
}, stream)
await new Promise<void>((resolve) => {
process.on("SIGTERM", () => {
log.info("received SIGTERM")
resolve()
})
process.on("SIGINT", () => {
log.info("received SIGINT")
resolve()
})
})
log.info("ACP server stopped")
},
})
}
}

View File

@@ -7,20 +7,15 @@ import type { ACPSessionState } from "./types"
export class ACPSessionManager {
private sessions = new Map<string, ACPSessionState>()
async create(
cwd: string,
mcpServers: McpServer[],
model?: ACPSessionState["model"],
): Promise<ACPSessionState> {
const sessionId = `acp_${Identifier.ascending("session")}`
const openCodeSession = await Session.create({ title: `ACP Session ${sessionId}` })
async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> {
const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` })
const sessionId = session.id
const resolvedModel = model ?? (await Provider.defaultModel())
const state: ACPSessionState = {
id: sessionId,
cwd,
mcpServers,
openCodeSessionId: openCodeSession.id,
createdAt: new Date(),
model: resolvedModel,
}
@@ -29,54 +24,22 @@ export class ACPSessionManager {
return state
}
get(sessionId: string): ACPSessionState | undefined {
get(sessionId: string) {
return this.sessions.get(sessionId)
}
async remove(sessionId: string): Promise<void> {
async remove(sessionId: string) {
const state = this.sessions.get(sessionId)
if (!state) return
await Session.remove(state.openCodeSessionId).catch(() => {})
await Session.remove(sessionId).catch(() => {})
this.sessions.delete(sessionId)
}
has(sessionId: string): boolean {
has(sessionId: string) {
return this.sessions.has(sessionId)
}
async load(
sessionId: string,
cwd: string,
mcpServers: McpServer[],
model?: ACPSessionState["model"],
): Promise<ACPSessionState> {
const existing = this.sessions.get(sessionId)
if (existing) {
if (!existing.model) {
const resolved = model ?? (await Provider.defaultModel())
existing.model = resolved
this.sessions.set(sessionId, existing)
}
return existing
}
const openCodeSession = await Session.create({ title: `ACP Session ${sessionId} (loaded)` })
const resolvedModel = model ?? (await Provider.defaultModel())
const state: ACPSessionState = {
id: sessionId,
cwd,
mcpServers,
openCodeSessionId: openCodeSession.id,
createdAt: new Date(),
model: resolvedModel,
}
this.sessions.set(sessionId, state)
return state
}
getModel(sessionId: string) {
const session = this.sessions.get(sessionId)
if (!session) return
@@ -90,4 +53,12 @@ export class ACPSessionManager {
this.sessions.set(sessionId, session)
return session
}
setMode(sessionId: string, modeId: string) {
const session = this.sessions.get(sessionId)
if (!session) return
session.modeId = modeId
this.sessions.set(sessionId, session)
return session
}
}

View File

@@ -4,12 +4,12 @@ export interface ACPSessionState {
id: string
cwd: string
mcpServers: McpServer[]
openCodeSessionId: string
createdAt: Date
model: {
providerID: string
modelID: string
}
modeId?: string
}
export interface ACPConfig {