summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-16 20:00:50 -0400
committerGitHub <[email protected]>2026-04-17 00:00:50 +0000
commit1291e82bb4d881a1c0ac5cb882da2b97a169ae9d (patch)
treeb01ae7e5122cdbf5479c0909819928d864bdee36
parent19d15d9ff7826db276219bccce278f78b654a431 (diff)
downloadopencode-1291e82bb4d881a1c0ac5cb882da2b97a169ae9d.tar.gz
opencode-1291e82bb4d881a1c0ac5cb882da2b97a169ae9d.zip
refactor: unwrap ACP namespace + self-reexport (#22936)
-rw-r--r--packages/opencode/src/acp/agent.ts2930
1 files changed, 1465 insertions, 1465 deletions
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index 5d8c723ea..7180feabc 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" })
-
- async function getContextLimit(
- sdk: OpencodeClient,
- providerID: ProviderID,
- modelID: ModelID,
- directory: string,
- ): Promise<number | null> {
- 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 log = Log.create({ service: "acp-agent" })
+
+async function getContextLimit(
+ sdk: OpencodeClient,
+ providerID: ProviderID,
+ modelID: ModelID,
+ directory: string,
+): Promise<number | null> {
+ 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<void> {
- 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
- })
+async function sendUsageUpdate(
+ connection: AgentSideConnection,
+ sdk: OpencodeClient,
+ sessionID: string,
+ directory: string,
+): Promise<void> {
+ 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
+ if (!messages) return
- const assistantMessages = messages.filter(
- (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant",
- )
+ 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 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)
+ 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
- }
+ 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)
+ 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 })
+ })
+}
- 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 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<string, string>()
+ private toolStarts = new Set<string>()
+ private permissionQueues = new Map<string, Promise<void>>()
+ 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<string, string>()
- private toolStarts = new Set<string>()
- private permissionQueues = new Map<string, Promise<void>>()
- 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()
- }
+ 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 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
-
- 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,
- })
- .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
+ 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 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,
})
-
- 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)
- }
- })
- 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
-
- if (part.type === "tool") {
- await this.toolStart(sessionId, part)
-
- 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) {
- 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((error) => {
- log.error("failed to send session update for todo", { error })
- })
- } else {
- log.error("failed to parse todo output", { error: parsedTodos.error })
- }
- }
+ return
+ }
- 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
+ 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,
+ })
}
- 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<InitializeResponse> {
- 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,
+ await this.sdk.permission.reply({
+ requestID: permission.id,
+ reply: res.outcome.optionId as "once" | "always" | "reject",
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,
+ .catch((error) => {
+ log.error("failed to handle permission", { error, permissionID: permission.id })
})
- }
-
- 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<ListSessionsResponse> {
- 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<ForkSessionResponse> {
- 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
+ .finally(() => {
+ if (this.permissionQueues.get(permission.sessionID) === next) {
+ this.permissionQueues.delete(permission.sessionID)
+ }
})
-
- 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<ResumeSessionResponse> {
- 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
+ this.permissionQueues.set(permission.sessionID, next)
+ return
}
- }
- 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
+ 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
- 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
+ return
+
case "running":
const output = this.bashOutput(part)
- const runningContent: ToolCallContent[] = []
+ const content: ToolCallContent[] = []
if (output) {
- runningContent.push({
+ 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",
@@ -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
- }
- } 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 })
- })
+ return
}
- } 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
- 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
+ // 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
- 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 }
+ return
+ }
- 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",
- messageId: message.info.id,
- content: {
- type: "text",
- text: part.text,
- },
+ 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((err) => {
- log.error("failed to send reasoning to ACP", { error: err })
- })
- }
+ },
+ })
+ .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
}
}
+ }
- 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
- }
+ async initialize(params: InitializeRequest): Promise<InitializeResponse> {
+ log.info("initialize", { protocolVersion: params.protocolVersion })
- 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 })
- })
+ const authMethod: AuthMethod = {
+ description: "Run `opencode auth login` in the terminal",
+ name: "Login with opencode",
+ id: "opencode-login",
}
- private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
- 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,
- }))
+ // 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",
+ },
+ }
}
- 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 }
+ 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,
+ },
}
+ }
- private async loadSessionMode(params: LoadSessionRequest) {
- const directory = params.cwd
+ 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)
- 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)
+
+ // 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,
}
- 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
+ } catch (e) {
+ const error = MessageV2.fromError(e, {
+ providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
+ })
+ if (LoadAPIKeyError.isInstance(error)) {
+ throw RequestError.authRequired()
+ }
+ throw e
+ }
+ }
- const commands = await this.config.sdk.command
- .list(
+ 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((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",
+ .then((x) => x.data)
+ .catch((err) => {
+ log.error("unexpected error when fetching message", { error: err })
+ return undefined
})
- const mcpServers: Record<string, ConfigMCP.Info> = {}
- for (const server of params.mcpServers) {
- if ("type" in server) {
- mcpServers[server.name] = {
- url: server.url,
- headers: server.headers.reduce<Record<string, string>>((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<Record<string, string>>((acc, { name, value }) => {
- acc[name] = value
- return acc
- }, {}),
- }
+ 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,
+ })
}
- await Promise.all(
- Object.entries(mcpServers).map(async ([key, mcp]) => {
- await this.sdk.mcp
- .add(
- {
- directory,
- name: key,
- config: mcp,
- },
- { throwOnError: true },
- )
- .catch((error) => {
- log.error("failed to add mcp server", { name: key, error })
- })
- }),
- )
+ for (const msg of messages ?? []) {
+ log.debug("replay message", msg)
+ await this.processMessage(msg)
+ }
- setTimeout(() => {
- void this.connection.sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "available_commands_update",
- availableCommands,
- },
- })
- }, 0)
+ await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
- 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,
- }),
+ 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 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)
+ async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
+ try {
+ const cursor = params.cursor ? Number(params.cursor) : undefined
+ const limit = 100
- const selection = parseModelSelection(params.modelId, providers)
- this.sessionManager.setModel(session.id, selection.model)
- this.sessionManager.setVariant(session.id, selection.variant)
+ const sessions = await this.sdk.session
+ .list(
+ {
+ directory: params.cwd ?? undefined,
+ roots: true,
+ },
+ { throwOnError: true },
+ )
+ .then((x) => x.data ?? [])
- const entries = sortProvidersByName(providers)
- const availableVariants = modelVariantsFromProviders(entries, selection.model)
+ 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)
- return {
- _meta: buildVariantMeta({
- model: selection.model,
- variant: selection.variant,
- availableVariants,
- }),
+ 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 setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
- 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}`)
+ async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
+ 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")
}
- this.sessionManager.setMode(params.sessionId, params.modeId)
- }
- async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
- 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 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)
}
- 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
+ await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
- return {
- configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
+ 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<ResumeSessionResponse> {
+ 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)
- async prompt(params: PromptRequest) {
- const sessionID = params.sessionId
- const session = this.sessionManager.get(sessionID)
- const directory = session.cwd
+ log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
- const current = session.model
- const model = current ?? (await defaultModel(this.config, directory))
- if (!current) {
- this.sessionManager.setModel(session.id, model)
+ 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()
}
- 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 }),
- })
+ 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 "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,
+ 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: "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,
+ })
+ }
- 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
+ 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 })
+ }
}
- parts.push(parsed)
+ 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 tool completed to ACP", { error: err })
+ })
break
-
- case "resource": {
- const resource = part.resource
- if ("text" in resource && resource.text) {
- parts.push({
- type: "text",
- text: resource.text,
+ 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,
+ },
+ },
})
- } 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,
+ .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
+ 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] ?? ""
- default:
- break
+ 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",
+ messageId: message.info.id,
+ content: {
+ type: "text",
+ text: part.text,
+ },
+ },
+ })
+ .catch((err) => {
+ log.error("failed to send reasoning 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
+ }
- 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,
+ 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 })
})
+ }
- if (!cmd) {
- const response = await this.sdk.session.prompt({
- sessionID,
- model: {
- providerID: model.providerID,
- modelID: model.modelID,
- },
- variant: this.sessionManager.getVariant(sessionID),
- parts,
- agent,
+ private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
+ const agents = await this.config.sdk.app
+ .agents(
+ {
directory,
- })
- const msg = response.data?.info
+ },
+ { 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,
+ }))
+ }
- await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
+ 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 }
+ }
- return {
- stopReason: "end_turn" as const,
- usage: msg ? buildUsage(msg) : undefined,
- _meta: {},
+ 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 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,
+ const commands = await this.config.sdk.command
+ .list(
+ {
directory,
- })
- const msg = response.data?.info
-
- await sendUsageUpdate(this.connection, this.sdk, sessionID, 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",
+ })
- return {
- stopReason: "end_turn" as const,
- usage: msg ? buildUsage(msg) : undefined,
- _meta: {},
+ const mcpServers: Record<string, ConfigMCP.Info> = {}
+ for (const server of params.mcpServers) {
+ if ("type" in server) {
+ mcpServers[server.name] = {
+ url: server.url,
+ headers: server.headers.reduce<Record<string, string>>((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<Record<string, string>>((acc, { name, value }) => {
+ acc[name] = value
+ return acc
+ }, {}),
}
}
+ }
- 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 },
)
- break
- }
-
- await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
-
- return {
- stopReason: "end_turn" as const,
- _meta: {},
- }
- }
+ .catch((error) => {
+ log.error("failed to add mcp server", { name: key, error })
+ })
+ }),
+ )
- async cancel(params: CancelNotification) {
- const session = this.sessionManager.get(params.sessionId)
- await this.config.sdk.session.abort(
- {
- sessionID: params.sessionId,
- directory: session.cwd,
+ setTimeout(() => {
+ void this.connection.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "available_commands_update",
+ availableCommands,
},
- { throwOnError: true },
- )
+ })
+ }, 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,
+ }),
}
}
- 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"
+ 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,
+ }),
}
}
- 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 []
- default:
- return []
+ async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
+ 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 function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
- const sdk = config.sdk
- const configured = config.defaultModel
- if (configured) return configured
+ async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
+ 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)
- const directory = cwd ?? process.cwd()
+ 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 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 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 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 []
- })
+ return {
+ configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
+ }
+ }
- if (specified && providers.length) {
- const provider = providers.find((p) => p.id === specified.providerID)
- if (provider && provider.models[specified.modelID]) return specified
+ 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
+ }
- if (specified && !providers.length) return specified
+ 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)
- 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),
+ 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
}
}
- const models = providers.flatMap((p) => Object.values(p.models))
- const [best] = Provider.sort(models)
- if (best) {
+ 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 {
- providerID: ProviderID.make(best.providerID),
- modelID: ModelID.make(best.id),
+ stopReason: "end_turn" as const,
+ usage: msg ? buildUsage(msg) : undefined,
+ _meta: {},
}
}
- if (specified) return specified
+ 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
- return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
- }
+ await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
- 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: pathToFileURL(path).href,
- filename: name,
- mime: "text/plain",
- }
- }
- }
return {
- type: "text",
- text: uri,
- }
- } catch {
- return {
- type: "text",
- text: uri,
+ stopReason: "end_turn" as const,
+ usage: msg ? buildUsage(msg) : undefined,
+ _meta: {},
}
}
- }
- 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
+ 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: {},
}
- return result
}
- function sortProvidersByName<T extends { name: string }>(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
- })
+ 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 modelVariantsFromProviders(
- providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
- 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 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 buildAvailableModels(
- providers: Array<{ id: string; name: string; models: Record<string, any> }>,
- options: { includeVariants?: boolean } = {},
- ): ModelOption[] {
- const includeVariants = options.includeVariants ?? false
- return providers.flatMap((provider) => {
- const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = 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 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 []
+ 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
}
- 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}`
+ 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),
+ }
+ }
}
- function buildVariantMeta(input: {
- model: { providerID: ProviderID; modelID: ModelID }
- variant?: string
- availableVariants: string[]
- }) {
+ const models = providers.flatMap((p) => Object.values(p.models))
+ const [best] = Provider.sort(models)
+ if (best) {
return {
- opencode: {
- modelId: `${input.model.providerID}/${input.model.modelID}`,
- variant: input.variant ?? null,
- availableVariants: input.availableVariants,
- },
+ providerID: ProviderID.make(best.providerID),
+ modelID: ModelID.make(best.id),
}
}
- function parseModelSelection(
- modelId: string,
- providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
- ): { 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 }
- }
+ if (specified) return specified
- // Check if modelID exists directly
- if (provider.models[parsed.modelID]) {
- return { model: parsed, variant: undefined }
- }
+ return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
+}
- // 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) {
+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 {
- model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) },
- variant: candidateVariant,
+ 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<T extends { name: string }>(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<string, { variants?: Record<string, any> }> }>,
+ 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<string, any> }>,
+ options: { includeVariants?: boolean } = {},
+): ModelOption[] {
+ const includeVariants = options.includeVariants ?? false
+ return providers.flatMap((provider) => {
+ const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = 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<string, { variants?: Record<string, any> }> }>,
+): { 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 } : {}),
- })),
- })
+ // 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 options
}
+
+ 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"