前言
最近 DSH 挺火的,但我自己平时写代码,主力还是习惯用 Claude Code 和 OMP
刚上手 DSH 的时候我就发现,在 DSH 里没法很丝滑地直接用上它们。所以我自己动手实现了 dsh-ccgui 这个插件。

这一篇咱们就来拆解一下:像 Claude Code 这种本来不归 DSH 管的外部 AI 工具,到底是怎么被我一步步「塞」进 DSH 里的。
读完这一篇,你能搞明白三件事:
- 说出 dsh-ccgui 到底解决了什么痛点;
- 跑通一段现成的小脚本,亲眼看看怎么按 ACP 格式和一个 AI 工具聊上一轮;
- 最后在源码里指出来,dsh-ccgui 到底是把这些工具挂在了 DSH 架构的哪个接口上。
咱们在第一篇里聊过,DSH 后台有一块专门负责「跟大模型说话」的积木,官方管它叫模型适配器(LLM Adapter)。
像 DeepSeek 这类纯粹的大模型,本质上只会「一问一答」。DSH 拿着上下文去问它:「下一步干啥?」它回答:「帮我读一下 index.html。」注意,这时候真正挽起袖子去磁盘上读文件的,是 DSH 自己内置的工具。
换句话说:模型只管动嘴,真正动手干活的是 DSH。那个推着 AI 一步接一步往前走的「主循环」,也稳稳地抓在 DSH 手里。
但 Claude Code 这类工具就完全不是这么回事了。你在终端里敲一句话给它,它会自己琢磨、自己去读文件、自己开终端跑命令,报错了还会自己改,一直折腾到把活儿干完为止。
也就是说,它自己肚子里本来就揣着一套主循环和一整套工具,它本身就是一个能独立干活的完整智能体(Agent),而不单单是一个光会动嘴的模型。
在 dsh-ccgui 的文档里,我把这类跑在命令行里的 AI 编程工具统称为 Agent CLI。既然 CLI 本身就是「命令行工具」的意思,为了顺口,下面咱们就直接叫它们 CLI。

既然两边都是有主见的「大爷」,那想把 CLI 塞进 DSH 里,咱们就必须先解决两个核心冲突:
- 话怎么传? DSH 和外部 CLI 本来是两个八竿子打不着的独立程序,它俩凑一块儿该用什么格式对暗号?
- 到底谁当家? 两边肚子里都有主循环,手头都有工具,真要跑一条命令的时候,到底该听谁的、由谁来跑?
咱们先来解决第一个问题:话怎么传。
一、CLI 对话过程
在枯燥地讲协议格式之前,咱们先拉一个 CLI 出来真刀真枪地聊上一轮。这次我用一段现成的小脚本来扮演 DSH,你只管在终端里按回车看戏就行。
不过,真实的 CLI 既要登录账号,每次大模型吐出来的回答又千变万化,拿来做实验很不稳定。所以我这次用一个「假 CLI」。
在 dsh-ccgui 的测试目录里,我专门写了一个测试用的假 CLI:它背后不连任何大模型,收到什么指令就严格按照预先写死的剧本回答,但它收发消息的协议格式跟真正的 Claude Code 分毫不差。
在上一篇里,我已经把 dsh-ccgui 的源码下载到了 dsh-branch-chip 文件夹下的 .reference/dsh-ccgui 目录里,那个假 CLI 就藏在里面。
如果你当时跳过了那一步没下载,现在打开终端进到 dsh-branch-chip 文件夹,补跑一下这行命令就行:
git clone --depth 1 https://github.com/zhukunpenglinyutong/dsh-ccgui.git .reference/dsh-ccgui接下来,我请出今天扮演 DSH 的小脚本。它会在后台替你把假 CLI 启动起来,然后一句接一句地跟它发消息:
把下载好的压缩包解压,把里面的 acp-walkthrough.mjs 放进 dsh-branch-chip 文件夹,然后在终端里跑起来:
node acp-walkthrough.mjs脚本每发出一句就会自动暂停,等你按一下回车,它才接着发下一句。这时候你在终端屏幕上看到的,就是原汁原味的协议原文:带 → 的是脚本发出去的话,带 ← 的是假 CLI 回过来的话,严格遵守「一条消息占一行」的规矩。
(之所以让你跑脚本而不是手粘,是因为协议规定一条消息不管多长都必须挤在一行里,最长的那条足足有四百多个字符。要是让你手动往终端里复制粘贴,但凡中间不小心折了行或者漏了个引号,假 CLI 就会当场装死不吭声,排查起来非常折磨人。现在脚本替你发的还是完全一样的消息,只不过苦力活交给了回车键。)
在下面的讲解里,为了让你一眼看清里面的字段结构,我把原本挤在一行里的 JSON 展开成了多行。记住,换行和缩进只是为了咱们阅读方便,真正顺着管道发出去的时候,它们依然是紧凑的一整行。
第一句:打招呼
脚本发出的第一句话是这样的:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": 1,
"clientCapabilities": {}
}
}假 CLI 立马秒回:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": 1,
"agentCapabilities": {
"loadSession": true,
"sessionCapabilities": {
"resume": {},
"delete": {},
"close": {}
}
},
"authMethods": []
}
}这里咱们重点盯住两个地方:
- 你发出去的那句里带着
"id": 1,它回过来的消息里同样挂着"id": 1。两边就是靠这个号码牌,才能对上「你现在回的到底是我的哪一句话」。 - 回复里的
agentCapabilities是假 CLI 在自报家门,告诉我它有哪些本事。比如里面的loadSession: true,就是在说「我支持把以前聊了一半的旧会话加载回来接着聊」。
第二句:新建会话
打完招呼,脚本发出了第二句:
{
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/tmp",
"mcpServers": []
}
}里面的 cwd 参数是在交代工作目录,意思是「待会儿你干活的时候,就在这个文件夹里折腾」。如果你是在 Windows 上跑的脚本,脚本会自动把它替换成你当前的文件夹路径,终端里印出来的就是你电脑上的真实路径。
假 CLI 收到后回答:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"sessionId": "fake-1",
"configOptions": [
{
"id": "model",
"name": "Model",
"category": "model",
"type": "select",
"currentValue": "fake-small",
"options": [
{ "value": "fake-small", "name": "Fake Small" },
{ "value": "fake-large", "name": "Fake Large" }
]
}
]
}
}这里同样看两处关键信息:
sessionId是假 CLI 给这次会话分配的专属房间号,叫fake-1。打这往后,咱们跟它说的每一句话都得把这个房间号带上。configOptions是它顺手报上来的「可调设置项」。比如这里它告诉我:我支持切换模型(model),目前默认选中的是fake-small,你也可以随时切到Fake Large。
第三句:发送消息
房间开好了,脚本发出第三句,也就是真正模拟用户在聊天框里打字:
{
"jsonrpc": "2.0",
"id": 3,
"method": "session/prompt",
"params": {
"sessionId": "fake-1",
"prompt": [
{ "type": "text", "text": "你好" }
]
}
}注意看,这一次假 CLI 没急着结账,而是连着吐出了三条消息:
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "fake-1",
"update": {
"sessionUpdate": "agent_thought_chunk",
"content": { "type": "text", "text": "thinking" }
}
}
}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "fake-1",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "echo: 你好" }
}
}
}
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"stopReason": "end_turn",
"usage": {
"totalTokens": 1200,
"inputTokens": 150,
"outputTokens": 50,
"cachedReadTokens": 1000
}
}
}仔细对比这三条:前两条消息身上根本没有 id 字段!这意味着它们不是在回答任何请求,而是 CLI 在干活中途主动往外广播的「实时进度」——第一条说自己正在思考(thinking),第二条吐出了真正的回复正文(echo: 你好)。
直到第三条,才重新挂上了 "id": 3。这才是对你刚才那句 session/prompt 的正式结账:里面的 stopReason: "end_turn" 宣告这一轮对话圆满结束。
以后你用真正的 Claude Code 或 Codex 时也是一模一样的节奏:在它闷头干活的那几分钟里,管道里会像流水一样源源不断地涌出不带 id 的进度通知;直到所有事情全部搞定,它才会把最后那条带着 id 的响应发回来。
第四句:申请权限
再来按一下回车,这次脚本会发一条特殊指令,命令假 CLI 去写一个文件:
{
"jsonrpc": "2.0",
"id": 4,
"method": "session/prompt",
"params": {
"sessionId": "fake-1",
"prompt": [
{ "type": "text", "text": "WRITE hello.txt" }
]
}
}假 CLI 收到后,先照例发来两条进度通知,报告说自己准备动手写 /tmp/hello.txt 了。紧接着,它突然抛出了这么一条消息:
{
"jsonrpc": "2.0",
"id": 1000,
"method": "session/request_permission",
"params": {
"sessionId": "fake-1",
"toolCall": {
"toolCallId": "tool-1",
"title": "/tmp/hello.txt",
"kind": "edit",
"status": "pending",
"content": [
{ "type": "diff", "path": "/tmp/hello.txt", "oldText": null, "newText": "hi\n" }
]
},
"options": [
{ "optionId": "once", "name": "Allow once", "kind": "allow_once" },
{ "optionId": "reject", "name": "Reject", "kind": "reject_once" }
]
}
}发完这一条,假 CLI 就彻底停住不动了。
为什么停了?你仔细看这条消息的长相,它跟前面发来的所有消息都不同:它身上既有 id(1000),又有 method(session/request_permission)。
这说明角色反转了——这次不是它在回答你,而是 CLI 反过来在向你发请求,并且死死卡在那里等你批复!
它请示的内容很清楚:我打算新建一个 /tmp/hello.txt,往里面写一行 hi,你批不批?同时它在 options 里给你递了两个按钮选项:选 once 代表「仅允许这一次」,选 reject 代表「拒绝」。
只要你不表态,它就永远等下去。咱们按一下回车,让脚本替用户按下「同意这一次」,把下面这条批复发回去:
{
"jsonrpc": "2.0",
"id": 1000,
"result": {
"outcome": {
"outcome": "selected",
"optionId": "once"
}
}
}看到了吧,我回过去的 id 也是 1000,正好跟它请示的那条消息严丝合缝地对上。假 CLI 一拿到圣旨,立刻接着往下干活,一口气打出最后三条收工消息:
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "fake-1",
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "tool-1",
"status": "completed",
"content": [
{ "type": "content", "content": { "type": "text", "text": "Created hello.txt" } }
]
}
}
}
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "fake-1",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "done" }
}
}
}
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"stopReason": "end_turn",
"usage": {
"totalTokens": 1200,
"inputTokens": 150,
"outputTokens": 50,
"cachedReadTokens": 1000
}
}
}这时候脚本跑完退出了,文件是不是真的写进磁盘了呢?我在终端里验一下货:
cat /tmp/hello.txt屏幕上稳稳地印出了 hi。