前面拆解 dsh-ccgui 的时候,你可能留意过它自带的 cordis.patch.yml。我们节选几项出来看看:
agents:
- id: claude
name: Claude Code
command: npx
args: [-y, '@agentclientprotocol/claude-agent-acp@0.101.0']
- id: kimi
name: Kimi CLI
command: npx
args: [-y, '@moonshot-ai/kimi-code@2.5.2', acp]
- id: opencode
name: OpenCode
command: opencode
args: [acp]看到这儿你可能会好奇:明明是不同公司做的 7 个 Agent,为什么 dsh-ccgui 光靠一个插件就能把它们全驱动起来?
答案很简单——这 7 个程序启动之后,讲的其实是同一种「语言」:ACP(Agent Client Protocol,Agent 客户端协议)。
这一节我们就把这层窗户纸捅破。读完之后,你能搞定两件事:
- 随便抓一个 CLI,能逐行看懂它和 dsh-ccgui 在后台来回传的到底是什么消息;
- 不装任何第三方依赖,自己手搓一个不到 100 行的 ACP Agent,直接塞进 dsh-ccgui 的菜单里跑起来。
一、ACP 到底解决什么问题?
咱们先算笔账。假设市面上有 4 个界面(Zed、JetBrains、Neovim、DSH)想接入 AI Agent,同时有 6 个 Agent(Claude Code、Codex、Kimi、OpenCode、PI、OMP)也想进驻这些界面。
如果大家没有共同的约定,每个界面就得给每个 Agent 单独写一套对接代码,算下来一共要写 $4 \times 6 = 24$ 份。哪天又冒出来一个新 Agent,4 个界面的维护者又得苦哈哈地各自加班再写一份。
但要是大家坐下来约定好讲同一套协议,事情就简单多了:每个界面按协议实现一次,每个 Agent 也实现一次,总共只要 $4 + 6 = 10$ 份代码。以后再有新 Agent 出来,只要它自己把协议接好,所有界面立马就能直接用。

这套大家共同遵守的协议就是 ACP。为了后面好聊,咱们先把协议里的两个核心角色定下来:
- Client(客户端):直接跟人打交道的那一端,负责展示对话、渲染工具调用卡片、弹出权限审批框。像 Zed、JetBrains,还有装了 dsh-ccgui 的 DSH,在这套体系里都属于 Client。
- Agent:在后台真正干苦力的那一端,负责调大模型、读写本地文件、跑终端命令。像 Claude Code、Codex 在这里都是 Agent。
如果你熟悉编辑器生态,肯定觉得这个套路很眼熟。没错,当年的 LSP(Language Server Protocol,语言服务器协议)就是这么干的——编辑器根本不用自己去解析 Python 语法,只要按 LSP 标准去问 Python 的语言服务器,就能直接拿到代码补全和定义跳转的结果。ACP 其实就是把 LSP 的成功经验原封不动地搬到了 AI Agent 领域。
顺带提一嘴它的来历:ACP 是由代码编辑器 Zed(用 Rust 写的那个)的团队发起的,2025 年 8 月 27 日正式发布,第一个吃螃蟹接入进来的是 Google 的 Gemini CLI。在那之前,Zed 只能把 Gemini CLI 塞进内置终端里跑,两边传的全是带颜色控制符的终端字符流,界面根本分不清哪段是正经回复、哪段是在调工具。有了 ACP,这些黑盒字符流才真正变成了结构清晰的消息。到了 2025 年 10 月,JetBrains 也宣布上船,和 Zed 一起推进这个协议。整个协议是完全开源的,采用 Apache 2.0 许可证。
打个比方:前厅和后厨
为了方便理解,整篇文章我们可以把它们想象成一家餐厅:
- Client 是前厅:负责招呼客人、端菜上桌,遇到拿不准的事替客人拍板。
- Agent 是后厨:在里屋真正颠勺做菜的厨师。
- ACP 就是前厅和后厨之间那张格式固定的「传菜单」:客人点了什么菜、后厨实时报进度(「下锅炒了」「这道出锅了」)、后厨拿不准时追问前厅(「这桌客人吃不吃辣?」),直到最后吆喝一声「这桌菜齐了」。
只要后厨按这套传菜单的规矩干活,不管前面换哪家前厅都能无缝对接;反过来,前厅也完全不用操心后厨到底是用煤气灶还是电磁炉炒的菜——对应到技术上,就是不用管背后接的大模型到底是 Claude 还是 DeepSeek。
不过先打个预防针:这个餐厅类比有一个地方是不成立的,而且恰恰是新手最容易踩坑的地方。等会儿聊到「权限控制」的时候,我们再专门把它拎出来讲。
二、先动手:亲自当一回 Client
枯燥的协议规则咱们先放一放,直接上手跑通一遍最直观。
下面我们要写一个极简的 Agent。它甚至不接任何大模型,从头到尾只干一件事:把你发给它的话写进工作目录下的 echo.txt 里,而且动笔写之前,还会乖乖先问你同不同意。咱们姑且叫它「回声 Agent」。
1. 环境准备
不需要装任何第三方依赖包,只要你电脑上有对应语言的运行环境就行:
准备 Python 3.8 或更高版本。新建一个文件夹 echo-agent,在里面建一个 echo_agent.py。
如果你用的是 Windows,记得把后面命令里的 python3 换成 python。
2. 完整代码
先把下面这段代码完整存下来,现在直接当黑盒用就行,等跑出效果了我们再回头拆细节。四种语言的代码我都按同样的逻辑分成了五段:
| 段 | 核心职责 |
|---|---|
| ① 收发 | 从 stdin 读一行 JSON,往 stdout 写一行 JSON |
| ② 反问 | Agent 反过来向 Client 发请求,并阻塞等待 Client 回答 |
| ③ 一轮对话 | 先回一句话,再申请写文件权限,最后看用户的脸色行事 |
| ④ 分发 | 看收到消息的 method 是什么,决定调哪个函数处理 |
| ⑤ 主循环 | 死循环一直读输入,直到 stdin 关闭才退出进程 |
#!/usr/bin/env python3
"""回声 Agent:一个不接模型的 ACP Agent,把你说的话写进 echo.txt。运行:python3 echo_agent.py"""
import json
import os
import sys
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
sessions = {} # sessionId -> 工作目录
next_id = 0 # Agent 自己发出的请求的编号,和 Client 的编号各数各的
# ① 收发:一行一条 JSON。stdout 只能写协议消息,日志写 stderr
def send(msg):
line = json.dumps({"jsonrpc": "2.0", **msg}, ensure_ascii=False, separators=(",", ":"))
print(line, flush=True)
def read(): # 读到 EOF 返回 None,空行跳过
for line in sys.stdin:
if line.strip():
return json.loads(line)
return None
def update(session_id, upd):
send({"method": "session/update", "params": {"sessionId": session_id, "update": upd}})
def say(session_id, text):
update(session_id, {"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text}})
# ② 反过来问 Client:发请求,然后一直读,直到读到同一个 id 的响应
def ask(method, params):
global next_id
my_id = next_id
next_id += 1
send({"id": my_id, "method": method, "params": params})
while True:
msg = read()
if msg is None:
sys.exit(0)
if msg.get("id") == my_id and "method" not in msg:
return msg.get("result", {})
log = json.dumps(msg, ensure_ascii=False, separators=(",", ":"))
print("等回复时收到,先忽略:" + log, file=sys.stderr)
# ③ 一轮对话:先回话,再申请写文件,按用户的决定办
def prompt(params):
sid = params["sessionId"]
text = "".join(b.get("text", "") for b in params["prompt"] if b["type"] == "text")
path = os.path.join(sessions[sid], "echo.txt")
say(sid, "收到:" + text + "\n")
update(sid, {"sessionUpdate": "tool_call", "toolCallId": "call_1", "title": "把这句话写进 echo.txt",
"kind": "edit", "status": "pending", "locations": [{"path": path}]})
answer = ask("session/request_permission", {
"sessionId": sid,
"toolCall": {"toolCallId": "call_1"},
"options": [{"optionId": "allow", "name": "允许", "kind": "allow_once"},
{"optionId": "reject", "name": "拒绝", "kind": "reject_once"}]})
outcome = answer.get("outcome", {})
if outcome.get("outcome") == "cancelled":
return {"stopReason": "cancelled"}
if outcome.get("optionId") == "allow":
with open(path, "w", encoding="utf-8") as f:
f.write(text + "\n")
update(sid, {"sessionUpdate": "tool_call_update", "toolCallId": "call_1", "status": "completed"})
say(sid, "写好了。")
else:
update(sid, {"sessionUpdate": "tool_call_update", "toolCallId": "call_1", "status": "failed"})
say(sid, "你没同意,我就不写了。")
return {"stopReason": "end_turn"}
# ④ 分发:按 method 决定怎么回,不认识的返回 None
def handle(msg):
method, params = msg.get("method"), msg.get("params", {})
if method == "initialize":
return {"protocolVersion": 1, "agentCapabilities": {},
"agentInfo": {"name": "echo-agent", "title": "回声 Agent", "version": "0.1.0"},
"authMethods": []}
if method == "session/new":
sid = "sess_" + str(len(sessions) + 1)
sessions[sid] = params["cwd"]
return {"sessionId": sid}
if method == "session/prompt":
return prompt(params)
return None
# ⑤ 主循环:读到 EOF(Client 关掉了 stdin)就退出
while (msg := read()) is not None:
if "id" not in msg: # 通知(比如 session/cancel)不用回
continue
result = handle(msg)
if result is None:
send({"id": msg["id"], "error": {"code": -32601, "message": "Method not found"}})
else:
send({"id": msg["id"], "result": result})3. 启动它,敲进第一行指令
打开终端,进入 echo-agent 文件夹把程序跑起来:
python3 echo_agent.py回车之后你会发现光标停在那里闪,屏幕上一行字都没有。别慌,这说明它已经正常启动,正在静静地等你(Client)跟它说话。
现在咱们就来扮演一回 Client。把下面这一整行 JSON 复制进终端,直接回车:
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"hand","version":"0.0.1"}}}几乎是一瞬间,它会给你吐回一行:
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"agentInfo":{"name":"echo-agent","title":"回声 Agent","version":"0.1.0"},"authMethods":[]}}不管是 Zed 还是 DSH,每次在后台拉起一个 Agent 时,干的第一件事永远都是这个:先报上自己支持的协议版本和能力,再听对方报上家门。这就叫握手。
4. 开启会话,正式聊一句
在继续之前,我们先另外开一个终端窗口,建一个空的测试目录,待会儿让 Agent 往这里面写文件:
mkdir -p /tmp/acp-demo(如果你用的是 Windows,可以用 mkdir C:\acp-demo 建目录,然后把下面几行 JSON 里的 /tmp/acp-demo 替换成 C:/acp-demo——注意在 JSON 里最好直接用正斜杠 /,省得写反斜杠还得双写转义。)
切回刚才跑着 Agent 的终端,粘贴第二行指令,让它新建一个会话:
{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp/acp-demo","mcpServers":[]}}它会马上给你回一个会话编号(sessionId):
{"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess_1"}}拿到会话编号后,粘贴第三行指令,这次咱们是真正在跟它说话了。在 ACP 协议里,把用户输入的话发给 Agent 用的就是 session/prompt,而用户发出去的这条消息本身也叫 prompt:
{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"sess_1","prompt":[{"type":"text","text":"你好,ACP"}]}}回车之后,它会一口气连回三行,然后光标又停住不动了:
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"收到:你好,ACP\n"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"把这句话写进 echo.txt","kind":"edit","status":"pending","locations":[{"path":"/tmp/acp-demo/echo.txt"}]}}}
{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"sess_1","toolCall":{"toolCallId":"call_1"},"options":[{"optionId":"allow","name":"允许","kind":"allow_once"},{"optionId":"reject","name":"拒绝","kind":"reject_once"}]}}仔细看最后这一行,它跟前面所有的回复长得都不太一样:它居然自己带了个 "method": "session/request_permission"。
这意味着什么?意味着它不是在回答你,而是反过来在问你:「我想写 echo.txt 这个文件,你批不批?」只要你不吭声,它的循环就卡在那里一直死等。
咱们给它批个条子,把下面这行代表「允许」的响应粘进去:
{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow"}}}收到批复后,它立马利索地吐出最后三行:
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"写好了。"}}}}
{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}现在你去打开 /tmp/acp-demo/echo.txt 看看,里面赫然躺着刚才那句「你好,ACP」。
试完之后,按 Ctrl+D(Windows 上是按 Ctrl+Z 再回车)关掉标准输入流,Agent 读到 EOF 就会自己体面地退出进程。
有兴趣的话你可以再试一次「拒绝」的分支:把 /tmp/acp-demo 清空,重新跑起 Agent,把前三条指令再粘一遍;等到它问你要权限的时候,把回复里的 "optionId":"allow" 改成 "optionId":"reject"。你会看到这次目录下根本不会生成 echo.txt,Agent 吐回来的最后三行也会变成这样:
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"failed"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"你没同意,我就不写了。"}}}}
{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}把刚才咱们手敲的这来回 12 行消息画成一张时序图,其实就是下面这个过程:

别看咱们刚才在终端里粘 JSON 粘得像个苦力,其实 dsh-ccgui 在后台干的跟咱们一模一样,只不过它是用代码自动帮你收发这些行而已。接下来,我们就把这 12 行消息掰开揉碎了挨个看一遍。
三、三种消息,两个方向
1. 铁律:一行就是一条消息
在 ACP 里,Client 会把 Agent 当成自己的子进程拉起来,然后两边的通信全靠标准输入输出:
- Client 往 Agent 的
stdin里写数据,Agent 往自己的stdout里吐数据; - 一条完整的消息必须严格占一行,以换行符
\n结尾,消息体内部绝不能出现未转义的换行; - 编码统一用 UTF-8;
- Agent 的
stdout是协议专用通道,只能写纯粹的协议 JSON。如果你自己的代码想打调试日志,必须写到stderr里去(Client 可能会收集stderr报错,也可能会直接忽略)。
这套基于标准输入输出的通信方式,叫 stdio 传输,也是目前 ACP 唯一正式定稿的传输方式(针对远程 Agent 的 HTTP 和 WebSocket 传输目前还在起草阶段)。
这几条规矩里,最后一条最容易让人翻车。很多人写代码习惯随手 print("debug: hello") 或者 console.log(...),这等于直接往 stdout 掺了沙子。我专门测过:dsh-ccgui 底层用的官方 SDK(1.4.0 版本)脾气还算好,读到这种非 JSON 的杂音行会先把它丢掉、打一条错误日志,然后接着往下跑;但协议规范里可没要求 Client 必须这么宽容,换个严格点的 Client 当场就给你断开连接了。所以你会看到,前面回声 Agent 里凡是打日志的地方,全都老老实实写进了 stderr。
2. 消息总共就三种长相
刚才那 12 行文本,每一行其实都是标准的 JSON-RPC 2.0 消息。JSON-RPC 是早在 2010 年就定下来的老前辈了,专门用来规范「怎么用 JSON 调远程函数」。它把天下所有的消息只分成三类:
- 请求(Request):同时带有
method(要对方干啥)和id(小票编号)。收到请求的一方必须给个回音。 - 响应(Response):只有
id,没有method。内容里要么装着成功的result,要么装着报错的error。 - 通知(Notification):只有
method,没有id。发出去就完事了,对方不需要(也不能)回消息。
所以你肉眼看日志的时候,根本不用背方法名,光扫一眼有没有 method 和 id 这两个字段,就能把消息的性质分得明明白白:

拿咱们刚才敲的那 12 行对号入座一下:你发出去的 initialize、session/new、session/prompt 全是请求;Agent 给它们回的确认都是响应;而 Agent 在干活中途吐出来的那四条 session/update,全都没有 id,属于标准的通知——所以当时你一条都没回它。
3. 双向请求,各自记账
细心的你刚才可能发现了一个怪事:在上面那 12 行记录里,居然有两条消息的 id 都等于 0——一条是你最开始发的 initialize,另一条是 Agent 中途反问你的 session/request_permission。两边都用 0 号,难道不会串台搞混吗?
答案是真不会。因为在 JSON-RPC 里,Client 和 Agent 各自拿一个小本本,只给自己发出去的请求编号,两套编号是完全独立的。