开源终端 Agent opencode 的 ACP 层拆解:重映射的代价
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
让外部编辑器接管一个终端 Agent,真正的工作量不在于把 JSON-RPC 接上,而在于把这个 Agent 原本靠终端 UI 兜住的每一处状态——哪个会话、用哪个模型、这一步要不要批准、这个工具该画成什么样——都在协议里找到一个对得上的字段;找不到对得上的地方,就是你后面要踩的坑。 opencode 这个开源终端编码 Agent 在 packages/opencode/src/acp/ 下用 12 个文件干的就是这件事,代价被摊开写在代码里,可以逐行读出来。
这篇只谈 opencode 自己的这层实现。同类话题里,Hermes Agent 的 ACP 适配层 讲的是另一个自托管项目怎么换壳、以及它双轨会话 id 的取舍;Agent 协议生态对比 站在协议层面横向看各家分工;MCP 协议是什么 讲的是 Agent 向下接工具的那条链路——而本篇是向上接编辑器的那条,两者在 opencode 的这层实现里恰好会撞在一起,后面第三节会说清楚撞在哪。
一、opencode acp 起来的进程,是外壳加服务两层
先看入口。packages/opencode/src/cli/cmd/acp.ts 注册的命令描述是 start ACP (Agent Client Protocol) server,在一组通用网络选项之外,还单独挂了一个 --cwd 选项用来指定工作目录。它做的事按顺序是:把环境变量 OPENCODE_CLIENT 置为 acp,起一个本地 HTTP 服务(Server.listen),然后用这个服务的地址创建一个客户端:
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
headers: ServerAuth.headers(),
})
接着把 process.stdin / process.stdout 包成流,交给 ndJsonStream,再用 new AgentSideConnection(...) 把协议连接建起来。
这个结构值得停一下。ACP 这一层并没有直接调用 opencode 的内部函数,它是本地 HTTP 服务的一个普通客户端,跟 TUI 用的是同一套对外接口。文档 packages/web/src/content/docs/acp.mdx 里那句”通过 stdio 上的 JSON-RPC 作为子进程与编辑器通信”,说的是外面那半;里面那半是一次本机 HTTP 往返。
对你意味着什么:编辑器里跑的这个 Agent,和你在终端里跑的是同一套内核、同一套配置、同一套 AGENTS.md 项目规则、同一套 MCP 配置。也意味着排查问题时你有两个可疑面——协议翻译层,和它下面那个本地服务。两者的日志、错误形态完全不同。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| CLI 入口 | 起本地服务、包 stdio 流、建协议连接 | packages/opencode/src/cli/cmd/acp.ts | 编辑器起不来 Agent、连接立刻断 |
| 协议方法壳 | 把 13 个协议方法转给内部服务,统一收敛错误 | packages/opencode/src/acp/agent.ts | 编辑器报 method not found |
| 核心服务 | 会话增删改、模型/模式切换、prompt 分派 | packages/opencode/src/acp/service.ts | 模型选不对、斜杠命令没反应 |
| 会话表 | 在内存里记协议会话的 cwd、模型、已知消息片段 | packages/opencode/src/acp/session.ts | 重启后历史对不上 |
| 事件订阅 | 订全局事件流,翻译成编辑器的增量更新 | packages/opencode/src/acp/event.ts | 输出不流式、工具卡片重复 |
| 权限处理 | 弹批准框、算 diff、把结果回执给内核 | packages/opencode/src/acp/permission.ts | 批准框卡住、误批准 |
| 工具映射 | 把内部工具名翻成协议的种类、标题、位置 | packages/opencode/src/acp/tool.ts | 自定义工具在编辑器里显示成”其它” |
| 错误映射 | 内部错误 → 协议标准错误码 | packages/opencode/src/acp/error.ts | 编辑器只看到一句笼统报错 |
二、会话:协议要的和内核有的,中间隔着一张内存表
协议侧要求 Agent 支持一串会话生命周期方法。agent.ts 里一字排开:newSession、loadSession、listSessions、resumeSession、closeSession、unstable_forkSession、setSessionConfigOption、setSessionMode、unstable_setSessionModel、prompt、cancel,加上 initialize 和 authenticate。带 unstable_ 前缀的两个,是协议里尚未定稳的部分。
session.ts 维护的是一张纯内存的 Map,每个会话记这些字段:id、cwd、mcpServers、createdAt、model、variant、modeId、knownParts。会话 id 直接复用内核创建出来的 id(newSession 里 sdk.session.create 的返回值),没有做两套 id 的转换。
代价在 knownParts。这是一张以 ${messageId}:${partId} 为键的表,记录每个消息片段的类型、角色、是否被忽略、对应哪个工具调用 id。它存在的原因在 event.ts 里:内核推来的增量事件 message.part.delta 只带 delta 文本和 id,不带”这是正文还是思考过程、是用户说的还是模型说的”。而协议侧必须区分——正文走 agent_message_chunk,推理过程走 agent_thought_chunk。查不到元数据时,代码会回源拉一次完整消息(sdk.session.message)再补记。
这张表是进程内的。listSessions 的实现能看出这一点:它把内核里查到的会话和内存里活着的会话做并集,内核查到的那批带着真实的更新时间,内存里这批压根没有更新时间这个字段,只能把 createdAt 顶上去当排序依据;两批合并后按时间倒序,分页游标用的是时间戳,每页 100 条。
于是有了第一个真实影响:编辑器重启、Agent 子进程被杀,内存表就没了。会话本身还在内核里(loadSession / resumeSession 能把它拉回来,resumeSession 只取最近 20 条消息),但流式渲染依赖的片段元数据要重新建。
配置项的映射也在这里落地。config-option.ts 只暴露三个 SessionConfigOption:id 为 model(名称 Model)、effort(名称 Effort,分类 thought_level)、mode(名称 Session Mode)。mode 的候选来自内核的 agent 列表,过滤掉 mode === "subagent" 和 hidden === true 的那些;没取到默认值时回退到 build。effort 只在当前模型带 variants 时才出现。
有一处细节能看出映射的难点:parseModelSelection 要从一个 provider/model 形式的字符串里还原选择,但模型 id 本身可能带斜杠,还可能在尾部跟一个 variant。代码的做法是先按已知 provider 前缀切,切完先查模型是否存在,不存在再从最后一个斜杠处试着切出 variant。把结构信息塞进一个扁平字符串,代价就是解析端得靠这种试探性回退兜底,这是接口设计里典型的返工点。
三、工具调用:把内部事件翻译成编辑器画得出来的东西
编辑器不认识 opencode 的工具名,它认识的是协议定义的一小撮种类。tool.ts 的 toToolKind 就是那张对照表:bash / shell → execute,webfetch → fetch,edit / apply_patch / patch / write → edit,grep / glob / context 及两个 context7 工具 → search,read → read,task → think,其余一律 other。
这张表是按名字硬匹配的。你通过 MCP 接进来的工具、你自己写的自定义工具,只要名字不在表里,在编辑器里就是”其它”这一类,拿不到专门的图标与展示。这不是 bug,是这种映射方式的固有上限。
toLocations 同理:只有列在 case 里的工具,代码才知道该从 filePath / filepath / parentDir / path 里的哪个字段掏出文件路径,编辑器才能把这次调用定位到某个文件上。
再往下有两处很实际的处理。一是 shell 工具的标题,源码里带注释说明了理由:对 shell 类工具用实际命令当标题,好让它在输出落地之前就可见;非 shell 工具保留模型给的标题。二是 shell 的 rawInput 会被补上解析后的工作目录,除非模型自己已经指定了——这样编辑器能显示命令到底在哪儿跑。
事件侧的去重逻辑在 event.ts。工具状态有 pending / running / completed / error 四态:第一次见到某个 callID 时发一条 tool_call(用 toolStarts 这个 Set 保证只发一次),后续发 tool_call_update。running 状态下如果是 bash,会拿输出快照跟上一次比,内容没变就发一条不带内容的更新,避免把同样的输出反复推给编辑器。completed / error 时清掉这两份缓存。
订阅本身是个 while 循环订全局事件流,流断了等 1 秒重连。这意味着断流期间的事件不会补发,编辑器界面上那一段会缺。
编辑器传来的 MCP server 会被注册进 opencode:registerMcpServers 把远程型转成 { type: "remote", url, headers },本地型转成 { type: "local", command, environment },再调 sdk.mcp.add。去重键是名字加上配置的稳定序列化结果。这里是向上接编辑器和向下接工具两条链路的交汇点,也是一个容易被忽略的暴露面——编辑器配置里写的 header 和环境变量(很可能包含各类凭证)会原样进到 opencode 的 MCP 配置里。凭证怎么放,API 密钥安全管理 那套原则在这儿要照用。
四、权限:三个选项、一条串行队列、批准后先写文件
permission.ts 是这层里最该逐行读的文件。
协议要求 Agent 把可选项报给客户端,opencode 只给三个,写死在一个常量里:once(种类 allow_once,显示 Allow once)、always(allow_always,Always allow)、reject(reject_once,Reject)。终端里那套更细的权限粒度,到了协议这边就压成了这三档。
处理逻辑有几处保守设计,值得单独点出来:
其一,按会话串行。Handler 内部维护一张 queues 表,同一个会话的权限请求会串成一条 Promise 链依次处理,不会同时弹两个框。
其二,能力缺失即拒绝。如果客户端连接上根本没有 requestPermission 这个能力,代码直接回一个 reject,不做任何”默认放行”。
其三,异常即拒绝。向客户端发起请求这一步如果抛错,catch 里做的也是 reject。
其四,只认明确的批准。selectedReply 要求响应的 outcome 是 selected,且 optionId 恰好是 once 或 always,其余情况一律按拒绝处理。
这四条合起来是同一个取向:拿不准就不放行。这跟 最小权限设计 里的默认拒绝原则是一致的,代价是三档粒度太粗——终端里能分得更细的那些场景,到这儿只剩”这次允许”和”以后都允许”。
最需要注意的是编辑相关的那条路径。弹框时,代码会先读磁盘上的原文件、用 applyPatch 把补丁应用一遍,把 oldText / newText 打包成一个 diff 类型的内容块塞进权限请求里,让你在编辑器里看到改动预览;补丁应用不上(applyPatch 返回 false)就不放这块内容,框照弹。
而在你点了批准之后、把批准结果回执给内核之前,还有一步 writeProposedEdit:它会再算一次补丁结果,然后调 connection.writeTextFile 把整份新内容推给编辑器。这一步是不等待返回、失败静默吞掉的。
这个顺序的含义要说清楚:这不是”未经批准就改文件”——它发生在批准之后。但它确实意味着,在内核那边真正落盘之前,编辑器缓冲区里可能已经是新内容了。两条写入路径同时存在,形态一致时无感,不一致时(比如补丁在这半秒内因为文件被别处改动而应用不上)就会出现编辑器显示与磁盘不一致。
五、边界与代价:它明确不管的那些事
它不管你终端里的全部交互习惯。 文档明确写了 /undo 和 /redo 这类内置斜杠命令目前不支持。机制上能看到原因:prompt 会先用 detectSlashCommand 判断输入是否以 / 开头,命中已知命令走 sdk.session.command,compact 走 sdk.session.summarize,剩下的既不执行也不当成普通提示词发给模型,直接返回一个空响应。所以你打一个它不认识的斜杠命令,得到的是”什么都没发生”,而不是一句报错。
它不把内部错误细节给编辑器。 error.ts 定义了 9 类带标签的错误,统一翻成协议的标准错误:找不到会话、配置项非法、模型/effort/模式不存在都归到 invalid params;需要登录归到 auth required;不支持的方法归到 method not found;剩下的走 internal error,对外只给一句 safeMessage,随附的数据最多带上服务名和错误类名,原始堆栈与内部报文不出这一层。未知异常的兜底文案是一句固定的 Internal service failure。调试友好度换的是不外泄内部细节,这个取舍你得认。
它不做跨进程的状态持久化。 前面说过,协议会话表在内存里。
它不改变风险的本质。 这是一个会在你机器上执行 shell 命令、直接改写你的代码文件、并把代码内容发给模型服务商的工具。套上编辑器的壳之后,风险一点没少,反而多了一层:终端里你至少看得见每一次执行,图形界面里批准框点起来更顺手,误批一次的成本是一样的——可能是一条删错目录的命令,可能是一份被覆盖的未提交改动,可能是一段本不该离开内网的私有代码进了模型请求。至于哪些内容会被发出去、发到哪家,取决于你选的 provider,各家规则不同且会调整,以官方最新说明为准。
它不承诺跨客户端一致。 文档列了 Zed、JetBrains 系 IDE、Avante.nvim、CodeCompanion.nvim 四类配置示例,每家的入口和配置文件都不一样。协议一致不等于体验一致:客户端有没有实现 writeTextFile、权限框长什么样、diff 怎么渲染,都由客户端决定。
六、上手与避坑清单
先在终端里把 opencode 跑通,再接编辑器。 为什么会踩:ACP 层是本地服务的客户端,登录、provider 配置、AGENTS.md 全部复用终端那一套。终端里没配好,编辑器里表现出来的是一个含义模糊的 auth required 或者笼统的内部错误。怎么避:先在终端确认能正常对话,再动编辑器配置。
编辑器里配的是命令加参数,不是一个可执行文件路径。 为什么会踩:四个客户端的配置形态各不相同,但都是把 opencode 当命令、把 acp 当参数传。JetBrains 的示例里用的是绝对路径。怎么避:如果编辑器不是从你的登录 shell 启动的,PATH 很可能不含 opencode,照 JetBrains 那个示例写绝对路径最稳。
别指望它认全部斜杠命令。 为什么会踩:不认识的斜杠命令是静默返回,你会以为是 Agent 卡住了。怎么避:把编辑器推给你的可用命令列表当准绳——newSession / loadSession 之后会推一条 available_commands_update,里面既有命令也有被并进来的技能。
自定义工具在编辑器里显示成”其它”是正常的。 为什么会踩:种类映射按名字硬匹配,不在表里就归为 other,你可能会去怀疑工具本身没注册成功。怎么避:先看工具有没有被调用、有没有返回,别拿显示样式当判断依据。
批准框弹出来之后别急着点。 为什么会踩:三档选项里的”总是允许”在图形界面里跟”允许一次”只差一个位置,点错了后面同类操作就不再问你。怎么避:先看框里的标题和 diff——编辑类操作的预览是提前算好的,读一眼再点;对涉及 shell 的调用,标题就是即将执行的命令原文。
编辑器里看到内容变了,不等于磁盘已经是那样。 为什么会踩:批准之后有一条推给编辑器的写入路径,跟内核落盘是两条路。怎么避:改动做完以后用 git status / git diff 对一遍,别只信编辑器缓冲区。
MCP 配置里的凭证会被带进来。 为什么会踩:编辑器传来的 server 配置里的 headers 和环境变量会原样注册。怎么避:编辑器配置文件里别硬写密钥,用环境变量引用;确认这个配置文件没被提交进仓库。
别在多个客户端同时开同一个工作目录。 为什么会踩:目录快照是按目录缓存的,会话表在进程内,两个客户端各起一个 Agent 子进程,各自有一份状态,但它们改的是同一份磁盘文件。怎么避:一个工作目录同时只挂一个客户端,需要并行就用互相独立的目录。
收束
这层适配的价值,恰恰在于它把”把 Agent 交给别人的界面”这件事的账单摊开了:细粒度权限压成三档、终端专属命令有一部分接不上、内部错误只剩一句安全文案、流式渲染要靠一张进程内的元数据表撑着、工具展示靠一张硬编码的名字对照表。这些不是实现没写完,是接口设计里绕不开的取舍。
如果你要接着往下读,按这个顺序:packages/opencode/src/cli/cmd/acp.ts 看进程怎么起,packages/opencode/src/acp/agent.ts 看方法全集,packages/opencode/src/acp/permission.ts 看最该谨慎的那条路径,最后回到 packages/opencode/src/acp/service.ts 看 prompt 的分派。四个文件读完,编辑器里出任何异常,你都能猜到该去哪一层看。
自检三问:你的工作目录里有没有不该发出去的东西;你上一次点”总是允许”批的是哪一类操作,还记不记得;Agent 改完之后你是看编辑器确认的,还是看 git diff 确认的。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 开源终端 Agent opencode:服务端、SDK 与会话分享 和 opencode 怎么住进编辑器:扩展只是启动器,Agent 还在终端。