拆解 DSH-CCGUI 上

8,644 字 · 预计阅读 25 分钟

前言

最近 DSH 挺火的,但我自己平时写代码,主力还是习惯用 Claude Code 和 OMP

刚上手 DSH 的时候我就发现,在 DSH 里没法很丝滑地直接用上它们。所以我自己动手实现了 dsh-ccgui 这个插件。

装上 dsh-ccgui 之后,输入框旁边多了一个菜单,可以选 Claude Code、Kimi CLI、Codex CLI、PI CLI、OMP CLI、OpenCode

这一篇咱们就来拆解一下:像 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。

接模型时,DSH 负责循环和执行;接 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 文件夹,补跑一下这行命令就行:

bash
git clone --depth 1 https://github.com/zhukunpenglinyutong/dsh-ccgui.git .reference/dsh-ccgui

接下来,我请出今天扮演 DSH 的小脚本。它会在后台替你把假 CLI 启动起来,然后一句接一句地跟它发消息:

把下载好的压缩包解压,把里面的 acp-walkthrough.mjs 放进 dsh-branch-chip 文件夹,然后在终端里跑起来:

bash
node acp-walkthrough.mjs

脚本每发出一句就会自动暂停,等你按一下回车,它才接着发下一句。这时候你在终端屏幕上看到的,就是原汁原味的协议原文:带 → 的是脚本发出去的话,带 ← 的是假 CLI 回过来的话,严格遵守「一条消息占一行」的规矩。

(之所以让你跑脚本而不是手粘,是因为协议规定一条消息不管多长都必须挤在一行里,最长的那条足足有四百多个字符。要是让你手动往终端里复制粘贴,但凡中间不小心折了行或者漏了个引号,假 CLI 就会当场装死不吭声,排查起来非常折磨人。现在脚本替你发的还是完全一样的消息,只不过苦力活交给了回车键。)

在下面的讲解里,为了让你一眼看清里面的字段结构,我把原本挤在一行里的 JSON 展开成了多行。记住,换行和缩进只是为了咱们阅读方便,真正顺着管道发出去的时候,它们依然是紧凑的一整行。

第一句:打招呼

脚本发出的第一句话是这样的:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": 1,
    "clientCapabilities": {}
  }
}

假 CLI 立马秒回:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": 1,
    "agentCapabilities": {
      "loadSession": true,
      "sessionCapabilities": {
        "resume": {},
        "delete": {},
        "close": {}
      }
    },
    "authMethods": []
  }
}

这里咱们重点盯住两个地方:

  • 你发出去的那句里带着 "id": 1,它回过来的消息里同样挂着 "id": 1。两边就是靠这个号码牌,才能对上「你现在回的到底是我的哪一句话」。
  • 回复里的 agentCapabilities 是假 CLI 在自报家门,告诉我它有哪些本事。比如里面的 loadSession: true,就是在说「我支持把以前聊了一半的旧会话加载回来接着聊」。

第二句:新建会话

打完招呼,脚本发出了第二句:

json
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "session/new",
  "params": {
    "cwd": "/tmp",
    "mcpServers": []
  }
}

里面的 cwd 参数是在交代工作目录,意思是「待会儿你干活的时候,就在这个文件夹里折腾」。如果你是在 Windows 上跑的脚本,脚本会自动把它替换成你当前的文件夹路径,终端里印出来的就是你电脑上的真实路径。

假 CLI 收到后回答:

json
{
  "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。

第三句:发送消息

房间开好了,脚本发出第三句,也就是真正模拟用户在聊天框里打字:

json
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "session/prompt",
  "params": {
    "sessionId": "fake-1",
    "prompt": [
      { "type": "text", "text": "你好" }
    ]
  }
}

注意看,这一次假 CLI 没急着结账,而是连着吐出了三条消息:

json
{
  "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 去写一个文件:

json
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "session/prompt",
  "params": {
    "sessionId": "fake-1",
    "prompt": [
      { "type": "text", "text": "WRITE hello.txt" }
    ]
  }
}

假 CLI 收到后,先照例发来两条进度通知,报告说自己准备动手写 /tmp/hello.txt 了。紧接着,它突然抛出了这么一条消息:

json
{
  "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 代表「拒绝」。

只要你不表态,它就永远等下去。咱们按一下回车,让脚本替用户按下「同意这一次」,把下面这条批复发回去:

json
{
  "jsonrpc": "2.0",
  "id": 1000,
  "result": {
    "outcome": {
      "outcome": "selected",
      "optionId": "once"
    }
  }
}

看到了吧,我回过去的 id 也是 1000,正好跟它请示的那条消息严丝合缝地对上。假 CLI 一拿到圣旨,立刻接着往下干活,一口气打出最后三条收工消息:

json
{
  "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
    }
  }
}

这时候脚本跑完退出了,文件是不是真的写进磁盘了呢?我在终端里验一下货:

bash
cat /tmp/hello.txt

屏幕上稳稳地印出了 hi。

本文是会员内容

全文 8,644 字,已试读约 30%。开通专业版可阅读全文,并解锁全部官方课程与后续更新。

开通专业版

支持 7 天无理由退款 · 早鸟价 99 元/年(售完恢复365/年)

课程中涉及的第三方项目,其著作权与商标归各自权利人所有;引用处的署名与许可见《开源与来源说明》。