From 1291e82bb4d881a1c0ac5cb882da2b97a169ae9d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 20:00:50 -0400 Subject: [PATCH] refactor: unwrap ACP namespace + self-reexport (#22936) --- packages/opencode/src/acp/agent.ts | 3030 ++++++++++++++-------------- 1 file changed, 1515 insertions(+), 1515 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 5d8c723ea7..7180feabcb 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -57,793 +57,262 @@ type ModelOption = { modelId: string; name: string } const DEFAULT_VARIANT_VALUE = "default" -export namespace ACP { - const log = Log.create({ service: "acp-agent" }) +const log = Log.create({ service: "acp-agent" }) - async function getContextLimit( - sdk: OpencodeClient, - providerID: ProviderID, - modelID: ModelID, - directory: string, - ): Promise { - const providers = await sdk.config - .providers({ directory }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to get providers for context limit", { error }) - return [] - }) +async function getContextLimit( + sdk: OpencodeClient, + providerID: ProviderID, + modelID: ModelID, + directory: string, +): Promise { + const providers = await sdk.config + .providers({ directory }) + .then((x) => x.data?.providers ?? []) + .catch((error) => { + log.error("failed to get providers for context limit", { error }) + return [] + }) - const provider = providers.find((p) => p.id === providerID) - const model = provider?.models[modelID] - return model?.limit.context ?? null + const provider = providers.find((p) => p.id === providerID) + const model = provider?.models[modelID] + return model?.limit.context ?? null +} + +async function sendUsageUpdate( + connection: AgentSideConnection, + sdk: OpencodeClient, + sessionID: string, + directory: string, +): Promise { + const messages = await sdk.session + .messages({ sessionID, directory }, { throwOnError: true }) + .then((x) => x.data) + .catch((error) => { + log.error("failed to fetch messages for usage update", { error }) + return undefined + }) + + if (!messages) return + + const assistantMessages = messages.filter( + (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant", + ) + + const lastAssistant = assistantMessages[assistantMessages.length - 1] + if (!lastAssistant) return + + const msg = lastAssistant.info + if (!msg.providerID || !msg.modelID) return + const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory) + + if (!size) { + // Cannot calculate usage without known context size + return } - async function sendUsageUpdate( - connection: AgentSideConnection, - sdk: OpencodeClient, - sessionID: string, - directory: string, - ): Promise { - const messages = await sdk.session - .messages({ sessionID, directory }, { throwOnError: true }) - .then((x) => x.data) - .catch((error) => { - log.error("failed to fetch messages for usage update", { error }) - return undefined - }) + const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0) + const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0) - if (!messages) return - - const assistantMessages = messages.filter( - (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant", - ) - - const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (!lastAssistant) return - - const msg = lastAssistant.info - if (!msg.providerID || !msg.modelID) return - const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory) - - if (!size) { - // Cannot calculate usage without known context size - return - } - - const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0) - const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0) - - await connection - .sessionUpdate({ - sessionId: sessionID, - update: { - sessionUpdate: "usage_update", - used, - size, - cost: { amount: totalCost, currency: "USD" }, - }, - }) - .catch((error) => { - log.error("failed to send usage update", { error }) - }) - } - - export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) { - return { - create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { - return new Agent(connection, fullConfig) + await connection + .sessionUpdate({ + sessionId: sessionID, + update: { + sessionUpdate: "usage_update", + used, + size, + cost: { amount: totalCost, currency: "USD" }, }, - } + }) + .catch((error) => { + log.error("failed to send usage update", { error }) + }) +} + +export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) { + return { + create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { + return new Agent(connection, fullConfig) + }, + } +} + +export class Agent implements ACPAgent { + private connection: AgentSideConnection + private config: ACPConfig + private sdk: OpencodeClient + private sessionManager: ACPSessionManager + private eventAbort = new AbortController() + private eventStarted = false + private bashSnapshots = new Map() + private toolStarts = new Set() + private permissionQueues = new Map>() + private permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ] + + constructor(connection: AgentSideConnection, config: ACPConfig) { + this.connection = connection + this.config = config + this.sdk = config.sdk + this.sessionManager = new ACPSessionManager(this.sdk) + this.startEventSubscription() } - export class Agent implements ACPAgent { - private connection: AgentSideConnection - private config: ACPConfig - private sdk: OpencodeClient - private sessionManager: ACPSessionManager - private eventAbort = new AbortController() - private eventStarted = false - private bashSnapshots = new Map() - private toolStarts = new Set() - private permissionQueues = new Map>() - private permissionOptions: PermissionOption[] = [ - { optionId: "once", kind: "allow_once", name: "Allow once" }, - { optionId: "always", kind: "allow_always", name: "Always allow" }, - { optionId: "reject", kind: "reject_once", name: "Reject" }, - ] + private startEventSubscription() { + if (this.eventStarted) return + this.eventStarted = true + this.runEventSubscription().catch((error) => { + if (this.eventAbort.signal.aborted) return + log.error("event subscription failed", { error }) + }) + } - constructor(connection: AgentSideConnection, config: ACPConfig) { - this.connection = connection - this.config = config - this.sdk = config.sdk - this.sessionManager = new ACPSessionManager(this.sdk) - this.startEventSubscription() - } - - private startEventSubscription() { - if (this.eventStarted) return - this.eventStarted = true - this.runEventSubscription().catch((error) => { - if (this.eventAbort.signal.aborted) return - log.error("event subscription failed", { error }) + private async runEventSubscription() { + while (true) { + if (this.eventAbort.signal.aborted) return + const events = await this.sdk.global.event({ + signal: this.eventAbort.signal, }) - } - - private async runEventSubscription() { - while (true) { + for await (const event of events.stream) { if (this.eventAbort.signal.aborted) return - const events = await this.sdk.global.event({ - signal: this.eventAbort.signal, + const payload = event?.payload + if (!payload) continue + await this.handleEvent(payload as Event).catch((error) => { + log.error("failed to handle event", { error, type: payload.type }) }) - for await (const event of events.stream) { - if (this.eventAbort.signal.aborted) return - const payload = event?.payload - if (!payload) continue - await this.handleEvent(payload as Event).catch((error) => { - log.error("failed to handle event", { error, type: payload.type }) - }) - } } } + } - private async handleEvent(event: Event) { - switch (event.type) { - case "permission.asked": { - const permission = event.properties - const session = this.sessionManager.tryGet(permission.sessionID) - if (!session) return + private async handleEvent(event: Event) { + switch (event.type) { + case "permission.asked": { + const permission = event.properties + const session = this.sessionManager.tryGet(permission.sessionID) + if (!session) return - const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() - const next = prev - .then(async () => { - const directory = session.cwd + const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() + const next = prev + .then(async () => { + const directory = session.cwd - const res = await this.connection - .requestPermission({ - sessionId: permission.sessionID, - toolCall: { - toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, - options: this.permissionOptions, + const res = await this.connection + .requestPermission({ + sessionId: permission.sessionID, + toolCall: { + toolCallId: permission.tool?.callID ?? permission.id, + status: "pending", + title: permission.permission, + rawInput: permission.metadata, + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), + }, + options: this.permissionOptions, + }) + .catch(async (error) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, }) - .catch(async (error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - await this.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return undefined - }) - - if (!res) return - if (res.outcome.outcome !== "selected") { await this.sdk.permission.reply({ requestID: permission.id, reply: "reject", directory, }) - return - } - - if (res.outcome.optionId !== "reject" && permission.permission == "edit") { - const metadata = permission.metadata || {} - const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" - const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : "" - const newContent = getNewContent(content, diff) - - if (newContent) { - void this.connection.writeTextFile({ - sessionId: session.id, - path: filepath, - content: newContent, - }) - } - } + return undefined + }) + if (!res) return + if (res.outcome.outcome !== "selected") { await this.sdk.permission.reply({ requestID: permission.id, - reply: res.outcome.optionId as "once" | "always" | "reject", + reply: "reject", directory, }) - }) - .catch((error) => { - log.error("failed to handle permission", { error, permissionID: permission.id }) - }) - .finally(() => { - if (this.permissionQueues.get(permission.sessionID) === next) { - this.permissionQueues.delete(permission.sessionID) + return + } + + if (res.outcome.optionId !== "reject" && permission.permission == "edit") { + const metadata = permission.metadata || {} + const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" + const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" + const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : "" + const newContent = getNewContent(content, diff) + + if (newContent) { + void this.connection.writeTextFile({ + sessionId: session.id, + path: filepath, + content: newContent, + }) } + } + + await this.sdk.permission.reply({ + requestID: permission.id, + reply: res.outcome.optionId as "once" | "always" | "reject", + directory, }) - this.permissionQueues.set(permission.sessionID, next) - return - } + }) + .catch((error) => { + log.error("failed to handle permission", { error, permissionID: permission.id }) + }) + .finally(() => { + if (this.permissionQueues.get(permission.sessionID) === next) { + this.permissionQueues.delete(permission.sessionID) + } + }) + this.permissionQueues.set(permission.sessionID, next) + return + } - case "message.part.updated": { - log.info("message part updated", { event: event.properties }) - const props = event.properties - const part = props.part - const session = this.sessionManager.tryGet(part.sessionID) - if (!session) return - const sessionId = session.id + case "message.part.updated": { + log.info("message part updated", { event: event.properties }) + const props = event.properties + const part = props.part + const session = this.sessionManager.tryGet(part.sessionID) + if (!session) return + const sessionId = session.id - if (part.type === "tool") { - await this.toolStart(sessionId, part) + if (part.type === "tool") { + await this.toolStart(sessionId, part) - switch (part.state.status) { - case "pending": - this.bashSnapshots.delete(part.callID) - return + switch (part.state.status) { + case "pending": + this.bashSnapshots.delete(part.callID) + return - case "running": - const output = this.bashOutput(part) - const content: ToolCallContent[] = [] - if (output) { - const hash = Hash.fast(output) - if (part.tool === "bash") { - if (this.bashSnapshots.get(part.callID) === hash) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - } - this.bashSnapshots.set(part.callID, hash) - } - content.push({ - type: "content", - content: { - type: "text", - text: output, - }, - }) - } - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - ...(content.length > 0 && { content }), - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - - case "completed": { - this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) - 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) { + case "running": + const output = this.bashOutput(part) + const content: ToolCallContent[] = [] + if (output) { + const hash = Hash.fast(output) + if (part.tool === "bash") { + if (this.bashSnapshots.get(part.callID) === hash) { 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, - } - }), + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + kind: toToolKind(part.tool), + title: part.tool, + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, }, }) .catch((error) => { - log.error("failed to send session update for todo", { error }) + log.error("failed to send tool in_progress to ACP", { error }) }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.error }) + return } + this.bashSnapshots.set(part.callID, hash) } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, - }, - }) - .catch((error) => { - log.error("failed to send tool completed to ACP", { error }) - }) - return - } - case "error": - this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - metadata: part.state.metadata, - }, - }, - }) - .catch((error) => { - log.error("failed to send tool error to ACP", { error }) - }) - return - } - } - - // ACP clients already know the prompt they just submitted, so replaying - // live user parts duplicates the message. We still replay user history in - // loadSession() and forkSession() via processMessage(). - if (part.type !== "text" && part.type !== "file") return - - return - } - - case "message.part.delta": { - const props = event.properties - const session = this.sessionManager.tryGet(props.sessionID) - if (!session) return - const sessionId = session.id - - const message = await this.sdk.session - .message( - { - sessionID: props.sessionID, - messageID: props.messageID, - directory: session.cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((error) => { - log.error("unexpected error when fetching message", { error }) - return undefined - }) - - if (!message || message.info.role !== "assistant") return - - const part = message.parts.find((p) => p.id === props.partID) - if (!part) return - - if (part.type === "text" && props.field === "text" && part.ignored !== true) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send text delta to ACP", { error }) - }) - return - } - - if (part.type === "reasoning" && props.field === "text") { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send reasoning delta to ACP", { error }) - }) - } - return - } - } - } - - async initialize(params: InitializeRequest): Promise { - log.info("initialize", { protocolVersion: params.protocolVersion }) - - const authMethod: AuthMethod = { - description: "Run `opencode auth login` in the terminal", - name: "Login with opencode", - id: "opencode-login", - } - - // If client supports terminal-auth capability, use that instead. - if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { - authMethod._meta = { - "terminal-auth": { - command: "opencode", - args: ["auth", "login"], - label: "OpenCode Login", - }, - } - } - - return { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - mcpCapabilities: { - http: true, - sse: true, - }, - promptCapabilities: { - embeddedContext: true, - image: true, - }, - sessionCapabilities: { - fork: {}, - list: {}, - resume: {}, - }, - }, - authMethods: [authMethod], - agentInfo: { - name: "OpenCode", - version: InstallationVersion, - }, - } - } - - async authenticate(_params: AuthenticateRequest) { - throw new Error("Authentication not implemented") - } - - async newSession(params: NewSessionRequest) { - const directory = params.cwd - try { - 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 }) - - const load = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - return { - sessionId, - configOptions: load.configOptions, - models: load.models, - modes: load.modes, - _meta: load._meta, - } - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - async loadSession(params: LoadSessionRequest) { - const directory = params.cwd - const sessionId = params.sessionId - - try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) - - log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - // Replay session history - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - - const lastUser = messages?.findLast((m) => m.info.role === "user")?.info - if (lastUser?.role === "user") { - result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` - this.sessionManager.setModel(sessionId, { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - }) - if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { - result.modes.currentModeId = lastUser.agent - this.sessionManager.setMode(sessionId, lastUser.agent) - } - result.configOptions = buildConfigOptions({ - currentModelId: result.models.currentModelId, - availableModels: result.models.availableModels, - modes: result.modes, - }) - } - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - async listSessions(params: ListSessionsRequest): Promise { - try { - const cursor = params.cursor ? Number(params.cursor) : undefined - const limit = 100 - - const sessions = await this.sdk.session - .list( - { - directory: params.cwd ?? undefined, - roots: true, - }, - { throwOnError: true }, - ) - .then((x) => x.data ?? []) - - const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) - const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted - const page = filtered.slice(0, limit) - - const entries: SessionInfo[] = page.map((session) => ({ - sessionId: session.id, - cwd: session.directory, - title: session.title, - updatedAt: new Date(session.time.updated).toISOString(), - })) - - const last = page[page.length - 1] - const next = filtered.length > limit && last ? String(last.time.updated) : undefined - - const response: ListSessionsResponse = { - sessions: entries, - } - if (next) response.nextCursor = next - return response - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - async unstable_forkSession(params: ForkSessionRequest): Promise { - const directory = params.cwd - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - - const forked = await this.sdk.session - .fork( - { - sessionID: params.sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - - if (!forked) { - throw new Error("Fork session returned no data") - } - - const sessionId = forked.id - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) - - const mode = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return mode - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - async unstable_resumeSession(params: ResumeSessionRequest): Promise { - const directory = params.cwd - const sessionId = params.sessionId - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } - } - - private async processMessage(message: SessionMessageResponse) { - log.debug("process message", message) - if (message.info.role !== "assistant" && message.info.role !== "user") return - const sessionId = message.info.sessionID - - for (const part of message.parts) { - if (part.type === "tool") { - await this.toolStart(sessionId, part) - switch (part.state.status) { - case "pending": - this.bashSnapshots.delete(part.callID) - break - case "running": - const output = this.bashOutput(part) - const runningContent: ToolCallContent[] = [] - if (output) { - runningContent.push({ + content.push({ type: "content", content: { type: "text", @@ -862,14 +331,15 @@ export namespace ACP { title: part.tool, locations: toLocations(part.tool, part.state.input), rawInput: part.state.input, - ...(runningContent.length > 0 && { content: runningContent }), + ...(content.length > 0 && { content }), }, }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) + .catch((error) => { + log.error("failed to send tool in_progress to ACP", { error }) }) - break - case "completed": + return + + case "completed": { this.toolStarts.delete(part.callID) this.bashSnapshots.delete(part.callID) const kind = toToolKind(part.tool) @@ -920,8 +390,8 @@ export namespace ACP { }), }, }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) + .catch((error) => { + log.error("failed to send session update for todo", { error }) }) } else { log.error("failed to parse todo output", { error: parsedTodos.error }) @@ -945,10 +415,11 @@ export namespace ACP { }, }, }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) + .catch((error) => { + log.error("failed to send tool completed to ACP", { error }) }) - break + return + } case "error": this.toolStarts.delete(part.callID) this.bashSnapshots.delete(part.callID) @@ -977,865 +448,1394 @@ export namespace ACP { }, }, }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) + .catch((error) => { + log.error("failed to send tool error to ACP", { error }) }) - break + return } - } else if (part.type === "text") { - if (part.text) { - const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined + } + + // ACP clients already know the prompt they just submitted, so replaying + // live user parts duplicates the message. We still replay user history in + // loadSession() and forkSession() via processMessage(). + if (part.type !== "text" && part.type !== "file") return + + return + } + + case "message.part.delta": { + const props = event.properties + const session = this.sessionManager.tryGet(props.sessionID) + if (!session) return + const sessionId = session.id + + const message = await this.sdk.session + .message( + { + sessionID: props.sessionID, + messageID: props.messageID, + directory: session.cwd, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((error) => { + log.error("unexpected error when fetching message", { error }) + return undefined + }) + + if (!message || message.info.role !== "assistant") return + + const part = message.parts.find((p) => p.id === props.partID) + if (!part) return + + if (part.type === "text" && props.field === "text" && part.ignored !== true) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + messageId: props.messageID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send text delta to ACP", { error }) + }) + return + } + + if (part.type === "reasoning" && props.field === "text") { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + messageId: props.messageID, + content: { + type: "text", + text: props.delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send reasoning delta to ACP", { error }) + }) + } + return + } + } + } + + async initialize(params: InitializeRequest): Promise { + log.info("initialize", { protocolVersion: params.protocolVersion }) + + const authMethod: AuthMethod = { + description: "Run `opencode auth login` in the terminal", + name: "Login with opencode", + id: "opencode-login", + } + + // If client supports terminal-auth capability, use that instead. + if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { + authMethod._meta = { + "terminal-auth": { + command: "opencode", + args: ["auth", "login"], + label: "OpenCode Login", + }, + } + } + + return { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + mcpCapabilities: { + http: true, + sse: true, + }, + promptCapabilities: { + embeddedContext: true, + image: true, + }, + sessionCapabilities: { + fork: {}, + list: {}, + resume: {}, + }, + }, + authMethods: [authMethod], + agentInfo: { + name: "OpenCode", + version: InstallationVersion, + }, + } + } + + async authenticate(_params: AuthenticateRequest) { + throw new Error("Authentication not implemented") + } + + async newSession(params: NewSessionRequest) { + const directory = params.cwd + try { + 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 }) + + const load = await this.loadSessionMode({ + cwd: directory, + mcpServers: params.mcpServers, + sessionId, + }) + + return { + sessionId, + configOptions: load.configOptions, + models: load.models, + modes: load.modes, + _meta: load._meta, + } + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + async loadSession(params: LoadSessionRequest) { + const directory = params.cwd + const sessionId = params.sessionId + + try { + const model = await defaultModel(this.config, directory) + + // Store ACP session state + await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + + log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) + + const result = await this.loadSessionMode({ + cwd: directory, + mcpServers: params.mcpServers, + sessionId, + }) + + // Replay session history + const messages = await this.sdk.session + .messages( + { + sessionID: sessionId, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((err) => { + log.error("unexpected error when fetching message", { error: err }) + return undefined + }) + + const lastUser = messages?.findLast((m) => m.info.role === "user")?.info + if (lastUser?.role === "user") { + result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` + this.sessionManager.setModel(sessionId, { + providerID: ProviderID.make(lastUser.model.providerID), + modelID: ModelID.make(lastUser.model.modelID), + }) + if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { + result.modes.currentModeId = lastUser.agent + this.sessionManager.setMode(sessionId, lastUser.agent) + } + result.configOptions = buildConfigOptions({ + currentModelId: result.models.currentModelId, + availableModels: result.models.availableModels, + modes: result.modes, + }) + } + + for (const msg of messages ?? []) { + log.debug("replay message", msg) + await this.processMessage(msg) + } + + await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) + + return result + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + async listSessions(params: ListSessionsRequest): Promise { + try { + const cursor = params.cursor ? Number(params.cursor) : undefined + const limit = 100 + + const sessions = await this.sdk.session + .list( + { + directory: params.cwd ?? undefined, + roots: true, + }, + { throwOnError: true }, + ) + .then((x) => x.data ?? []) + + const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) + const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted + const page = filtered.slice(0, limit) + + const entries: SessionInfo[] = page.map((session) => ({ + sessionId: session.id, + cwd: session.directory, + title: session.title, + updatedAt: new Date(session.time.updated).toISOString(), + })) + + const last = page[page.length - 1] + const next = filtered.length > limit && last ? String(last.time.updated) : undefined + + const response: ListSessionsResponse = { + sessions: entries, + } + if (next) response.nextCursor = next + return response + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + async unstable_forkSession(params: ForkSessionRequest): Promise { + const directory = params.cwd + const mcpServers = params.mcpServers ?? [] + + try { + const model = await defaultModel(this.config, directory) + + const forked = await this.sdk.session + .fork( + { + sessionID: params.sessionId, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + + if (!forked) { + throw new Error("Fork session returned no data") + } + + const sessionId = forked.id + await this.sessionManager.load(sessionId, directory, mcpServers, model) + + log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) + + const mode = await this.loadSessionMode({ + cwd: directory, + mcpServers, + sessionId, + }) + + const messages = await this.sdk.session + .messages( + { + sessionID: sessionId, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((err) => { + log.error("unexpected error when fetching message", { error: err }) + return undefined + }) + + for (const msg of messages ?? []) { + log.debug("replay message", msg) + await this.processMessage(msg) + } + + await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) + + return mode + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + async unstable_resumeSession(params: ResumeSessionRequest): Promise { + const directory = params.cwd + const sessionId = params.sessionId + const mcpServers = params.mcpServers ?? [] + + try { + const model = await defaultModel(this.config, directory) + await this.sessionManager.load(sessionId, directory, mcpServers, model) + + log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) + + const result = await this.loadSessionMode({ + cwd: directory, + mcpServers, + sessionId, + }) + + await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) + + return result + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + private async processMessage(message: SessionMessageResponse) { + log.debug("process message", message) + if (message.info.role !== "assistant" && message.info.role !== "user") return + const sessionId = message.info.sessionID + + for (const part of message.parts) { + if (part.type === "tool") { + await this.toolStart(sessionId, part) + switch (part.state.status) { + case "pending": + this.bashSnapshots.delete(part.callID) + break + case "running": + const output = this.bashOutput(part) + const runningContent: ToolCallContent[] = [] + if (output) { + runningContent.push({ + type: "content", + content: { + type: "text", + text: output, + }, + }) + } await this.connection .sessionUpdate({ sessionId, update: { - sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk", - messageId: message.info.id, - content: { - type: "text", - text: part.text, - ...(audience && { annotations: { audience } }), + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + kind: toToolKind(part.tool), + title: part.tool, + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, + ...(runningContent.length > 0 && { content: runningContent }), + }, + }) + .catch((err) => { + log.error("failed to send tool in_progress to ACP", { error: err }) + }) + break + case "completed": + this.toolStarts.delete(part.callID) + this.bashSnapshots.delete(part.callID) + 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, + 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, + rawInput: part.state.input, + rawOutput: { + output: part.state.output, + metadata: part.state.metadata, }, }, }) .catch((err) => { - log.error("failed to send text to ACP", { error: err }) + log.error("failed to send tool completed to ACP", { error: err }) }) - } - } else if (part.type === "file") { - // Replay file attachments as appropriate ACP content blocks. - // OpenCode stores files internally as { type: "file", url, filename, mime }. - // We convert these back to ACP blocks based on the URL scheme and MIME type: - // - file:// URLs → resource_link - // - data: URLs with image/* → image block - // - data: URLs with text/* or application/json → resource with text - // - data: URLs with other types → resource with blob - const url = part.url - const filename = part.filename ?? "file" - const mime = part.mime || "application/octet-stream" - const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" + break + case "error": + this.toolStarts.delete(part.callID) + this.bashSnapshots.delete(part.callID) + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "failed", + kind: toToolKind(part.tool), + title: part.tool, + rawInput: part.state.input, + content: [ + { + type: "content", + content: { + type: "text", + text: part.state.error, + }, + }, + ], + rawOutput: { + error: part.state.error, + metadata: part.state.metadata, + }, + }, + }) + .catch((err) => { + log.error("failed to send tool error to ACP", { error: err }) + }) + break + } + } else if (part.type === "text") { + if (part.text) { + const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk", + messageId: message.info.id, + content: { + type: "text", + text: part.text, + ...(audience && { annotations: { audience } }), + }, + }, + }) + .catch((err) => { + log.error("failed to send text to ACP", { error: err }) + }) + } + } else if (part.type === "file") { + // Replay file attachments as appropriate ACP content blocks. + // OpenCode stores files internally as { type: "file", url, filename, mime }. + // We convert these back to ACP blocks based on the URL scheme and MIME type: + // - file:// URLs → resource_link + // - data: URLs with image/* → image block + // - data: URLs with text/* or application/json → resource with text + // - data: URLs with other types → resource with blob + const url = part.url + const filename = part.filename ?? "file" + const mime = part.mime || "application/octet-stream" + const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" - if (url.startsWith("file://")) { - // Local file reference - send as resource_link + if (url.startsWith("file://")) { + // Local file reference - send as resource_link + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + messageId: message.info.id, + content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, + }, + }) + .catch((err) => { + log.error("failed to send resource_link to ACP", { error: err }) + }) + } else if (url.startsWith("data:")) { + // Embedded content - parse data URL and send as appropriate block type + const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) + const dataMime = base64Match?.[1] + const base64Data = base64Match?.[2] ?? "" + + const effectiveMime = dataMime || mime + + if (effectiveMime.startsWith("image/")) { + // Image - send as image block await this.connection .sessionUpdate({ sessionId, update: { sessionUpdate: messageChunk, messageId: message.info.id, - content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, + content: { + type: "image", + mimeType: effectiveMime, + data: base64Data, + uri: pathToFileURL(filename).href, + }, }, }) .catch((err) => { - log.error("failed to send resource_link to ACP", { error: err }) + log.error("failed to send image to ACP", { error: err }) }) - } else if (url.startsWith("data:")) { - // Embedded content - parse data URL and send as appropriate block type - const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) - const dataMime = base64Match?.[1] - const base64Data = base64Match?.[2] ?? "" + } else { + // Non-image: text types get decoded, binary types stay as blob + const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" + const fileUri = pathToFileURL(filename).href + const resource = isText + ? { + uri: fileUri, + mimeType: effectiveMime, + text: Buffer.from(base64Data, "base64").toString("utf-8"), + } + : { uri: fileUri, mimeType: effectiveMime, blob: base64Data } - const effectiveMime = dataMime || mime - - if (effectiveMime.startsWith("image/")) { - // Image - send as image block - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { - type: "image", - mimeType: effectiveMime, - data: base64Data, - uri: pathToFileURL(filename).href, - }, - }, - }) - .catch((err) => { - log.error("failed to send image to ACP", { error: err }) - }) - } else { - // Non-image: text types get decoded, binary types stay as blob - const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" - const fileUri = pathToFileURL(filename).href - const resource = isText - ? { - uri: fileUri, - mimeType: effectiveMime, - text: Buffer.from(base64Data, "base64").toString("utf-8"), - } - : { uri: fileUri, mimeType: effectiveMime, blob: base64Data } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { type: "resource", resource }, - }, - }) - .catch((err) => { - log.error("failed to send resource to ACP", { error: err }) - }) - } - } - // URLs that don't match file:// or data: are skipped (unsupported) - } else if (part.type === "reasoning") { - if (part.text) { await this.connection .sessionUpdate({ sessionId, update: { - sessionUpdate: "agent_thought_chunk", + sessionUpdate: messageChunk, messageId: message.info.id, - content: { - type: "text", - text: part.text, - }, + content: { type: "resource", resource }, }, }) .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) + log.error("failed to send resource to ACP", { error: err }) }) } } - } - } - - private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return - if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return - const output = part.state.metadata["output"] - if (typeof output !== "string") return - return output - } - - private async toolStart(sessionId: string, part: ToolPart) { - if (this.toolStarts.has(part.callID)) return - this.toolStarts.add(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((error) => { - log.error("failed to send tool pending to ACP", { error }) - }) - } - - private async loadAvailableModes(directory: string): Promise { - const agents = await this.config.sdk.app - .agents( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - return agents - .filter((agent) => agent.mode !== "subagent" && !agent.hidden) - .map((agent) => ({ - id: agent.name, - name: agent.name, - description: agent.description, - })) - } - - private async resolveModeState( - directory: string, - sessionId: string, - ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { - const availableModes = await this.loadAvailableModes(directory) - const currentModeId = - this.sessionManager.get(sessionId).modeId || - (await (async () => { - if (!availableModes.length) return undefined - const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) - const resolvedModeId = - availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id - this.sessionManager.setMode(sessionId, resolvedModeId) - return resolvedModeId - })()) - - return { availableModes, currentModeId } - } - - private async loadSessionMode(params: LoadSessionRequest) { - const directory = params.cwd - const model = await defaultModel(this.config, directory) - const sessionId = params.sessionId - - const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentVariant = this.sessionManager.getVariant(sessionId) - if (currentVariant && !availableVariants.includes(currentVariant)) { - this.sessionManager.setVariant(sessionId, undefined) - } - const availableModels = buildAvailableModels(entries, { includeVariants: true }) - const modeState = await this.resolveModeState(directory, sessionId) - const currentModeId = modeState.currentModeId - const modes = currentModeId - ? { - availableModes: modeState.availableModes, - currentModeId, - } - : undefined - - const commands = await this.config.sdk.command - .list( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - const availableCommands = commands.map((command) => ({ - name: command.name, - description: command.description ?? "", - })) - const names = new Set(availableCommands.map((c) => c.name)) - if (!names.has("compact")) - availableCommands.push({ - name: "compact", - description: "compact the session", - }) - - const mcpServers: Record = {} - for (const server of params.mcpServers) { - if ("type" in server) { - mcpServers[server.name] = { - url: server.url, - headers: server.headers.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - type: "remote", - } - } else { - mcpServers[server.name] = { - type: "local", - command: [server.command, ...server.args], - environment: server.env.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - } - } - } - - await Promise.all( - Object.entries(mcpServers).map(async ([key, mcp]) => { - await this.sdk.mcp - .add( - { - directory, - name: key, - config: mcp, + // URLs that don't match file:// or data: are skipped (unsupported) + } else if (part.type === "reasoning") { + if (part.text) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + messageId: message.info.id, + content: { + type: "text", + text: part.text, + }, }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to add mcp server", { name: key, error }) }) - }), - ) + .catch((err) => { + log.error("failed to send reasoning to ACP", { error: err }) + }) + } + } + } + } - setTimeout(() => { - void this.connection.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "available_commands_update", - availableCommands, - }, - }) - }, 0) + private bashOutput(part: ToolPart) { + if (part.tool !== "bash") return + if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return + const output = part.state.metadata["output"] + if (typeof output !== "string") return + return output + } - return { + private async toolStart(sessionId: string, part: ToolPart) { + if (this.toolStarts.has(part.callID)) return + this.toolStarts.add(part.callID) + await this.connection + .sessionUpdate({ sessionId, - models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), - availableModels, + update: { + sessionUpdate: "tool_call", + toolCallId: part.callID, + title: part.tool, + kind: toToolKind(part.tool), + status: "pending", + locations: [], + rawInput: {}, }, - modes, - configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), - availableModels, - modes, - }), - _meta: buildVariantMeta({ - model, - variant: this.sessionManager.getVariant(sessionId), - availableVariants, - }), - } + }) + .catch((error) => { + log.error("failed to send tool pending to ACP", { error }) + }) + } + + private async loadAvailableModes(directory: string): Promise { + const agents = await this.config.sdk.app + .agents( + { + directory, + }, + { throwOnError: true }, + ) + .then((resp) => resp.data!) + + return agents + .filter((agent) => agent.mode !== "subagent" && !agent.hidden) + .map((agent) => ({ + id: agent.name, + name: agent.name, + description: agent.description, + })) + } + + private async resolveModeState( + directory: string, + sessionId: string, + ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { + const availableModes = await this.loadAvailableModes(directory) + const currentModeId = + this.sessionManager.get(sessionId).modeId || + (await (async () => { + if (!availableModes.length) return undefined + const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) + const resolvedModeId = + availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id + this.sessionManager.setMode(sessionId, resolvedModeId) + return resolvedModeId + })()) + + return { availableModes, currentModeId } + } + + private async loadSessionMode(params: LoadSessionRequest) { + const directory = params.cwd + const model = await defaultModel(this.config, directory) + const sessionId = params.sessionId + + const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) + const entries = sortProvidersByName(providers) + const availableVariants = modelVariantsFromProviders(entries, model) + const currentVariant = this.sessionManager.getVariant(sessionId) + if (currentVariant && !availableVariants.includes(currentVariant)) { + this.sessionManager.setVariant(sessionId, undefined) } - - async unstable_setSessionModel(params: SetSessionModelRequest) { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - - const selection = parseModelSelection(params.modelId, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, selection.model) - - return { - _meta: buildVariantMeta({ - model: selection.model, - variant: selection.variant, - availableVariants, - }), - } - } - - async setSessionMode(params: SetSessionModeRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.modeId)) { - throw new Error(`Agent not found: ${params.modeId}`) - } - this.sessionManager.setMode(params.sessionId, params.modeId) - } - - async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - - if (params.configId === "model") { - if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string") - const selection = parseModelSelection(params.value, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - } else if (params.configId === "mode") { - if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.value)) { - throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` })) + const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const modeState = await this.resolveModeState(directory, sessionId) + const currentModeId = modeState.currentModeId + const modes = currentModeId + ? { + availableModes: modeState.availableModes, + currentModeId, } - this.sessionManager.setMode(session.id, params.value) - } else { - throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` })) - } + : undefined - const updatedSession = this.sessionManager.get(session.id) - const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true) - const availableModels = buildAvailableModels(entries, { includeVariants: true }) - const modeState = await this.resolveModeState(session.cwd, session.id) - const modes = modeState.currentModeId - ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } - : undefined + const commands = await this.config.sdk.command + .list( + { + directory, + }, + { throwOnError: true }, + ) + .then((resp) => resp.data!) - return { - configOptions: buildConfigOptions({ currentModelId, availableModels, modes }), - } - } - - async prompt(params: PromptRequest) { - const sessionID = params.sessionId - const session = this.sessionManager.get(sessionID) - const directory = session.cwd - - const current = session.model - const model = current ?? (await defaultModel(this.config, directory)) - if (!current) { - this.sessionManager.setModel(session.id, model) - } - const agent = - session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))) - - const parts: Array< - | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } - | { type: "file"; url: string; filename: string; mime: string } - > = [] - for (const part of params.prompt) { - switch (part.type) { - case "text": - const audience = part.annotations?.audience - const forAssistant = audience?.length === 1 && audience[0] === "assistant" - const forUser = audience?.length === 1 && audience[0] === "user" - parts.push({ - type: "text" as const, - text: part.text, - ...(forAssistant && { synthetic: true }), - ...(forUser && { ignored: true }), - }) - break - case "image": { - const parsed = parseUri(part.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "image" - if (part.data) { - parts.push({ - type: "file", - url: `data:${part.mimeType};base64,${part.data}`, - filename, - mime: part.mimeType, - }) - } else if (part.uri && part.uri.startsWith("http:")) { - parts.push({ - type: "file", - url: part.uri, - filename, - mime: part.mimeType, - }) - } - break - } - - case "resource_link": - const parsed = parseUri(part.uri) - // Use the name from resource_link if available - if (part.name && parsed.type === "file") { - parsed.filename = part.name - } - parts.push(parsed) - - break - - case "resource": { - const resource = part.resource - if ("text" in resource && resource.text) { - parts.push({ - type: "text", - text: resource.text, - }) - } else if ("blob" in resource && resource.blob && resource.mimeType) { - // Binary resource (PDFs, etc.): store as file part with data URL - const parsed = parseUri(resource.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "file" - parts.push({ - type: "file", - url: `data:${resource.mimeType};base64,${resource.blob}`, - filename, - mime: resource.mimeType, - }) - } - break - } - - default: - break - } - } - - log.info("parts", { parts }) - - const cmd = (() => { - const text = parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("") - .trim() - - if (!text.startsWith("/")) return - - const [name, ...rest] = text.slice(1).split(/\s+/) - return { name, args: rest.join(" ").trim() } - })() - - const buildUsage = (msg: AssistantMessage): Usage => ({ - totalTokens: - msg.tokens.input + - msg.tokens.output + - msg.tokens.reasoning + - (msg.tokens.cache?.read ?? 0) + - (msg.tokens.cache?.write ?? 0), - inputTokens: msg.tokens.input, - outputTokens: msg.tokens.output, - thoughtTokens: msg.tokens.reasoning || undefined, - cachedReadTokens: msg.tokens.cache?.read || undefined, - cachedWriteTokens: msg.tokens.cache?.write || undefined, + const availableCommands = commands.map((command) => ({ + name: command.name, + description: command.description ?? "", + })) + const names = new Set(availableCommands.map((c) => c.name)) + if (!names.has("compact")) + availableCommands.push({ + name: "compact", + description: "compact the session", }) - if (!cmd) { - const response = await this.sdk.session.prompt({ - sessionID, - model: { - providerID: model.providerID, - modelID: model.modelID, - }, - variant: this.sessionManager.getVariant(sessionID), - parts, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, + const mcpServers: Record = {} + for (const server of params.mcpServers) { + if ("type" in server) { + mcpServers[server.name] = { + url: server.url, + headers: server.headers.reduce>((acc, { name, value }) => { + acc[name] = value + return acc + }, {}), + type: "remote", + } + } else { + mcpServers[server.name] = { + type: "local", + command: [server.command, ...server.args], + environment: server.env.reduce>((acc, { name, value }) => { + acc[name] = value + return acc + }, {}), } } + } - const command = await this.config.sdk.command - .list({ directory }, { throwOnError: true }) - .then((x) => x.data!.find((c) => c.name === cmd.name)) - if (command) { - const response = await this.sdk.session.command({ - sessionID, - command: command.name, - arguments: cmd.args, - model: model.providerID + "/" + model.modelID, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, - } - } - - switch (cmd.name) { - case "compact": - await this.config.sdk.session.summarize( + await Promise.all( + Object.entries(mcpServers).map(async ([key, mcp]) => { + await this.sdk.mcp + .add( { - sessionID, directory, - providerID: model.providerID, - modelID: model.modelID, + name: key, + config: mcp, }, { throwOnError: true }, ) + .catch((error) => { + log.error("failed to add mcp server", { name: key, error }) + }) + }), + ) + + setTimeout(() => { + void this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands, + }, + }) + }, 0) + + return { + sessionId, + models: { + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + availableModels, + }, + modes, + configOptions: buildConfigOptions({ + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + availableModels, + modes, + }), + _meta: buildVariantMeta({ + model, + variant: this.sessionManager.getVariant(sessionId), + availableVariants, + }), + } + } + + async unstable_setSessionModel(params: SetSessionModelRequest) { + const session = this.sessionManager.get(params.sessionId) + const providers = await this.sdk.config + .providers({ directory: session.cwd }, { throwOnError: true }) + .then((x) => x.data!.providers) + + const selection = parseModelSelection(params.modelId, providers) + this.sessionManager.setModel(session.id, selection.model) + this.sessionManager.setVariant(session.id, selection.variant) + + const entries = sortProvidersByName(providers) + const availableVariants = modelVariantsFromProviders(entries, selection.model) + + return { + _meta: buildVariantMeta({ + model: selection.model, + variant: selection.variant, + availableVariants, + }), + } + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + const session = this.sessionManager.get(params.sessionId) + const availableModes = await this.loadAvailableModes(session.cwd) + if (!availableModes.some((mode) => mode.id === params.modeId)) { + throw new Error(`Agent not found: ${params.modeId}`) + } + this.sessionManager.setMode(params.sessionId, params.modeId) + } + + async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { + const session = this.sessionManager.get(params.sessionId) + const providers = await this.sdk.config + .providers({ directory: session.cwd }, { throwOnError: true }) + .then((x) => x.data!.providers) + const entries = sortProvidersByName(providers) + + if (params.configId === "model") { + if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string") + const selection = parseModelSelection(params.value, providers) + this.sessionManager.setModel(session.id, selection.model) + this.sessionManager.setVariant(session.id, selection.variant) + } else if (params.configId === "mode") { + if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") + const availableModes = await this.loadAvailableModes(session.cwd) + if (!availableModes.some((mode) => mode.id === params.value)) { + throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` })) + } + this.sessionManager.setMode(session.id, params.value) + } else { + throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` })) + } + + const updatedSession = this.sessionManager.get(session.id) + const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) + const availableVariants = modelVariantsFromProviders(entries, model) + const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true) + const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const modeState = await this.resolveModeState(session.cwd, session.id) + const modes = modeState.currentModeId + ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } + : undefined + + return { + configOptions: buildConfigOptions({ currentModelId, availableModels, modes }), + } + } + + async prompt(params: PromptRequest) { + const sessionID = params.sessionId + const session = this.sessionManager.get(sessionID) + const directory = session.cwd + + const current = session.model + const model = current ?? (await defaultModel(this.config, directory)) + if (!current) { + this.sessionManager.setModel(session.id, model) + } + const agent = + session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))) + + const parts: Array< + | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } + | { type: "file"; url: string; filename: string; mime: string } + > = [] + for (const part of params.prompt) { + switch (part.type) { + case "text": + const audience = part.annotations?.audience + const forAssistant = audience?.length === 1 && audience[0] === "assistant" + const forUser = audience?.length === 1 && audience[0] === "user" + parts.push({ + type: "text" as const, + text: part.text, + ...(forAssistant && { synthetic: true }), + ...(forUser && { ignored: true }), + }) + break + case "image": { + const parsed = parseUri(part.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "image" + if (part.data) { + parts.push({ + type: "file", + url: `data:${part.mimeType};base64,${part.data}`, + filename, + mime: part.mimeType, + }) + } else if (part.uri && part.uri.startsWith("http:")) { + parts.push({ + type: "file", + url: part.uri, + filename, + mime: part.mimeType, + }) + } + break + } + + case "resource_link": + const parsed = parseUri(part.uri) + // Use the name from resource_link if available + if (part.name && parsed.type === "file") { + parsed.filename = part.name + } + parts.push(parsed) + + break + + case "resource": { + const resource = part.resource + if ("text" in resource && resource.text) { + parts.push({ + type: "text", + text: resource.text, + }) + } else if ("blob" in resource && resource.blob && resource.mimeType) { + // Binary resource (PDFs, etc.): store as file part with data URL + const parsed = parseUri(resource.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "file" + parts.push({ + type: "file", + url: `data:${resource.mimeType};base64,${resource.blob}`, + filename, + mime: resource.mimeType, + }) + } + break + } + + default: break } + } + + log.info("parts", { parts }) + + const cmd = (() => { + const text = parts + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join("") + .trim() + + if (!text.startsWith("/")) return + + const [name, ...rest] = text.slice(1).split(/\s+/) + return { name, args: rest.join(" ").trim() } + })() + + const buildUsage = (msg: AssistantMessage): Usage => ({ + totalTokens: + msg.tokens.input + + msg.tokens.output + + msg.tokens.reasoning + + (msg.tokens.cache?.read ?? 0) + + (msg.tokens.cache?.write ?? 0), + inputTokens: msg.tokens.input, + outputTokens: msg.tokens.output, + thoughtTokens: msg.tokens.reasoning || undefined, + cachedReadTokens: msg.tokens.cache?.read || undefined, + cachedWriteTokens: msg.tokens.cache?.write || undefined, + }) + + if (!cmd) { + const response = await this.sdk.session.prompt({ + sessionID, + model: { + providerID: model.providerID, + modelID: model.modelID, + }, + variant: this.sessionManager.getVariant(sessionID), + parts, + agent, + directory, + }) + const msg = response.data?.info await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) return { stopReason: "end_turn" as const, + usage: msg ? buildUsage(msg) : undefined, _meta: {}, } } - async cancel(params: CancelNotification) { - const session = this.sessionManager.get(params.sessionId) - await this.config.sdk.session.abort( - { - sessionID: params.sessionId, - directory: session.cwd, - }, - { throwOnError: true }, - ) - } - } - - function toToolKind(toolName: string): ToolKind { - const tool = toolName.toLocaleLowerCase() - switch (tool) { - case "bash": - return "execute" - case "webfetch": - return "fetch" - - case "edit": - case "patch": - case "write": - return "edit" - - case "grep": - case "glob": - case "context7_resolve_library_id": - case "context7_get_library_docs": - return "search" - - case "read": - return "read" - - default: - return "other" - } - } - - function toLocations(toolName: string, input: Record): { 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 [] - default: - return [] - } - } - - async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { - const sdk = config.sdk - const configured = config.defaultModel - if (configured) return configured - - const directory = cwd ?? process.cwd() - - const specified = await sdk.config - .get({ directory }, { throwOnError: true }) - .then((resp) => { - const cfg = resp.data - if (!cfg || !cfg.model) return undefined - return Provider.parseModel(cfg.model) - }) - .catch((error) => { - log.error("failed to load user config for default model", { error }) - return undefined + const command = await this.config.sdk.command + .list({ directory }, { throwOnError: true }) + .then((x) => x.data!.find((c) => c.name === cmd.name)) + if (command) { + const response = await this.sdk.session.command({ + sessionID, + command: command.name, + arguments: cmd.args, + model: model.providerID + "/" + model.modelID, + agent, + directory, }) + const msg = response.data?.info - const providers = await sdk.config - .providers({ directory }, { throwOnError: true }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to list providers for default model", { error }) - return [] - }) + await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - if (specified && providers.length) { - const provider = providers.find((p) => p.id === specified.providerID) - if (provider && provider.models[specified.modelID]) return specified - } - - if (specified && !providers.length) return specified - - const opencodeProvider = providers.find((p) => p.id === "opencode") - if (opencodeProvider) { - if (opencodeProvider.models["big-pickle"]) { - return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } - } - const [best] = Provider.sort(Object.values(opencodeProvider.models)) - if (best) { - return { - providerID: ProviderID.make(best.providerID), - modelID: ModelID.make(best.id), - } + return { + stopReason: "end_turn" as const, + usage: msg ? buildUsage(msg) : undefined, + _meta: {}, } } - const models = providers.flatMap((p) => Object.values(p.models)) - const [best] = Provider.sort(models) + switch (cmd.name) { + case "compact": + await this.config.sdk.session.summarize( + { + sessionID, + directory, + providerID: model.providerID, + modelID: model.modelID, + }, + { throwOnError: true }, + ) + break + } + + await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) + + return { + stopReason: "end_turn" as const, + _meta: {}, + } + } + + async cancel(params: CancelNotification) { + const session = this.sessionManager.get(params.sessionId) + await this.config.sdk.session.abort( + { + sessionID: params.sessionId, + directory: session.cwd, + }, + { throwOnError: true }, + ) + } +} + +function toToolKind(toolName: string): ToolKind { + const tool = toolName.toLocaleLowerCase() + switch (tool) { + case "bash": + return "execute" + case "webfetch": + return "fetch" + + case "edit": + case "patch": + case "write": + return "edit" + + case "grep": + case "glob": + case "context7_resolve_library_id": + case "context7_get_library_docs": + return "search" + + case "read": + return "read" + + default: + return "other" + } +} + +function toLocations(toolName: string, input: Record): { 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 [] + default: + return [] + } +} + +async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { + const sdk = config.sdk + const configured = config.defaultModel + if (configured) return configured + + const directory = cwd ?? process.cwd() + + const specified = await sdk.config + .get({ directory }, { throwOnError: true }) + .then((resp) => { + const cfg = resp.data + if (!cfg || !cfg.model) return undefined + return Provider.parseModel(cfg.model) + }) + .catch((error) => { + log.error("failed to load user config for default model", { error }) + return undefined + }) + + const providers = await sdk.config + .providers({ directory }, { throwOnError: true }) + .then((x) => x.data?.providers ?? []) + .catch((error) => { + log.error("failed to list providers for default model", { error }) + return [] + }) + + if (specified && providers.length) { + const provider = providers.find((p) => p.id === specified.providerID) + if (provider && provider.models[specified.modelID]) return specified + } + + if (specified && !providers.length) return specified + + const opencodeProvider = providers.find((p) => p.id === "opencode") + if (opencodeProvider) { + if (opencodeProvider.models["big-pickle"]) { + return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } + } + const [best] = Provider.sort(Object.values(opencodeProvider.models)) if (best) { return { providerID: ProviderID.make(best.providerID), modelID: ModelID.make(best.id), } } - - if (specified) return specified - - return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } } - 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 models = providers.flatMap((p) => Object.values(p.models)) + const [best] = Provider.sort(models) + if (best) { + return { + providerID: ProviderID.make(best.providerID), + modelID: ModelID.make(best.id), + } + } + + if (specified) return specified + + return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } +} + +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: "file", + url: uri, + filename: name, + mime: "text/plain", + } + } + 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: uri, + url: pathToFileURL(path).href, filename: name, mime: "text/plain", } } - 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: pathToFileURL(path).href, - filename: name, - mime: "text/plain", - } - } - } - return { - type: "text", - text: uri, - } - } catch { - return { - type: "text", - text: uri, - } } - } - - function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined { - const result = applyPatch(fileOriginal, unifiedDiff) - if (result === false) { - log.error("Failed to apply unified diff (context mismatch)") - return undefined - } - return result - } - - function sortProvidersByName(providers: T[]): T[] { - return [...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 - }) - } - - function modelVariantsFromProviders( - providers: Array<{ id: string; models: Record }> }>, - model: { providerID: ProviderID; modelID: ModelID }, - ): string[] { - const provider = providers.find((entry) => entry.id === model.providerID) - if (!provider) return [] - const modelInfo = provider.models[model.modelID] - if (!modelInfo?.variants) return [] - return Object.keys(modelInfo.variants) - } - - function buildAvailableModels( - providers: Array<{ id: string; name: string; models: Record }>, - options: { includeVariants?: boolean } = {}, - ): ModelOption[] { - const includeVariants = options.includeVariants ?? false - return providers.flatMap((provider) => { - const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values( - provider.models, - ) - const models = Provider.sort(unsorted) - return models.flatMap((model) => { - const base: ModelOption = { - modelId: `${provider.id}/${model.id}`, - name: `${provider.name}/${model.name}`, - } - if (!includeVariants || !model.variants) return [base] - const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE) - const variantOptions = variants.map((variant) => ({ - modelId: `${provider.id}/${model.id}/${variant}`, - name: `${provider.name}/${model.name} (${variant})`, - })) - return [base, ...variantOptions] - }) - }) - } - - function formatModelIdWithVariant( - model: { providerID: ProviderID; modelID: ModelID }, - variant: string | undefined, - availableVariants: string[], - includeVariant: boolean, - ) { - const base = `${model.providerID}/${model.modelID}` - if (!includeVariant || !variant || !availableVariants.includes(variant)) return base - return `${base}/${variant}` - } - - function buildVariantMeta(input: { - model: { providerID: ProviderID; modelID: ModelID } - variant?: string - availableVariants: string[] - }) { return { - opencode: { - modelId: `${input.model.providerID}/${input.model.modelID}`, - variant: input.variant ?? null, - availableVariants: input.availableVariants, - }, + type: "text", + text: uri, + } + } catch { + return { + type: "text", + text: uri, } } +} - function parseModelSelection( - modelId: string, - providers: Array<{ id: string; models: Record }> }>, - ): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { - const parsed = Provider.parseModel(modelId) - const provider = providers.find((p) => p.id === parsed.providerID) - if (!provider) { - return { model: parsed, variant: undefined } - } +function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined { + const result = applyPatch(fileOriginal, unifiedDiff) + if (result === false) { + log.error("Failed to apply unified diff (context mismatch)") + return undefined + } + return result +} - // Check if modelID exists directly - if (provider.models[parsed.modelID]) { - return { model: parsed, variant: undefined } - } +function sortProvidersByName(providers: T[]): T[] { + return [...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 + }) +} - // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high") - const segments = parsed.modelID.split("/") - if (segments.length > 1) { - const candidateVariant = segments[segments.length - 1] - const baseModelId = segments.slice(0, -1).join("/") - const baseModelInfo = provider.models[baseModelId] - if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) { - return { - model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) }, - variant: candidateVariant, - } +function modelVariantsFromProviders( + providers: Array<{ id: string; models: Record }> }>, + model: { providerID: ProviderID; modelID: ModelID }, +): string[] { + const provider = providers.find((entry) => entry.id === model.providerID) + if (!provider) return [] + const modelInfo = provider.models[model.modelID] + if (!modelInfo?.variants) return [] + return Object.keys(modelInfo.variants) +} + +function buildAvailableModels( + providers: Array<{ id: string; name: string; models: Record }>, + options: { includeVariants?: boolean } = {}, +): ModelOption[] { + const includeVariants = options.includeVariants ?? false + return providers.flatMap((provider) => { + const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values( + provider.models, + ) + const models = Provider.sort(unsorted) + return models.flatMap((model) => { + const base: ModelOption = { + modelId: `${provider.id}/${model.id}`, + name: `${provider.name}/${model.name}`, } - } + if (!includeVariants || !model.variants) return [base] + const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE) + const variantOptions = variants.map((variant) => ({ + modelId: `${provider.id}/${model.id}/${variant}`, + name: `${provider.name}/${model.name} (${variant})`, + })) + return [base, ...variantOptions] + }) + }) +} +function formatModelIdWithVariant( + model: { providerID: ProviderID; modelID: ModelID }, + variant: string | undefined, + availableVariants: string[], + includeVariant: boolean, +) { + const base = `${model.providerID}/${model.modelID}` + if (!includeVariant || !variant || !availableVariants.includes(variant)) return base + return `${base}/${variant}` +} + +function buildVariantMeta(input: { + model: { providerID: ProviderID; modelID: ModelID } + variant?: string + availableVariants: string[] +}) { + return { + opencode: { + modelId: `${input.model.providerID}/${input.model.modelID}`, + variant: input.variant ?? null, + availableVariants: input.availableVariants, + }, + } +} + +function parseModelSelection( + modelId: string, + providers: Array<{ id: string; models: Record }> }>, +): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { + const parsed = Provider.parseModel(modelId) + const provider = providers.find((p) => p.id === parsed.providerID) + if (!provider) { return { model: parsed, variant: undefined } } - function buildConfigOptions(input: { - currentModelId: string - availableModels: ModelOption[] - modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined - }): SessionConfigOption[] { - const options: SessionConfigOption[] = [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: input.currentModelId, - options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), - }, - ] - if (input.modes) { - options.push({ - id: "mode", - name: "Session Mode", - category: "mode", - type: "select", - currentValue: input.modes.currentModeId, - options: input.modes.availableModes.map((m) => ({ - value: m.id, - name: m.name, - ...(m.description ? { description: m.description } : {}), - })), - }) - } - return options + // Check if modelID exists directly + if (provider.models[parsed.modelID]) { + return { model: parsed, variant: undefined } } + + // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high") + const segments = parsed.modelID.split("/") + if (segments.length > 1) { + const candidateVariant = segments[segments.length - 1] + const baseModelId = segments.slice(0, -1).join("/") + const baseModelInfo = provider.models[baseModelId] + if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) { + return { + model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) }, + variant: candidateVariant, + } + } + } + + return { model: parsed, variant: undefined } } + +function buildConfigOptions(input: { + currentModelId: string + availableModels: ModelOption[] + modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined +}): SessionConfigOption[] { + const options: SessionConfigOption[] = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: input.currentModelId, + options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), + }, + ] + if (input.modes) { + options.push({ + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: input.modes.currentModeId, + options: input.modes.availableModes.map((m) => ({ + value: m.id, + name: m.name, + ...(m.description ? { description: m.description } : {}), + })), + }) + } + return options +} + +export * as ACP from "./agent"