DeepSeek Harness
白皮书
一切皆插件:从装上它,到看懂它,到自己扩展它
一切皆插件:从装上它,到看懂它,到自己扩展它
七个部分,三十八章。每一部只回答一个问题,答完就把你交给下一部。你不必从头读到尾,但顺序是有理由的。
| 部 | 你在这一部要回答的问题 | 章 |
|---|---|---|
| 一 · 认识它 | 这东西到底是什么,值不值得我花时间 | 01–05 |
| 二 · 装上它,用起来 | 怎么在我自己的机器上跑起来、配上模型 | 06–10 |
| 三 · 它由什么组成 | 我看到的这两百多个包,是按什么分的 | 11–14 |
| 四 · 走完它的全程 | 我说一句话之后,里面究竟发生了什么 | 15–20 |
| 五 · 它的五个特点 | 它跟别的 harness 的差别落在哪几处 | 21–25 |
| 六 · 插件 | 我要自己扩展它,从哪一行开始写 | 26–35 |
| 七 · 判断 | 这一轮我到底该不该用它 | 36–38 |
| 你是哪一种 | 只读这些就够 | 篇幅 |
|---|---|---|
| 只想用它 | 01 → 04 → 06–10 → 38 | 约 32 页 |
| 要写插件 | 03 → 04 → 11 → 13 → 26–35 | 约 36 页 |
| 要做选型 | 一页摘要 → 05 → 第五部代价页 → 36–38 | 约 23 页 |
时间只够读两部,就读第一部和第七部:一头告诉你它是什么,一头告诉你要不要碰。
harness 是让模型能读文件、跑命令、连着干活的那层外围程序;模型本身只会文字进文字出。DeepSeek 在 2026-08-13 用 MIT 协议开源了自己的这一层,叫 dsh。
| 优点 | 任何一层都能换:模型、文件系统、沙箱、压缩策略;连主循环、工具表、会话日志也只是配置里的一行——能摘能换,但没有契约接住你:它是「一行配置」,不是「一个接缝」 |
| 优点 | 模型看见的每个字都写进只追加的日志,完整、可重放、不加密 |
| 优点 | 质量门禁密度罕见:37 条 verify 脚本、逐文件覆盖率、文档陈旧会让 CI 变红。代价是它只服务内部:拦不住加载路径上的错误(33.1),对 fork 是长亮红灯(25.3) |
| 代价 | 概念门槛高一个量级;改配置的值不用重启,但增删插件本身要重启,浏览器侧尤其如此 |
| 代价 | 插件与主程序同进程、无隔离:装一个插件等于交出全部权限 |
| 代价 | 官方明说会有破坏兼容的变更;不接受外部 PR,也没有 Issues 入口 |
先不装、先不改。把它是什么、跟别人差在哪、现在处在什么阶段,一次说清楚。
模型只会文字进、文字出:读不了文件,按不下回车,也记不住上一轮。把这三样接上、让循环转起来的那层程序,就是 harness。
把身份设定、工具说明、历史对话拼成一次请求,顺序直接决定表现。
模型说「调用 bash,命令是 ls」,得有人真去执行,再把输出交回去。
说了什么、调了什么、返回了什么全落进日志,下一轮和复盘都靠它。
再问模型一次,还是收工?判断错了不是半途而废,就是原地打转烧钱。
删文件、装依赖之前先让人点头。没有这一层,就不敢让它碰真实项目。
docs/architecture.md:13apps/cli/src/profile-boot.ts:60判断一个东西是不是真插件化,只有一条标准:产品自带的功能,本身是不是也是一行普通配置。
能被一个 id 定位、能被一行 disabled: true 关掉、能被别的包顶替——三条都满足才叫零件;否则「插件」只是厂商在固定内核旁边留给你的几个挂钩。
dsh 满足。每个组合的根配置文件,字面上就是一个空数组,整个产品是打在这个空数组上的若干层补丁:
packages/bundle/web-app/cordis.patch.ymldisabled: true,全部指向 base 层的行。这不是理论上可行。你跑 dsh web 时,官方的 web 组合第一件事就是把基础层里二十多个功能整块摘掉。
dsh 的组合是分层叠加的:先铺一层通用的基础配置(base),再叠一层针对 Web 界面的配置(web-app)。而 web-app 这一层干的事,主要是关行——
| 被关掉的行 | 本来是什么 |
|---|---|
| tool-bash / tool-fs / tool-fs-search | 执行命令、读写文件、搜索代码这三组工具 |
| plan-mode / tool-todo / tool-goal | 计划模式与待办、目标状态的维护 |
| compaction-basic / tool-result-pruner | 上下文压缩与工具结果裁剪 |
| tool-subagent / tool-workflow / tool-ralph | 派子代理、跑工作流、循环重试 |
关掉之后不是没了,而是交给下一层:每开一个会话,再按这个会话选定的组合把需要的行装回来。同一个进程因此可以同时跑一个功能齐全的 agent 和一个只有两把工具的极简 agent。
disabled 而不是删除是刻意的——base 是所有组合共享的层,删掉的行会在某人重排组合的那天悄悄复活。前四个回答同一件事:能力是谁提供的;后两个讲事情记在哪、谁去干。各花十秒钟对上号。
dsh 就是一个 harness。apply 函数的模块,外加配置清单里的一行。产品的每一部分都是插件,包括主循环。bash、read。内置 52 个。再复杂的细节都能塞进这三个词里。读不下去的时候回到这一页。
负责让插件挂上去、找到彼此、卸载时干净地撤回来。它不含任何产品语义——没有 agent,没有会话,没有工具。
产品的全部功能都在这里,219 个包。模型适配器、工具表、会话日志、主循环,一个不例外。
一份 YAML,逐行写明这次启动装哪些插件、每个插件怎么配。改产品形态就是改这份清单。
三者的关系是:框架不知道产品是什么,插件不知道自己会被谁组合,清单才是那个做决定的人。这句话解释了本书后面几乎所有的设计选择。
三个词各自的来历留给后面:框架叫 Cordis,不是 DeepSeek 为这次发明的(附录 A),它只干三件事(第 11 章);219 个包按什么分组见第 12 章;那份清单不是写出来的、是若干层补丁叠出来的(第 13 章),--dump-config 能把叠完的结果连同来源注释一起打印出来(10.5)。
这几个数字放在一起,比任何一段评价都更能说明它现在是什么。
| 信号 | 值 | 怎么读 |
|---|---|---|
| 开源时间 | 2026-08-13 | 与 V4-Pro 正式上线、API 调价同一天 |
| 许可 | MIT | 作者在 HN 澄清过:永久,不会改 |
| 版本 | 0.1.0-rc | 全部是预发布,没有一个正式 release |
| Star | 87,843 | 开源约 25 小时后的快照 |
| Issue / PR | 0 / 0 | README 与 CONTRIBUTING 把反馈引向 Discussions 和 Discord,没给 Issues 入口 |
| 发布物 | 无 | 没有 tag、没有 release、没有 CHANGELOG |
还有一条更有信息量:公开仓库最近一次合并来自另一个组织的 2519 号 PR,而这里的 PR 计数是 0。公开仓库更像一份单向发布的镜像。推下去有三层意思:你读到的是内部主干在某个时点的快照;这两条线此后可能已经分叉,你看不到分在哪;社区拿到的任何东西都永远滞后一截。
这不是免责声明,是决策依据。四条里踩中任意两条,这一轮就先别上。
README 原话是「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」,开发约定里写得更直白:正式版之前宁可重命名重组,也不做兼容层。
会话格式版本号停在 0,不作兼容承诺,后端遇到旧格式直接拒绝而不是升级。
贡献指南写明目前不接受外部 pull request,反馈只引向 Discussions 和 Discord。发现缺陷只能自己在本地打补丁,或者等。
插件跑在主进程、拥有你的全部权限,安装就是一次无审核的 pnpm add。
这一部里的每条命令都能直接敲。装、配、用、接脚本、找配置文件放在哪——按顺序走完,你会有一个能干活的 agent 和一份自己能改的配置。
README.md · package.json engines五项清单:前四项决定装不装得上,key 那项可以留到最后。
^22.19.0 || >=24.0.0 里,这是仓库写死的引擎范围。dsh plugin 装插件。它必须在 PATH 上。dsh,那个目录就是默认工作区根。README.md#run装好 Node 之后,从敲下命令到看见界面,中间没有别的步骤。
node -v 打印的版本必须落在 22.19.0 ~ 22.x 之间,或者 24.0.0 及以上;23.x 不在支持范围内。不满足就先升级,后面每一步都白搭。
成功的标志是终端打印出一行访问地址,并且进程停在那里不退出。
默认是 http://127.0.0.1:3080。页面能加载出来,就说明这一步成了。
HTTP_PROXY / HTTPS_PROXY,启动时得带上 NODE_USE_ENV_PROXY=1。npx 拉包走的是 npm 自己的代理设置,两回事。Ctrl+C 进程能干净退出。apps/cli/package.json 的 bin 字段dsh 命令都假设它在 PATH 上,而 npx 不会把它装进去dsh 是 @deepseek-ai/dsh 这个包声明的可执行文件。它怎么进到你的终端,决定了后面几章你要不要每次都加前缀。
| 怎么调用它 | 代价 |
|---|---|
| npx @deepseek-ai/dsh … | 不落地任何东西,随用随取。每条命令都得带这个前缀 |
| npm i -g @deepseek-ai/dsh | 把 dsh 装进 PATH,之后裸命令直接能敲。升级要你自己管 |
| pnpm dsh … | 只在源码检出的仓库根目录里可用,不进 PATH |
本书从第 9 章起写的都是裸 dsh。没有全局装的话,把每条命令前面补上 npx @deepseek-ai/dsh 就是等价写法——dsh web 即 npx @deepseek-ai/dsh web。
npx @deepseek-ai/dsh@0.1.0-rc.6 web。pnpm dsh 只在仓库里。敲一次 dsh --help 能出启动器的帮助,就是第二种。apps/cli/reference/README.md 的 Source execution 一节pnpm run build 是不能省的那步pnpm dsh 直接跑 TypeScript 入口,它不构建,也不检查产物新不新。
漏了构建,boot 阶段直接报模块解析错误,不会提示你去补。
pnpm run build。packages/boot/app-boot/README.md自动发生的只有三件事:前两件在你的家目录里,第三件在你敲命令的那个目录里。
web 和 headless 两个组合在首次使用时从随包模板自动初始化。别的名字不会自动建,会明确报错并提示你用 dsh plugin 创建。
每次启动都重建 $DSH_HOME/profiles/node_modules 里的扁平软链,让组合里写裸包名也能解析到安装自带的那份。
调用目录成为默认工作区根,该目录下适用的 AGENTS.md 或 CLAUDE.md 会被读进上下文,渲染预算 65,536 字节。
$DSH_HOME/profiles/<名字>/cordis.yml——它每次启动都被覆写成空列表。你要改的是同目录下的 cordis.patch.yml。docs/user/guide/providers.md不用重启服务,不用重开会话,不用改任何文件。
页面上第一张就是 DeepSeek 卡片,它只暴露一个 API key 字段。
模型路线立刻可用。改动在下一次请求时生效,服务器不需要重启。
选中它,它同时成为以后新建会话的默认模型。已经发过请求的会话保留自己日志里记下的那一个,不受影响。
Select model 并一直锁着,直到你另选一个。MISSING_CREDENTIAL。packages/credentials/credentials-local/README.md写入是单向的:页面能提交,不能读回。
保存之后,浏览器收到的只是一个脱敏描述符,永远拿不到明文。密钥落在 $DSH_HOME/.credentials.yaml,是 0700 目录下的 0600 文件;设置文件里只留一个指向它的凭据引用。
规则是先命中的赢——和第 13 章讲配置补丁时「越晚的层赢」正好相反。
| 1 | 继承的进程环境 | 启动时环境里已有的变量。永远最优先,而且只读 |
| 2 | $DSH_HOME/.credentials.yaml | 托管文档,Models 页面写的就是它 |
| 3 | 调用目录的 .env | 项目级环境层 |
| 4 | $DSH_HOME/.env | 用户级环境层 |
第 1 层最高,正是 CI 和容器该走的那条:DEEPSEEK_API_KEY=… dsh …、CI secret、容器的 -e 都落在这里,不需要任何文件。它也因此是只读的。
0600 挡的是别的系统用户,不是模型。真正守住的是两条:从不把这个文件的路径交给模型,从不把它装进 process.env。docs/user/guide/providers.md两个按钮,对应两条完全不同的路径。
目录内的厂商,比如 Anthropic 或 OpenAI。选中、填 key、保存,端点、协议、模型列表全由已安装的目录提供。
公司网关、自建服务、目录里没有的厂商。要填五项:小写 Provider ID、base URL、API 协议、凭据、至少一个模型。
api-version,Codex 走 OAuth。给它附一张图,请求在发出去之前就被拒绝,并且点名是哪个模型。
表单里没有这个字段。要让一个自定义厂商的视觉模型收图,得去 $DSH_HOME/settings.yaml 给那个模型补一行 input。本片段假设你已经在表单里建好了 my-gateway 这个厂商,这里只是补表单填不了的那一项。
apiKeyEnv 是按请求解析的凭据引用,机密本身不进这个文件。写在路线上的 defaultInput 是回退而不是覆盖,默认 [text]。目录内的厂商没有 models 列表可写,要收窄某个模型的模态得写在 modelOverrides 下面、按模型 id 作键。DeepSeek 自己的 chat-completions 路线本身就是纯文本,配不出图片输入。
input 是一个声明,不是一次校验。你写了端点其实不支持的模态,这里不拦;请求发出去,由厂商替你拒绝。docs/user/guide/index.md全新的 Web 界面没有已选工作区。这一步没人替你做。
入口在界面里始终可见,因为在选定之前你什么也开始不了。
加完之后还要选中它——添加和选中是两个动作。
dsh 进程当然知道自己从哪个目录启动,那个目录也确实是默认的文件系统位置。但它只是一个默认值,不等于你授权 agent 去动它。所以界面把「选定」做成一个你必须亲手完成的动作。
docs/user/guide/index.md · apps/cli/reference/README.md官方指南给的第一句话就是让它读代码库:Summarize this repository and identify its main packages。
| 读写工作区文件 | 范围就是你选定的那个目录 |
| 执行命令 | 走沙箱化的 bash |
| 委派子代理 | 把能并行的活拆出去 |
| 维护一份计划 | 计划本身是被记进日志的状态 |
建会话时还可以挑一个会话预设:一份决定这个会话带哪些面向模型的插件的配方,随包的有四个。其中「极简模式」把系统提示固定成一句话,只带持久 bash 和 str_replace_editor,其余面向模型的插件在那个 agent 里全部不在场;而浏览器、工作区、持久化、沙箱这套宿主设施照旧。预设是写进会话日志的,重开旧会话不会换成另一份。
packages/interaction/permission-presets/README.md界面上是一个选择器,实际上一次选中两件事:沙箱管到哪,以及需要审批时怎么办。
| 档位 | 沙箱 | 审批 |
|---|---|---|
| read-only | 完全不许写 | 问你 |
| workspace-write | 写入限制在工作区内 | 问你(新会话默认) |
| danger-full-access | 不再限制 | 不问,直接拒 |
三档里 read-only 适合「只让它读和分析、不许改任何文件」的场景。一次批准也只对被问的那一次操作生效。进程级的回退档位可以用环境变量 DSH_PERMISSION_MODE 改——写成 danger-full-access 会顺手把审批策略也关成 never。
danger-full-access 不是「什么都允许」。它把审批策略关成 never——仍然需要审批的动作会被自动拒绝,既不弹窗,也不放行。四步,十分钟以内。做完你手上会有一份 git diff,那才是「它真的在干活」的证据。
干净的工作树是这次验收的基线:跑完之后 git status 里出现的每一行都是它写的。
例:「读一遍 src/,把 README 里过时的安装步骤改对,然后跑一次测试,把结果告诉我。」有对象、有动作、有验收方式。
工作区内的读写和跑命令都在档位允许范围内,不打断你。bash 要写到工作区外面,才会带着一句理由申请更宽的沙箱模式——你点同意,只对那一次调用生效。
git diff 看改了什么,再自己跑一遍测试。
git diff 里能看到那处改动,你自己跑测试的结果和它说的一致。对不上,问题多半不在模型,在你的任务没有验收方式。apps/cli/README.mddsh 命令本身。它只管把层叠好、把树点起来,不认识任何应用功能。选一个组合,把那棵插件树点起来。区别只在点起来之后谁接管。
| 命令 | 做什么 |
|---|---|
| dsh --profile <名字> | 启动指定组合 |
| dsh --profile headless "任务" | 跑完一次任务、打印结果、退出 |
| dsh web | 等价于 --profile web 的硬编码别名 |
| dsh plugin --profile <名字> | 管理该组合的插件,参数转发给 pnpm |
这一章往后写的都是裸 dsh。没全局装的话,按 6.3 的写法在前面补 npx @deepseek-ai/dsh。
dsh 自己只认四个 flag——--profile、可重复的 --patch、--dump-config、--dump-default-config——从它不认识的第一个 token 起,后面全部原样交给启动的那个应用。所以 dsh --profile web --port 8080 里的 --port 属于 web 应用;dsh --help 打的是启动器的帮助,dsh --profile web --help 打的是 web 应用的帮助,而且什么都不会启动。
packages/bundle/headless/README.md最轻的一条脚本入口:只看退出码和 stdout。
没有特殊通道,走的就是界面里那条路。
等完 agent 自己发起的每一轮,不是等第一次回复。
先落盘再读结果,打印的和存下的是同一份。
turn/end 是 completed 就是 0,其他都是 1。成功的运行 stderr 全空,也不开任何端口。packages/bundle/base/cordis.patch.yml · credentials-local/README.md这个组合不挂 Host、不挂 Web、不开端口。第 7 章那条填表路径在这里不存在。
凭据四层里第 1 层是继承的进程环境,优先级最高(7.2)。CI 的 secret、容器的 -e、上面这种一次性前缀,全都落在这一层,不需要往磁盘上写任何文件。
模型路线用的是 base 层的出厂默认:厂商 deepseek-official、模型 deepseek-v4-flash。要换成别的,去 $DSH_HOME/cordis.patch.yml(对这台机器上所有组合生效)或者组合自己的 cordis.patch.yml 里,patch agent-default-model 那一行的 provider 与 model。
$DSH_HOME/.credentials.yaml,headless 同样读得到(第 2 层)。只有跨机器——CI、容器、别人的构建机——才必须走环境变量。echo $? 是 0、stdout 有一段助手文本、stderr 全空。报 MISSING_CREDENTIAL 就是这四层一层都没命中。apps/cli/reference/README.md 的 Web alias 一节默认服务在 http://127.0.0.1:3080,这是一个本机工具的默认值。
| --host | 换绑定地址,但填 0.0.0.0 会以用法错误退出 |
| --port | 换端口 |
| --trusted-host | 可重复,给 /api 的浏览器信任围栏加一个可接受的名字 |
「浏览器信任围栏」是 /api 那一侧的一道检查:只接受来自被认可名字的请求。--trusted-host 就是给这一次调用临时添一个名字。
第一次 SIGINT 或 SIGTERM 开始一段最多五秒的优雅拆卸。SIGTERM 是监管进程的普通停止请求,退出码 0;SIGINT 退 130。第二次信号立刻强制退出。
--host 把它开给别的机器——填 0.0.0.0 会直接以用法错误退出,官方给的理由是「这会把远程代码执行暴露到网络上」。注意命令行拦得住、配置层拦不住:webserver 那一行的 host 只认两个值,改配置仍然绑得上。为什么最好别这么干,见 37.3。docs/user/guide/python-sdk.md · examples/要在任务中途拿到事件、答审批、或者取消,就得换一条。
| 入口 | 什么时候换它 |
|---|---|
| Python SDK | 官方唯一正式 SDK。自带同版本 runtime,机器上不需要装 Node;官方跑 benchmark 就走它 |
| examples/jsonrpc-agent | 用 JSON-RPC 驱动一个无人值守的编码 agent,逐条拿事件 |
| examples/acp-agent | Agent Client Protocol 自动化服务,带会话、审批应答和取消 |
Python SDK 支持 Linux x64、Linux arm64 与 macOS 14 以上的 arm64,Windows 不在其中。它跑的是仓库里那个 examples/jsonrpc-agent 例子:给一个工作区、一个会话目录、一句任务,打印最终回复,同时在会话目录里留下一份 JSONL 日志。
$DSH_HOME,最后回落到 ~/.dsh。0。读到别的版本号它拒绝打开整个日志(第 23 章)。默认是 ~/.dsh。知道里面有什么,就知道该去哪儿改、该备份什么。
| 路径 | 是什么 |
|---|---|
| .credentials.yaml | 托管的凭据文档。Models 页面写的就是它 |
| settings.yaml | 用户设置。只留凭据引用,不留明文 |
| cordis.patch.yml | 机器本地的组合覆盖,所有组合共享 |
| .env | 普通启动环境层,会进 process.env |
| sessions/ | 会话日志。对话、工具调用、改动记录全在这儿,体积会一直涨 |
| skills/ | 用户级技能,怎么加见下面那一小节 |
| attachments/v1/objects/ | 按内容寻址的图片附件,被会话日志引用 |
| profiles/<名字>/ | 一个组合目录 |
| profiles/node_modules/ | 每次启动自愈的扁平软链,别手动动 |
新建 $DSH_HOME/skills/<名字>/SKILL.md,名字用 kebab-case,frontmatter 里 name 和 description 缺一项就被跳过。目录是被监听的,不用重启:回输入框敲 /,菜单里就有它。
package.json 里声明它导出一份 patch 清单。apps/cli/README.md 的 Profiles 一节刚建好的时候里面有三个文件,外加一个每次启动都被重写的根锚点。
| package.json | 树外插件的依赖,加一份 dsh.profile 清单,里面的 bundles 是有序的层列表 |
| cordis.patch.yml | 你自己那一层。要改配置就改这个 |
| pnpm-workspace.yaml | 给 pnpm 的设置,让树外插件共用同一份 cordis |
| cordis.yml | 每次启动被重写成空列表的根锚点。不用管,也别改 |
| node_modules/ | 跑过一次 dsh plugin 之后才有,连同一份 lockfile |
| 1 | 清单里每个 bundle 的 patch,按清单顺序 |
| 2 | 这个组合自己的 cordis.patch.yml |
| 3 | $DSH_HOME/cordis.patch.yml |
| 4 | 命令行上每个 --patch 覆盖,按 argv 顺序 |
apps/cli/reference/README.md 的 Plugin management 一节dsh plugin 把参数原样转发给 pnpm,工作目录设成那个组合的目录。add、remove、why、update 全都照常工作。
相对路径按你敲命令的那个目录解析,不是按组合目录,所以在插件检出里 add . 装的就是这份检出。demo 这个名字没有随包模板,dsh plugin 会当场替你建出来,只叠 @deepseek-ai/dsh-base 一层。
每次成功之后,它会拿安装结果去核对那份 bundles 清单:凡是依赖包在自己的 package.json 里声明了 bundle patch 的,自动加进层列表;没声明的留成普通依赖,并给一次性的提醒;被移除的自动退出列表。
prepare 脚本,pnpm 10 以上默认拦下来。照它打印的提示把那个键写进组合目录的 pnpm-workspace.yaml,再跑一次。装现成的 tarball 或本地检出不需要这一步。dsh --profile <名字> 能起来,并且你能在下一页说的 dump 输出里看到新插件贡献的那些行。apps/cli/reference/README.md--dump-config 看一眼它现在长什么样两条命令,差别在于叠不叠你自己那几层。
前者只打 bundle 层,后者再叠上组合自己的 patch、家目录的 patch 和每个 --patch 覆盖。打出来的是一份仍然合法可加载的 YAML;没命中目标的 patch 报到 stderr。
!!js 表达式原样打印,不求值;dump 也不运行应用的命令行解析,所以带应用参数的 dump 会被直接拒绝,--port 的效果在这里看不见。packages/boot/app-boot 的 renderConfigDumpid,不是包名——同一个包可以出现在多行里。这是排查「我改的那行到底生没生效」的全部依据。
连续来自同一来源的行归成一段,段前那行注释先写它们出自哪个包或哪个文件,后半句 patched by 列出改过它们的每一层,按叠加顺序。上面这段的意思是:这两行都来自 base,其中第二行被你家目录那层动过。
反过来就是排查方法:你刚改过的那一行,如果段注释里没有 patched by,说明没有任何一层动过它——多半是 id 写错了(这种情况 dump 还会往 stderr 报一条没命中),或者那份 patch 文件这次启动根本没被叠上。
id 找到。找不到,就说明它藏在嵌套的 include 后面,patch 够不着。examples/README.md--patch 文件,只在这一次启动里叠上去,不改你的任何配置。仓库 examples/ 下有六个能直接跑的例子,每个解决一个不同的问题。
这一条跑完,界面上立刻多出三个工具(建定时提醒、列出、删除)。关掉进程再普通启动,它们就没了——这就是覆盖层:一次性的、不落地的一层 patch。
| 示例 | 它演示什么 |
|---|---|
| web-schedule | 给 Web 加一组会话内的定时提醒工具 |
| web-cordis | 让 agent 检查、修改自己那棵内存里的插件树 |
| mcp-memory | 三份接第三方记忆 MCP server 的现成配置 |
| headless-agent | 一次性任务,输出可换成机器可读格式 |
| jsonrpc-agent | 用 Python SDK 加 JSON-RPC 驱动无人值守 agent |
| acp-agent | Agent Client Protocol 自动化服务 |
--patch 的那次启动多出新工具,去掉就没了,你自己的配置文件全程没被动过。examples/mcp-memory/README.md · packages/mcp/mcp-clientmcp__<server>__<tool>,和 Claude Code、Codex 一致。出厂状态下一个 MCP server 都不装——每个 server 的命令都是沙箱之外的可信可执行代码,得由你自己点头。
三条前提:可执行文件要你自己先装好,dsh 只负责启动和停止它;stdio 的桥在启动子进程前会摘掉名字看起来像凭据的环境变量和所有 DSH_*,要传的密钥得显式写进这一行的 env;HTTP 传输则要求上游服务已经在跑。
mcp__my_server__* 开头的名字。没出现就去看启动日志,多半是那个可执行文件不在 PATH 上。你已经把它装上、跑起来了。现在拆开看零件。屏幕上那一整套东西,是三样东西拼出来的:一个只干三件事的框架、219 个各司其职的包、一份启动时现算出来的清单。这一部不讲它怎么转,只讲它由什么拼成、拼法是什么。
vendor/cordis/src/context.ts、reflect.tsctx.fs 这种写法能成立,是因为 ctx 是个代理对象,读属性时才现去找实现。一个插件被装上时,框架只递给它一个对象:ctx。这个对象同时是三样东西——服务台、清洁工、广播站。这一节只讲服务台。
服务台的用法只有一种:喊一个名字,拿到一个能干活的东西。一个插件要读文件,它写 ctx.fs。至于名字背后是本机磁盘、是沙箱、还是一台远端容器,它不知道,也不需要知道。
这像酒店前台:你不认识洗衣房的师傅,你只跟前台说「洗衣服」。哪天酒店换了一家洗衣公司,你要说的那句话一个字都不用改。
| 你喊的名字 | 它代表的能力 | 现在谁在应 |
|---|---|---|
| ctx.fs | 读写文件 | dsh-fs-sandbox |
| ctx.shell | 跑命令 | dsh-bash-sandbox |
| ctx.llm | 调模型 | dsh-llm-deepseek |
第三列是出厂组合里真正挂着的包。Windows 上第二行换成 dsh-pwsh-sandbox,靠一个平台判断二选一。
这样的名字一共 56 个,其中 26 个明确留给人换实现,本书叫它们能力接缝。
vendor/cordis/src/fiber.ts(effect 与逆序回滚)ctx 的第二重身份是清洁工。这一件事决定了这套东西能不能真的热插拔。
一个插件装上之后会做很多事:登记一个工具、加一段系统提示词、开一个定时器、连一个数据库。换在别处,这些都要作者自己记住,并在卸载时反着来一遍。漏一个,就是一条幽灵监听器。
这里反过来。每一次登记,框架当场把「怎么撤销」收走,记在这个插件实例名下——一个插件在运行时的那一份实例,仓库里叫 fiber;它记着这个插件登记过什么,卸载时逆序撤回。作者从头到尾没写过一行清理代码。
同一条回滚路径服务三个场景:你改了 YAML 触发重装、依赖的服务下线了、进程要退出了。三种情况下插件的卸载过程一模一样。
return。packages/guard/timeout-policy/src/index.tsctx 的第三重身份是广播站——审批、沙箱、超时,全靠站到中间实现。
常见的事件系统是「我喊一声,谁想听谁听,我不等你」。这里最要紧的那种不是:监听器按登记顺序一层层套在真正的动作外面,像洋葱——每一层先做点事再往下交,最里面那层才是本来要发生的事。
于是链上每一层能做三件事:放行、改写、拦下。本书把这种能站在中间的事件叫拦截点(源码里的 waterfall),只能旁观的叫广播点,在 ctx 上添东西的那些方法叫注册入口。
packages/README.md、pnpm-workspace.yaml一个包一件事,一个目录一类事。这不是文档整理出来的分类,是仓库结构本身。
| 这一类 | 目录举例 | 它们是什么 |
|---|---|---|
| 能力家族 | shell fs llm sandbox compaction | 一个能力的定义、若干实现、外加把它做成工具的那个包,全装在同一个目录里 |
| 产品脊柱 | core session api host client | 产品自身的功能:会话、提示词、工具表、界面。它们也全是插件 |
| 支撑设施 | util boot bundle test-support | 不面向模型,给上面两类用 |
上表没画进去的还有一格:packages/README.md 的总表给每个组标了「发布预期」,绝大多数写的是 stable API,只有远端沙箱那一组 e2b/ 标着 POC。要拿哪个组当依赖,先翻那一格。
packages/shell/README.md、各组 README认得这条命名规则,你不看文档也能判断出一个包是干什么的。
| packages/shell/ 里的包 | 角色 | 它干什么 |
|---|---|---|
| dsh-shell | 能力定义 | 只写清「跑命令」这件事长什么样,一行怎么跑都没有 |
| dsh-bash-sandbox | 实现 | 开子进程跑,但每次都关进沙箱。出厂组合挂的是这一个 |
| dsh-bash-local | 另一个实现 | 同样的机制,不进沙箱。仓库里有,出厂清单里没有 |
| dsh-tool-bash | 工具 | 把这个能力包装成模型看得见、能调的工具 |
换实现只需要在清单里关掉一行、装上另一行。上面那个工具包一个字都不用改——它认的是名字 ctx.shell,不是某个具体实现。
tool- 开头的是模型看得见的,ui- 开头的是人看得见的,剩下的是插上去的实现和策略。apps/cli/src/profile-boot.ts 的 composeProfile()agent-presets 行时补上预置 agent 的路径;设了 DSH_TELEMETRY_DISABLED 时补上遥测硬关开关。两条都不满足就一条都不追加。组合的根配置是一个空数组。这不是还没写完,是设计。
组合目录下那份 cordis.yml,全文是三行注释加一对空方括号。它每次启动都会被原样覆写回去,所以在里面写什么都留不住。整棵树由补丁叠出来,包括 agent 主循环那一行。
补丁按固定顺序叠四层:先是组合 package.json 里列的各个 bundle——dsh-base 永远第一,一次插进几十行,产品的绝大部分就是这一下来的;然后是组合目录里的 cordis.patch.yml;接着是家目录的 $DSH_HOME/cordis.patch.yml;最后是命令行上每个 --patch,按你敲进去的先后。四层各自装什么,10.2 有一张完整的表。
cordis.yml。你要改的是同一个目录里的 cordis.patch.yml。vendor/include/src/index.ts 的 applyEntryPatches补丁的语义只有两种,一分钟能讲完。
第一种是按 id 覆盖:写上目标行的 id 和新配置,这一行的配置被整块替换。第二种是追加:往清单尾巴上插一批新行;插进来的行会立刻登记 id,所以再后面的层能接着改它。
光是 web 这一层,就用「关掉」摘掉了 24 行。为什么只有关掉、没有删除,2.2 讲过成因。
config 就是整块替换,漏抄的字段会退回插件自己的默认值。想改一个字段,得把这一行的全部字段重抄一遍。docs/architecture.md、vendor/README.md、packages/boot/app-boot/「一切皆插件」这句话值多少,全看剩下那一小撮有多小、装了什么。
| 内核成分 | 为什么它不能是插件 |
|---|---|
| 框架本体 | 服务查找、生命周期、事件分发本身。插件靠它才存在 |
| Loader 与它的两个内建插件 | Loader、cordis:include、cordis:group:它们是插件,但被静态导入、硬编码挂载,因此无法被配置替换 |
| 补丁层的顺序 | 四层的先后写死在启动器里 |
| 启动器注入的三个槽位 | 启动环境快照、命令行原始参数、退出函数 |
| 浏览器侧的模块加载器 | 「插件代码怎么到达浏览器」这一层 |
| 几条全局契约 | 模型看得见的必须能从日志重建(运行期不变量断言);跨边界的 id 必须带类型标记(编译期);少数服务只允许一个提供者 |
| 遥测退出开关 | 刻意抬到配置层之上:配置里怎么开,环境变量都能一票关掉 |
这张表里没有 agent、没有会话、没有提示词、没有工具,一个都没有。产品的全部语义都在补丁层——那正是第 21 章那句「没有特权内核」的实际含义。
packages/bundle/base/cordis.patch.yml、packages/session/session-telemetry-otel/README.md这一行被刻意抬到配置层之上,值得单独说清楚。
| 三档 | 这一档会传出什么 |
|---|---|
| DISABLED | 出厂默认。整条管道根本不构建,一个字节都不发 |
| FEEDBACK_ONLY | 只有你在界面里提交一次反馈,才回放并上传那一段会话日志 |
| FULL | 会话事件持续上传 |
端点不是空的:dsh-base 里写死了 https://harness-telemetry.deepseeksvc.com/v1/logs,只有 DSH_TELEMETRY_OTLP_URL 能改。代码里确实带着一条指向 DeepSeek 的默认出口,只是出厂时那条管道没打开。
DSH_TELEMETRY_DISABLED——只要非空就生效,填 0 或 false 同样是关。你在输入框里敲下一句话,按下回车,然后一路跟着这句话走。它会依次经过五个站点,每一站决定一件事,每一站都留了给你改的口子。
packages/core/agent-loop/src/agent.ts每一站的产物就是下一站的输入。知道自己在第几站,比记住每个模块叫什么名字有用得多。
现拼出这次要发给模型的全部内容:提示词、工具清单、对话历史。
交给某一家模型厂商,接住它一帧一帧吐回来的回答,边接边落盘。
模型要求调用工具时,这一站决定哪些能跑、按什么顺序跑、要不要先问你一句。
每件事都在发生的当时被追加进一份只增不删的日志。界面与导出都是它的投影。
是回到第一站再问一次模型,还是这一轮到此为止、把控制权还给你。
packages/core/agent-loop/src/agent.ts、packages/core/session/src/types.ts这两个词在后面五章里反复出现。先把它们的嵌套关系定死,后面就不会乱。
一个步(step)就是走一遍上一页的前四站:拼好内容、问一次模型、把模型要求的工具跑完,全程落盘。一个回合(turn)是一串步——你发一句话开一个回合,回合内不断开新的步,直到没有新东西需要再问模型为止。全书此后一律只用中文的「回合」和「步」。
| 单位 | 由什么开启 | 什么时候结束 |
|---|---|---|
| 回合 | 你发出的一句话,或一个子代理收到的委派 | 六种结束原因中的一种:正常完成、被打断、被策略挡下、出错、撞上输出上限、崩溃后被持久化后端补上句号 |
| 步 | 回合内每一次准备向模型提问 | 模型这一次的回答处理完,包括它要求的工具全部跑完 |
「撞上输出上限」这一条是黏的:一个回合里只要有一个步碰到过输出上限,哪怕后面的步都正常完成,这个回合的结束原因仍然记成撞上上限。这样你在日志里一眼能看出这一回合的输出被截断过。
packages/core/system-prompt/src/index.ts没有模板文件,也没有一个「主提示词」等着插件往里填空。它是一张注册表:谁拥有某件事实,谁就注册这一段。
{{name}} 插值。没注册过的名字、求值为空的值,当场报错,不静默留白。排序号有三条约定的号段:−100 是固定的 harness 身份,0 是部署方写的那一段人设,100–199 留给各个工具包的使用说明。号段是全局唯一的协调手段——除此之外,各段互不知道对方存在。
packages/core/agent-loop/src/runtime-context.ts理由很实在:省钱。系统提示词在请求的最前面,它改一个字,前缀缓存就从第一个 token 起全部作废。
当前目录的状态、待办清单、计划模式开没开——这类事实每一步都可能不同。如果它们住在系统提示词里,那就等于每一步都在改请求的开头,每一步都按全价重算。
实际做法是:提示词装配完之后,把这些动态段落单独渲染成一段文本,跟上一次留下的那段比。没变就什么都不做;变了,才往日志里追加一条 user 角色的消息。历史因此只增不改,请求的开头始终稳定,缓存一路命中到最新那条消息为止。
大意:当前没有任何运行期上下文,之前那几张快照都已作废。所有上下文都消失时它会写这一句,而不是悄悄什么都不写——否则模型会拿着一张过期快照继续干活。
packages/compaction/compaction-basic/ctx.toolResultPruner;不挂也能压缩。base 层挂着,headless 沿用;web 那份把它连同 compaction-basic 一起关掉,交给会话自己的组合装回来。日志只增不删是硬约定。压缩因此是追加一条新消息,声明「我遮住前面第 a 到第 b 段」。
一是每步开始前按压力估算,超过阈值就动手;二是厂商直接回「上下文超了」。
两条路径都先跑一遍不需要模型的工具结果剪枝再重新计量;按压力触发的那条如果因此降回阈值以下就到此为止,一次模型调用都不发。厂商报「上下文超了」的那条不看阈值,剪枝之后直接进摘要。
选一段旧内容交给模型摘要,结果作为遮罩消息落进日志。原事件一条没删。
packages/llm/llm/src/types.ts厂商吐回来的字节流,在适配器里被翻译成一条统一的帧流。模型的一次回答由若干「块」组成:一段正文是一块,一段思考是一块,一次工具调用也是一块。
| 帧 | 含义 |
|---|---|
| block-start | 开一个新块,并说明它是文本、推理还是工具调用 |
| text-delta | 可见正文又多了一小段 |
| reasoning-delta | 思考过程又多了一小段 |
| tool-call-delta | 某个工具调用的参数又多了一小段 |
| block-end | 关掉某个块,并直接附上组装好的完整块 |
| usage | 这次调用花了多少 token |
| finish | 结束,并说明为什么停:正常停、要调工具、撞上输出上限、出错、被取消 |
两次折叠:同一批帧,一次被逐条追加进会话日志各拿一个序号,一次在界面里折叠成正在生长的段落。渲染和存档不是两条通路,是同一批事实的两次纯计算——屏幕上看到的和日志里存下来的,不可能不一致。
packages/llm/llm-deepseek/src/@earendil-works/pi-ai 这套第三方 SDK。差异没有散在各处。它被按在三处——两个文件加适配器类上的一个方法,其余代码对厂商一无所知。
把 harness 的消息翻成这家厂商的请求格式。出方向。
把这家厂商的响应帧翻成上一页那七种统一帧。回方向。
报出这个模型的能力:上下文窗口多大、默认输出上限多少、有哪几档思考强度。
第三项最容易被做错。常见做法是造一个「低 / 中 / 高」的通用枚举,各家再往上映射——结果必然出现悄悄降档和语义漂移。这里的选择是:档位归适配器所有,核心不枚举取值。你选了一个这个模型不支持的档位,请求在发出任何网络包之前就报错,绝不自动帮你换成相近的一档。
packages/llm/llm-retry/README.md一条已经吐出半截字的流,没有干净的重来点。所以重试被整个搬到了回合边界上。
如果按常规做法在流外面包一层重试:前半截帧已经落进日志了,再试一次又落一批,日志里就留下两段互相矛盾的半截回答。这条流没有持久的「一次尝试」边界,包住它就是在污染事实。
换成的做法是:一次适配器调用就是一次尝试,绝不在里面偷偷重试。失败被统一包成一个失败对象——一个厂商无关的错误码,外加可选的状态码、厂商建议的等待时长、请求 id。这个对象被抛到回合边界上的一个拦截点,重试插件在那里决定退避多久,然后开一个全新编号的回合,请求从日志重新拼一遍。
重试次数也不放在内存里,而是往回翻日志数出来的。所以进程重启不会把计数清零,而路由被换成另一套策略之后,计数会重新开始。
always 就没有次数上限:鉴权失败、配额用尽这类永远不会自己好的错误也会被无限重试。只有成功、取消或卸载插件能让它停。packages/core/tools/src/index.ts、docs/tool-catalog.md工具交出的是一个规范 JSON 值——只含字符串、数字、布尔、数组、对象的普通数据,不能有函数、Map 或循环引用。给模型看的那段话是另外渲染出来的。注册一个工具,要一次声明齐下面四件事。
名字、一句说明、参数表。别的字段永远不上线——投影时走的是白名单。
拿到已冻结的参数,返回那个值。抛出的错误由注册表归一化成带 isError 的结果,异常不会冒进循环。
值的 JSON Schema,加一个把值渲染成模型可读内容的纯函数,再加一个可选的界面卡片投影。
超时预算、能否与别的调用并发、卡片长什么样。这些不进提示词,不花 token。
packages/core/tools/README.md、docs/tool-execution-pipeline.md超时、权限、脱敏、外部钩子——这些东西一个都不写在工具里。它们是挂在管线接缝上的独立插件。
| 挂点 | 它能做什么 | 谁挂在这 |
|---|---|---|
| pre-execute | 放行、拒绝、或转人工确认。权限就挂在这一环:工具动手前决定要不要问人、问的结果算不算过(机制见第 24 章) | 权限、外部钩子 |
| guard | 只能拒绝,不能放行 | 所有者策略 |
| execute | 环绕整次调用,只准换取消信号 | 超时、埋点 |
| post-execute | 换内容、换值、或整个拦下 | 外溢、循环提醒 |
| finalizeContent | 工具自己的最后一次内容改写,同步、必跑、只能改内容 | 工具定义自带,不对外开放 |
| result | 广播点:只读观察,改不了 | 审计、指标 |
一条策略管住所有工具,而任何一个工具都不知道这条策略存在。想给全部工具加超时,挂一个环绕插件;想让某类调用必须人批,挂一个前置监听器。两件事都不用改任何一个工具的代码。
沙箱和审批这两个旋钮本身怎么转,在第 24 章。这里只讲被它们拦下之后还剩哪条路:升级请求要同时满足四个条件才成立,缺一个就当场拒绝。
拿请求的模式和这次调用的实际模式比。平级或更窄都拒。判定发生在执行期,因为实际模式是每次调用才确定的。
理由字段和目标模式必须成对出现,且不能为空。理由会原样进审批记录。
走的还是那条审批通道,结果还是那四个值。没有应答者就是拒。
批准被盖在发起请求的那一次调用的策略上。下一次调用回到原来的模式,什么都没被记住。
packages/core/session、docs/persistence-catalog.md几家 harness 都往磁盘写 jsonl,也都能靠它续上会话。差别不在落不落盘,在约束:这里有运行期断言盯着「模型看得见的,日志里必然已经有一笔」,而且压缩只加遮罩、不删事件。
本仓库共声明 44 种事件类型,其中只有三种能进:user/message、assistant/message、tool/result。而且它们必须在写入时声明怎么进——是接在末尾,还是替换掉前面某一段。别的事件只落日志,模型永远看不见。插件用类型合并加进来的自定义事件,同样受这条规则管。
碰到本次构建不认识的事件类型,读取端直接拒绝打开整个会话,而不是跳过它。只有写入方明确打了「可跳过」标记的纯信息记录例外。
packages/core/session/src/json.ts、surface.ts校验不放在落盘的时候,放在写入内存的那一步。三道关全过才算写进去,任何一关不过,日志一个字节都不变。
递归拷一份。遇到大整数、函数、Map、循环引用当场抛错。
整棵对象树变成只读。观察者拿到的是数据快照,不是能反过来改状态的活对象。
如果这条事件要替换掉前面一段区间——也就是「第 a 条到第 b 条这一段」——先验这段在不在。
三道关全过,事件才被追加进日志并同步通知观察者。序号恒等于追加前的日志长度,不跳号,所以任何一条事件的位置都是可算的,不需要额外索引。观察者自己出的错会被记录并隔离,不会让一次已经提交的追加反悔。
reasoning-chunks 这种不带斜杠的行是存储写法,读的时候会被还原成一条条原始事件。examples/acp-agent/tests/snapshots/仓库自带的端到端快照,截短脱敏后逐行标注。这是「44 种事件类型」落到磁盘上的实际形状。
type 选事件,seq 等于它在日志里的下标,callId 串起一次调用,surfaceOp 决定它进不进模型视野。packages/core/session/src/repair.ts崩溃、断电、被杀进程,日志都停在半路:有一个回合没有结束标记,有工具调用没有结果。冷加载时有一段专门的修复代码无条件跑一遍,把这些断口补成一份模型能接着往下读的记录。
助手已经要求调用但日志里没有开始记录,补一条 TOOL_NOT_STARTED;已经开始但没有结果,补 TOOL_OUTCOME_UNKNOWN——这条会明确告诉模型:只有只读或幂等的活可以直接重试,有副作用的要先核实,或者问人。
再补一条步结束,和一条结束原因为 interrupted 的回合结束。这正是循环自己永远不会产生的那个值——所以你在日志里看到它,就知道这是崩溃恢复补的。
压缩分帧的最后一帧只写了一半,读取端保留前面完整的记录,从那一帧截断再重写。但坏在最后一个已提交的回合结束之前,那就是损坏,直接拒绝。
aborted,只有崩溃恢复补的才是 interrupted。模型看到的历史、界面看到的状态、落盘的存储、遥测导出的记录、检索用的索引——五份东西全是同一条流的折叠结果,因此不可能互相分叉。
最实用的一条是回放测试:录一次真会话,之后的回归全部离线跑,不需要任何 API key。浏览器端的界面快照测试在持续集成里也被强制成只回放、不重录。
逐 token 的增量事件,JSON 信封比载荷本身大得多——源码注释记的实测值是约 56 倍。缓解办法有两层:连续同块的增量打包成一行(够三条才打),落盘再默认套一层 Zstandard 分帧。两层都是纯存储编码,逻辑日志一个字节没变。
仓库里签入的 74 份端到端会话快照合计约 720 KB、182 步,平均一步约 4 KB(未压缩、已打包)。按这个单价推测:一次半小时、跑一两百步的编码会话,$DSH_HOME/sessions 大约多出 0.4–0.8 MB,压缩后更小。真正会把量级拉上去的是大段工具输出,不是对话本身。
packages/core/agent-loop/src/agent.ts一步跑完,循环只看两个数据:这一回合有没有已经定下的结束原因,收件箱里还有没有待办输入。两个都为空才收工。
| completed | 模型自然停下,或首步输入被改写成空 |
| blocked | 这一步被拦下,整个驱动器退回空闲 |
| max-tokens | 撞上输出上限。这个原因是黏的,后面步骤跑成功也不会降级 |
| error | 模型请求最终失败 |
| aborted | 收到取消 |
| interrupted | 循环永远不会产生这个值,它只由崩溃恢复补写(19.4) |
packages/subagent/subagent/README.md一个子代理 = 一份持久会话,外加最多一份还活着的进程内实例(它创建的后代都结束了才释放)。那份实例可以跑很多轮;进程重启后从存储里冷恢复。
| 父这边能做 | 效果 |
|---|---|
| send_message | 在同一段对话上开一个新轮次,不是新建一个孩子 |
| interrupt_agent | 只停当前轮次。尚未认领的排队消息、进程内实例、已发布的后代都保留;已被这一轮认领的工作不会重新排队 |
| list_agents | 默认只列直接的可续聊孩子;scope: descendants 才按稳定先序走完整棵树。两种都不唤醒、不恢复任何一个孩子 |
| report | 反向:孩子主动多次回传中间发现 |
孩子拿到一个全新的作用域,只继承父的显式沙箱设置;审批策略一律被钉成「不问」,需要人批的操作在它那里是确定性拒绝。
沙箱覆盖和审批策略以「来自委派」为来源,落到孩子自己的会话里。所以冷恢复时只读孩子的日志,就能重建它当时的有效策略,不必去问父亲。
工具里根本没有「子代理类型」这个参数。类型由部署时挂几个实例决定——不同的工具名、不同的后端、不同的人格、不同的工具过滤。权限收缩是部署方的决定,不是模型运行时的选择。
node:vm 只是塑形 API、不是安全边界;逃出去的脚本能以宿主进程的权限拿回 Node 能力。脚本是一段普通 JavaScript,跑在一个独立工作线程里;脚本里调用的几个钩子通过消息打回主进程,去起真正的子代理。模型写的是编排本身,不是一句一句的委派。
一是模型写的同步计算不会卡住主事件循环;二是终止线程是真正的最终止损——脚本迟迟不肯收尾时,宿主等一段宽限期后强制收尾,并给悬空的记录补上取消标记。
docs/tool-catalog.md、packages/core/tools/README.md这五种不是互相替代。它们按两件事分开:编排由谁写,以及那段编排模型改不改得动。
| 手上的活 | 用哪个 | 为什么 |
|---|---|---|
| 一问一答就能完 | 普通工具 | 最便宜。结果直接进上下文,别的都是在这条路上再加一层 |
| 要来回追问、中途叫停 | 子代理 | 一段可续聊的持久对话,父这边能续、能停、能枚举 |
| 一次调一串工具,只要汇总 | run_code | 中间结果留在程序里,只有 print 或 return 的部分回到上下文 |
| 扇出到大量子任务再收口 | workflow | 多文件审计、迁移、多角度调研、对抗性核验,一次写完整套编排 |
| 要一条模型改不动的流程 | ralph | 循环写死成常量:模型只能给不可改的目标和轮数上限,每轮开一个全新的孩子 |
仓库里管 run_code 这套叫 Code Mode:注册表不再把每个工具的 schema 发给模型,而是生成一段 SDK 声明,模型写 TypeScript 去调它们。纯 code 模式下它是模型唯一能直接调的工具。
这五个特点本身,第三部和第四部已经讲过怎么运转。所以这一部不重讲机制,只算账:21 到 23 每章一页,只写这个特点的代价;24 章权限例外,它是全书唯一完整讲权限的地方,机制一页、代价一页;25 章讲仓库自己的工程约定,三页。只讲好处的那种介绍,你在别处已经看够了。
docs/capability-seams.md、packages/boot/app-boot/README.mdpackages/core/scope/README.md 明写:作用域路由的是可信的同进程插件,不是沙箱,也不是权限边界。这个特点第 14 章讲过,这里只算账:「什么都能替换」和「什么都能被替换掉」是同一句话。
先把最诱人的那句收紧。主循环在配置里也只是一行,能摘能换——但那一行没有契约接住你:ctx.agentLoop 标的角色是打包层,不是能力接缝。它是「一行配置」,不是「一个接缝」。
挂在 tools/pre-execute 上的监听器只要不往下传,工具就不会执行。策略能拦住危险操作,靠的正是同一个机制。
依赖没人提供的插件既不报错也不运行,只停在等待态,而等待态的插件实例不保活事件循环。启动这条路已加固:harness 在启动末尾对整棵树跑一遍断言,点名「谁缺哪个服务」并失败退出。没人管的是启动后动态挂的。
examples/headless-agent/e2b.cordis.ymlname 是守卫。替换只能「禁掉旧行 + 插入新行」,不能悄悄把一行改成别的包。这个特点第 11 章讲过,这里只算账。仓库里有个现成例子:把整个执行世界搬到远端沙箱。
被禁掉的只有进程和文件两个底层实现。bash 执行器那一行一个字都没改——它只认契约,不认谁在下面执行。
packages/session/session-persistence/src/coordinator.ts、packages/core/session/src/repair.ts这个特点第 19 章讲过,这里只算账。拒绝本身是对的——静默跳过会「成功地重建出一个错误的会话」。
「这个 build 认识哪些事件」是仓库内类型声明生成的固定清单,当前 44 个。第三方插件新增的类型天生不在里面,重开那条会话就会被判为不支持。信封上留了个「可忽略」标记位,但当前的写入接口不接受它。
格式版本对不上就拒绝打开,并告诉你往哪个方向走;派生数据——全文索引、投影缓存——则直接重建。这套做法只在预发布立场下成立,第一个正式版本之后要重做。注意区分:被崩溃写坏的半截会话不走这条路,冷加载时会无条件跑一遍修复,给没等到结果的工具调用合成错误结果。
sandbox/sandbox-policy/、interaction/user-approval/、interaction/permission-presets/(均在 packages/ 下)read-only 是这个发行版加的。界面上那三个档位不是三种权限模型,是这两个旋钮的三组取值捆绑。
| 旋钮 | 取值 | 它唯一决定的事 |
|---|---|---|
| 沙箱模式 | read-only | 一个字都不许改 |
| workspace-write | 只许改会话工作区(建会话时钉死的目录)底下的东西,外加一些平台临时目录 | |
| danger-full-access | 不再限制文件改动 | |
| 审批策略 | ask | 问出去;没有应答者就当拒绝——服务自己从不弹窗 |
| never | 连交互都不进,直接拒,并告诉模型别再申请放宽沙箱 |
批准只有 allowed-once 一个取值:没有「以后都允许」,没有规则表,没有撤销——一次批准只对被问的那一次调用生效,下次重新问。三档只是把两个取值捆成一个名字,切换时只有真正变了的那个旋钮会被拧。被沙箱拦下之后还留了一条路:模型可以申请更宽的模式,那次放宽同样只盖在这一次调用上,成立条件见 18.3。
custom——能显示,不能选。packages/interaction/user-approval/README.md、packages/bundle/base/cordis.patch.ymlDSH_PERMISSION_MODE 决定。git status,于是长任务里人会把它关掉。机制在 24.1,这里只算账:克制是真的,可预见的退化也是真的。
递给答复者的只有工具名、原因和一个可选的调用 id——「只放行 git status」在这个接口上连表达的余地都没有。
只有「这一次允许」,没有「以后都允许」,跑几十步的任务就问几十次。
忍不了弹窗的人会把策略调成不问,保护就只剩沙箱一层——而沙箱只管写不管读。出厂三档里「不问」还跟「完全放开」捆着,选它等于两层一起关。
这两个旋钮不是凭空来的:它与 Codex CLI 的沙箱模式和审批策略几乎同名同义,差别是 Codex 的审批策略还有 on-request 和 on-failure 两档。所以「这一版还没有规则表」比「刻意没有」准确——on-failure 正是为缓解第三条设计的。
.agents/notes/implemented/process/2026-06-11-quality-gates.md它不是一句宣传语。原话写在决策笔记里,而且直接解释了后面所有看起来过分的东西。
大意:这个代码库主要由编码 agent 开发。比起散文写成的约定,agent 遵守被强制执行的门禁要可靠得多;而当干活的是 agent 时,「工作量大」不构成一条成本论据。
后半句是这一章的钥匙。人类主导的仓库里,「每个包都要写一份运行时不变量」这种提案会被工作量直接否掉。这里不会——所以凡是散文约定加 code review 保不住的,一律下沉成脚本。整套东西分四层:
| 层 | 它承载什么 |
|---|---|
| 指令层 | AGENTS.md:每个会话都要在上下文里的标准命令,每条一到三行 |
| 门禁层 | 几十条会让 CI 变红的脚本,由一个带依赖图的编排器统一调度 |
| 测试层 | 单测、逐文件覆盖率、无密钥回放、真实接口端到端 |
| 记忆层 | 决策笔记:承载「为什么」和「放弃了什么」 |
package.json 的 scripts、vitest.config.ts下面这些数字不是规模炫耀,它们是「工作量不算成本」这条前提的直接产物。
工具目录不是解析源码得来的,而是真的把每个工具插件启动一遍,从运行时注册里读计算后的结果。配了检查模式的生成器挂进 CI,于是「文档过期」是一次 CI 失败,不是一次 review 疏漏。
回放测试直接用产品写出的会话日志当素材:从里面反推出每一次模型调用的输出序列,无需密钥就能确定性重放。于是「能回放」和「日志格式没坏」变成同一个断言。
CONTRIBUTING.md、docs/i18n/README.md读到这里容易生出「我们团队也该这么干」的冲动。先看两条账。
仓库明说目前不接受外部 pull request,社区参与被引导到写插件、写文章、答疑上。于是这几十条门禁的维护、修复和升级全部由内部团队和它们的 agent 承担,外部既贡献不了改进,也分担不了负担。前面那句「工作量不构成成本论据」的成立条件,也只有他们自己满足——你的团队要抄这套,先问问干活的是不是 agent。
每份在范围内的文档都是三兄弟:英文、中文、加一个记录两侧内容哈希的配对文件。改了英文而没同步中文,门禁立刻红——而它的清单里只有排除项,没有逐文件的启用名单,也就是默认全覆盖。你 fork 之后随手改一段说明,就得连中文一起改并重录哈希。不打算维护中文的 fork,等于永久带着一条红色检查。
前面五部都在说这套东西怎么转。这一部换个立场:你要往里加东西。
packages/hooks/hooks-claude-code/README.md · packages/hooks/hooks-codex/README.md扩展点多不说明问题。能不能把宿主自己的功能摘掉,才说明产品和插件站在同一个平面上。
这条通路第 02 章已经证过:整个产品叠在一个空数组根配置上,官方自己的 web 组合就在走它。这一页只做一件事——把 dsh 放回同类系统里比一比,只跟做同一件事的东西比。
| 同类系统 | 你能改到哪一层 |
|---|---|
| Claude Code | 命令钩子加子代理。钩子是外部进程,靠退出码和 stdout 说话,能拦,不能替换内置实现 |
| Codex CLI | 十个固定钩子点。要动钩子够不着的地方,只剩 fork 一条路 |
| Koishi / Cordis 原生 | 能整体替换一个服务实现,但 app 内核仍然是特权的,摘不掉 |
| dsh | 按 id 写一行 disabled: true 就摘掉,或整块换掉那行——主循环、会话持久化、权限审批一视同仁 |
前三行不是能力不足,是产品选择:可动的面越窄,官方越能保证装了别人的东西之后自己还站得住。dsh 反过来,上限抬得很高,代价是它必须假设所有插件都可信。那堵墙第 37 章正面讲。
docs/architecture.md认清这四条,第 31 章那张全图就只是在往里填名字。
在补丁层里写同一个 id,那行配置被整块替换;写 disabled: true,那行不挂载。是整块替换不是深合并——改一个字段,要保留的字段得全部重写。
「注册入口」是 ctx 上那些往系统里添内容的方法:一个工具、一条斜杠命令、一段提示词片段、一个界面槽位。加进去的和官方自带的排在同一张表里。
tools/pre-execute、agent/pre-step、system-prompt/assemble 都是拦截点:监听器不调 next(),下游全部拿不到这次调用,包括 harness 自己的工具分发。标 complete: true 的提示词片段也在这一层,它一出现,别人的片段全部作废。
把 shell 那行摘掉、挂一个远端的,声明依赖 shell 的插件会干净地重启一遍。packages/e2b/ 就是实证:换掉文件系统和子进程两个实现,整套搬到远端。
docs/capability-seams.md · docs/tool-catalog.mdapps/cli/config/agent-presets/这五类的写法、依赖、难度完全不同。搞混了,第一步就会走岔。
| 角色 | 官方有多少 | 它做什么 |
|---|---|---|
| 能力定义 | 26 | 只定义抽象类和词汇,不含实现。直接挂载会抛错 |
| 实现 | 约 45 | 把某个能力定义实现出来,占住那个服务名 |
| 工具 | 24 包 / 52 个 | 把能力包装成模型看得见、能调用的工具 |
| 策略钩子 | 十几个 | 不注册工具、不提供服务,只挂监听器 |
| 界面 | 39 | 浏览器那半边,往界面的槽位里放组件 |
代表例子:ctx.fs、ctx.shell、ctx.llm 是能力定义;dsh-fs-sandbox、dsh-bash-sandbox、fs-e2b 是实现;tool-bash、tool-fs 是工具;timeout-policy 是策略钩子;ui-plan、ui-jobs 是界面。
那 52 个工具不会同时摆在模型面前。web 组合在基础层上一口气关掉 24 行(工具、计划模式、压缩引擎都在内),改由每次会话挂上的会话预设决定这一次露出哪些——出厂四个预设里,「极简模式」只给两个:持久 bash 和 str_replace_editor。逐条清单见附录 C。
packages/guard/timeout-policy/src/index.ts它不改任何数据结构,也不进模型看到的清单,却能实实在在改变系统的行为。
官方自己就有一批这样的包,每个只干一件事:
组织里最常见的需求——禁掉某些命令、按小组限制工具、把每次调用送去审计——全部落在这一类里,二十来行就能起步。
docs/cookbook/extension-cookbook.mdpackages/mcp/mcp-client/README.md先认清自己站在哪一级,再决定这个周末要走多远。
| 门槛 | 做法 |
|---|---|
| 不写代码 | 写一段配置把外部 MCP 服务器挂进来,它的工具直接出现在模型面前;或复用你已有的 Claude Code / Codex 钩子配置 |
| 极低 | 纯注册一个领域工具。注册表直接收裸 JSON Schema,MCP 来的工具就是这么进来的 |
| 低 | 策略钩子、斜杠命令、系统提示词片段、网页检索与抓取的实现 |
| 中 | 模型适配器,shell / 文件系统 / 子进程 / 沙箱的实现,技能来源、压缩引擎、子代理后端、会话存储后端 |
| 高 | 浏览器侧界面模块、对话节点、RPC 契约、工作流引擎、整体改写提示词装配 |
前三级都在主机这一侧,一个下午能上线;第五级是另一个复杂度量级。
cordis.patch.yml 或 --patch 覆盖层,一个服务器最少六到八行。docs/user/develop/basic/publish.md$DSH_HOME/profiles/ 下的一个目录,描述一套可启动的搭配。这三个问题决定了你写的是哪种文件、放在哪个目录、别人怎么拿到。答错一个就得返工。
主机侧——工具、策略、能力实现、提示词、斜杠命令——一个下午能上线。浏览器侧是另一回事:组件永远拿不到上下文对象,只能走槽位注册和四条固定的属性通道,而且插件集变了必须重启进程。
机器级的偏好写 $DSH_HOME/cordis.patch.yml;只给某一套搭配用,写那个组合目录里的 cordis.patch.yml;只想给某一类会话换一套能力,用会话预设。层级选错,要么别人装不上,要么你其他组合被连累。
只给自己用,停在 --patch 覆盖层,不用建包。给团队用,打成 bundle 走 dsh plugin add,这是唯一的分发路径。第三档——合进官方仓库——现在走不通,成因与它对你的实际影响见第 37 章。
--patch,别急着建包——建早了每改一次都要重新构建一次。docs/user/develop/basic/index.mdpnpm install && pnpm run build 之后用 pnpm dsh。不用 npm、不用打包、不用建 package.json。
scratch-plugin/src/my-plugin.ts。导出一个 apply 函数就是一个插件,没有基类和清单文件。
scratch-plugin/cordis.yml。一条 insert,把这个文件插进正在跑的组合里。
启动时带上这个覆盖层,它叠在所有官方层之后。
[hello-plugin] plugin loaded!,界面照常打开。docs/user/develop/basic/index.md:56vendor/loader/src/config/group.ts:79 · packages/boot/app-boot/src/index.ts:773这是新手在这条路径上最常摔的一跤。好在它摔得很响。
补丁文件只贡献配置,不会把模块解析的基准目录挪到自己身上——相对路径按组合目录算。
按组合目录找不到那个文件,启动不会带着半棵树继续跑,而是直接终止,终端上打出 dsh: plugin tree failed to load: failed to apply loader entry hello (…)。看到这条,先怀疑路径基准,别去翻插件代码。
package.json、别声明 dsh.bundle、别发 npm。打包是第 32 章的事,现在建包只会让你每改一行都要重新构建一次。apps/cli/src/dump-config.ts大半的问题在这一步就现形了:你那行到底进没进去,一眼可见。
层标签有两种形状:bundle 那层印的是纯包名,你自己那层印的是解析后的绝对路径——不是命令行里敲的 ./scratch-plugin/…。想 grep 自己那段,按绝对路径找。
id: hello 那一段:找得到、并且下面的 name 是一条真实存在的绝对路径,问题就在插件代码里;找不到,问题在补丁层,翻代码是白费。docs/user/develop/basic/tool.mdinject 声明本插件必需的服务,服务没就绪之前它不会被加载。替换掉上一章那个文件的全部内容,重启就能用。没有省略。
greet,拿回 Hello, Ada!。packages/core/tools/src/index.ts:1039output 不是可选的美化项,少写它,注册当场就抛错。很多人照着别处的工具写法只填 parameters 和 execute,然后在启动阶段撞上一条看不懂的报错。就是这里。
output 里有两件事,分工很清楚:
execute 的返回值必须符合它。每次成功返回、以及被策略替换过的返回值,都会拿它校验一遍。output 不是对象、或者 render 不是函数,直接抛 tool "greet" must declare output { schema, render, presentationMeta? }。这条错误信息很直白,但如果你在启动一大堆插件,它容易被淹在别的输出里。execute 返回什么模型就看到什么。不是。模型看到的是 render 的产物;execute 的返回值先要过 schema 那一关。packages/core/tools/src/schema.ts:54530.1 那段代码里,除了 output,其余三段各自在替你挡掉一类麻烦事。
声明完这一段,defineTool 会据此推导出 args 的类型,并在进 execute 之前替你校验模型传来的东西。模型给错参数,根本走不到你的函数里。
示例里只用了 args,实际还有第二个参数 exec,带着这次调用的身份、调用方,以及一个 exec.signal。写长任务必须自己检查它——取消是协作式的,没人会替你把函数掐断。
它返回一个撤销函数,并自动挂在当前这个插件实例上。插件被卸载,这个工具就从模型的清单里消失,不需要你写任何清理代码。
schema 的值。其余每一步框架都已经接管了。docs/capability-seams.md上一部那四条路里,除了改配置那条不用写代码,剩下三条就是这一章的三页,各是一张清单。翻之前先用这张表把你要做的事归到一类——归错类,后面三页都白读。
| 这一类 | 什么时候用它 | 难度 | 看哪页 |
|---|---|---|---|
| 注册内容 | 你要往里加一样东西:一个工具、一段提示词、一条人敲的命令 | 低 | 31.1 |
| 拦截插话 | 别人的东西已经在跑,你要在它做决定之前改掉输入或结果 | 中 | 31.2 |
| 换掉实现 | 某项能力整体换个来源:模型、文件系统、搜索、凭据 | 中到高 | 31.3 |
三类的写法都是同一句——在 ctx 对象上调一个方法,拿回一个撤销函数。区别只在你站的位置:第一类是在旁边加一样东西,谁都不挡;第二类是站进别人的调用链中间,你不放行下游就不动;第三类是把某个能力的唯一实现整块顶掉,上游一行代码都不改。
docs/capability-seams.md这一类的写法都是同一句:在 ctx 对象上调一个注册方法,拿回一个撤销函数。八个最常用的入口在下面。
| 你想做的事 | 入口 | 难度 |
|---|---|---|
| 给模型加一个工具 | ctx.tools.register | 容易 |
| 往系统提示词加一段,或加一个变量 | ctx.systemPrompt.section | 容易 |
| 加一条斜杠命令(人敲的,不走模型回合) | ctx.commands.register | 容易 |
| 给所有命令子进程注入环境变量 | ctx.shellEnv.register | 容易 |
| 不可翻案地禁掉某个工具,或收窄子代理的工具集 | ctx.tools.guard / restrict | 中等 |
| 贡献一项技能,或接一个技能来源 | ctx.skills.registerProvider | 中等 |
| 注册一份可回放的会话状态折叠 | ctx.sessionProjections.register | 中等 |
| 加一条 HTTP 路由,或声明自己的设置命名空间 | ctx.webServer / ctx.settings | 中等 |
next() 就是主动短路。一个忘了放行的日志监听器会吃掉全体下游行为。这是第三方最被低估的一类:不注册工具、不提供服务,纯靠监听器改变行为。官方自己就有十几个这样的包,最短的八十来行。
| 事件 | 你能做什么 | 形态 | 难度 |
|---|---|---|---|
| tools/pre-execute | 放行、拒绝、或转人工确认 | 拦截点 | 容易 |
| tools/execute | 环绕执行,换一个自己的超时信号 | 拦截点 | 中等 |
| tools/post-execute | 改写结果,或把它拦成一次失败 | 拦截点 | 中等 |
| tools/result | 只读观察最终结果,用于审计计量 | 广播点 | 容易 |
| agent/pre-step | 改写模型这一步将看到的消息 | 拦截点 | 中等 |
| agent/request | 替换这次模型调用的配置 | 拦截点 | 中等 |
| agent/request-error | 出错后决定重试、压缩、还是放弃 | 拦截点 | 中等 |
| session/event | 跟住整条会话事件流,做界面或桥接 | 广播点 | 容易 |
| fs/write-intent | 写入与编辑前的文件策略,如先读后写 | 拦截点 | 中等 |
| system-prompt/assemble | 整体改写系统提示词的装配结果 | 拦截点 | 较高 |
next(),就是它自己拿了主意。has been registered。你不是加东西,而是把某个能力的实现整块换掉。依赖它的插件会干净地重启,接到你这一份上。
| 换什么 | 入口 | 要实现 | 难度 |
|---|---|---|---|
| 沙箱 / 外溢存储 / 工作流引擎 / 网页搜索与抓取 | ctx.sandbox、spillStore、web | 各一个方法 | 容易 |
| 模型适配器 | ctx.llm.registerAdapter | 一个流式方法 | 中等 |
| 命令执行器 / 子进程运行时 | ctx.shell、ctx.subprocess | 各三个方法 | 中等 |
| 凭据来源 | ctx.credentials | 四个方法 | 中等 |
| 子代理 / 语言服务 / 终端后端 | registerProvider | 一到两个 | 中等 |
| 压缩引擎 / 后台任务 / 文件系统 | ctx.compaction、jobs、fs | 三 / 九 / 十二个以上 | 较高 |
表里的外溢存储(spillStore)最容易被跳过:工具结果太大时先落到旁边的存储里,只把摘要给模型。
docs/user/develop/basic/publish.mddsh plugin 负责创建和维护。安装只建立在这两个概念上。两者都由一份 package.json 描述,但回答的是完全不同的问题。
| 问的这件事 | bundle | profile |
|---|---|---|
| 谁产出它 | 你,插件作者 | 用户,由 dsh plugin 建和维护 |
| 物理上是什么 | 一个 npm 包 | 用户目录下的一个文件夹 |
| 里面装的是 | 随包带的一层配置补丁 | 一串有序的 bundle 名 |
| 回答什么问题 | 这个包贡献什么 | 这套设置由哪些包按什么顺序组成 |
最终配置由四层依次盖出来,顺序见 13.1。作为 bundle 作者你只需要记住其中一条:你贡献的是最靠前的那一层,用户在后面三层里随时能盖掉你——所以默认值要选用户大概率会保留的那种。
用户也不必手写清单。web 和 headless 两个组合首次使用时自动初始化,其余的用 dsh plugin 创建;你的安装说明只需要给一条命令。
dsh-hello-plugin。dsh.client 声明和一个 ./client 导出,两者必须同有同无。缺了那一行,包照样装得进去,只是变成一个普通依赖:安装时打一行警告,然后什么层都不激活。
github:你/仓库#提交号、打好的 .tgz。安装命令本身很短。分歧在其中一种来源:从 git 装是唯一需要用户额外点头的路径,而这个头点下去的分量比看上去重。
第一条装包并把这一层追加进组合;第二条不启动、只打印组合出来的树。
allowBuilds,再重跑一次。@deepseek-ai/schemastery 放普通依赖。它是运行时校验器,仓库内也这么办。peerDependencies 的理由:「树外插件直接 import 它们」。peerDependencies。这不是风格偏好,是会不会跑起来的问题。ctx、服务和插件实例这一套,认的是「是不是同一份」,不是「长得一不一样」。
把框架包写进 dependencies,包管理器会在用户的组合目录里再装一份。于是你的服务注册进了另一棵服务树,而你依赖的能力在这棵树上根本不存在。peerDependencies(同伴依赖)的意思正好相反:这个包由宿主提供,我不自带。
@deepseek-ai/ 开头的,除校验器外全部写进 peerDependencies,并在 devDependencies 里用同样区间再写一遍。绝不把它们打包进自己的产物。docs/postmortem/0001-acp-default-export-drops-inject.md官方有一份事故复盘专门记这两条。共同点是:代码看起来正常,类型检查也过,只在真实加载路径上炸。
apply 在一个零依赖的插件实例里运行。对策命名空间形态绝不写默认导出;提供服务的类形态则必须写。两者规则正好相反。ctx.get('名字')。packages/boot/app-boot/src/index.ts:692新手最常撞的一类,但它一点都不安静:两种都让启动当场失败。
apply 从不执行,日志里没有任何输出。原因你声明依赖的服务没人提供,插件就一直等。对策结算会报 did not activate,并打出 pending (waiting for services: …)——照那个服务名查谁该提供它。dsh: plugin tree failed to load: failed to apply loader entry <id> (<你写的名字>)。原因Loader 的挂载是事务性的:一个条目 apply 失败,整棵树回滚并抛出,启动器再冠上阶段名。对策报错原样打出你写的 id 和名字,直接比对拼写和路径基准。配置层是这套架构最强的地方,也最容易安静出错。两条规则都不报错。
config 和 disabled 求值。写在 id、name、group、inject、intercept、isolate 里的表达式不会被插值,只变成一条恒为真的数据。对策元数据只写字面量。packages/host/apiproxy/src/api-proxy.ts:126 与 :256、packages/subprocess/subprocess/src/index.ts:44前面几条是你写错了。这两条不是——你写得完全正确,限制在别人的代码里。
DSH_ 开头的都不传下去。命令执行、MCP、语言服务、终端共用这一份定义。对策要转发就在那一行的显式环境配置里点名写出来——显式层在洗过之后合并。这一页是整章的入口。绝大多数人问「我该怎么给它写插件」的时候,其实并不需要写插件。
查内部接口、跑数据库查询、调公司系统,这些都不用动 dsh。挂一行配置就接进来,而且同一个服务器在别的 harness 上也能用。
MCP 只能往里加工具,管不了别人的工具怎么执行。禁掉某类命令、给某些操作加人工确认、在模型出错后自动补救,这些只有插件能做。
把文件系统和子进程整块换成远端,读写、命令、终端、语言服务会全部跟着搬过去,上层一行不改。换模型适配器同理。
peerDependencies 里有没有官方包,比看星标靠谱得多。判断标准两条:有没有现成接缝能干净地做,官方和社区是不是都还没做。
| 方向 | 挂在哪 | 为什么值得 |
|---|---|---|
| 接自建或第三方模型 | ctx.llm.registerAdapter | 只需实现一个流式方法。社区几乎无人做 |
| 企业内网检索 | ctx.web.registerSearchProvider | 模型看到的工具名不变,换的是背后来源 |
| 组织策略网关 | tools/pre-execute 加 guard | 官方指南里有可直接抄的完整例子 |
| 审计与合规出口 | ctx.sessionTelemetry | 这个接缝在进程内没有消费者,旁路最干净 |
| 团队级技能来源 | ctx.skills.registerProvider | 官方只有文件系统与内置徽章两个实现 |
| 企业密钥托管 | ctx.credentials | 四个方法,每次操作重新取值,支持轮换 |
| 远程执行世界 | ctx.fs 加 ctx.subprocess | 官方远端沙箱三件套是完整模板 |
它们都是真需求。前六类结构上给不了;最后一类给得了,但没有契约接住你。
| 想做的 | 为什么做不成 |
|---|---|
| 不可信第三方插件市场,或带沙箱的插件运行时 | 插件与主程序同进程、无权限模型。工作线程和虚拟机不是安全边界 |
| 给插件加网络限制,或把容器与微虚拟机做成沙箱后端 | 沙箱接缝只表达文件效果;容器与微虚拟机被官方排除,正确做法是整组替换文件系统和子进程 |
| 做一个运行时的插件启停管理面板 | 插件清单是只读投影,不能启用、禁用、增删;插件集变更要重启 |
| 让自己的配置项出现在 Web 设置页 | 硬编码白名单,见 33.4 |
| 多个工作流引擎并存,或多套会话命名策略并存 | 这两个接缝是独占型,没有按名字挑选的注册表 |
| 依赖会话日志磁盘格式的外部分析工具 | 格式版本停在 0 且无兼容承诺。正确姿势是写一个遥测实现 |
| 换掉整个主循环 | 能摘能换,但没有契约接住你——等于把 dsh-agent 的事件与服务重新实现一遍。是一行配置,不是一个接缝 |
前面几节的所有坑,压缩成一张可以逐条打勾的表。它们的共同点是:不过也不报错。
| 检查项 | 怎么算过 |
|---|---|
| 导出形态 | 命名空间形态没有默认导出;提供服务的类形态有 |
| 依赖位置 | 官方包全在 peerDependencies,devDependencies 同区间再写一遍 |
| 版本标签 | 装的是显式版本或 next,不是默认标签(成因与自查见 37.2) |
| 包声明 | 有 bundle 声明;模块格式是 ESM;补丁在打包清单里 |
| 浏览器半边 | 两处声明同有同无 |
| 覆盖别人的行 | 需要保留的每个字段都完整重写了 |
| 表达式位置 | 只出现在配置和禁用两个字段里 |
| 监听器放行 | 拦截点上只观察的监听器也调了 next() |
| 服务命名 | 自定义服务名带了前缀,框架已占 56 个键 |
| 装上能看见 | 组合树里有你那一层,启动日志没有未解析服务名 |
模型不读你的代码,只读工具描述。把描述写成「什么时候该用它」,再补一段顺序在 100 到 199 之间的提示词片段。
先查浏览器半边的两处声明是不是只写了一半——那种情况什么都不贡献,也哪儿都不报错。
每个工具的结构声明都会进提示词,抢模型的注意力。少而准优于多而全,真需要很多就按作用域收窄。
启动期不会有这种事:依赖没满足和模块名拼错都会让启动中止并报出名字。真安静就说明它是启动之后动态挂的。
三个信号任一出现就转:要给别人装、要跟着版本走、要带一组默认配置。
前面六部讲的是它是什么、怎么装、怎么转、能扩展什么。这一部只回答最后一个问题:这东西值不值得你投入,投到什么程度,什么时候该抽身。
任何一个 agent harness 都可以拆成四层。你在哪一层需要说了算,就决定了你该选谁。
换厂商、换模型、调思考强度档位。主流产品都给了这一层的旋钮。
模型看得见哪些工具、什么时候停、哪些动作要人点头。主流产品给有限的钩子。
bash 在哪台机器执行、文件读写落到哪、会话存进哪个库。主流产品把这层焊死了。
人怎么看到过程、怎么中途插手。终端、Web、编辑器插件,各家形态不同。
dsh 的差异只在第三层:ctx.fs、ctx.shell 被做成 26 个可整块换掉的接缝。一、二层与别家相当,第四层更弱。
packages/ui/tui 于 2026-08-04 整包删除,不留兼容包也不留别名。网上流传的「dsh 只有 Web,没有命令行」是错的。它有官方命令行启动器,也有一次性执行模式。
| 对比项 | Claude Code / Codex CLI | DeepSeek Harness |
|---|---|---|
| 日常交互面 | 成熟的交互式终端 | 只有 Web 界面 |
| 命令行 | 有 | 有:dsh --profile |
| 一次性执行 | 有 | 有:--profile headless "任务" |
| 扩展点 | 钩子、MCP 与官方扩展机制(未逐项核实) | 整棵插件树,任意一行可替换 |
| 换执行世界 | 未见整块替换 fs/shell 的公开接缝(未核实) | 能,换两个接缝就整体搬走 |
| 源码开放 | Codex:Apache-2.0 可 fork;Claude Code:闭源 | MIT 全开源 |
| 外部修复通道 | Codex 接受 PR;Claude Code 可提 Issues | 不接 PR,无 Issues 入口 |
| 稳定性承诺 | 有版本与发布节奏 | 只有预发布 rc,无 CHANGELOG、无兼容承诺 |
packages/subagent/、packages/hooks/仓库里真实存在把竞品当插件用的包。这是它对自己位置的声明:编排层,不是又一个终端 agent。
subagent-claude-code 走官方 Agent SDK,subagent-codex 起 codex app-server,subagent-acp 接任意 ACP agent。它们都以子代理身份出现。
ACP server、JSON-RPC、headless 三个入口。但 ACP 那一面自陈是仅供自动化的传输适配器,不是完整的编辑器集成。
hooks-claude-code 和 hooks-codex 是桥接器:指向你已有的 hooks.json,让那些 shell 钩子原样跑起来。mcp-client 用的工具命名也是 mcp__<server>__<tool>,跟两家一致。已经攒下的这些资产,换过来不用重写。
AGENTS.md、docs/capability-seams.md仓库里没有 CHANGELOG,没有任何兼容承诺,也没有划出哪些是公开 API。
发布通道本身是有的——release.yml 从 dsh-v* 标签手动触发发布,带 rc 段的版本一律进 next 标签,vendor 那条序列另有自己的标签与工作流。但那是发版机制,不是稳定性承诺。
大意:因为没有外部使用者,宁可把地基做对也不做兼容垫片;可以随意改名和重新打包,后端直接拒绝旧的磁盘格式。
文档走样已经开始了。生成的能力接缝表里,ctx.lsp 的实现写作 lsp-local、ctx.codeRuntime 写作 code-runtime-worker,而仓库里这两个包实际叫 lsp-stdio 和 code-runtime-worker-thread——改名发生在 2026-08-11,开源在两天后,文档没跟上。源码注释里还留着指向已不存在目录的链接。
packages/core/tools/src/index.ts:466node_modules 里数一遍 dsh-tools,两次就是踩中了(见 32.4)。@deepseek-ai/dsh 的 latest 指向 0.1.0-rc.6,但几个子包的 latest 还停在 0.0.1-rc.1,next 才是新版。
插件按 latest 拉到旧版 dsh-tools,宿主用新版。同一个包,磁盘上两份。
TOOL_RUNTIME_SCHEDULER 是模块内的 Symbol(),不是 Symbol.for()。两份拷贝的符号永远不相等。
取到 undefined,工具调度抛错。日志里的孤儿调用等会话重新加载时由持久化层补齐。
这是纯 registry 状态,读到这里可能已经修好了,自己复核一次:
2026-08-14 实测返回 latest=0.0.1-rc.1、next=0.1.0-rc.6。
@deepseek-ai/dsh-*。显式写 @next 或钉死具体版本号,并且一律放进 peerDependencies。packages/host/webserver/README.md127.0.0.1 和 0.0.0.0,没有中间态。--host 0.0.0.0 会直接报错退出(9.4);但配置层的 host 只有 127.0.0.1 和 0.0.0.0 两个合法值,改配置文件仍然绑得上。也就是说走到这一步需要一次刻意的动作,而不是手滑。这不是猜测,是官方在自己的 README 里写明的已知限制。
大意:没有传输加密、没有身份认证、没有来源校验;绑一个非回环地址,就等于把这台服务器暴露给那个网络。
要理解这条有多重,得把它和背后的东西连起来看:连上这个端口的人,指挥的是一个握有本机 shell(ctx.shell)、能读写全盘文件(ctx.fs)、能起子进程的 agent。没有登录页,没有令牌,没有来源检查——端口通了就是全部权限。
官方把加固明确划在范围之外,说这是「面向开发者的 v1」,让你自己放到真正的反向代理后面。
127.0.0.1 就是它唯一安全的姿势。要给第二个人用,先有反向代理和鉴权,再谈改 host。CONTRIBUTING.md、八个包的 READMEnode:vm 只约束模型写的代码,不约束插件。两堵都不是 bug,是这一版有意留下或尚未处理的边界。
插件跑在主进程,拥有宿主的全部权限:起进程、连网络、读写用户文件,一律不受限。安装路径是把参数原样转给 pnpm,没有签名、没有审核、没有能力声明。装一个插件,等于允许它以你的身份执行任意代码。官方自己也不掩饰:至少八个包在 README 里明写自己不是安全边界——code-runtime、code-runtime-worker-thread、workflow、workflow-worker-thread、tool-cordis、cordis-host-runner、cordis-client-runner、fs-sandbox。
官方把反馈入口指向 GitHub Discussions 和 Discord,README 里没有 Issues 入口;贡献指南原文写着目前无法接受外部 PR。发现核心缺陷你只能发讨论帖,或者自己 fork 绕过。
还有一堵矮墙,成因见 33.4:设置面板只服务源码里两份写死的白名单(7 项 Web 设置 + 2 项产品设置)外加模型服务商动态注册的那几个,此外的命名空间即使注册了配置 schema,页面也只回答 settings-not-exposed。
packages/bundle/base/cordis.patch.yml:129DSH_TELEMETRY_DISABLED 只要非空就生效,写 0 或 false 一样是关掉。出厂挂着 session-telemetry-otel,模式取自 DSH_TELEMETRY_MODE,三档差别是硬的。
| 档位 | 行为 |
|---|---|
| DISABLED | 出厂默认。不构造 SDK 管线,一条记录都不出进程 |
| FEEDBACK_ONLY | 你点了「反馈」,才把此前那段会话日志回放上传 |
| FULL | 每条会话事件产生即交给 OTel SDK |
端点硬编码:exporter.url 默认取 https://harness-telemetry.deepseeksvc.com/v1/logs,只有 DSH_TELEMETRY_OTLP_URL 能覆盖。要整台机器断掉,用 DSH_TELEMETRY_DISABLED——启动器在加载之前就把这一行 patch 成禁用,压在所有组合层之上:配置里怎么开,这个环境变量都能一票关掉。
sessionTelemetry/record 脱敏拦截点,但仓库不带任何一条规则——没人挂规则,记录就原样离开进程。DSH_TELEMETRY_DISABLED,要么开之前先挂上自己的脱敏规则。投入越深,绑住你的东西越不可迁移。这三档的差别很大,值得在动手前分清。
| 投入深度 | 被绑住的是什么 | 退出成本 |
|---|---|---|
| 只当工具用 | 一个家目录和一批会话日志 | 几乎为零 |
| 写策略与工具插件 | 注册入口与拦截点的名字、接缝契约;业务逻辑可以留下 | 中等 |
| 换执行世界或做 UI | Cordis 的生命周期语义和前端插槽契约 | 很高 |
三档里真正决定退出难度的不是代码量,是你把多少判断写进了框架的语义。第二档的插件文件里如果只剩注册与拆卸,业务逻辑整份都能搬到别的 harness 上;第三档一旦用上 Cordis 的加载顺序、插件实例的逆序撤回、前端插槽的生命周期,这些语义在别处没有对应物,只能重写。
THIRD_PARTY_NOTICES.md、vendor/README.md要向法务解释为什么可以内网部署,需要的三份材料仓库里都有,且都能复核。
| 要回答的问题 | 去哪里拿 |
|---|---|
| 本体什么许可 | LICENSE:MIT |
| 整套复制进来的 Cordis 呢 | 九个包全是 MIT,各自目录保留上游 LICENSE,只把包名重挂到 @deepseek-ai 域下 |
| 第三方依赖清单 | THIRD_PARTY_NOTICES.md——由脚本从工作区清单生成,提交钩子重算、测试断言字节一致 |
| 完整传递闭包 | pnpm-lock.yaml(pnpm licenses list 可列),Python 那侧在 python/sdk/uv.lock |
vendor 下登记着 18 处本地修改不影响再分发:vendor/README.md 要求这份修改日志必须穷尽,18 条逐条写明改了哪个文件、为什么改;改动落在 MIT 代码上,MIT 本来就允许修改后分发,只要保留许可与版权声明——而每个目录的上游 LICENSE 都还在。
「值不值得用」没有统一答案,取决于你打算拿它干什么。
它是目前公开的 harness 里把内部结构摊得最开的一个。用 --dump-config 把实际启动的整棵树打印出来,对着读。
官方只给了一两个内置实现、社区又几乎没人做的方向最值得占位:模型适配器、压缩引擎、LSP provider、遥测出口。不要给它排上线时间。
反向代理加身份认证是前提,版本全部钉死,遥测端点当场确认。没有这三条就别开给第二个人。
四条各自独立的理由:没有版本承诺、服务端没有鉴权、没有外部修复通道,以及谁也拿不出成本与效果数据——下一页专讲这条空白。
BENCHMARK.md(全文三行)这是本次评估的已知空白,不是漏写。拿它进选型会之前,这一组数字得你自己产出。
官方那一侧是空的。仓库根目录确实有一个 BENCHMARK.md,但全文只有三行,讲的是怎么装 Python SDK、跑哪个最小变体、每个基准任务要用独立的工作区和会话 ID。没有任务集,没有对照组,一个数字也没有。
市面上流传的数字同样不能用。全书唯一出现过的一条(见 5.2)转述自一家中文媒体,说同款模型接别的框架只消耗它的三成多——单一来源、未交叉验证,本书不为它背书,也不建议你拿去做预算。
真要测,最小口径是这几条:同一个模型;三个代表性任务,每个各跑三次取中位数;同时记 token 用量和墙钟时间;并把两边的工具集大小与系统提示词长度一起记下来。少了最后一条,数字换个人就复用不了。
每一条都对应前面已经出过事的一个坑,不是泛泛的最佳实践。
所有 @deepseek-ai/* 一律放进 peerDependencies,绝不打包进你的产物。
显式写 @next 或钉死版本号,版本区间给一个 <0.2.0 的上界。
README 里写明你验证过的 rc 版本和 commit,让用它的人知道基线在哪。
只用文档化的注册入口与拦截点,避开需要改核心才能走通的路径。
业务逻辑写成普通 TypeScript 模块,插件文件里只留注册和拆卸。
服务端 host 保持默认的回环地址;要开放先有反向代理和鉴权。
今天的「不要投生产」是对 2026 年 8 月这个状态说的,不是永久判决。
| 信号 | 看哪里 |
|---|---|
| 出现兼容承诺 | 仓库有了 CHANGELOG,或写明的 semver 政策与公开 API 划线 |
| 服务端有了鉴权 | webserver 那条「无 TLS、无认证、无来源策略」的已知限制消失 |
| 设置页对插件开放 | 两份硬编码白名单被换成由插件自己注册 |
| 组织通道打开 | 开始接受外部 PR,或官方给出 Issues 入口 |
| 有了分发支持 | 官方脚手架、插件目录或兼容性矩阵之一落地 |
反向信号同样要看:连续数月没有新提交、讨论区堆积无人回应,说明这个项目的优先级变了,那时候该重算的是另一个方向的结论。
不是「暂未涉及」,是我们清楚它重要、这一版确实没做。
书里只交代了它们存在、各自是什么定位,没有给出一次从头到尾的接入。
讲了它的工具命名和两家对齐,没讲客户端与服务端的配置文件到底怎么写。
27.1 只给了个数字就走了。浏览器那半边是公认最难的一级,本书没有拆开。
提示词、工具集、思考强度档位都是围着自家模型调的:base 默认 deepseek-v4-flash,headless 的角色设定直接写 {{model}}。17.2 讲了适配器怎么写,没讲换完掉不掉链子。
38.2 专讲这条:官方没有,本书也没有。
由大到小排:从最外层的 harness,一路收窄到只管一个会话的 preset。
ctx 上的一个名字,如 ctx.fs。全仓共 56 个。前三个说一个插件自己是什么,后四个说它怎么跟外面接上。
agent/pre-step、agent/request、llm/stream、三个 tools/*、approval/request;agent/turn-stopping 是例外,它是没有 next() 的 serial 事件。run_code。日志是唯一真相,其余六个词里有四个是它的投影。
step);从一次用户输入到它停下来叫一个回合(turn)。surface(session.surface、surfaceOp)。docs/capability-seams.mdctx. 前缀。| fs | 文件系统 |
| shell | 命令执行 |
| subprocess | 子进程 |
| terminals | 持久终端 |
| sandbox | 进程沙箱 |
| codeRuntime | 代码执行 |
| llm | 模型适配 |
| web | 搜索与抓取 |
| lsp | 语言服务 |
| skills | 技能来源 |
| subagents | 子代理 |
| jobs | 后台作业 |
| workflowEngine | 流程引擎 |
| sessionPersistence | 会话落盘 |
| sessionQuery | 会话检索 |
| sessionTitle | 会话标题 |
| sessionTelemetry | 遥测出口 |
| storage | 通用存储 |
| attachments | 附件存储 |
| spillStore | 外溢存储 |
| compaction | 上下文压缩 |
| settings | 用户设置 |
| credentials | 凭据托管 |
| approval | 审批决策 |
| userQuestions | 向人提问 |
| directoryPicker | 选工作区 |
这 26 个的难度差得很远:sandbox、spillStore、workflowEngine 各只有一个抽象方法,fs 有十二个。想动手,就从只有一个方法的那几个挑。
docs/tool-catalog.md@deepseek-ai/dsh- 前缀;这份目录是生成的,不是手写的。要知道自己那套组合到底能看到几个:web 这一层补丁关掉了 24 行,2.2 举了其中一部分;要精确到行,读 packages/bundle/web-app/cordis.patch.yml。
读者有权知道每一句话的可信度从哪来。
一、数字必须能复算。219 个包、56 个服务键、26 个接缝、52 个工具、44 种会话事件,全部来自仓库的生成产物或直接计数。
二、源码压过文档。文档和源码对不上的,以源码为准,并在正文里点出差异。
三、推测必须标明。我们的推断在正文里写了「推测」二字;没写的都能在仓库里找到出处。
npm 周下载量取不到,star 数的构成无法独立验证;社区插件仓库的真实数量只能估计;中英文官网对运行模式的命名不一致,本书以英文原文为准;npm 上的 latest 与本书的源码基线不是同一版。还有一处是有意留白:本书没做成本与效果实测,官方也没给 benchmark 数据(见 38.2);另有五处内容缺口列在 38.5。
所以这本书是地图,不是合同。这个项目每天都在动,真要动手,以你当天 checkout 的那份源码为准。
这大概就是 DeepSeek Harness 想说的全部。它做得对不对,要看接下来有没有人真的去换。