ALL-IN-ONE MASTERY PORTAL

一切皆插件的 agent harness

dsh 没有需要打补丁的特权内核——模型适配器、工具注册表、会话日志、乃至 agent 主循环本身,都是可以从配置里替换掉的插件行。这份门户把它拆到底:怎么运作、为什么这样设计、以及怎么操纵它。

基准 0.1.0-rc.5 / npm rc.6 章节 18 动态架构图 7 研究 agent 88
00

总览与学习路径

入门

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 / 129headless / web profile 组合出的插件行数「一切皆插件」的字面体现
13,633headless 首个请求的 token 信封(实测)其中 91% 是 harness 自己写的、每次请求完全相同
48% / 43%工具 schema / skill 目录 占那 13,633 的比例system prompt 只占 7.4%——只看 prompt 会低估一个数量级
3沙箱模式数(read-only / workspace-write / danger-full-access)它只管文件写入,不管读、不管网络、不管进程可见性
0SESSION_FORMAT_VERSION预发布,零兼容承诺,磁盘格式随时会变
1372仓库里的 Agent Notes 篇数设计理据的真正住所;代码和文档都不承载「为什么」
233已公开发布的 npm 包数(3 条独立版本线)加 2 个 PyPI 包;生态开放,代码库本身不接受外部 PR

三条学习路径

🌱 我只想用它干活
把 dsh 当一个可编程的 coding agent 子进程。跳过架构。
⚡ 我要改它的行为
换模型、换工具集、加插件、接自己的 provider。
🏛 我要读懂/贡献代码
理解控制脊与不变量,并知道什么会让 PR 挂掉。
⚠️ 读这份门户前必须知道的三件事
  • 它是 developer preview,且兼容立场比「preview」更强硬:后端直接拒绝旧的磁盘格式,SESSION_FORMAT_VERSION 钉在 0 且明确无兼容承诺。不要把任何东西当稳定 API。
  • 生成的目录 > 散文文档 > Agent Notesdocs/tool-catalog.md 之类由 doc-sync 门禁保证新鲜;包 README 会过期(本门户就抓到 4 处);implemented/ 笔记应当与实际发布同步但会松动——冲突时源码是权威
  • 本门户的数字来自这台机器的实测(0.1.0-rc.5,macOS/arm64,135 个已装 skill)。比例结论可迁移,绝对值会随你的 skill 数量、模型和平台变化。

掌握度自测

勾选记录在浏览器本地,刷新不丢。

01

Cordis 心智模型

精通

读懂 dsh 的前提。Cordis 不是「插件 + ctx」两件套——专家脑子里装的是五个对象:Context、Service、Fiber、Effect、Event。

对象是什么最容易踩的那一点
Context能力的取用面,ctx.foo 是 Proxy读一个你没 inject 的服务会 throw——教程只暗示了这条
ServiceService 子类;super(ctx, name) 本身就是注册服务方法执行时 this.ctx 被重绑到调用方的 context,所以它创建的 disposer 会随调用方一起回收
Fiber插件实例的生命周期节点,六种状态FiberStateconst enum数值顺序不等于生命周期顺序
Effect可逆副作用;ctx.effect(fn) 返回 disposerawait 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 到底怎么走

waterfall · next() 按位置消费链条
dispatchlistener Anext()listener Bnext()默认实现
A 不调 next()链条在此终止,B 与默认实现都不执行

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。
02

组合系统 · profile 与 patch

进阶

一个运行中的 dsh 是组合出来的,不是配置出来的。层按顺序盖在一个空列表上,后面的层逐行覆盖前面的。

图 1 · 分层组合:五层 patch 盖在空根上鼠标悬停看节点;橙色请求点表示组合流向
dsh composition: ordered patch layers over an empty root produce the mounted plugin tree Five layers apply in order to an empty entry list: dsh-base, then the mode bundle, then the profile patch layer, then the home-level patch layer which outranks it, then any --patch overlays in argv order. The result is the composed tree, 81 rows for the headless profile and 129 for web. dsh --dump-config prints it with provenance comments. A running dsh is composed, not configured Layers apply to an EMPTY entry list, later winning per row. A patch replaces the targeted row’s whole config — there is no deep merge. PATCH LAYERS applied in this order @deepseek-ai/dsh-base model adapters · tools · persistence · sandbox · settings 1 @deepseek-ai/dsh-web-app / dsh-headless mode bundle: browser app, or the one-shot runner 2 profiles/<name>/cordis.patch.yml this profile's own overrides 3 $DSH_HOME/cordis.patch.yml machine-local — OUTRANKS the per-profile layer 4 --patch <file> (repeatable, argv order) this invocation only; the last one wins 5 cordis.yml → [ ] (empty entry list) you never edit this file — edit cordis.patch.yml COMPOSED TREE (what actually mounts) llm llm-deepseek · llm-pi-ai session append-only log + JSONL persistence tools guarded execution pipeline sandbox sandbox-policy + bash/fs sandbox agent agent-loop · the driver … 76 more rows headless: 81 · web: 129 compose --dump-config prints the tree + which layer supplied each row
★ 真实层数是 6 层,不是 4 层

文档说的是 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
行为真相
整体替换的边界替换的是该行的 configdisabled/inject/isolate/intercept 是 config 的兄弟字段,会存活下来
patch 里的 name:断言守卫,不是重写——你永远不能把一行重新指向另一个包
insert 的副作用同一个 entry 里 insert静默丢弃其他所有键;带 idinsert 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 参数如果正好等于 webplugin,会劫持成那个子命令。
⚠️ --help 里的 tui profile 不存在

dsh --helpapps/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 表达式。

💡 实战:bare 插件名的解析规则会咬你

raw cordis.yml 里的 bare 名按最近的 package.jsonnode_modules 解析,不是按运行的二进制。实测:放在 /tmp 的组合里写 @deepseek-ai/dsh-sdk-jsonrpc-serverloader 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 永远不会被恢复。只有 webheadless 两个名字会自动初始化,其它名字响亮失败。

bundle 顺序是承重的,但顺序错了只表现为警告。列了一个「不是 bundle」或解析不到的包,则在加载期响亮失败——绝不静默跳过。

03

控制脊 · turn 与 step

精通

一个 step = 一次模型请求 + 它调的那些工具。一个 turn = 零个或多个 step:它在第一份输入被 claim 之前打开,在「什么都不欠」时关闭。

图 2 · turn/step 循环:实线框是持久事件,虚线框是活的扩展点8 个流动点各自代表一次请求在脊上的旅程
The dsh turn and step loop, with durable session events and live extension points turn/start, then claim input from the inbox, assemble prompt sections and tool schemas, pass agent/pre-step which may reject or enter messages, step/start, agent/request, assistant chunks and message, tool calls through the guarded pipeline of pre-execute, execute and post-execute, tool results, step/end, then loop for another step if work is owed, else agent/turn-stopping and turn/end. A rejected first claim still closes a durable turn that spent no step. One turn = zero or more steps. One step = one model request plus the tools it calls. Solid boxes are DURABLE session events (they survive a reload). Dashed boxes are live extension points — plugins hook here, nothing is logged. ONE DURABLE TURN opens before the first input is claimed · closes when nothing is owed turn/start durable 1 claim input inbox + 1 queued msg 2 assemble sections + schemas 3 step/start durable 4 agent/request waterfall 5 assistant/* chunk* → message 6 tool/call* durable 7 agent/pre-step waterfall enter(messages) reject turn closes, 0 steps — still logged GUARDED TOOL PIPELINE pre-execute → execute → post-execute (all waterfalls) pre-execute execute post-execute tool/result* durable step/end durable more owed? yes → next step agent/turn-stopping serial · no next() no turn/end reason.kind: completed | max-tokens | error A blocking Stop hook steers here and forces another step. Waterfall listeners MUST call next() — returning without it short-circuits the chain.
★ 「零个 step 的 turn」不是边界情况,是设计

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/endturn/end 写在 finally 里——失败的 step 仍会闭合自己的边界;但失败的 turn/start 不会。
  • agent/turn-stopping检查两次,而且决定权在数据,不在监听器顺序。

作用域:registrations 向下继承,events 向上准入

agent.ctxscope.ctx.extend({ agent: this }),而那个 scope 是从 loop 的 context 铸出来的,不是调用方的。作用域键构成一条父链,两个方向相反

作用域方向
host 平面registrations 继承agent scopechild scope
host 平面events 准入agent scopechild scope
⚠️ 把工具挂在 host 平面 = 泄漏给每一个 agent

这是「global-layer trap」。想只给某一个 agent 一个能力,注册到那个 agent 的 agent.ctx。反过来,child-scoped 的 report 工具正因为注册在子作用域,才能存活于全局 toolFilter 之外——这是可复用的模式。

文档漂移与冷知识
  • agent.runMaintenance() 是 architecture 文档里没写的第三阶段,从外面看像 idle。
  • maxParallelToolCalls实时读穿的 getteragents 被故意排除在 Settings 之外。
  • 工具的 additionalContexts 绕过 send()——直接拼进 next-step,且不唤醒 driver。
  • prompt 变量 provider/model/cwd 读的是 AgentOptions不是实际使用的 route;切换 route 必须同时更新两个表面。
  • TurnTriggersteering/message已退役的词汇,但文档里还在引用。
  • agent/* 的 emit 模式被重新实现过以容纳单个监听器的失败——唯一例外是 agent/created,它可以否决。
04

会话日志 · 唯一真相源

精通

model-visible ⟺ logged。凡是能到达一次模型请求的东西,都必须能从这条 append-only 日志重建出来——而且有一个运行时断言在强制它,不只是约定。这就是「新增一个模型可见输入必须新增一个 session event」的原因。

图 5 · 日志是源,其余一切都是投影下半部分是磁盘现实与 zstd 陷阱
The dsh session log as the single source of truth, and its on-disk reality Producers append durable facts to the append-only SessionEvent log: the user prompt, runtime context, the skill catalog, assistant chunks, tool calls and results, approval decisions. Everything else derives from that log: deriveMessages projects the model history for the next request, session projections fold todos and permissions, transcripts and replay use the raw assistant chunks, fork branches a live session, resume restores one, and telemetry exports a suffix. On disk each session is one file per session directory, zstd by default. The zstd artifact is a concatenation of independent frames and Node built-in decoders stop after the first one while reporting success. Model-visible ⟺ logged. The session log is not a debug convenience. Anything that reaches a model request must be reconstructable from the append-only log, and a runtime invariant asserts it. That is why a new model-visible input requires a new session event. PRODUCERS append durable facts user prompt runtime context skill catalog assistant/chunk* tool/call + result approval decisions SessionEvent log append-only · ctx.sessions EVERYTHING DERIVES FROM IT deriveMessages() the model history for the next request session projections todos · permissions · subagent catalog transcripts & replay assistant/chunk keeps UI fidelity fork(source, boundary) branch a live session resume an end-seed keeps its pinned permissions telemetry / feedback off by default, no redaction rule ON DISK <root>/<project>/session-<uuid>/session.jsonl.zstd <root>/<project>/session-<uuid>/session.jsonl only with compression: none A root belongs to exactly ONE encoding — mixing rejects with an error naming the artifact. The .zstd file is a CONCATENATION OF INDEPENDENT FRAMES: one for the header line, then one per append batch. Node’s zstdDecompressSync and createZstdDecompress BOTH stop after the first frame and report success — a naive decode silently yields only the header. Use `zstd -dc`. Nothing ever deletes session files. SESSION_FORMAT_VERSION = 0, no compatibility promise.
★ 那个不变量的真实机制(研究纠正了一个常见误解)

不是「用一个全新 Session 重建再比对」。packages/core/agent-loop/src/invariant.ts 注册一个前置的全局 llm/stream waterfall 监听器(由 isAgentLoopRequest(options) 把门),然后检查:request 已冻结、sessionId 存在且在 ctx.sessions 里活着、messages 数组已冻结、日志里至少有一个 step/startfoldRequestHeader(session.events) !== undefined,最后把 JSON.stringify(options.messages)活动 sessionderiveMessages() 直接比对,再逐字段比对 model/system/temperature/maxTokens/stop/tools。

另一个不变量(session 侧)走 internal/dispatch提交前校验,再在 session/event 上应用状态转移。

磁盘现实:三个会让你读错数据的坑

⚠️ 坑一:物理行 ≠ 逻辑事件(packed chunk rows)

日志里的 assistant/chunk 不是一行一个。存储层有一套独立词汇:packed row,信封用 seq0/time0(不是 seq/time)加一个 delta-time 数组 dt,且 len(dt) == len(members) - 1。所以你数出来的「事件直方图」是物理行直方图,不是逻辑事件直方图。

⚠️ 坑二:一次性 zstd 解码器在这些文件上会静默给你错的数据

.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/messagedata.content[] 里的 text 块;来源在 data.source{kind:'user'}{kind:'plugin',plugin:'…'}
assistant/messagedata.message.content[],混着 {type:'text'}{type:'reasoning'}
tool/calldata.name + data.arguments一个 JSON 字符串,供应商原样)
tool/resultdata.message.content[] → 一个 tool-result 块,它自己的 content[] 才是文本;isError 在那个块上
request/headerdata.header.system / .tools[] / .config(观测到最肥的一行,31 KB)
turn/enddata.reason.kind

一次递归遍历 content 数组就能覆盖全部——不要为每种事件写一个抽取器。

落盘时机:三个语义屏障,不是「turn 结束」

「每个 turn 结束才 flush」是 2026-06 的原始设计,2026-07-21 被判定「作为唯一崩溃恢复点太粗」而修掉。现役是一个独立于持久化的零配置插件 dsh-session-checkpoint-policy,在三个语义屏障上 await ctx.sessions.flush(),且失败关闭

  1. agent/pre-step——在下一次请求被推导之前,把 prompt 输入或上一轮的响应/结果批次刷下去;
  2. llm/stream 内部——request/header 已记录、但适配器流尚未构造之时;
  3. 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   # 事件直方图 + 注入字节数
05

提示装配与上下文经济

精通

这一章是全门户最省钱的一章。结论先说:headless 首个请求约 13,633 token,其中 91% 是 harness 自己写的、每次请求一模一样——而 system prompt 只占 7.4%。任何只展示「system prompt」的说明都把成本低估了一个数量级。

实测成本构成(headless profile,98 个 skill 进目录)

成分token占比它是什么
工具 JSON schema6,55648.1%header.tools独立的 wire 字段:25 个 schema,26,208 字符
skill 目录5,90443.3%一条 user/message,30,634 字节——单一最大输入
system prompt1,0097.4%4,017 字符,除 {{cwd}} 替换外各 profile 逐字节相同
runtime-context 快照1250.9%沙箱策略 + 审批策略,也是 user message
你的那句话390.3%——
⚠️ 这个计量器本身有两个校准偏差,用之前必须知道
  • CJK 被低估 2–3 倍estimateSystemTokens/estimateToolsTokens 用 JS .length(UTF-16 code unit)除以 4,所以 skill 目录的 30,634 字节被按 23,582 字符计价。中文描述吃亏最狠。
  • 图片几乎隐形ImageBlock 落进 estimateContentdefault 分支,按 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.」

★ 最高杠杆的两个旋钮
  1. 关掉 skill 目录(三行 patch):注入的 user message 从 50,416 B → 1,383 B(实测)。对自动化调用是纯赚。
  2. 调小 catalogDescriptionMaxLength:默认 500 字符(最小 3),观测到的 98 条里有 7 条被截到 500。降到 ~120 能从每次请求里去掉大约 20 KB,同时保留 skill 可用。这是「既要目录又要省钱」的答案。
# 方案二:保留 skill,但把目录压瘦
- id: tool-skill
  config:
    catalogDescriptionMaxLength: 120

system prompt 与 runtime context 是两种东西

这是最容易混淆的一处:runtime context 不是 prompt 文本,它是一条 role: user 的消息,source{kind:'plugin'}

prompt sectionruntime 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 都会深拷贝每个工具的 parameterstoolOrder 在 waterfall 之前应用。

AGENTS.md / CLAUDE.md 的真实行为

  • 渲染预算 65,536 字节,界定的是完整渲染后的消息,并且有一条六级降级阶梯——最后两级会把 <system-reminder> 外框都丢掉。
  • 发现过程做按目录的内容去重跨信任边界跟随 symlink,而且没有 watcher
  • 工作区指令是持久的 user message,靠文件系统触碰来刷新。
  • 指令状态完全活在消息的 typed source 里,绝不在模型可见文本里;而且只有当某个内容字节存活下来时,变更才会被记录。
💡 KV cache 教义(从各包的 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'

06

工具管线与工具全表

精通

工具注册表 + 三段 waterfall 受控管线。这一章合并两件事:管线怎么运作(改行为时要懂),和出货了哪些工具(用它时要懂)。

受控管线

一次工具调用的完整判定
tool/calltools/pre-execute
waterfall
allow / deny / ask
deny变成模型可见的错误字符串仍会到达tools/post-execute
askctx.approval 机会性服务无应答者降级为 deny
tools/executetools/post-execute
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

0 条 · 标注「opt-in」的不在出货树里
工具要点 / 陷阱状态
⚠️ 生成的 docs/tool-catalog.md 在几处与出货现实不符
  • 它漏报了 bash 的真实 schema:缺 sandbox_permissions 参数和约 1200 字符的升权说明。
  • 更险的是:sandbox_permissions 即使未被广告,也仍然能到达 execute()——schema 校验只检查已广告的键。这是个刻意留的洞,带自己的错误。
  • glob 的超限行为,目录展示的与出货的相反(那是个必须显式选择的 config,生成器替它选了另一支)。
  • subagent vs subagent_fork 的默认值不一致且反直觉:出货的 subagent 默认后台并返回一个持久 subagent idsubagent_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_imagemagic bytes 而非扩展名判断,并有一个针对模型切换的已记录 TOCTOU 竞态。
  • session_* 五个查询工具即使挂上也搜不了:出货的 SQLite 是 :memory: + openAt: never,搜索直接 SESSION_QUERY_SEARCH_DISABLED。要开就在后续 patch 层把 openAt 改成 first-search/startup,通常还得配一个持久 path
  • cordis_* 七个自我修改工具不在任何 bundle 里,而且那个 vm 是「对诚实代码的容纳」,不是安全边界
07

沙箱 · 权限 · 审批

进阶

先说清边界,因为它比大多数人以为的:dsh 的沙箱是一套 文件写入 策略。读、网络、进程可见性完全不受约束——它不是防外泄的安全边界。

图 3 · 每一次变更的沙箱判定,以及升权重试如何失败关闭
The dsh sandbox and approval decision for a mutating tool call A tool call that mutates is checked against the session sandbox mode, pinned at session creation. read-only grants nothing. danger-full-access passes straight through with approval policy never. workspace-write allows effects inside the session workspace plus platform temp roots and otherwise returns a sandbox denial string, after which the model may retry requesting escalation. That retry raises an approval request: a Web or ACP client can answer allow_once for that retry only, while headless has no answerer and the request resolves unavailable, failing closed. Reads, network access, and process visibility are never confined. The sandbox decision: every mutation, every time The sandbox confines MUTATIONS only. Reads, network access, and process visibility are not confined — do not treat a dsh sandbox as a security boundary against exfiltration. model calls a tool bash · write · edit · terminal mutation? or just a read runs unconfined reads · network · ps are NOT fenced no sandbox/mode pinned at session creation yes read-only grants nothing danger-full-access no fence · approval policy = never passes straight through inside workspace? or a platform temp root workspace-write (the default) allowed the effect happens yes [sandbox: file access denied under workspace-write mode] no This string is POLICY, not a bug — the model is expected to read it and adapt. THE ESCALATION RETRY the model may retry asking for danger-full-access approval/asked answerer? allow_once that retry only Web / ACP unavailable FAILS CLOSED headless
⚠️ 三个模式的语义不对称

writableRoots()read-onlydanger-full-access 都返回 []——一个是「什么都不给」,一个是「不需要问」。别把这个返回值当权限清单读。

另有一处跨后端的可写根漂移:fs 围栏授予 os.tmpdir(),而 bwrap 和 Landlock 的 profile 不授予/dev/null 由四种不同机制分别授予,而在 Windows 上根本不授予

出货的预设表有个,不是两个

presetsandbox/modeapproval/policy
read-onlyread-onlyask
workspace-write(默认)workspace-writeask
danger-full-accessdanger-full-accessnever

文档里说的「默认两项表」只是服务 Config 的兜底,不是出货组合。

⚠️ 包 README 的三个名字都过期了

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/askedapproval/decided outcome=unavailable → 工具结果是上面那句 → 模型自己改用 UV_CACHE_DIR=<workspace>/.uv-cache 并成功。这就是「拒绝必须是结构化可读的」的价值:模型能据此自我修正,而不是卡死。

★ 好消息:模型的 bash 读不到你的 API key

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-ComputerInfowhoami 在两种受限模式下都不可用。
  • 授权根之外的 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 可选属性的形式出现。
08

LLM 接缝 · 模型 · 压缩

进阶

ctx.llm 是一个适配器注册表加一个流式调用 API。写适配器只需继承 LlmAdapter 并实现 stream()——其余都是共享的折叠逻辑。

换模型 / 换供应商的旋钮在哪

你想改改哪里
默认 provider + modelpatch agent-default-model
DeepSeek 的 thinking / reasoningEffortpatch 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]      # 只作用于这一个模型

modelOverridesmodels 设计上互斥,写错会响亮拒绝。手写的 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 是一等块。
  • resolveCallConfig vs prepareCall:默认值在后者里物化,loop 用的是 prepared handle。
  • agent/request 的提案会剥掉被标记为适配器默认的字段,好让一次 route 切换重新解析出自己的值。
  • DeepSeek 侧:off 从不跨 wire;session-title 调用强制关 thinking;per-model 覆盖压过 route,且有一条硬性不 clamp 规则(超了就是超了,不给你截断)。
  • 两个独立的上下文溢出分类器,归一到一个规范码,另有一条基于 usage 的路径。
  • replay 状态是适配器实例作用域的,一旦监听器改写了内容就会被丢弃。
  • discoverModels草稿期的端点问询,按 settings 命名空间键控,绝不是一次目录刷新。
09

委派 · subagent 与 workflow

精通

subagent 是全仓唯一带命名多 provider 注册表的能力接缝。同一个接口后面可以是:进程内新子代理、完成前缀 fork、独立进程的 dsh SDK runtime、甚至委派给 Codex 或 Claude Code 的一个 turn。

两个轴不要混:providerName vs toolName

providerNametoolName
是什么注册表的(传输方式)模型看到的工具名
出货值spawn, forksubagent, subagent_fork
关系一个 tool-subagent 插件实例绑定到恰好一个 provider。出货树把同一个包装了两次
⚠️ 两个实例的默认值相反,而且各处配置互相矛盾
  • 出货的 subagentcontinuable,省略 run_in_background默认后台,返回一个持久 subagent id,settle 后自动投递。
  • 出货的 subagent_forkone-shot默认前台,返回一个 job id
  • 配置漂移dsh-base 让 fork 保持 one-shot,但三个 CLI agent preset 都把它改成 continuable,与 base 和 fork 的 README 矛盾。要知道某个部署的真实行为,读那个 preset。

子代理的结果怎么回到父代理

关键认知:子代理的会话事件从不进入父代理的模型上下文。存在的是四个独立的观察平面七条有界通道,而不是「事件流上浮」。

回传路径
childchild-scopedreport 工具父会话里的一条 user 消息
runtime 自己的 settlement 通告不同的 source kind父会话
parentsend_message · interrupt_agent · list_agents全局一套child
  • 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 也不传 labelctx.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 个已准入轮次之前被拒
⚠️ Codex 与 Claude Code provider 不在任何出货 profile 里

「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 布尔值——一个不显眼的持久性注意点。
10

Code Mode

精通

DSH_TOOLS_MODE 选进程级的工具呈现方式:native(默认)/ code / both。任何其它值在启动时失败——包括空字符串,且大小写敏感。

图 7 · native 与 Code Mode 的真实代价对比左右两侧的 token 数字都是本机实测
Native tool transport versus Code Mode in dsh Under native mode the system prompt carries one schema per tool, roughly 24 in the shipped headless profile, and the model emits one tool call per action into the guarded pipeline. Under code mode the prompt carries a single run_code schema plus a generated SDK in the runtime language, and the model writes a program whose binding calls become nested code-dispatch events that re-enter the same guarded pipeline, so the sandbox, approval policy and events all still apply. Code mode trades N schemas of prompt prefix for requiring the model to write correct code. The both mode exposes each. Any other value of DSH_TOOLS_MODE fails at boot. DSH_TOOLS_MODE: native · code · both Code Mode is not a shortcut around the pipeline, and — measured on this build — it is not a token saving either. Nested calls RE-ENTER the complete guarded pipeline: sandbox, approval and events all still apply. native (the default) system prompt carries N tool schemas headless ships 24–25 of them bash read write edit glob grep todo_write … 18 more model emits one tool_call per action guarded tool pipeline pre-execute → execute → post-execute MEASURED: 4,017-char prompt + 25 schemas (26,208 chars) = ~7,565 tokens. The model needs no programming ability — one call, one action. Log: tool/call → tool/result, one pair per action. Every intermediate result enters the conversation. code ONE schema + a generated SDK — 8.5x the prompt text the tool surface moves INTO the system prompt run_code the registry’s only wire contribution under `code` model WRITES A PROGRAM that calls the bindings submission-ordered starts · concurrency-safe bodies overlap up to maxParallelSubCalls code-dispatch bash code-dispatch read code-dispatch the SAME guarded tool pipeline sandbox · approval · events — nothing is bypassed MEASURED: 34,246-char prompt (8.5x) + 1 schema = ~8,790 tokens — 16% MORE, not less. The SDK block carries a full ToolOutputMap of return types the JSON schemas never had. Log: one tool/call run_code + a code-dispatch pair per sub-call, all linked to the outer result. So the win is NOT a smaller prefix. It is that intermediate sub-call results never enter the conversation, and one result card replaces N. `both` keeps native schemas AND the SDK block. Any other DSH_TOOLS_MODE value fails at boot rather than defaulting. Under `code` the collapse is enforced in the EXECUTOR: a model-direct native call is UNKNOWN_TOOL before policy.
⚠️ 最反直觉的一条:Code Mode 在这个 build 里不省 token,反而贵 16%

广为流传的说法是「N 个 schema 塌缩成 1 个,省下大量前缀」。实测不成立:

system promptschema启发式合计
native4,017 字符 → 1,009 tok25 个 / 26,208 字符 → 6,556 tok7,565
code34,246 字符(8.5×)→ 8,566 tok1 个 / 877 字符 → 224 tok8,790(+16%)

原因:生成的 SDK 块携带了一个完整的 ToolOutputMap——每个工具的返回类型,包括 subagent/ralph/read_image/todo_write 的判别联合——这是 JSON schema 里从来没有的信息;再加上 type JsonValue、每项都 & Record<string, JsonValue>ToolArgsMaptype ToolNamedeclare class ToolCallErrordeclare const tools

dsh-tools 的 README 自己就把这话说明白了:它「trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction」。

★ 那它到底赢在哪
  1. 中间工具结果永远不进对话。 这是真正的收益,而且它随调用次数放大——一个跑 20 次 grep 再汇总的程序,native 模式要把 20 份输出全塞进上下文,Code Mode 只回传最后那个值。
  2. 一个结果卡替代 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 tools
11

Skills · 命令 · 人机交互

进阶

如果你在用 Claude Code,这一章最实用:两边的 skill 格式几乎一样,但 dsh 的校验严得多,会静默丢掉你一大批 skill

五个默认根,以及两个不是根的 rank

rank来源路径
100project-dsh<projectRoot>/.dsh/skills
200project-agents<projectRoot>/.agents/skills
250runtime不是文件系统根
300customConfig.customSkillDirs把 Claude Code 目录接进来的位置
400user-dsh$DSH_HOME/skills
500user-agents$DSH_AGENTS_HOME/skills(默认 ~/.agents/skills
600bundled(条件性)可选的第六个

~/.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
⚠️ 实测掉落漏斗:139 目录 → 128 有 SKILL.md → 101 kebab 命名 → 98 进目录

三档杀手,按杀伤力排序:

  1. name: 不是 kebab-case → 整个 skill 被删。正则是 ^[a-z0-9]+(?:-[a-z0-9]+)*$。而且发布名来自 frontmatter,不是文件名——目录名只是发现用的键。所以 agentdb-vector-search/SKILL.md 里写 name: AgentDB Vector Search 就直接消失。本机 24 个 skill 死于此。
  2. YAML 严格性——最隐蔽的杀手。frontmatter 由 yaml@2.9.0parse() 解析,比 gray-matter/Python 习惯严厉得多。本机实测的三个真实失败:
    • planning-with-filesMap keys must be unique at line 28metadata: 下重复的 version:
    • autoresearchNested mappings are not allowed in compact mappings(description 里未加引号的 Iterations: N
    • project-initUnexpected flow-seq-start at node end
    每个都记一行 skill file <path> ignored: invalid YAML frontmatter: … 然后从所有表面移除。开头的 --- 必须是文件第一行,结尾的 --- 必须独占一行;顶层解析成非对象(数组/标量)也是致命的。
  3. 调用策略失败关闭:恰好三个 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-approvaluser-questions两个不同的接缝,前者按构造失败关闭、一次性、且只在打开的 turn 内有效。
12

Web 应用与客户端架构

精通

最反直觉的一件事先说:浏览器里也挂了一个 vendored Cordis Loader,客户端的组合完全由宿主推来的图决定,外壳自己不做任何组合决策

两阶段启动

AppWebEntry.run() · 一个类里的两个面
module face读 window.__DSH_BOOT__并行 prefetch immediately 层只注册 factory
plugin face挂 Cordis Loader每行图数据一个 entrysettle 门整个 UI 一次切换出现
  • 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 在运行时挂一个)。
⚠️ 读 web 的 --dump-config 回答「web agent 有哪些工具」会得到错误答案

dsh-web-appdsh-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-presetsconfig.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。
  • 围栏只守 /apiindex.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。

💡 不花一分钱验证围栏(无需 API key、不发模型请求)
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 全部 404events.mux 用 GET 得 426dsh 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,没有组件库、没有 Tailwindwebserver不认识任何 harness 概念、且从不打印
13

四种编程接口

进阶

四条路都驱动同一棵组合好的插件树。差别只在:轮数谁决定沙箱你必须装什么

图 6 · 四种接口,以及那个看着像 API 其实不是的第五个右下角是最容易咬人的 wire 语义
The four programmatic surfaces of dsh and the wire semantics that surprise callers Headless runs one turn and needs only the dsh bin. The TypeScript SDK and the Python SDK both run many turns over stdio JSON-RPC; the TypeScript one needs a built checkout while the Python wheel carries a bundled runtime. The ACP server runs many turns and can answer permission requests per turn. The Web /api route is not a REST API but a browser-trust-fenced RPC channel that returns 404 to plain probes. Key semantics: a prompt returns only a queued message id, run owns an interval to the next idle, finalResponse is the last assistant text in that interval rather than a causal reply, and there is no cancel on the wire. Four programmatic surfaces — and the one that looks like an API but is not All four drive the same composed plugin tree. What differs is turn count, who owns the sandbox choice, and what you must have installed. ONE COMPOSED PLUGIN TREE the surface only changes how prompts get in and results get out agent-loop tools (guarded pipeline) sandbox + approval session log llm adapter dsh --profile headless "task" ONE turn · sandboxed · needs only the dsh bin TypeScript SDK (dsh-sdk-client) MANY turns · sandbox is the composition’s choice · needs a built checkout Python SDK (deepseek-harness-sdk) MANY turns · pip only, bundled runtime, no Node.js needed ACP server (dsh-acp) MANY turns · sandboxed · per-turn permission answers Web /api route NOT a REST API. A browser-trust-fenced RPC channel: every path returns 404 to a plain HTTP probe. Do not script it. THE SEMANTICS THAT BITE • session/prompt returns the QUEUED message id — not an assistant message, turn, or result. • run() owns an interval: from the durable inbox receipt to the next whole-agent idle. • finalResponse is the last assistant text IN that interval — NOT causally tied to your prompt. • TS RunResult has no finishReason. The Python one does. Same wire, different client. • No cancel method on the wire: abandoning a turn means closing the runtime.
接口轮数默认沙箱前提
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
⚠️ 会咬人的 wire 语义(两个 SDK 都一样)

整个协议只有 3 个 client→server 请求initialize/session/prompt/shutdown)和 4 个 server→client 通知session.event/session.status/subagent.started/subagent.finished)。以下五条必须内化:

  1. messageId 只标识那条被排队的 user 消息——不是 turn、不是 assistant 回复、不是「这次 prompt 的结果」。
  2. run() 拥有一个活动区间:从持久 inbox 收据到下一次整体 agent idle。两个 SDK 实现的是同一个循环。
  3. finalResponse 是那个区间里最后一条已提交的 assistant 文本——明确不是因果上分配给你那句 prompt 的回复。steering、注入的上下文、其它排队工作都可能在 idle 之前贡献内容。
  4. 不对称:TS 的 RunResult 没有 finishReason,Python 的(还多一个 session_root,且遇到畸形 turn/end 会抛)。
  5. 没有 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)
⚠️ Python 的两个硬限制
  • 没有 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 就是协议本身。这是部署方的义务,插件无法强制——组合里任何一个往 stdout 打印的 logger 都会破坏协议。同理:stdin EOF 会切断飞行中的 turn,只有协议 shutdown 是有序的。

ACP:唯一能按轮回答权限的接口

  • session/new 带一个每会话的绝对 cwd——这就是它的多租户全部故事;additionalDirectoriesmcpServers 非空会被拒
  • 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 providerdsh-subagent-dsh-sdk)——一个 dsh 可以把活派给另一个进程里的 dsh。

14

能力接缝与事件图谱

精通

这一章是整个架构的导航底座。一个能力接缝有三个角色,缺一不成缝:Service Definition 声明接口、Service Provider 实现它、Consumer 使用它(通常是一个模型可见的工具)。给 dsh 加一个能力,意味着三个都要设计

图 4 · 接缝解剖,以及为什么换一个 provider 会移动整个产品右侧:local ↔ E2B 的可互换 provider 组
The dsh capability seam: Service Definition, Provider, Consumer — and the payoff of swapping a provider A seam has three roles. The Service Definition declares the interface and its ctx key. A Provider implements it. A Consumer, usually a model-facing tool, uses it. register() returns a disposer because registrations are reversible effects. Because bash, the filesystem tools, the PTY and LSP all consume ctx.fs and ctx.subprocess, replacing the local providers with E2B providers moves all of them at once. Cordis, model calls, agent and session state, session logs and skills remain on the host. A capability seam has three roles — and one role alone is not a seam Adding a capability to dsh means designing all three: the Service Definition that declares the interface, a Provider that implements it, and a Consumer that uses it (usually a model-facing tool). SEAM ANATOMY Service Definition declares the interface + the ctx key, e.g. ctx.fs Service Provider implements it Consumer a tool, usually register() returns the disposer — registrations are reversible effects Swap the provider, the whole product moves bash read/write/edit PTY lsp consumers ctx.fs filesystem seam ctx.subprocess process seam LOCAL the shipped default dsh-fs-local dsh-subprocess-local E2B SANDBOX one overlay file e2b filesystem e2b subprocess swap bash, the editor, the PTY and LSP all sit on these two keys — so pointing them at a remote sandbox moves EVERY one, with no provider forks. Does NOT move: Cordis, model calls, agent/session state, logs, skills. E2B is a provider-composition POC — it does not upload or mount your workspace.

接缝目录(按 ctx 键)

ctx 键能力出货的 provider典型 Consumer
ctx.llm模型访问llm-deepseek, llm-pi-aiagent-loop
ctx.shell命令执行bash-sandbox/bash-local, pwsh-*bash, pwsh
ctx.subprocess子进程组subprocess-localshell, glob/grep, lsp
ctx.fs文件系统fs-sandbox fs-local(互斥)read/write/edit, str_replace_editor
ctx.sandbox约束执行sandbox-local → seatbelt / landlock / windows-aclshell、fs 围栏
ctx.terminals常驻 PTYterminal-bashterminal_*, 常驻 bash
ctx.lsp语言服务lsp-stdiolsp
ctx.web搜索 / 抓取web-search-deepseekweb_search, web_fetch
ctx.skillsskill 发现skill-filesystem, skill-badgeskill
ctx.subagents委派spawn, fork(+ dsh-sdk / codex / claude-code,未出货)subagent, subagent_fork
ctx.workflowEngine脚本化扇出workflow-worker-thread只允许一个workflow, ralph
ctx.codeRuntimeCode Mode 执行worker-thread providerrun_code
ctx.sessionPersistencedurable 会话session-persistence-jsonlcheckpoint policy
ctx.sessionQuery内容检索session-query-sqlitesession_*(5 个)
ctx.sessionProjections折叠视图session-projectionlist_agents, Web 侧栏
ctx.approval审批user-approvaltools 管线、沙箱升权
ctx.userQuestions提问人类Web / ACP 应答者ask_user_question, exit_plan_mode
ctx.commands人类命令commands/compact 等六个
ctx.jobs后台工作jobs-localjob_*(3 个)
ctx.goals同会话目标goal + round drivercreate_goal
ctx.spillStore大结果外溢spill-localspill policy
ctx.settings / ctx.credentials配置 / 凭证settings-file, credentials-localllm 适配器、Web 设置页
★ 接缝的回报:换两个 provider,移动整个执行世界

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 工具管线 的目录漂移)。

15

陷阱与教训

进阶

这一章来自 4 篇正式 postmortem + 154 篇 bug-fix 笔记 + docs/defensive-patterns.md。每一条都是真的发生过的缺陷,不是理论风险。

★ 为什么所有安全网都没拦住:五条反复出现的逃逸路线
  1. 100% 行覆盖不是行为覆盖。「coverage 证明代码行跑过了,它对功能是否按出货方式工作一言不发。」ACP 桥曾经有 178 个绿色测试 + 满覆盖率,却完全不能用
  2. 手动挂载的插件无法验证「加载」。 ctx.plugin({name, inject, apply})手填的 inject,它永远不会调 unwrapExports;而扁平单根的测试脚手架还会掩盖 fiber 拓扑 bug(!runtime 旁路整个跳过 fiber walk)。至少要有一个测试端到端驱动真实 Loader——而当那个主操作不调模型时,它不需要 API key,所以应该进 CI,而不是躲在 key 门后面。
  3. key-gated 的测试就是被跳过的测试。 唯一驱动 session/new/session/load 的测试被 key 门控,CI 直接跳过;本地「通过」只是因为一份过期的 lib/ 恰好满足了模块解析。
  4. 自跳过的平台测试不承载任何回归。 需要在原生边界上放一个确定性 fake加上一条组装好的产品路径。
  5. 刷新过的 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/statuswhenIdle() 当成某一次 follow-up 的结果。而且这把刀两面开:如果那个被等待的转移永远不会发生,等待就是挂死——「没有什么可等」必须显式处理
4dispose 必须达到静止,不只是请求静止先 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 failedundici 把可操作的原因藏在 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 状态要读持久的完成事件,绝不从中途数据推断完成。
16

负空间 · 测试 · 门禁

精通

一个成熟项目最有价值的部分,常常是它刻意不做的那些事。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…」

十一条「删除法则」(简化笔记里反复出现的判据)

  1. 要有当前的 owner 和当前的需求——带牙齿的 YAGNI。
  2. 绝不广告一个没有任何路径履行的能力。
  3. 一个 owner、一条路径、一种表示。
  4. 兜底层只为「OS 提供且可能缺失」的工具而存在。
  5. 没有兼容 shim、没有迁移、没有墓碑——预发布立场。
  6. 呈现、外框、遥测都不是日志的职责。
  7. 一个 turn 意味着一次模型循环执行;不要为了记账合成一个 turn。
  8. 把可观察的机器收缩到「每个扩展职责一个边界」。
  9. 在出 v1 之前删掉新功能里投机的那一半。
  10. 不要拥有第二个生命周期,如果包管理器或 OS 已经拥有它。
  11. 能力边界:策略从 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 idBranded<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 hygieneknip + publint + workspace 约束 + 包不变量
CI=true pnpm exec vitest run <path>窄跑,别默认全套——CI 拥有穷尽覆盖与平台矩阵

硬规矩:每一个非平凡的、模型可见或用户可见的行为变更,都必须在同一个 PR 里通过一个真实可运行的 example 增加或更新一份无 key 快照。包测试、只在 e2e 里的断言、纯 mock 的 fixture 都不能替代那份组装好的应用转写。fixture 必须在 macOS 和 Linux 上都能重放——修 fixture,不要修 normalizer

还有一条容易忽略的:测试通过 tsconfig.base.jsonpaths 解析源码平面,因为一份过期的 lib/ 会加载模块单例的第二份副本。e2e 测试自己拥有资源(在测试里创建、在 afterEach 里释放,即使失败/重试/超时;绝不 import 另一个 *.e2e.ts——那会重复注册它的 describe 并重复真实 API 调用)。

💡 约 60 个门禁,写 PR 前先跑相关那几个

scripts/run-gates.ts 定义了具名门禁集(check-allci-primaryci-staticci-coverageci-snapshotdoc-sync…),根 package.json 才是真正的命令面。少数最容易挂的:verify-export-jsdoc(每个 function-like 导出要 @param/@returns)、verify-package-invariantsverify-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-checksdsh-code-reviewdsh-doc-standardsdsh-prose-standarddsh-archive-agent-notes ——优先用它们,别自己发明流程。

⚠️ Agent Notes 的生命周期决定你能信它多少
proposed/还没建成(或只部分建成)
implemented/已发布,应当与实际出货保持同步——但研究实测会漂移,冲突时源码是权威
rejected/被否决;「不要再提」账本
archived/冻结。绝不编辑,也绝不当成当前权威引用

刻意没有集中式 INDEX.md(有一篇笔记专门记录这个决定);导航方式是浏览 lifecycle/class 目录或直接搜仓库。

17

速查 · 术语 · 行动清单

入门

这一章可以单独打印。命令、环境变量、术语、以及一条按天推进的路径。

装好并跑起来(本机实测过的完整序列)

# ① 方案 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。而本仓库的根 postinstallscripts/install-lefthook.mjs)会拒绝替换一个它不拥有的 core.hooksPath——在这台机器上 /etc/gitconfig 把它指向 Amazon git-defender。这个拒绝是正确的,但它以非零码退出,把外层脚本一起带崩。

失败信号极具误导性pnpm run build 打印 Already up to date不产出任何产物apps/cli/lib/ 不存在)。看起来像缓存命中,其实是被中止的构建。

install-lefthook.mjs:692CI=trueGITHUB_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_URLOpenAI 兼容端点,替代公开 API
DSH_HOMEHarness home,默认 ~/.dsh:profiles / sessions / credentials / settings
DSH_AGENTS_HOME共享 agent 配置根,默认 ~/.agents(skill 从这里被扫)
DSH_PERMISSION_MODEworkspace-writedanger-full-access只能进程级——项目 .env 里设它会让启动响亮失败
DSH_TOOLS_MODEnative(默认)/ code / both其它任何值在启动时失败,包括空串
DSH_TELEMETRY_DISABLED任何非空值都是权威硬退出
DSH_TELEMETRY_MODEFULL / 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_SHELLDSH_SESSION_IDDSH_SESSION_JSONL…)——不要自己设它们

术语卡

最佳实践(可复制为 prompt 喂给任何 AI)

行动清单

💡 配套 skill:让 Claude Code 直接操纵 dsh

本门户配套一个 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 跑 …」即可触发。

⌨ 快捷键速查