一切皆插件的 agent harness
dsh 没有需要打补丁的特权内核——模型适配器、工具注册表、会话日志、乃至 agent 主循环本身,都是可以从配置里替换掉的插件行。这份门户把它拆到底:怎么运作、为什么这样设计、以及怎么操纵它。
总览与学习路径
入门dsh 是 DeepSeek AI 开源的 agent harness。它的架构主张只有一句:一切皆插件——模型适配器、工具注册表、会话日志、乃至 agent 主循环本身,都是可以从配置里替换掉的插件行。没有需要打补丁的特权内核。
node apps/cli/lib/bin.js --profile headless --dump-default-config 会打印完整组合出来的插件树,每一行还带一句来源注释(# == @deepseek-ai/dsh-base、# == @deepseek-ai/dsh-base, patched by @deepseek-ai/dsh-headless)。整个产品——LLM 访问、会话、持久化、工具、遥测、HMR——就是一个 YAML 列表,任何一层 patch 都能重排、重配、或关掉其中任意一行。不发一次模型请求。
先记住这七个数字
| 数字 | 是什么 | 为什么重要 |
|---|---|---|
| 81 / 129 | headless / web profile 组合出的插件行数 | 「一切皆插件」的字面体现 |
| 13,633 | headless 首个请求的 token 信封(实测) | 其中 91% 是 harness 自己写的、每次请求完全相同 |
| 48% / 43% | 工具 schema / skill 目录 占那 13,633 的比例 | system prompt 只占 7.4%——只看 prompt 会低估一个数量级 |
| 3 | 沙箱模式数(read-only / workspace-write / danger-full-access) | 它只管文件写入,不管读、不管网络、不管进程可见性 |
| 0 | SESSION_FORMAT_VERSION | 预发布,零兼容承诺,磁盘格式随时会变 |
| 1372 | 仓库里的 Agent Notes 篇数 | 设计理据的真正住所;代码和文档都不承载「为什么」 |
| 233 | 已公开发布的 npm 包数(3 条独立版本线) | 加 2 个 PyPI 包;生态开放,代码库本身不接受外部 PR |
三条学习路径
- 它是 developer preview,且兼容立场比「preview」更强硬:后端直接拒绝旧的磁盘格式,
SESSION_FORMAT_VERSION钉在0且明确无兼容承诺。不要把任何东西当稳定 API。 - 生成的目录 > 散文文档 > Agent Notes。
docs/tool-catalog.md之类由doc-sync门禁保证新鲜;包 README 会过期(本门户就抓到 4 处);implemented/笔记应当与实际发布同步但会松动——冲突时源码是权威。 - 本门户的数字来自这台机器的实测(0.1.0-rc.5,macOS/arm64,135 个已装 skill)。比例结论可迁移,绝对值会随你的 skill 数量、模型和平台变化。
掌握度自测
勾选记录在浏览器本地,刷新不丢。
Cordis 心智模型
精通读懂 dsh 的前提。Cordis 不是「插件 + ctx」两件套——专家脑子里装的是五个对象:Context、Service、Fiber、Effect、Event。
| 对象 | 是什么 | 最容易踩的那一点 |
|---|---|---|
Context | 能力的取用面,ctx.foo 是 Proxy | 读一个你没 inject 的服务会 throw——教程只暗示了这条 |
Service | Service 子类;super(ctx, name) 本身就是注册 | 服务方法执行时 this.ctx 被重绑到调用方的 context,所以它创建的 disposer 会随调用方一起回收 |
Fiber | 插件实例的生命周期节点,六种状态 | FiberState 是 const enum,数值顺序不等于生命周期顺序 |
Effect | 可逆副作用;ctx.effect(fn) 返回 disposer | await ctx.effect(...) 不会释放它;plain function apply(ctx) 形式的插件被 new 调用,它 return 的 disposer 会被静默忽略 |
Event | 声明合并出来的类型化事件 | 五种 dispatch 模式,不是四种(primer 的表漏了 bail) |
五种 dispatch 模式
| 模式 | await? | 有返回值? | 监听器义务 |
|---|---|---|---|
emit | 否 | 否(丢弃) | 自己 try/catch,见下方警告 |
parallel | 是 | 否 | 唯一会聚合失败的扇出 |
serial | 是 | 否 | 按顺序跑完 |
bail | 是 | 第一个非 bail 值 | isBailed 很精确:0 和 '' 会 bail |
waterfall | 是 | 是 | 必须调 next(),否则短路整条链 |
emit 不含任何错误容纳EventsService.emit 只是 dispatch('emit', args).map(cb => cb(...args))——没有 try/catch,也没有 .catch()。后果实测:
- 监听器里同步 throw 会穿透到发射方;
async监听器的 rejection 逃逸成 unhandledRejection,而 dsh 的installFailLoud会把它变成进程 abort。
所以:永远不要在 emit 模式事件上注册 async 监听器而不自带 try/catch;如果监听器确实需要 await,就不该把事件声明成 emit。另有一个诊断陷阱:parallel() 内部调的是 dispatch('emit', …),所以 internal/dispatch 诊断事件里 parallel 也报 'emit'——别靠那个字段区分两者。
waterfall 到底怎么走
next() 按位置消费链条,而且你不能向下游传新参数。若要改下游看到的东西,改的是被传递对象的内容,不是参数列表。
在 apply 里自我 dispose 会把 cordis.yml 截断成 []。已被两种启动器复现:某行的 apply 同步调 ctx.fiber.dispose(),Loader 会在 EntryGroup.update 尚未提交 this.data(此刻还是 [])时调用 tree.write(),于是整个文件被写成空列表,而其余行仍然 ACTIVE、进程还退出 0。
同类危害永远成立:任何写回(self-dispose、对 loader entry 调 fiber.update()、loader.create/remove/update)都会用 js-yaml.dump 重新序列化整个文件,注释和原始引号全部丢失。防御:绝不在 apply 里自我 dispose;手写的 cordis.yml 一定进版本控制;优先用 dsh 的 patch 层文件(它们是只读输入)。
!!js 的真实作用域
配置值和一行的 disabled 支持 !!js(不是 !js)。它在 ESM 环境里求值,作用域里只有 process 和启动器注入的 dshHomePath()——没有 require,没有 import。实测写 !!js require('node:path')… 会在加载期报 ReferenceError: require is not defined。
# 能用
mode: !!js "process.env.DSH_PERMISSION_MODE ?? 'workspace-write'"
root: !!js dshHomePath('sessions')
dir: !!js (process.env.HOME ?? process.env.USERPROFILE) + '/.claude/skills'
disabled: !!js process.platform !== 'win32'
# 加载期直接炸
dir: !!js require('node:path').join(...)
!!js 通过 internal/config 这个惰性配置 waterfall 解析,时机是每次激活、在该行自己的 fiber 里、且在注入生效之后——这正是 port: !!js ctx.webStartup.port ?? 3080 能让命令行 flag 打败写在旁边的字面值的机制。
更多专家级锐边(点开)
- vendored 的是 fork,不是 upstream 原版 Cordis。
vendor/里是钉死 SHA 的源码副本,且带本地修改。 - 注册身份是回调函数。对象形式的插件按其
apply做键,而且只有第一次注册的Config生效。 - 插件体永远不同步执行(至少隔一个 microtask),失败被容纳并记日志、不向外抛。
- 点号服务名可以扩展别人的表面:
ctx.provide('reg.extra', v)会变成ctx.reg.extra。 - ACTIVE 的真实含义是一个由被注入 provider 的 fiber uid 拼出的「epoch」字符串;
[Service.check]是就绪门,能让依赖方在 provider 已 ACTIVE 时仍停在 PENDING。 - 事件投递按 dispatch 的
thisArg过滤;被隔离的服务必须写ctx.emit(this, name, …)。 - HMR 的重载单元是 loader entry 的模块,
node_modules对它不可见,改到框架文件会强制整进程重启。 - 诊断「我的插件什么都没打印」:绝大多数是没
inject、或依赖不 ACTIVE 让你停在 PENDING。
组合系统 · profile 与 patch
进阶一个运行中的 dsh 是组合出来的,不是配置出来的。层按顺序盖在一个空列表上,后面的层逐行覆盖前面的。
文档说的是 4 层(bundle → profile patch → home patch → --patch)。实际还有两个 boot-only 覆盖层,任何 --dump-config 都看不到它们。所以当 dump 的内容与实际行为不符时,先怀疑这两层,而不是怀疑自己读错了。
三种 patch 形式
# ① 覆盖配置 —— 替换整个 config 值,没有深合并,保留的字段必须重写一遍
- id: agent-default-model
config:
provider: deepseek-official
model: deepseek-v4-pro
# ② 关掉一行 —— config 原样保留
- id: tool-web
disabled: true
# ③ 插入新行
- id: my-plugin
name: '@scope/my-dsh-plugin'
config:
threshold: 3
| 行为 | 真相 |
|---|---|
| 整体替换的边界 | 替换的是该行的 config;disabled/inject/isolate/intercept 是 config 的兄弟字段,会存活下来 |
patch 里的 name: | 是断言守卫,不是重写——你永远不能把一行重新指向另一个包 |
insert 的副作用 | 同一个 entry 里 insert 会静默丢弃其他所有键;带 id 的 insert targets 一个 group 行的子列表并要求 group: true |
| 插入行的可见性 | 被某层插入的行会进入扁平索引,后续层可以再 patch 它——这就是「一层配置另一层插入的东西」的机制 |
| 目标 id 不存在 | stderr 警告,不致命(故意的,好让一个 overlay 被多个 profile 共享)。所以打错 id 会静默无效——用 --dump-config 确认 |
| 空文件 / 只有注释 | throw(它解析成「无」,不是「空列表」)。想中和一层,写字面量 [] |
| home 层 vs profile 层 | home 层更高——理由是作用域(机器级偏好压过单个 profile),不是「更具体者胜」 |
命令行边界
dsh --profile web --port 8080 # --port 属于 web app
dsh --profile web --help # web app 自己的 help,不启动任何东西
dsh --help # 启动器自己的 help
dsh --profile headless "task" # task 是 headless app 的位置参数
启动器只解析自己的 flag,第一个它不认识的 token 就开始 app 参数。三个后果值得记住:
- 打错的启动器 flag 会静默变成 app 参数,并且照常把整棵树启动起来——不会报错。
- 启动器只吃一个
--;要传字面量--给 app 得写-- --。 - 第一个 app 参数如果正好等于
web或plugin,会劫持成那个子命令。
--help 里的 tui profile 不存在dsh --help 和 apps/cli/src/args.ts 在四处示例里用了 --profile tui,但仓库里没有 tui bundle(packages/bundle/ 只有 base、headless、web-app)。README 注了一句「example, assuming the tui profile is installed」,help 文本没注。
验证与恢复
node apps/cli/lib/bin.js --profile headless --dump-config # 含用户层与 --patch
node apps/cli/lib/bin.js --profile headless --dump-default-config # 只有 bundle 层 —— 恢复用诊断
node apps/cli/lib/bin.js --profile web --patch ./x.yml --dump-config
--dump-default-config 是恢复诊断:它两个用户层都不解析,所以当你的 patch 文件把启动搞坏时,用它确认基线还是好的。两种 dump 都不发模型请求。
另有两个 dump 的盲区:dump 模式完全跳过 loadLayeredEnv,所以 dump 里的 !!js 环境表达式只反映你继承的环境;--dump-config 无法告诉你有效的 tools mode,它打印的是未求值的 !!js 表达式。
raw cordis.yml 里的 bare 名按最近的 package.json 的 node_modules 解析,不是按运行的二进制。实测:放在 /tmp 的组合里写 @deepseek-ai/dsh-sdk-jsonrpc-server → loader entries failed to apply;把同一个文件放进 examples/node_modules/.cache/(parent-walk 能到 examples/node_modules,那里有 108 个 @deepseek-ai/*)→ 正常启动。仓库根 node_modules 里没有这些包,因为这是 pnpm workspace。
profile 的 cordis.patch.yml 是另一回事:那里的 bare 名走 profile 目录的 parent-walk,能命中 $DSH_HOME/profiles/node_modules 这个兜底农场——它是一份完整依赖闭包(含 peerDependencies,约 241 个 symlink),每次启动自愈。
还有两个组合平面,以及 profile 目录的自动改写
组合有两个平面:进程级的 profile patch 层,和每会话的 agent preset。preset 组合是 bundle 层下面一个独立的、不可 patch 的层。这解释了 web profile 的一个反直觉现象——见 12 Web 应用与客户端架构。
<profile>/cordis.yml 在每次启动时都被重写;而被你删掉的 cordis.patch.yml 永远不会被恢复。只有 web 和 headless 两个名字会自动初始化,其它名字响亮失败。
bundle 顺序是承重的,但顺序错了只表现为警告。列了一个「不是 bundle」或解析不到的包,则在加载期响亮失败——绝不静默跳过。
控制脊 · turn 与 step
精通一个 step = 一次模型请求 + 它调的那些工具。一个 turn = 零个或多个 step:它在第一份输入被 claim 之前打开,在「什么都不欠」时关闭。
agent/pre-step 可以 reject,也可以第一次 enter 一个空批次。两种情况下 turn 都照样持久关闭,日志因此记录下这次尝试。而且两者产生不同的 turn/end reason:blocked vs completed——这个区分驱动 foldConsumedWork。
代价:一次 pre-step 拒绝会搁死剩下的 next-turn 队列并让 driver 转入 idle。
顺序上容易搞反的几处
| 直觉 | 实际 |
|---|---|
| pre-step 之后才装配 prompt | 装配发生在 agent/pre-step 之前,结果为整个 step 冻结——连每次 request-error 重试都用同一份 |
| pre-step 看到的就是 claim 到的消息 | 默认批次还包含 loop 注入的一份 runtime-context 快照 |
agent/request 可以改 messages | 不行。它跑在历史推导之后,不变量强制它与日志逐字节相等 |
| turn 由某个调度器打开 | turn 在 agent 空闲时,由 followup()/steer() 内部同步打开 |
| inbox 是一个队列 | 是两个有序列表,claim 不对称:全部 next-step + 恰好一条 next-turn |
max-tokens 会静默丢掉这一 step 的工具调用触到 max-tokens 时:该 step 的 tool calls 被丢弃,记一个无内容的 anchor,并且 turn 的结局变得粘性(后续无法翻回 completed)。所以 turn/end reason=max-tokens 意味着「模型可能本来要干活,但那些动作从未发生」——不要当成普通完成。
取消与错误恢复
- 一个 turn 一个 AbortController。取消会清 inbox、latch 住 wake、并合成工具结果补齐未闭合的调用。
- abort 之后到来的唤醒输入被静默改投到 next-turn——你调的
steer()可能实际变成了followup()。 agent/request-error是唯一的模型失败修补接缝;其它任何异常都直接关闭 turn。step/end和turn/end写在finally里——失败的 step 仍会闭合自己的边界;但失败的turn/start不会。agent/turn-stopping被检查两次,而且决定权在数据,不在监听器顺序。
作用域:registrations 向下继承,events 向上准入
agent.ctx 是 scope.ctx.extend({ agent: this }),而那个 scope 是从 loop 的 context 铸出来的,不是调用方的。作用域键构成一条父链,两个方向相反:
这是「global-layer trap」。想只给某一个 agent 一个能力,注册到那个 agent 的 agent.ctx。反过来,child-scoped 的 report 工具正因为注册在子作用域,才能存活于全局 toolFilter 之外——这是可复用的模式。
文档漂移与冷知识
agent.runMaintenance()是 architecture 文档里没写的第三阶段,从外面看像 idle。maxParallelToolCalls是实时读穿的 getter;agents被故意排除在 Settings 之外。- 工具的
additionalContexts绕过send()——直接拼进 next-step,且不唤醒 driver。 - prompt 变量
provider/model/cwd读的是AgentOptions,不是实际使用的 route;切换 route 必须同时更新两个表面。 TurnTrigger和steering/message是已退役的词汇,但文档里还在引用。agent/*的 emit 模式被重新实现过以容纳单个监听器的失败——唯一例外是agent/created,它可以否决。
会话日志 · 唯一真相源
精通model-visible ⟺ logged。凡是能到达一次模型请求的东西,都必须能从这条 append-only 日志重建出来——而且有一个运行时断言在强制它,不只是约定。这就是「新增一个模型可见输入必须新增一个 session event」的原因。
它不是「用一个全新 Session 重建再比对」。packages/core/agent-loop/src/invariant.ts 注册一个前置的全局 llm/stream waterfall 监听器(由 isAgentLoopRequest(options) 把门),然后检查:request 已冻结、sessionId 存在且在 ctx.sessions 里活着、messages 数组已冻结、日志里至少有一个 step/start、foldRequestHeader(session.events) !== undefined,最后把 JSON.stringify(options.messages) 与活动 session 的 deriveMessages() 直接比对,再逐字段比对 model/system/temperature/maxTokens/stop/tools。
另一个不变量(session 侧)走 internal/dispatch 做提交前校验,再在 session/event 上应用状态转移。
磁盘现实:三个会让你读错数据的坑
日志里的 assistant/chunk 不是一行一个。存储层有一套独立词汇:packed row,信封用 seq0/time0(不是 seq/time)加一个 delta-time 数组 dt,且 len(dt) == len(members) - 1。所以你数出来的「事件直方图」是物理行直方图,不是逻辑事件直方图。
.jsonl.zstd 是独立 frame 的拼接:1 个 header frame + 每批 durable append 各 1 帧,每帧独立可解、带校验和。实测 Node 的 zstdDecompressSync 和 createZstdDecompress 都只解第一帧、并且报告成功——一份 231 事件的日志只解出 2 行。
正确做法:zstd -dc,或者按 frame magic(28 B5 2F FD)自己切帧逐帧解(实测同样得到 231 事件)。
顺带一提,撕裂尾部的恢复是在撕裂那一帧的起点做字节偏移截断,加上 ZSTD_e_flush 前缀打捞——zstd -dc 给你看到的就是同一段前缀。
| 事件 | 文本/关键字段的真实路径 |
|---|---|
user/message | data.content[] 里的 text 块;来源在 data.source({kind:'user'} 或 {kind:'plugin',plugin:'…'}) |
assistant/message | data.message.content[],混着 {type:'text'} 与 {type:'reasoning'} |
tool/call | data.name + data.arguments(一个 JSON 字符串,供应商原样) |
tool/result | data.message.content[] → 一个 tool-result 块,它自己的 content[] 才是文本;isError 在那个块上 |
request/header | data.header.system / .tools[] / .config(观测到最肥的一行,31 KB) |
turn/end | data.reason.kind |
一次递归遍历 content 数组就能覆盖全部——不要为每种事件写一个抽取器。
落盘时机:三个语义屏障,不是「turn 结束」
「每个 turn 结束才 flush」是 2026-06 的原始设计,2026-07-21 被判定「作为唯一崩溃恢复点太粗」而修掉。现役是一个独立于持久化的零配置插件 dsh-session-checkpoint-policy,在三个语义屏障上 await ctx.sessions.flush(),且失败关闭:
agent/pre-step——在下一次请求被推导之前,把 prompt 输入或上一轮的响应/结果批次刷下去;llm/stream内部——request/header已记录、但适配器流尚未构造之时;- turn 边界。
另一条纪律:session/flush 必须走 ctx.sessions.flush();裸调 ctx.parallel('session/flush') 是不变量违规。写入批处理窗口是固定截止时间——后来的事件不会重置它。
fork / resume 的锐边
fork(source, boundary?, childSessionId?)的 boundary 是包含式的,而且必须结束在一个 open turn 之外——它绝不静默裁剪,会直接拒绝。- 被中断的 turn 是被关闭的,不是被截断——靠合成的 closer 事件。
session/end-seed:读最后一个,别假设有一个正好坐在firstLiveSeq。- resume 和 fork 的
seedLength含义不同,而firstLiveSeq也不是header.seedLength。 deriveMessages()会跳过两样常被忘记的东西:raw chunks,以及内容为空的 assistant message。
其它硬事实
| 第一行 | 是 SessionHeader,tag 为 {type:'session'}——它不是一个 event |
| 编码 | 一个 root 只能有一种编码,混用会带着具体文件名报错拒绝。实测 ~/.dsh/sessions 是 zstd,~/.dsh/runs 是 raw |
| 接受门 | append 时的 JSON 校验是唯一的接受门,实现为一次递归 read-validate-copy,好让有状态的 getter 无法说谎 |
| 词汇拒绝 | KNOWN_SESSION_EVENT_TYPES 是一份生成的 44 项白名单——拒绝按词汇,不只按版本 |
| 版本拒绝 | 从原始 header 行读,在任何形状校验之前——未来格式报「upgrade」,绝不报「corrupt」 |
ignorable: true | 全仓零生产者,是纯读侧的向前兼容槽位 |
| 发布方式 | POSIX 用 link()+unlink(),不是 rename(),外加四级 fsync 阶梯 |
| 清理 | 没有任何东西删除会话日志——~/.dsh/sessions 和 ~/.dsh/runs 得你自己清 |
# 本门户配套 skill 里的读取器,7 种视图,无 zstd CLI 也能跑
~/.claude/skills/dsh/scripts/dsh-log.mjs --workspace DIR # 时间线
~/.claude/skills/dsh/scripts/dsh-log.mjs --workspace DIR --view tools # 调用+完整结果
~/.claude/skills/dsh/scripts/dsh-log.mjs --workspace DIR --view prompt # 模型真正看到的 prompt 与 schema
~/.claude/skills/dsh/scripts/dsh-log.mjs --workspace DIR --view stats # 事件直方图 + 注入字节数提示装配与上下文经济
精通这一章是全门户最省钱的一章。结论先说:headless 首个请求约 13,633 token,其中 91% 是 harness 自己写的、每次请求一模一样——而 system prompt 只占 7.4%。任何只展示「system prompt」的说明都把成本低估了一个数量级。
实测成本构成(headless profile,98 个 skill 进目录)
| 成分 | token | 占比 | 它是什么 |
|---|---|---|---|
| 工具 JSON schema | 6,556 | 48.1% | header.tools 是独立的 wire 字段:25 个 schema,26,208 字符 |
| skill 目录 | 5,904 | 43.3% | 一条 user/message,30,634 字节——单一最大输入 |
| system prompt | 1,009 | 7.4% | 4,017 字符,除 {{cwd}} 替换外各 profile 逐字节相同 |
| runtime-context 快照 | 125 | 0.9% | 沙箱策略 + 审批策略,也是 user message |
| 你的那句话 | 39 | 0.3% | —— |
- CJK 被低估 2–3 倍。
estimateSystemTokens/estimateToolsTokens用 JS.length(UTF-16 code unit)除以 4,所以 skill 目录的 30,634 字节被按 23,582 字符计价。中文描述吃亏最狠。 - 图片几乎隐形。
ImageBlock落进estimateContent的default分支,按4 + ceil(JSON.stringify(block).length/4)≈ 50–60 token 计价,与分辨率无关。视觉会话对 harness 自己的压力表来说是系统性失真的。
token-meter 的 README 自己就写了:contextBreakdown 那三个数字「will not sum to projectedTokens… Present them as an approximate composition, never as a total.」
- 关掉 skill 目录(三行 patch):注入的 user message 从 50,416 B → 1,383 B(实测)。对自动化调用是纯赚。
- 调小
catalogDescriptionMaxLength:默认 500 字符(最小 3),观测到的 98 条里有 7 条被截到 500。降到 ~120 能从每次请求里去掉大约 20 KB,同时保留 skill 可用。这是「既要目录又要省钱」的答案。
# 方案二:保留 skill,但把目录压瘦
- id: tool-skill
config:
catalogDescriptionMaxLength: 120system prompt 与 runtime context 是两种东西
这是最容易混淆的一处:runtime context 不是 prompt 文本,它是一条 role: user 的消息,source 为 {kind:'plugin'}。
| prompt section | runtime context | |
|---|---|---|
| 落在哪 | header.system | 一条 user/message |
| 更新方式 | 每 step 重新装配 | last-writer-wins 全量快照,带一个独立的 CLEARED 标记,键是被保留事件的文本 |
| 模型看到的开头 | — | Current runtime context. This snapshot supersedes earlier runtime-context snapshots. |
| 出货的 provider | 多个,按 order 带排 | 只有三个,全是 agent-scoped,渲染进同一条 user message |
推论:一次 裸 assemble() 什么 runtime context 都拿不到;而一次会话中途的策略变更会产生两条模型可见消息,来自两个不同的插件名。
prompt section 的实际 order 带
-100 身份(harness identity)
0 persona # 出货的 base 是 persona-less,由 profile 供一行
50 plan mode
99 tools:code-only # 只有 mode: code 会渲染出内容
100–149 工具指引段(11 段)
115 tool:report # 只在 continuable 子代理作用域里存在
150 Code Mode SDK 块
110 沙箱策略 ┐ 这两条走 runtime-context
115 审批策略 ┘ (user message,不是 system 文本)
装配还有两个细节:每个 step 都会深拷贝每个工具的 parameters;toolOrder 在 waterfall 之前应用。
AGENTS.md / CLAUDE.md 的真实行为
- 渲染预算 65,536 字节,界定的是完整渲染后的消息,并且有一条六级降级阶梯——最后两级会把
<system-reminder>外框都丢掉。 - 发现过程做按目录的内容去重,跨信任边界跟随 symlink,而且没有 watcher。
- 工作区指令是持久的 user message,靠文件系统触碰来刷新。
- 指令状态完全活在消息的 typed source 里,绝不在模型可见文本里;而且只有当某个内容字节存活下来时,变更才会被记录。
KV Cache effect 段汇总)规则简单到可以背下来:prompt = 前缀稳定;所有动态内容 = 只追加;compaction 是唯一的失效源。
实测一次 9 step 的 turn:首个请求 14,472 token 未命中缓存,此后每 step 约 150;缓存读取以 256 token 为量子增长。skill 目录虽然巨大,但从第 2 步起是可缓存的——它的真正代价是首个请求,以及任何一次目录变更引发的整表替换。
另一个易漏点:压力计量是请求+响应双向的,而且信封里一个字节的变化会静默丢掉 provider 的锚点,回落到启发式。
装配的失败响亮度地图
值得记住哪些错在什么时候炸:加载期 throw(组合本身非法)、每次 assemble 都拒绝(section 契约违规)、只拒绝第一个 turn(首轮才需要的输入缺失)。renderPrompt 的严格插值有四种不同的 throw 情形,以及一处刻意的放行。
dsh-persona 是唯一能让单个 agent 拥有不同身份的途径,complete: true 是接管整份 prompt 的总开关。
还有一个给任何「按来源过滤」逻辑埋的雷:消息的 source kind 是可合并扩展的,而 'user-rpc' 刻意保留 kind:'user'。
工具管线与工具全表
精通工具注册表 + 三段 waterfall 受控管线。这一章合并两件事:管线怎么运作(改行为时要懂),和出货了哪些工具(用它时要懂)。
受控管线
waterfall→allow / deny / ask
waterfall→tool/result
| 事实 | 细节 |
|---|---|
| 注册即效果 | register() 在注册时校验,返回那个确切的 effect disposer;所属层是调用方 context 的 scope |
| 可见性解析 | 一次 view(scope) 遍历:restriction 只过滤一个 scope 继承来的东西,它自己的注册免疫 |
| 拒绝的形态 | 拒绝不带结构化错误码,只是一个字符串。被拒的调用仍然到达 tools/post-execute;但 collapse-denial 和非 JSON 参数不会(且会丢掉 finalizeContent) |
| 守卫的例外 | guard 是唯一不发 tools/change 的注册表变更 |
| 超时 | 零配置插件,按 signal 而非结果计时,并就地改写共享的 exec |
| 溢出(spill) | 跳过列表里硬编码了 exec.name === 'read';替换后的内容保证 ≤ 上限,但装不下的通告会留下一个孤儿 spill 文件 |
| 结果剪枝 | ctx.toolResultPruner 住在 compaction/,不在 session/;它改写surface,绝不改日志 |
| 渲染意图 | 是工具设计的一部分(generic/terminal/diff + locations),presenter 必须是 args 的纯函数;defineTool 对显示路径做软校验 |
出货的 24–25 个工具(headless,实测自真实 request/header)
| 工具 | 包 | 要点 / 陷阱 | 状态 |
|---|
docs/tool-catalog.md 在几处与出货现实不符- 它漏报了
bash的真实 schema:缺sandbox_permissions参数和约 1200 字符的升权说明。 - 更险的是:
sandbox_permissions即使未被广告,也仍然能到达execute()——schema 校验只检查已广告的键。这是个刻意留的洞,带自己的错误。 glob的超限行为,目录展示的与出货的相反(那是个必须显式选择的 config,生成器替它选了另一支)。subagentvssubagent_fork的默认值不一致且反直觉:出货的subagent默认后台并返回一个持久 subagent id;subagent_fork默认前台并返回一个 job id。
结论:要知道某个部署真实的工具名单,读会话日志的 request/header,不要读目录。
bash@deepseek-ai/dsh-tool-bash(一次性)和 @deepseek-ai/dsh-tool-bash-persistent(owner 隔离的常驻 PTY)注册的是同一个名字 bash,但是两个不同的工具。web 的 minimal preset 用的是常驻那个——它只给两个工具:常驻 bash + str_replace_editor。
常驻版的状态丢失陷阱:exit、超时、和取消 三者都会静默重置 shell(cwd、导出变量、shell 函数全丢)。
read/write/edit没有超时,read-before-write 由另一个事件策略插件把门,不在 schema 里。- 只有 web 工具声明了超时预算;
bash/read/write/edit刻意不声明。 glob/grep走打包的 ripgrep,永远不是后台 job,并且前置--no-config以防宿主RIPGREP_CONFIG_PATH注入--pre。lsp坐标是一基、UTF-16;provider 缺失时返回LSP_UNAVAILABLE而不是让工具消失。read_image按magic bytes 而非扩展名判断,并有一个针对模型切换的已记录 TOCTOU 竞态。session_*五个查询工具即使挂上也搜不了:出货的 SQLite 是:memory:+openAt: never,搜索直接SESSION_QUERY_SEARCH_DISABLED。要开就在后续 patch 层把openAt改成first-search/startup,通常还得配一个持久path。cordis_*七个自我修改工具不在任何 bundle 里,而且那个 vm 是「对诚实代码的容纳」,不是安全边界。
沙箱 · 权限 · 审批
进阶先说清边界,因为它比大多数人以为的窄:dsh 的沙箱是一套 文件写入 策略。读、网络、进程可见性完全不受约束——它不是防外泄的安全边界。
writableRoots() 对 read-only 和 danger-full-access 都返回 []——一个是「什么都不给」,一个是「不需要问」。别把这个返回值当权限清单读。
另有一处跨后端的可写根漂移:fs 围栏授予 os.tmpdir(),而 bwrap 和 Landlock 的 profile 不授予。/dev/null 由四种不同机制分别授予,而在 Windows 上根本不授予。
出货的预设表有三个,不是两个
| preset | sandbox/mode | approval/policy |
|---|---|---|
read-only | read-only | ask |
workspace-write(默认) | workspace-write | ask |
danger-full-access | danger-full-access | never |
文档里说的「默认两项表」只是服务 Config 的兜底,不是出货组合。
permission-presets 的 README 说事件叫 permissionPresets/preset、设置命名空间叫 permissionPresets、命令叫 /permissionPresets。源码全都不同意:session.append('permission/preset', …)、settingsNamespace('permission')、commands.register({ name: 'permission' })。docs/subsystems/ + known-event-types.ts + 真实日志三方一致——信它们。
钉死在会话创建时刻
实测每一份真实日志的 seq 0/1/2 就是这三件事,顺序固定:
{"type":"permission/preset","seq":0,"data":{"preset":"workspace-write"}}
{"type":"sandbox/mode", "seq":1,"data":{"mode":"workspace-write"}}
{"type":"approval/policy", "seq":2,"data":{"policy":"ask"}}
钉死之后,后续任何设置变更都不会改动已存在的会话。三个补充事实:pinInitialPermission 有三条不同路径,其中一条根本不写 preset 事件;set() 不产生模型可见的切换通告,只有 /permission 命令会;custom 是派生的,它读活的服务配置作兜底,所以可能在没有任何新事件的情况下翻转。
升权重试:完整的失败阶梯
模型撞到拒绝后可以带 sandbox_permissions 重试。那次重试本身就是同意提示,而且加宽是严格检查的——但检查在执行时,不在 schema 里。
| 环节 | 结果 |
|---|---|
| Web / ACP 有应答者 | allow_once / reject_once,只对那一次重试生效 |
| headless | 结构上永远 unavailable——headless bundle 根本不挂应答者 |
| 模型看到的字符串 | Error: sandbox escalation to "danger-full-access" requires approval, but no approval channel is available |
| ACP 收到无 callId 的询问 | 委派(失败关闭),只提供两个一次性选项 |
never 的强制点 | 在 ApprovalService.request 内部,专门为了让 prepend 的应答者无法绕过 |
| 审批的前置条件 | 必须有一个打开的 turn,否则在追加任何东西之前就 throw |
一次真实 headless 运行:agent 要用 uv,其缓存在 ~/.cache/uv(workspace 之外)→ 被拒 → 它请求升权 → approval/asked → approval/decided outcome=unavailable → 工具结果是上面那句 → 模型自己改用 UV_CACHE_DIR=<workspace>/.uv-cache 并成功。这就是「拒绝必须是结构化可读的」的价值:模型能据此自我修正,而不是卡死。
SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i,加上所有 DSH_*(大小写不敏感),在 spawn 前被 scrubbedParentEnv() 清掉。PATH/HOME/locale/proxy 存活。实测 bash -c 'echo $DEEPSEEK_API_KEY' 什么都看不到。
但它是名字启发式:*PASSPHRASE* 这类会漏网。显式的 spec env 在 scrub 之后合并,这也是 shell-env 注册表重新注入 DSH_HOME/DSH_SHELL/DSH_SESSION_ID、以及子代理刻意转发 DEEPSEEK_API_KEY 的机制。
平台后端的真实差异
sandbox-exec(Seatbelt)profile 是 allow-default:读、网络、进程可见性按构造完全不受限。ps在任何 sandbox-exec profile 下都不可用——这是 setuid 导致的,不是 dsh 的规则。别拿它当 bug 报。- darwin 是唯一候选,因此不经探测就被选中。
- Landlock 走
@deepseek-ai/node-addon-landlock-run这个原生 launcher(native/)。 - Landlock 的 runner 失败按退出码 125 判定;bwrap 和 seatbelt 只靠签名判定。
- 拒绝分类要求非零退出码——被信号杀掉、或退出 0 的拒绝不产生标记、也不产生升权提示。
- ACL 受限令牌,两个独立 SID 身份:确定性的常驻 workspace 授权,和随机、可撤销的每会话临时子目录。
- 大 workspace 上第一次受限写入会阻塞几十秒——整树 ACE 是预先传播的。
- 受限进程无法通过管道捕获孙进程(EPERM)——所以捕获输出的工具在受限模式下跑不了。
read-only会静默把 PowerShell 降级到 ConstrainedLanguage;WMI/CIM、Get-ComputerInfo、whoami在两种受限模式下都不可用。- 授权根之外的 FAT 类卷在两种受限模式下仍可写。
dsh-fs-sandbox取代dsh-fs-local。同时挂两个 → Cordis 报service "fs" has been registered at <fiber>,加载失败(实测)。而且拆开的组合是在工具插件加载时失败,各家族有不同的消息。DSH_PERMISSION_MODE只能是进程级。项目里的.env想设它会让启动响亮失败,而且是在任何东西被应用之前。
更多锐边
- fs 围栏返回一个重新规范化过的目标,实际变更用的是那个,不是工具手里那个可能已过期的路径。
fs-observation-policy是先到先得的单槽,而且它不是强制——它的状态在 resume 时死亡。- 读/编辑的二进制判定不对称:一个靠后的 NUL 字节读得进去,但会拒绝编辑。
- 一个打开的常驻 terminal 会话会硬阻塞任何沙箱模式变更,并可能留下一个孤儿 preset 事件。
- 子代理委派强制子代理为
never;委派策略在委派边界处钉死,并写进子代理自己的日志。 - 不变量伴生插件会在加载时和追加时拒绝伪造的持久安全事件。
danger-full-access从不咨询ctx.sandbox;后台句柄也不携带任何沙箱事实。- Code Mode 里升权同样可用——字段以 TypeScript 可选属性的形式出现。
LLM 接缝 · 模型 · 压缩
进阶ctx.llm 是一个适配器注册表加一个流式调用 API。写适配器只需继承 LlmAdapter 并实现 stream()——其余都是共享的折叠逻辑。
换模型 / 换供应商的旋钮在哪
| 你想改 | 改哪里 |
|---|---|
| 默认 provider + model | patch agent-default-model 行 |
| DeepSeek 的 thinking / reasoningEffort | patch llm-deepseek;出货默认是 full thinking + max effort |
| 接别的供应商 / 自建 OpenAI 兼容端点 | llm-pi-ai + $DSH_HOME/settings.yaml |
| 单个模型的能力声明 | models[].input(自定义 provider)或 modelOverrides.<id>(catalog provider) |
| 压缩阈值 | patch compaction-basic |
# $DSH_HOME/settings.yaml —— 自建端点 + 声明视觉能力
llm-pi-ai:
providers:
my-gateway:
apiKeyEnv: GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.example/v1
defaultInput: [text] # 兜底,不是覆盖;默认 [text]
models:
- id: legacy-chat
- id: vision-preview
input: [text, image] # 只作用于这一个模型
modelOverrides 和 models 设计上互斥,写错会响亮拒绝。手写的 route 只能到达三种 wire 协议,其余是 catalog-only。
手写的模型默认按纯文本对待——因为没有任何办法去问一个端点它接受什么模态。给它附图会在发送之前就被拒绝,并点名那个模型。反过来,声明了 image 但端点不支持,这里不会被抓到,是供应商去拒绝请求。拒绝发生在 BFF 层、两次,而未知模态是放行的。
还有一个恢复陷阱:附上的图片留在会话日志里,所以同一个请求会一直重复,直到你把会话切到别的模型为止。
压缩(compaction)的真实行为
- 三个比率(
thresholdRatio/retainRatio/retainTokens)都是相对实际路由到的模型的上下文窗口缩放的,不是相对某个常量。 - 保留是头部锚定的,并且总是至少留一个节点;
retainTokens=0就是「恰好留一个」。 - 压缩决策有两条不同入口,锁与容量要求也不同。溢出重试的计账是按 Agent 存在 WeakMap 里的,任何一次成功的 assistant message 都会把它重置。
- 摘要请求省略了
reasoningEffort——于是 thinking token 会吃掉那个 8192 的上限。 - 摘要必须严格变短;图片输出是被拒绝而不是丢弃。
- 压缩刻意复用温缓存前缀:那条指令是最后一条 user 消息,而不是一个 summarizer 的 system prompt。
- 一个孤儿的
compaction/start就是预期的崩溃信号,不是 bug。
MISSING_CREDENTIAL | 没有 key。凭证解析读的是启动环境,不是活的 process.env |
UNKNOWN_MODEL | 模型没在这条 route 上配 |
QUOTA | 账户状态,不是配置问题。树已启动、会话已开,第一个请求被拒。查 https://api.deepseek.com/user/balance |
EMPTY_RESPONSE | 有自己的默认重试预算 |
重试发生在 agent turn 边界,绝不是流的包装器;always 模式会连永久性失败一起重试。流空闲看门狗默认 5 分钟,只在 next() 挂起时武装,而 SSE 注释算活动。
写适配器前要知道的
ContentBlock是可合并扩展的 map,reasoning是一等块。resolveCallConfigvsprepareCall:默认值在后者里物化,loop 用的是 prepared handle。agent/request的提案会剥掉被标记为适配器默认的字段,好让一次 route 切换重新解析出自己的值。- DeepSeek 侧:
off从不跨 wire;session-title 调用强制关 thinking;per-model 覆盖压过 route,且有一条硬性不 clamp 规则(超了就是超了,不给你截断)。 - 有两个独立的上下文溢出分类器,归一到一个规范码,另有一条基于 usage 的路径。
- replay 状态是适配器实例作用域的,一旦监听器改写了内容就会被丢弃。
discoverModels是草稿期的端点问询,按 settings 命名空间键控,绝不是一次目录刷新。
委派 · subagent 与 workflow
精通subagent 是全仓唯一带命名多 provider 注册表的能力接缝。同一个接口后面可以是:进程内新子代理、完成前缀 fork、独立进程的 dsh SDK runtime、甚至委派给 Codex 或 Claude Code 的一个 turn。
两个轴不要混:providerName vs toolName
| providerName | toolName | |
|---|---|---|
| 是什么 | 注册表的键(传输方式) | 模型看到的工具名 |
| 出货值 | spawn, fork | subagent, subagent_fork |
| 关系 | 一个 tool-subagent 插件实例绑定到恰好一个 provider。出货树把同一个包装了两次 | |
- 出货的
subagent:continuable,省略run_in_background时默认后台,返回一个持久 subagent id,settle 后自动投递。 - 出货的
subagent_fork:one-shot,默认前台,返回一个 job id。 - 配置漂移:
dsh-base让 fork 保持 one-shot,但三个 CLI agent preset 都把它改成 continuable,与 base 和 fork 的 README 矛盾。要知道某个部署的真实行为,读那个 preset。
子代理的结果怎么回到父代理
关键认知:子代理的会话事件从不进入父代理的模型上下文。存在的是四个独立的观察平面和七条有界通道,而不是「事件流上浮」。
report是每个 child 单独安装的,不是全局注册——所以它能存活于全局toolFilter之外,但必须在 host 平面恰好注册一次(重复注册会 throw)。report的接受不等于投递;一次失败的 report 调用仍可能已经到达。send_message永远无法操纵正在飞行中的工作;list_agents的 depth-1 就是send_message的作用前沿。interrupt()的权限刻意比投递更宽,且是 fire-and-return,只停当前 turn。list_agents是投影支撑的读取,三级服务阶梯 + 一个null哨兵,不加载任何 Agent、不需要查询服务。注意它的模型词汇是服务词汇的精化:ready意思是可恢复,不是「完成了」。
maxDepth 是绝对上限,不是相对预算它约束的是子代理算出来的深度,而且到了上限工具依然可见(只是调用会失败)。delegationDepth 是持久 header 字段、单调下限、磁盘上必填,由接缝拥有而不是 loop。
两个 workflow 引擎的坑:它既不传 maxDepth 也不传 label 给 ctx.subagents.start();而且 agent(prompt, { provider, model }) 选的是 LLM route,不是 subagent 传输方式——这个参数名极易误读。
workflow / ralph / goal
| 要点 | |
|---|---|
workflow | 接缝只允许一个引擎;脚本永远看不到路由策略。worker/vm 是API 塑形,不是安全。meta 是参数不是代码;单项失败变 null;误用 hook 一定会杀掉整个脚本——fatal 会穿过组合器重新抛出。这条 fatal-vs-null 纪律是最重要的编写规则。 |
ralph | 刻意做成出货里最窄的工具:固定脚本、每轮一个全新子代理、仅前台、只在人类明确要求时用、轮数上限 64。那个上限同时是默认值、天花板、和引擎的 maxTotalAgents。 |
goal | 事件溯源的同会话目标状态,activation 永不持久化(持久 phase 与进程内 activation 正交)。权限检查是机械的:ctx.agents.roots() 成员资格 + 当前打开 turn 里一个 {kind:'user'} 来源。create/edit/pause/resume 要直接人类 root;complete/blocked 也接受 goal-round turn。blocked 在 3 个已准入轮次之前被拒。 |
「dormant(休眠加载)」的说法已被推翻:dsh-base 只出货 spawn + fork,codex/claude-code 在每一个出货 profile 里都不存在(有两个被禁用的委派工具 subagent_codex/subagent_claude_code)。
而 DSH_CLAUDE_CODE_EXECUTABLE 什么都不激活——它是一个内部的 Windows 批处理 shim 载体。claude-code provider 最有意思的设计决定是它刻意省略 settingSources,让宿主设置保持权威。
并发与持久化的细节
- 并行的兄弟委派按构造安全,但 job id 按 dispatch 竞态顺序到达——别依赖顺序。
- Activation 状态是派生的,不是第二个状态机;
waiting正是让父代理无法 settle 的那个状态。 followup()的路由纯粹由 residency 决定,调用方的 signal 在 inbox 接受时就过期了。- 结构化输出是child 作用域的强制捕获协议,不是对模型的格式请求——一个爱多事的 prompt 监听器能把它弄坏。
- 所有 provider 共享的结果选择规则:最后一条非空 assistant 消息,跳过 usage-only 消息。
- settlement 会
await ctx.sessions.flush()但忽略它的 participation 布尔值——一个不显眼的持久性注意点。
Code Mode
精通DSH_TOOLS_MODE 选进程级的工具呈现方式:native(默认)/ code / both。任何其它值在启动时失败——包括空字符串,且大小写敏感。
广为流传的说法是「N 个 schema 塌缩成 1 个,省下大量前缀」。实测不成立:
| system prompt | schema | 启发式合计 | |
|---|---|---|---|
| native | 4,017 字符 → 1,009 tok | 25 个 / 26,208 字符 → 6,556 tok | 7,565 |
| code | 34,246 字符(8.5×)→ 8,566 tok | 1 个 / 877 字符 → 224 tok | 8,790(+16%) |
原因:生成的 SDK 块携带了一个完整的 ToolOutputMap——每个工具的返回类型,包括 subagent/ralph/read_image/todo_write 的判别联合——这是 JSON schema 里从来没有的信息;再加上 type JsonValue、每项都 & Record<string, JsonValue> 的 ToolArgsMap、type ToolName、declare class ToolCallError、declare const tools。
dsh-tools 的 README 自己就把这话说明白了:它「trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction」。
- 中间工具结果永远不进对话。 这是真正的收益,而且它随调用次数放大——一个跑 20 次 grep 再汇总的程序,native 模式要把 20 份输出全塞进上下文,Code Mode 只回传最后那个值。
- 一个结果卡替代 N 个。 UI 上一次
run_code只渲染一张卡,绝不按嵌套调用渲染。
所以选择标准是:任务是否会产生大量你不想留在上下文里的中间输出——而不是「想不想缩小前缀」。
安全性不打折
嵌套调用重新进入完整的受控管线:沙箱、审批、guard、事件全都照常。而且塌缩是在执行器里强制的,不是靠 schema 省略:模型若直接点名一个 native 工具,会在策略之前就得到 UNKNOWN_TOOL。升权在 Code Mode 里同样可用,字段以 TypeScript 可选属性出现。
并发的真相(和一个让人失望的数字)
- 提交顺序的启动保证 +
maxParallelSubCalls+ 「函数体必须并发安全」规则。 - 并发安全的工具只有 8 个——对一个 coding agent 来说,
Promise.all基本什么也没帮上。 - 一条单车道 driver 拥有所有有序阶段:程序里一次审批提示会冻结其它所有 sub-call 的启动与提交。
- 分类在启动时重新读取,不是提交时——一次注册表变更能把已排队的调用翻成独占。
run_code自身在 native loop 里是独占的,所以both模式永不让两个程序重叠。
--dump-config告诉不了你有效的 mode——它打印未求值的!!js。而DSH_TOOLS_MODE只在两个出货 profile 里被读,别处静默失效。toolOrder是切到code时的地雷:knownNames会塌缩成恰好['run_code']。- 一个 sub-call 可以先完成、然后结果被丢弃——副作用发生了,程序却拿不到值。
- 程序拿到值是在持久日志副本写入之前;慢的 spill 后端会对后续启动施加背压。
- 程序可见的失败刻意信息稀薄:
ToolCallError只带message+toolName。 - 只有最外层 payload 被字节封顶;中间值无界且不可重放。
- SDK 的参数类型是开放的(
& Record<string, JsonValue>)、纯建议性——拼错的参数名没有任何地方会抓到。 - Python 分支在呈现侧完全接好了但结构上不可达,那条分支里藏着真实隐患。
run_code是无条件保留的:不可注销、不可遮蔽、不可 restrict,而且刻意没有结果 presenter。- 「fresh-per-run」是可重建性不变量,不是性能选择——所以没有 REPL kernel,别指望跨
run_code保留变量。
不要看 dump,看会话日志:一次 Code Mode turn 的骨架是一条 tool/call run_code,下面挂 tool/code-dispatch-start + tool/code-dispatch 成对事件(log-only),全部链接到那个外层结果。
# 离线看组合(但不能确认 mode)
DSH_TOOLS_MODE=code node apps/cli/lib/bin.js --profile headless --dump-config
# 跑一次后从日志确认(真相在这里)
~/.claude/skills/dsh/scripts/dsh-log.mjs --workspace DIR --view toolsSkills · 命令 · 人机交互
进阶如果你在用 Claude Code,这一章最实用:两边的 skill 格式几乎一样,但 dsh 的校验严得多,会静默丢掉你一大批 skill。
五个默认根,以及两个不是根的 rank
| rank | 来源 | 路径 |
|---|---|---|
| 100 | project-dsh | <projectRoot>/.dsh/skills |
| 200 | project-agents | <projectRoot>/.agents/skills |
| 250 | runtime | 不是文件系统根 |
| 300 | custom | Config.customSkillDirs ← 把 Claude Code 目录接进来的位置 |
| 400 | user-dsh | $DSH_HOME/skills |
| 500 | user-agents | $DSH_AGENTS_HOME/skills(默认 ~/.agents/skills) |
| 600 | bundled(条件性) | 可选的第六个 |
~/.claude/skills 不在其中——源码与实测双重确认(那里有 147 个目录,零个进入目录)。项目根检测只认 .git。
# 让 dsh 看见 Claude Code 的 skill(rank 300)
- id: skill-filesystem
config:
providerName: filesystem
includeDefaultRoots: true
customSkillDirs:
- !!js (process.env.HOME ?? process.env.USERPROFILE) + '/.claude/skills'
- !!js process.cwd() + '/.claude/skills'
watch: true
三档杀手,按杀伤力排序:
name:不是 kebab-case → 整个 skill 被删。正则是^[a-z0-9]+(?:-[a-z0-9]+)*$。而且发布名来自 frontmatter,不是文件名——目录名只是发现用的键。所以agentdb-vector-search/SKILL.md里写name: AgentDB Vector Search就直接消失。本机 24 个 skill 死于此。- YAML 严格性——最隐蔽的杀手。frontmatter 由
yaml@2.9.0的parse()解析,比gray-matter/Python 习惯严厉得多。本机实测的三个真实失败:planning-with-files→Map keys must be unique at line 28(metadata:下重复的version:)autoresearch→Nested mappings are not allowed in compact mappings(description 里未加引号的Iterations: N)project-init→Unexpected flow-seq-start at node end
skill file <path> ignored: invalid YAML frontmatter: …然后从所有表面移除。开头的---必须是文件第一行,结尾的---必须独占一行;顶层解析成非对象(数组/标量)也是致命的。 - 调用策略失败关闭:恰好三个 camelCase 键会 throw,任何非布尔值都会丢掉整个 skill。
而 headless 不挂 logger,所以这些警告你一条也看不到。
# 用真实会话日志做 ground truth,逐条报出被丢的原因(含精确 YAML 错误)
~/.claude/skills/dsh/scripts/dsh-run.sh --keep-skills -w /tmp/probe "Reply: ok"
~/.claude/skills/dsh/scripts/dsh-skills-audit.mjs --workspace /tmp/probe每一次 skill() 调用都重读文件——所以改正文完全不需要任何失效、哈希、缓存清理或通知模型。只有 frontmatter(影响目录的 name/description)变更才需要重新发现。
目录到达模型的方式是一条持久的 user 角色 <system-reminder>,通过 agent/pre-step waterfall 追加——不是字面意义上的 agent.inject()。重新发布由 source.entries 的摘要驱动,不是渲染后的文字。当 skill 工具被隐藏或遮蔽时,目录整体被抑制(按身份判定,不按名字)。
出货的命令:六个,且文档里的名字是错的
| 命令 | 作用 |
|---|---|
/compact | 手动触发压缩 |
/feedback | 记录反馈(recordInput:false,无持久性屏障) |
/goal | 人类侧目标管理 |
/permission | 不是 /permissionPresets——包 README 过期了 |
/plan | 进出 plan mode;off 是保留字面量,其它任何参数会被当真实 user 消息转向 |
/export | 仅 Web |
命令派发完全不经过模型,并记两个 log-only 事件(command/run + command/done)。
还有一个文档里没写的用户手势
/name 可以出现在消息的任何位置(以空白为界),这是 disable-model-invocation: true 的 skill 唯一的门。
plan mode、ask_user_question、todo 的锐边
- plan mode 是被记录的状态:
plan/mode是 log-only 的整值替换事件,生效状态是一次纯折叠。 exit_plan_mode两种状态下都保持注册——为的是工具目录稳定(切换 plan 模式不产生目录变动),但行为差别极大。「对话里同意了」不构成批准。ask_user_question无超时预算,会无限阻塞直到有应答者返回;它拒绝 runtime 拥有的子代理(DELEGATED_CALLER)。todo_write是会话自有状态、整表替换、没有回读工具,而allowParallelInProgress是必须显式选择的部署选项——连工具描述本身都会随之改变。- 「feedback」是两个无关的子系统共用了一个词,别混:一个是
/feedback命令,一个是消息级 feedback(严格 CAS 的 sidecar,刻意不是 session event)。 user-approval与user-questions是两个不同的接缝,前者按构造失败关闭、一次性、且只在打开的 turn 内有效。
Web 应用与客户端架构
精通最反直觉的一件事先说:浏览器里也挂了一个 vendored Cordis Loader,客户端的组合完全由宿主推来的图决定,外壳自己不做任何组合决策。
两阶段启动
window.__DSH_BOOT__是宿主推送的入口图——Node 与浏览器两半之间唯一的 wire 源。它被解析成两个视图,其中inject边只是信息性的(有一条边甚至指向一个非 row)。immediately层是硬屏障,不是提示;但单行的 prefetch 失败是刻意被吞掉的。- settle 门是在补偿 cordis 没有 inject 超时——
assertEntriesActive()是唯一的响亮失败点。 - 有两个 entry 是外壳注入的、不是宿主图的行,遍历时必须跳过
MODULES_ID。 - HMR 的重载序列有一个承重的顺序陷阱:
registry.delete必须在fiber dispose之前。 - 启动图里可能包含
--dump-config里没有的行(directory-picker-auto在运行时挂一个)。
--dump-config 回答「web agent 有哪些工具」会得到错误答案dsh-web-app 把 dsh-base 里整个全局模型可见工具集都 patch 成 disabled: true:tool-bash、tool-pwsh、tool-jobs、tool-fs、tool-fs-search、agent-instructions、skill-filesystem、skill-badge、tool-skill、plan-mode、compaction-basic、tool-subagent*、tool-workflow、tool-todo、tool-goal、tool-ralph、tool-str-replace-editor、tool-web……(129 行里25 行 disabled)。最后一行是 agent-presets,config.default: standard。
所以 dsh web 的全局工具注册层按构造是空的,每一个模型可见工具和 prompt 都来自每会话的 preset 挂载。真相在 apps/cli/config/agent-presets/<id>/agent.cordis.yml。
第二个后果:一个什么都不 join 的子代理(composeFrom 返回 undefined)会带着零个工具到达模型。
Typert API Gateway 与浏览器信任围栏
@Remote/@RemoteScope决定哪些方法过 wire。构建期为每个贡献包生成五个文件,写到lib/而不是src/。TypertLookupMap让一个Agent参数变成 wire 上的agentId字段。- 单一
/api路由跑在 Connection RPC 之上,带一个「path 必须等于 method」的防混淆检查;Gateway 只认两段式端点。 - 围栏的主锚是
Host头,不是Origin(实测:Host 必填、无 marker 捷径,Origin 可选,跨站被拒)。 - 围栏里还有第二道更严的围栏:
PRIVILEGED_METHODS(16 个)通过用空信任列表调用同一个函数,被钉在 loopback。 - 围栏只守
/api:index.html和启动清单对任何 Host 头都提供。events.mux/events.host是纯 WebSocket,GET 返回 426,没有 SSE 兜底。
一个未认证的 loopback /api 调用方可以恢复一个冷的持久会话——lookup 解析器会静默完成它。机制是两层 lookup 策略:业务包注册的是仅活体的默认解析(resolve: sessionId => this.get(sessionId)),而 packages/api/remotes/src/agent-lookup.ts 随后为整个部署覆盖成 agentFor()——它会复用活体 Agent、恢复普通冷会话、对并发恢复做 single-flight,只拒绝子代理拥有的身份。
已记录的限制:lookup 策略是按 key 配置的,所以所有 agent/session 参数共享这个冷恢复行为——单个 Remote 参数或端点无法选择「仅活体」。实际后果:打开历史会话有真实的延迟和副作用(没有只读的持久化路径);loopback 上的任何进程都能物化宿主侧的 Agent。
node apps/cli/lib/bin.js web --port 3096 &
sleep 10
curl -s -o /dev/null -w "index:%{http_code}\n" http://127.0.0.1:3096/
curl -s -o /dev/null -w "api:%{http_code}\n" http://127.0.0.1:3096/api
curl -s -o /dev/null -w "mux:%{http_code}\n" http://127.0.0.1:3096/events.mux
kill %1
实测:/ 200(内联 __DSH_BOOT__);/api、/api/health、/api/typert、/api/rpc 全部 404;events.mux 用 GET 得 426。dsh web --host 0.0.0.0 是硬性用法错误(已验证)。
给客户端插件作者的注意事项
- SRC 模式下没有 Typert 编译器:源码启动的 Host 退化成一个弱描述符,Client 拒绝挂载。只有契约变更才需要
pnpm run build:lib,而且阶段顺序是强制的。 api-remotes是唯一拆分 TypeScript 面的包,有两个源文件刻意同时属于两面。- 转发的 Host 事件(
ctx.remote.$on)是逐字的、走白名单,且重连后不重放。 - 投影缓存是四级冷读阶梯,带一个「低一位锚点」的崩溃修复检测技巧;投影单元有三条不明显的硬规则:同引用、同步、stateVersion。
- Slot 系统把「声明 = 渲染授权 = 运行时规格」放进一张表,用 declaration-epoch 注入。Conversation Node 约 30 种,走
conversation.chat.node键控派发;未知工具静默回退。注册一个原子工具视图是两行的模式。 apps/web主动拒绝独立运行:裸 Vite dev/preview 在配置期就 throw。客户端 bundle 并不局限在packages/client/——有五个贡献者住在别处。- Agent preset:出货 4 个,发现过程未记忆化,坏掉的 preset 是被列出而不是被跳过,创作方式只有拷贝。
极简/minimal是 realm 隔离遮蔽宿主 provider 的最清楚例子。 - 样式是 CSS Modules +
--dsw-*token,没有组件库、没有 Tailwind。webserver层不认识任何 harness 概念、且从不打印。
四种编程接口
进阶四条路都驱动同一棵组合好的插件树。差别只在:轮数、谁决定沙箱、你必须装什么。
| 接口 | 轮数 | 默认沙箱 | 前提 |
|---|---|---|---|
dsh --profile headless "task" | 一轮 | 有(workspace-write) | 只要 dsh 二进制 |
| TypeScript SDK | 多轮 | 由组合决定 | 构建好的 checkout |
| Python SDK | 多轮 | 由组合决定 | 只要 pip;自带 runtime,不需要 Node.js |
| ACP server | 多轮 | 有 | checkout + 一个 ACP 客户端 |
Web /api | 不是 REST API——浏览器信任围栏后的 RPC,裸 HTTP 探测全 404 | ||
整个协议只有 3 个 client→server 请求(initialize/session/prompt/shutdown)和 4 个 server→client 通知(session.event/session.status/subagent.started/subagent.finished)。以下五条必须内化:
messageId只标识那条被排队的 user 消息——不是 turn、不是 assistant 回复、不是「这次 prompt 的结果」。run()拥有一个活动区间:从持久 inbox 收据到下一次整体 agent idle。两个 SDK 实现的是同一个循环。finalResponse是那个区间里最后一条已提交的 assistant 文本——明确不是因果上分配给你那句 prompt 的回复。steering、注入的上下文、其它排队工作都可能在 idle 之前贡献内容。- 不对称:TS 的
RunResult没有finishReason,Python 的有(还多一个session_root,且遇到畸形turn/end会抛)。 - 没有 cancel、没有 session-close、没有协议版本协商。放弃一个 turn 的唯一办法是关掉 runtime。
env 语义在两个 SDK 之间相反TS 的 HarnessClientOptions.env 整体替换子进程环境(给 undefined 才是逐字继承);Python 是在 os.environ 之上合并。搬代码时这条最容易造成「凭证怎么丢了」。
copy-paste:TypeScript
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
await using harness = new DeepSeekHarness({
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
maxTokens: 49_152,
cwd: '/abs/workspace',
})
const a = await harness.run('第一步', { sessionId: 's1' })
const b = await harness.run('接着上一步', { sessionId: 's1' }) // 复用同一会话
console.log(b.finalResponse)
close()(或 await using)是必须的,它走一条四级阶梯:协议 shutdown → stdin EOF → SIGTERM → SIGKILL,三个各自独立默认的计时器,Windows 跳过 SIGTERM,只有在进程真的退出后才 resolve。
copy-paste:Python(零配置)
python -m pip install deepseek-harness-sdk # import 名仍是 deepseek_harness
from deepseek_harness import DeepSeekHarness
with DeepSeekHarness(cwd="/abs/workspace", session_root="/abs/sessions") as h:
r = h.run("修好失败的测试", session_id="task-001")
print(r.final_response, r.finish_reason)
- 没有 Windows wheel,也没有 macOS-Intel wheel——在那两个平台上安装直接失败。支持的是 Linux x64 / Linux arm64 / macOS 14+ arm64,Python 3.10+。
- bundled runtime 有两个载体,其中一个永远不会被自动选中;macOS 还有一个 spawn-helper,缺失时是硬启动错误。
另外:DSH_CORDIS_CONFIG 的注入是有条件的,runtime_bin/bridge_bin/launch_args_override 三个选项任一都会静默关掉它。
stdout 就是协议本身。这是部署方的义务,插件无法强制——组合里任何一个往 stdout 打印的 logger 都会破坏协议。同理:stdin EOF 会切断飞行中的 turn,只有协议 shutdown 是有序的。
ACP:唯一能按轮回答权限的接口
session/new带一个每会话的绝对 cwd——这就是它的多租户全部故事;additionalDirectories或mcpServers非空会被拒。session/request_permission只提供两个一次性选项(allow_once/reject_once),其它任何情况失败关闭。stopReason不是 prompt 的结果:触到 token 上限也 settle 成end_turn,被丢弃的准入 settle 成cancelled。- 生命周期:一个被记忆化的 teardown,由客户端断连和 Cordis 释放共享,作用域限于本连接自己的 agent。
复用一个 session id 也会复用那个会话自有的 Bash 进程——包括它的 cwd、导出变量、shell 函数。这是多轮里最容易忘、也最好用的一件事(也最容易在你想要「干净起点」时坑你)。独立任务请用新的 session id。
还有第四种接口容易被忽略:SDK client 本身就是一个 subagent provider(dsh-subagent-dsh-sdk)——一个 dsh 可以把活派给另一个进程里的 dsh。
能力接缝与事件图谱
精通这一章是整个架构的导航底座。一个能力接缝有三个角色,缺一不成缝:Service Definition 声明接口、Service Provider 实现它、Consumer 使用它(通常是一个模型可见的工具)。给 dsh 加一个能力,意味着三个都要设计。
接缝目录(按 ctx 键)
| ctx 键 | 能力 | 出货的 provider | 典型 Consumer |
|---|---|---|---|
ctx.llm | 模型访问 | llm-deepseek, llm-pi-ai | agent-loop |
ctx.shell | 命令执行 | bash-sandbox/bash-local, pwsh-* | bash, pwsh |
ctx.subprocess | 子进程组 | subprocess-local | shell, glob/grep, lsp |
ctx.fs | 文件系统 | fs-sandbox 或 fs-local(互斥) | read/write/edit, str_replace_editor |
ctx.sandbox | 约束执行 | sandbox-local → seatbelt / landlock / windows-acl | shell、fs 围栏 |
ctx.terminals | 常驻 PTY | terminal-bash | terminal_*, 常驻 bash |
ctx.lsp | 语言服务 | lsp-stdio | lsp |
ctx.web | 搜索 / 抓取 | web-search-deepseek | web_search, web_fetch |
ctx.skills | skill 发现 | skill-filesystem, skill-badge | skill |
ctx.subagents | 委派 | spawn, fork(+ dsh-sdk / codex / claude-code,未出货) | subagent, subagent_fork |
ctx.workflowEngine | 脚本化扇出 | workflow-worker-thread(只允许一个) | workflow, ralph |
ctx.codeRuntime | Code Mode 执行 | worker-thread provider | run_code |
ctx.sessionPersistence | durable 会话 | session-persistence-jsonl | checkpoint policy |
ctx.sessionQuery | 内容检索 | session-query-sqlite | session_*(5 个) |
ctx.sessionProjections | 折叠视图 | session-projection | list_agents, Web 侧栏 |
ctx.approval | 审批 | user-approval | tools 管线、沙箱升权 |
ctx.userQuestions | 提问人类 | Web / ACP 应答者 | ask_user_question, exit_plan_mode |
ctx.commands | 人类命令 | commands | /compact 等六个 |
ctx.jobs | 后台工作 | jobs-local | job_*(3 个) |
ctx.goals | 同会话目标 | goal + round driver | create_goal 等 |
ctx.spillStore | 大结果外溢 | spill-local | spill policy |
ctx.settings / ctx.credentials | 配置 / 凭证 | settings-file, credentials-local | llm 适配器、Web 设置页 |
bash、编辑器、PTY、LSP 全都坐在 ctx.fs + ctx.subprocess 上。所以把这两个键指向远端沙箱,会同时移动它们全部——不需要任何 provider 分叉。examples/headless-agent/e2b.cordis.yml 就是这么做的。
但要诚实说清它不移动什么:Cordis、模型调用、agent/会话状态、会话日志、skill、SDK 缓冲区全部留在宿主;而且那个 overlay 既不上传也不挂载你的 workspace——它只是在沙箱里创建同一个绝对 cwd。这是provider 组合的 POC,不是部署方案。
事件:三个域,五种模式
| 域 | 例子 | 持久? | 用来干什么 |
|---|---|---|---|
| Session 事件 | turn/*, step/*, user/message, assistant/*, tool/*, permission/preset, sandbox/mode, approval/* | 是,追加进日志并经 session/event 广播 | 事实必须活过重载时用它 |
| Agent 事件 | agent/pre-step(waterfall), agent/request(waterfall), agent/turn-stopping(serial), agent/request-error, agent/created | 否(活的) | 观察或拦截飞行中的工作 |
| Capability 事件 | fs/*, tools/pre-execute·execute·post-execute(三个 waterfall), llm/stream(waterfall), telemetry/*, hook/* | 否 | 给接缝挂策略与适配器,不 import loop |
词汇是生成并守卫的:KNOWN_SESSION_EVENT_TYPES 是一份生成的 44 项白名单,磁盘拒绝按词汇判定而不仅按版本。事件 JSDoc 必须带 @mode,生成的目录会把声明与实际 dispatch 点对账。
仓库对运行时不变量有明确纪律:检查权威事件流或可变数据,不检查服务/方法是否存在、不检查插件元数据或 effect、不检查固定的纯例子。如果一个包找不到「合理的关系」可断言,那么一个带解释的空伴生文件才是正确答案。session 侧的不变量还会拒绝伪造的持久安全事件(加载时和追加时都查)。
doc-sync 保鲜)docs/tool-catalog.md | 每个模型可见工具的 schema——生成器真的启动每个工具插件并读 ctx.tools.schemas() |
docs/config-catalog.md | 每个插件的每个 Config 字段与默认值(3151 行) |
docs/persistence-catalog.md | 每个持久产物 |
docs/event-producer-consumer.md | 每个事件的生产者与消费者 |
docs/module-graph.md / graph-atlas.md | 模块与图谱 |
apps/cli/composition.md | 渲染出来的组合图 |
完整性守卫会 glob packages/*/tool-*,任何缺席于生成器 boot 清单的包都会让门禁失败——新工具无法被静默漏文档。但注意它有真实盲区(见 06 工具管线 的目录漂移)。
陷阱与教训
进阶这一章来自 4 篇正式 postmortem + 154 篇 bug-fix 笔记 + docs/defensive-patterns.md。每一条都是真的发生过的缺陷,不是理论风险。
- 100% 行覆盖不是行为覆盖。「coverage 证明代码行跑过了,它对功能是否按出货方式工作一言不发。」ACP 桥曾经有 178 个绿色测试 + 满覆盖率,却完全不能用。
- 手动挂载的插件无法验证「加载」。
ctx.plugin({name, inject, apply})是你手填的inject,它永远不会调unwrapExports;而扁平单根的测试脚手架还会掩盖 fiber 拓扑 bug(!runtime旁路整个跳过 fiber walk)。至少要有一个测试端到端驱动真实 Loader——而当那个主操作不调模型时,它不需要 API key,所以应该进 CI,而不是躲在 key 门后面。 - key-gated 的测试就是被跳过的测试。 唯一驱动
session/new/session/load的测试被 key 门控,CI 直接跳过;本地「通过」只是因为一份过期的lib/恰好满足了模块解析。 - 自跳过的平台测试不承载任何回归。 需要在原生边界上放一个确定性 fake,加上一条组装好的产品路径。
- 刷新过的 fixture 不等于评审过的 fixture,而一个能因错误原因而通过的测试(把超时误当 fail-fast)比没有测试更糟。
正面规则:只 mock 昂贵/不确定的那一层边界(LLM 适配器、网络、时钟),下游全部保持真实;验证世界,不要验证自述——重新跑一遍命令、或从外部重读文件,因为对 agent 自己输出做关键词探测会让一个作弊的 agent 通过;并断言未触碰的文件逐字节相同。
八条防御性规则(每一条都对应一个出过货或差点出货的缺陷)
| # | 规则 | 它防的是什么 |
|---|---|---|
| 1 | 正交的结果各自独立上报 | 一个进程可以既超时又退出 0(它 trap 了信号)。timedOut/signal/exitCode 各报各的,绝不把一个标志的上报嵌进另一个的分支里——否则调用方把「被砍断的运行」读成「干净成功」 |
| 2 | 公共契约两侧都要守 | 实现层可能 throw 或 emit finish{kind:'error'};公共 API 必须归一化。LlmRuntime.stream() 只以终止 finish chunk 暴露模型请求失败,中间件与消费者的缺陷仍然是抛出——于是消费者永远不必猜一个异常来自供应商、包装器、chunk 记录、还是自己的装配 |
| 3 | 异步状态不是同步状态 | agent.followup() 没有按消息的完成或结果;后台 job 的完成会与 turn 边界竞速;reader.close() 对 EOF 和 dispose 都会触发。绝不把 agent/status 或 whenIdle() 当成某一次 follow-up 的结果。而且这把刀两面开:如果那个被等待的转移永远不会发生,等待就是挂死——「没有什么可等」必须显式处理 |
| 4 | dispose 必须达到静止,不只是请求静止 | 先 kill 再 await done;而且在 kill 之前先关掉监听器/通知注册表,让迟到的完成保持沉默 |
| 5 | 回调异常在派发器里被容纳 | 一个会抛的用户监听器不能拒绝它所在的那个 promise,也不能饿死后面的监听器 |
| 6 | 绝不把环境变量或可预测路径交给不可信输出 | spawn 的命令拿到的是 scrub 过的环境(丢 *KEY*/*SECRET*/*TOKEN*/*PASSWORD*);临时/spill 文件用私有 0700 目录、随机名、独占 owner-only 打开('wx', 0o600) |
十个最值得记住的具体陷阱
| 症状 | 根因 |
|---|---|
| ACP server 第一个请求就崩 | export default apply 会静默删掉插件的 inject。这是「著名的那条」。读可选服务要用 ctx.get(name),绝不用 ctx.<name> |
disabled: !!js … 不起作用 | !!js 只在插件 config 里被插值;写在别处它是一个永真的对象。(注:dsh 的 loader 后来支持了 entry disabled,但这条教训对其它元数据仍然成立) |
| Code Mode 声明只有一个工具,实际全都能调 | schema 省略不是强制。修法是把塌缩下沉到执行器 |
| Ctrl+C 杀不掉 CLI | 一个单向布尔闩把卡住的遥测 disposer 变成不可杀;另一处是 fail-loud 没有释放终端,shell 把 Device Attributes 应答吃成了用户输入。退出计时器必须保持 ref() |
| 本地子进程在退出时泄漏 | Node 不会 await exit 监听器——process.exit() 跑得比异步释放快,必须放一个同步的 exit 监听器 |
| 消息永远停在队列里 | cancel() 在 driver 收敛之前就返回,唤醒落进那个缝隙 |
| 一个「成功完成」的 turn 什么也没做 | 格式正确但零内容块的流是失败,不是完成——它曾把 turn 记成 completed 并烧掉一个 driver 轮次 |
网络错误只说 TypeError: fetch failed | undici 把可操作的原因藏在 error.cause。每个诊断边界都要渲染整条 cause 链。而 pi-ai 曾把 cause 链拍平,导致一次可恢复的流中断变成永久不可重试 |
| 压缩恰好在对话最大时扔掉 KV 缓存 | 摘要指令必须放在最后——一份新的 system prompt 会让整个 KV 缓存失效 |
Windows 上递归清理删掉了仓库自己的 scripts/ | Windows 把 junction 当目录;existsSync 跟随 reparse point(还因此把 PowerShell 7 静默降级到 5.1);原子替换会丢掉目标的 DACL,除非先把它拷到 temp 文件上 |
- HTTP 200 不是应用就绪。 曾经 agent 验证的是一个替换掉的服务器,而不是用户当时那个页面。「HTTP 200、构建成功、启动清单存在」是三个不同的事实。
- 共享的 stderr 前缀不是协议。 Landlock 一句无害通告,让 ripgrep 的 exit 1 看起来像沙箱失败——于是每个子进程的退出码都被误判。
- 投影的顺序不是模型 surface 的顺序。 一次压缩曾因此在浏览器里擦掉了已读的对话。UI 状态要读持久的完成事件,绝不从中途数据推断完成。
负空间 · 测试 · 门禁
精通一个成熟项目最有价值的部分,常常是它刻意不做的那些事。dsh 把这些记在 .agents/notes/rejected/,并配了一条硬规矩。
rejected/ 是「不要再提」的账本,不是墓地只有 11 篇(feature/ 1 篇 + simplification/ 10 篇),因为一篇笔记只在它的理据仍能阻止一个诱人的错误时才留着。约束性规则来自那篇 NIH 审计笔记:
「未来任何针对某一项的提案,必须打败它被记录下来的那个理由,而不只是重新引用一遍政策。」
对贡献者的实际含义:在提任何删除或依赖替换之前,先 grep .agents/notes/rejected/。连两个元决策都记在那儿:「什么都不记、让 PR 正文承载结论」被否(PR 正文不属于被维护的记录);「每项一篇 rejected 笔记」也被否(约 30 个文件的仪式,而它们共享同一套证据标准)。
被明确否决的提案(部分)
| 被否的提案 | 为什么这条否决很重要 |
|---|---|
从规范会话日志里去掉 assistant/chunk | 它按体积主导日志,但token 级重放保真度依赖它 |
| 去掉 bash 全量输出的 spill 文件 | 被截断的结果需要一个可跟进的落点 |
移除持久的 step/start/step/end 边界事件 | 没有它们,「一个 step 花了什么」无法从日志重建 |
把 dsh-session-persistence 折进 dsh-session | 接缝必须留着(但镜像的那一例被接受了——不是教条) |
| 加载时截断被中断的最后一个 turn | 宁可合成 closer 事件,也不丢掉有效的尾部工作 |
| 把 workflow 能力收缩成已被使用的前台核心 | —— |
用 node:timers/promises 换掉手写的可取消 sleep | 被实现证伪——试过,不行 |
| 约 35 项依赖替换(2026-07 NIH 审计整批否决) | 与「优先用维护良好的依赖」这条政策并不矛盾:门槛是「它是否真的删掉了我们自有的代码和测试」 |
为 Windows 沙箱采用 @landstrip/landstrip | 确立了安全不变量的依赖门槛:「landstrip is not battle-tested…」 |
十一条「删除法则」(简化笔记里反复出现的判据)
- 要有当前的 owner 和当前的需求——带牙齿的 YAGNI。
- 绝不广告一个没有任何路径履行的能力。
- 一个 owner、一条路径、一种表示。
- 兜底层只为「OS 提供且可能缺失」的工具而存在。
- 没有兼容 shim、没有迁移、没有墓碑——预发布立场。
- 呈现、外框、遥测都不是日志的职责。
- 一个 turn 意味着一次模型循环执行;不要为了记账合成一个 turn。
- 把可观察的机器收缩到「每个扩展职责一个边界」。
- 在出 v1 之前删掉新功能里投机的那一半。
- 不要拥有第二个生命周期,如果包管理器或 OS 已经拥有它。
- 能力边界:策略从 provider 里拿出来,模型可见概念从存储里拿出来。
两个防止「简化变成破坏」的配重:每篇删除笔记都必须写「Alternatives considered」,并且每个删除都要写清自己的「返程票」(reintroduction condition)。
我在配套 skill 里写过「lint 豁免目录 = 永不清理的目录」并把它归为 dsh 的规则。研究核实:那不是 dsh 的规则(它来自我个人的全局规范)。dsh 的实际立场记在 AGENTS.md/packages/AGENTS.md 的禁止清单里——见下。
dsh 明确禁止的反模式
| 插件里的硬编码可调参数 | 随部署变化的选择必须是可从 cordis.yml 改的、经校验的 Config 字段;一个 DEFAULT_* 常量或测试钩子不算可配置性。协议常量、外部规范、安全不变量保持固定 |
| 包边界上的隐式默认 | defaulting 必须是拥有方实现里一个显式的 resolve(request): Spec 步骤,绝不是 run() 里藏一个 ?? default |
| 为静态接口已保证的值加运行时校验 | 在同进程的类型化边界上要相信 TypeScript;只在 parser/config、队列、模型/工具 JSON、durable/文件、worker、进程、wire 这些真边界上校验 |
| 静默跳过误配置 | 响亮失败:自足时在加载期失败,否则在最早可解析处失败;绝不静默跳过一个缺失的引用对象 |
空的 catch | 必须点名它吞掉了什么,以及为什么别的东西到不了这里;try 保持一条语句 |
跨边界裸 string id | 用 Branded<B>。但政策本身有边界:「品牌是给跨包且可能被混淆的 id 的;不是每个字符串都需要品牌」,而工厂是纯 cast——一个格式正确但内容错误的 id 仍然通过类型检查 |
| 平行值的无解释不对称 | 通常意味着漏了一次抽取 |
测试:你的改动需要什么证据
| 命令 | 它是什么 |
|---|---|
CI=true pnpm run test:coverage | 这才是 CI 的覆盖率门(不是 test):packages/*/*/src 上逐文件 100% |
CI=true pnpm run test:snapshot | 无需 key 的重放:ACP/headless 组装后的转写 vs 期望输出 |
CI=true pnpm run test:e2e | 真实 API;无 key 自跳过 |
CI=true pnpm run doc-sync | 所有文档门禁(生成目录的新鲜度、双语配对、链接、预算) |
CI=true pnpm run hygiene | knip + publint + workspace 约束 + 包不变量 |
CI=true pnpm exec vitest run <path> | 窄跑,别默认全套——CI 拥有穷尽覆盖与平台矩阵 |
硬规矩:每一个非平凡的、模型可见或用户可见的行为变更,都必须在同一个 PR 里通过一个真实可运行的 example 增加或更新一份无 key 快照。包测试、只在 e2e 里的断言、纯 mock 的 fixture 都不能替代那份组装好的应用转写。fixture 必须在 macOS 和 Linux 上都能重放——修 fixture,不要修 normalizer。
还有一条容易忽略的:测试通过 tsconfig.base.json 的 paths 解析源码平面,因为一份过期的 lib/ 会加载模块单例的第二份副本。e2e 测试自己拥有资源(在测试里创建、在 afterEach 里释放,即使失败/重试/超时;绝不 import 另一个 *.e2e.ts——那会重复注册它的 describe 并重复真实 API 调用)。
scripts/run-gates.ts 定义了具名门禁集(check-all、ci-primary、ci-static、ci-coverage、ci-snapshot、doc-sync…),根 package.json 才是真正的命令面。少数最容易挂的:verify-export-jsdoc(每个 function-like 导出要 @param/@returns)、verify-package-invariants、verify-cordis-config(raw cordis.yml 的 bare 插件必须出现在解析清单的 dependencies 里)、verify-doc-budgets(字数上限)、verify-translation-pairing(双语三件套)、verify-package-readme-model-experience(每个包 README 必须有 Model Experience 段)。
仓库还自带专用 skill:dsh-pre-push-checks、dsh-code-review、dsh-doc-standards、dsh-prose-standard、dsh-archive-agent-notes ——优先用它们,别自己发明流程。
proposed/ | 还没建成(或只部分建成) |
implemented/ | 已发布,应当与实际出货保持同步——但研究实测会漂移,冲突时源码是权威 |
rejected/ | 被否决;「不要再提」账本 |
archived/ | 冻结。绝不编辑,也绝不当成当前权威引用 |
刻意没有集中式 INDEX.md(有一篇笔记专门记录这个决定);导航方式是浏览 lifecycle/class 目录或直接搜仓库。
速查 · 术语 · 行动清单
入门这一章可以单独打印。命令、环境变量、术语、以及一条按天推进的路径。
装好并跑起来(本机实测过的完整序列)
# ① 方案 A:npm,不需要 checkout。除多轮 SDK 外一切可用
npx -y @deepseek-ai/dsh --version
npx -y @deepseek-ai/dsh web
# ② 方案 B:源码 checkout(推荐 —— 多轮只能走这条)
git clone https://github.com/deepseek-ai/deepseek-harness.git && cd deepseek-harness
CI=true pnpm install # CI=true 是这台机器的硬性前提,见下方警告
CI=true pnpm run build
# ③ 验证(都不发模型请求,不花钱)
node apps/cli/lib/bin.js --help
node apps/cli/lib/bin.js --profile headless --dump-config | grep -c '^- id:' # 期望 81
ls apps/web/dist/index.html
CI=true pnpm exec vitest run packages/bundle/headless
# ④ 用起来
node apps/cli/lib/bin.js --profile headless "run the tests and fix what fails"
node apps/cli/lib/bin.js web --port 3080
CI=true:为什么每个 pnpm run 都要加pnpm 11 在每个 pnpm run <script> 前跑 runDepsStatusCheck,判定 tree 不干净就自动补跑 pnpm install。而本仓库的根 postinstall(scripts/install-lefthook.mjs)会拒绝替换一个它不拥有的 core.hooksPath——在这台机器上 /etc/gitconfig 把它指向 Amazon git-defender。这个拒绝是正确的,但它以非零码退出,把外层脚本一起带崩。
失败信号极具误导性:pnpm run build 打印 Already up to date 却不产出任何产物(apps/cli/lib/ 不存在)。看起来像缓存命中,其实是被中止的构建。
install-lefthook.mjs:692 在 CI=true 或 GITHUB_ACTIONS=true 时直接 return,所以加前缀即绕过,且完全不动继承来的 hooksPath。变量必须加在最外层(嵌套 install 会继承它)。不要设 DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1——那正是这个拒绝在保护的东西。npm_config_verify_deps_before_run=false 无效,pnpm 11 忽略该设置的 env 形式。
环境变量(harness 读 的那些)
| 变量 | 作用 |
|---|---|
DEEPSEEK_API_KEY | 凭证。解析顺序:启动环境 → $DSH_HOME/.credentials.yaml → 调用目录的 .env → $DSH_HOME/.env。托管文档绝不被物化进 process.env |
DEEPSEEK_BASE_URL | OpenAI 兼容端点,替代公开 API |
DSH_HOME | Harness home,默认 ~/.dsh:profiles / sessions / credentials / settings |
DSH_AGENTS_HOME | 共享 agent 配置根,默认 ~/.agents(skill 从这里被扫) |
DSH_PERMISSION_MODE | workspace-write 或 danger-full-access。只能进程级——项目 .env 里设它会让启动响亮失败 |
DSH_TOOLS_MODE | native(默认)/ code / both;其它任何值在启动时失败,包括空串 |
DSH_TELEMETRY_DISABLED | 任何非空值都是权威硬退出 |
DSH_TELEMETRY_MODE | FULL / FEEDBACK_ONLY;默认关闭,且不出货任何脱敏规则 |
DSH_CORDIS_CONFIG | 指定组合文件(SDK runtime 的两个发现通道之一,env 优先,无兜底) |
NODE_USE_ENV_PROXY=1 | 让支持的 Node 版本尊重 HTTP_PROXY/HTTPS_PROXY |
出货的 base 没有任何脱敏规则,所以显式开启的导出可以包含消息文本、工具参数与结果、以及 workspace 路径。另外:匿名身份是每个 harness home 一个 UUID,并且会被上报给 DeepSeek,与遥测模式无关——DSH_TELEMETRY_DISABLED 拦不住其中两条上报路径。投递是至多一次,seq 出现空洞是正常的(只送每个 (turn,step) 的第一个 assistant/chunk)。
还有一类变量是 harness 注入给子进程的(DSH_SHELL、DSH_SESSION_ID、DSH_SESSION_JSONL…)——不要自己设它们。
术语卡
最佳实践(可复制为 prompt 喂给任何 AI)
行动清单
本门户配套一个 user 级 Claude Code skill(~/.claude/skills/dsh/),把上面这些固化成可执行脚本:
scripts/dsh-run.sh # 一次性委派;stdout 只有答案,可管道
scripts/dsh-session.mjs # 多轮共享会话(带沙箱的组合)
scripts/dsh-log.mjs # 会话日志阅读器,7 种视图,无 zstd CLI 也能跑
scripts/dsh-skills-audit.mjs # 用真实日志做 ground truth 审计 skill 掉落
assets/lean.patch.yml # 关掉 skill 目录的三行 patch
assets/share-claude-skills.patch.yml # 让 dsh 读 ~/.claude/skills
直接说「用 dsh 跑 …」即可触发。