ARTICLE · 1220694
DeepSeek-Harness 源码深读(27):skills、commands、hooks 三张脸,翻过来是同一副骨架
摘要:模型按需加载的 skills、人手敲的 commands、社区抄来的 hooks 脚本,dsh 做成三套独立运行时,底下是同一个模式:分层注册表近层遮蔽、可失败的 provider 发现、机器节拍上的确定性分发。外加 Stop 钩子 deny 靠 steer 续跑的机关,和跑不起来的钩子为什么不炸 turn。
给一个 harness 留扩展缝,留着留着就会留出三张脸。我们给一个仓库装三件自定义试试:.dsh/skills/code-review/ 放一份评审清单,想让模型干活时自己翻;/lint 注册一个命令,想自己敲;再从 Claude Code 社区抄一个 PreToolUse 钩子脚本,拦住每条 bash 命令要确认。三件事听起来是一类,都是「塞点自己的逻辑进去」。
先把结论摆出来。dsh 为这三件事做了三套独立运行时:skill 注册表、commands 运行时、hooks 方言桥。但把源码翻过来,三张脸底下是同一副骨架:一个分层注册表(谁提供扩展)、一个可失败的发现层(扩展从哪来)、一个确定性分发点(扩展在哪个机器节拍上执行)。这篇文章就把三张脸逐张掀开,最后对一次账。
一、skill:注册表不问出处
第一件麻烦是上下文。评审清单写了两千字,塞进系统提示,每轮请求都带着这两千字跑,不管这轮任务用不用得上。所以 skill 的第一设计决策是按需加载:清单放在磁盘上,模型目录里只放一行简介,要用时通过 skill 工具把全文读进来。
目录从哪来?@deepseek-ai/dsh-skill 挂出 ctx.skills,自己却一个 skill 都不发现。它只定义契约:
export interface SkillProvider { /** Unique provider name in the `ctx.skills` registry. */ readonly name: string /** * List available skill candidates for the current lookup context. // … */ readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation> /** * Load a complete skill body for a previously listed candidate. // … */ readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>}这段是 provider 契约(skill/src/index.ts:248-268,注释有压缩)。注意看两个方法只管列出和加载,认证、远端初始化、目录扫描一概不管。文件系统实现是另一个包 dsh-skill-filesystem,注册进来才扫磁盘。注册表不问出处,磁盘、远端、内存里的 skill 走同一扇门。
文件系统 provider 常驻扫五个根,各有 rank:
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot },{ path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot },// customSkillDirs,rank: CUSTOM_RANK(300){ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },这段是五个常驻根的装配(skill-filesystem/src/index.ts:245-254,中间一行是 custom 目录的等价注册)。注意看 rank 的取值:100、200、300、400、500。项目 .dsh 压得最紧,用户目录在常驻根里垫底。常驻之外还有第六个根:宿主配置了 bundledSkillDir 时,随发行版打包的 skill 以 rank 600 按需加入(:257-259),真论松紧它才是最松的那层。rank 在同名冲突时裁决,但只在同一层内。
层是另一回事。注册表按「全局层 + scope 链」分层,近层的同名条目直接遮蔽远层,rank 只在层内兜底。也就是说项目里放一份 code-review,会把用户目录里那份整个盖掉,不看 rank。这条规则 commands 也用,第四节对账。
读路径带缓存,key 的构造有点讲究:
const MAX_COLLECT_ATTEMPTS = 2private collectCacheKey(cwd: string | undefined, chain: ScopeKey[], revision: number): string { return JSON.stringify({ cwd, scopes: chain.map(key => this.scopeId(key)), revision })}这段是缓存 key(skill/src/index.ts:22 与 644-646)。注意看 key 里除了 cwd 还有整条 scope 链和 revision。会话重组可以把一个已有 scope 换父节点而不惊动注册表,只有带链的 key 能让下一次读看到新拓扑。发现期间 revision 变了就重试,最多两次,仍不稳定就返回标记不可缓存的结果:不完整的观察永不入缓存,消费者保留上一份好的。
目录怎么进模型上下文?dsh-tool-skill 在 agent/pre-step 注入,注入是内容寻址的:
const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)const digest = digestCatalogEntries(entries)const history = catalogHistory(agent)const existing = catalogMessage(decision.messages)if (history.visibleDigest === digest) { return existing === undefined ? decision : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }}这段是目录注入的去重核心(tool-skill/src/index.ts:227-235)。注意看 digest 相等时干的事:什么都不注入,甚至把已有的旧目录摘掉。digest 变了则把旧消息原位替换成更新版。目录不随会话变长无限增殖,十轮对话之后上下文里也只躺着一份目录。
skill 还有第二条入口。frontmatter 里写了 disable-model-invocation 的 skill,目录和 skill 工具都看不见它,只有用户在消息首行敲 /code-review 才能触发。这个手势的注入位置,源码注释写得极清楚:
// User-explicit skill invocation: a claimed user message whose first line// starts with `/<name>` naming a user-invocable skill is a deterministic// load gesture. The rendered body enters this step as injected// instructions context appended after every other injection — background// first (workspace rules, runtime policy, the catalog), the material the// model must act on last, closest to its answer. // …// Only `source.kind === 'user'` messages are scanned — external text// cannot forge the gesture — and a token naming no user-invocable skill// stays ordinary prose (the command registry is a different closed// namespace, resolved client-side before a line ever becomes a prompt).这段是手势入口的规则注释(tool-skill/src/index.ts:163-176,后半有省略)。注意看两件事。注入次序是背景在前、要执行的材料在后,注册顺序保证了这个位置确定。扫描范围只认 source.kind === 'user' 的消息,外部注入的文本伪造不了这个手势。
但 skill 说到底服务的是模型,加载进来的是材料,不是动作。我们想敲一下就跑一段逻辑,不用模型转发,这就轮到第二张脸。
二、commands:同一副骨架,第二次实例化
/lint 想要的行为很直接:敲下去,宿主直接跑 lint,结果回显,模型全程不参与。dsh-commands 挂出 ctx.commands,入口是对输入行的解析:
export function parseCommand(line: string): ParsedCommand | undefined { const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line) if (match === null) return undefined const name = match[1] /* v8 ignore next -- the first capture is required whenever the regular expression matches */ if (name === undefined) return undefined return Object.freeze({ name, rawInput: line.slice(match[0].length) })}这段是命令解析(commands/src/index.ts:116-123)。注意看正则的形状:小写字母开头,只认 [a-z][a-z0-9_-]*,后面必须顶到行尾或空白。解析失败返回 undefined,这条输入就当普通 prompt 走。第一节注释里那句「command 注册表是另一个封闭命名空间,客户端先解析」说的就是这次先后:斜杠 token 先问 commands,问不出才落到 prompt,再问 skill 手势。两个封闭命名空间,谁也串不了台。
命令的注册同样分层,per-agent 遮蔽的用法直接写在报错里:
constructor(scope: ScopeKey | undefined) { this.commands = new NamedEntries(name => new Error(scope === undefined ? `command 「${name}」 is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)` : `command 「${name}」 is already registered in this scope`))}这段是命令层的重名报错(commands/src/index.ts:92-96)。注意看错误文案后半句:想让某个 agent 有一份专属 /lint,把 command-injected 插件挂到那个 agent 的 agent.ctx 下就行。报错信息顺手当教程用,全局层被 scoped 层遮蔽的规则不用翻文档。
执行链上值得看的是 id 和日志。命令铸一个确定性 id,然后追加一对 log-only 事件:
private mintCommandId(): CommandId { this.commandSeq += 1 return CommandId(`cmd-${this.instanceToken}-${this.commandSeq}`)}this.appendLifecycle(agent.session, 'command/run', { commandId, name: parsed.name, ...command.definition.recordInput === false ? {} : { args: parsed.rawInput }, source: { kind: 'user' },})这段是 id 铸造与开跑事件(commands/src/index.ts:411-414 与 341-346)。注意看 commandId 的三段式:实例 token 加序号,resume 之后接着铸也不会跟旧日志撞车。command/run 先落,handler 跑完 settle 时落配对的 command/done。两个事件不包 turn、不强制 flush,recordInput: false 的命令连 args 都不记。这一对事件是 plan-mode 投影这类下游的状态源,第二十八篇会再见到它。
handler 本身也被当不可信输入:返回值过 normalizeResult,手写 handler 返回的非法形状在这里被拒。这个主题第三节还会加重。
到这里,进程内的扩展都齐了。可是社区里现成的扩展不长这样,它们是 shell 脚本,是别人家 harness 的钩子配置。第三张脸就是为它们留的。
三、hooks:这次消费者是外部脚本
从 Claude Code 项目抄来的 PreToolUse 钩子,是一段读 stdin JSON、往 stdout 写决策的 shell 脚本。dsh 不可能为每个社区脚本写适配,它的做法是两个方言桥加一个中立协议:dsh-hooks-claude-code 和 dsh-hooks-codex 各自负责 payload 构造和环境变量,执行与解析共享 dsh-hook-protocol。
执行完全托付给 ctx.shell,超时默认十分钟。最有意思的是跑不起来的钩子怎么处理:
} catch (error: unknown) { // The executor rejects only on infrastructure faults (unusable workdir, // missing shell). A hook that cannot run is a non-blocking error: no exit // code, the failure on stderr for the record. The turn proceeds. const message = error instanceof Error ? error.message : String(error) return { output: parseHookOutput(undefined, '', message), durationMs: now() - started, }}这段是基础设施故障的折叠(hook-protocol/src/runner.ts:95-104)。注意看注释那句 The turn proceeds:workdir 不可用、shell 缺失这类故障不炸 turn,折叠成无退出码的非阻塞结果,失败记进 stderr 备查。钩子的价值是锦上添花,添不上不能把整床被子烧了。
多个钩子意见不合,折叠规则在 merge.ts:
export interface MergedHookOutcome { /** * The most-restrictive permission decision across all hooks (`deny` > `ask` > * `allow`), or `none` when no hook expressed one. // … */ decision: MergedDecision /** `true` when any hook asked to halt (`continue:false`). */ stop: boolean /** Every hook's `additionalContext`, in hook order (no joining — the bridge decides). */ additionalContext: string[]}这段是多钩子结果的词汇表(merge.ts:14-31,注释有压缩)。注意看裁决方向:权限取最严,deny 压过 ask 压过 allow;第一个喊停的钩子粘住 stop;理由按胜者拼接,additionalContext 却只按序累积不拼接,留给出题的桥自己决定。
整条 hooks 链上最妙的机关是 Stop 钩子。Claude Code 语义里,Stop 钩子 deny 的意思是模型想停,你不许它停。dsh 没有为它新造一个 run 级 halt 机制,翻译成的是一次 steer:
// A blocking Stop hook steers at the stopping boundary, which makes the// machine observe pending input and run another step.// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => { const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation. const text = merged.reason ?? 'continue: blocked by Stop hook' agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE })) }})这段是 Stop 钩子的翻译(hooks-claude-code/src/index.ts:267-277)。注意看 deny 分支干的事:构造一条普通的用户侧消息,走 agent.steer。模型收到的就是一条「continue: blocked by Stop hook」,机器观察到有待处理输入,于是再多跑一步。续跑原语被系统内部复用,一行新机制都没加。代价也在注释里:TODO(stop-loop-guard),强制续跑目前没有次数上限,钩子得自觉。
忠实但降级的地方也不藏着。CC 钩子的 updatedInput 想重写工具入参,dsh 解析了,然后只 warn 不生效:
if (output.updatedInput !== undefined) { ctx.logger.warn(`hooks-claude-code: ${point} hook requested updatedInput, which is not yet honored (ignored)`)}这段是降级的留痕(hooks-claude-code/src/index.ts:176-178)。注意看这条 warn 的位置:它在正常执行路径上,不是角落里的死代码。存量 hooks.json 大部分场景可用,覆盖不到的部分在日志里可见,这套兼容策略的取舍就是这样摊开的。
四、把三张脸翻过来
三张脸全掀完了,现在对账。

三个扩展面共享注册、发现、分发三层
三套运行时为什么长得像?读 dsh-skill 和 dsh-commands 会发现 ScopedLayers 加 NamedEntries 的结构几乎是平移,连变更通知的写法都各写了一份双胞胎:两边的 notifyChange 都手动 try/catch 每个监听器(skill/src/index.ts:649-660、commands/src/index.ts:440-454),理由相同,Cordis emit 的 Array.map 里一个同步 throw 会饿死后面的监听器。hooks 没有注册表,它的「发现层」是方言桥读配置,「分发点」是 agent 拦截事件,形状照样是三层。
注册层面三个扩展面各自要什么?答案一样的:全局加 scope 链,同名近层遮蔽远层。skill 靠它让项目覆盖用户配置,commands 靠它做 per-agent 变体,hooks 的 matcher 折叠是同一棵树上多一份优先级规则。
发现层面怕什么?怕 provider 坏掉。skill 的 provider 抛错只降级当次发现(skill/src/index.ts:600-609 的 try/catch),commands 的 handler 返回值过 normalizeResult,hooks 的 stdout 过 codec 解码再丢掉错位字段。每个扩展作者,插件作者、hook 脚本、外部 JSON,都被当作不可信输入。这三个校验关卡落在三个包里,是同一道题的三份答卷。
分发点在哪?三张脸各有一个明确的机器节拍:skill 在模型工具调用和 pre-step 注入,commands 在用户输入行解析,hooks 在 agent 拦截事件。触发时机不同,注册和覆盖的规矩相同,这就是骨架的含义。
可挑的刺也有三根。Stop 钩子强制续跑无上限,一个有 bug 的钩子理论上能让会话永不停;hook 配置是进程级一次读取,多项目部署下一个进程只能有一套;skill 目录的 digest 分支是三件套里最难一眼读对的代码。前两根在源码里各有 TODO 自认,第三根难读是我们自己的读后感。
回到开头装的三件套。code-review 的清单躺在 .dsh/skills 里等 digest 变化;/lint 铸了 cmd- 开头的 id,run 和 done 一对事件躺在日志里等投影来读;抄来的 PreToolUse 脚本被桥翻译到 tools/pre-execute,deny 时连同理由一起折进 decision。三张脸各就各位,底下那副骨架一次都没露面。
命令的事件对既然是投影的状态源,用户敲的字和系统的回应怎么在四条通道里流转,下一篇拆 user-interaction,深读第二十八篇见。
你的 Agent 用哪张脸扩展得最多,踩过哪张脸的坑?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。















































































