DeepSeek Harness 深度评测 · Ep3DEEP DIVE
DeepSeek Harness Ep 3:热卸载——装上容易,卸下来要静默
视频版:B站 公开投稿中无对应视频(已核对全部 16 个投稿) 视频总览 →
本期要点POINTS
本期要点
关键实测数据DATA
关键实测数据
锐Owl · 解构系列 · DeepSeek Harness(Ep 3/5) 素材:2026-08-17 clone(commit 99f6f02)源码逐层拆解,全部论断带文件路径/行号
开篇:为什么拆「卸」
上一期拆完 skill,我们留下一个问题:装上容易,卸下来呢?
插件系统有个心照不宣的规矩——大家都爱讲「装」:市场、一键安装、生态多繁荣。没人爱讲「卸」:卸的时候会不会留垃圾?会不会打断正在跑的事?会不会把整个系统搞崩?
dsh 的答案不在文档里,在代码里:卸载不是一条命令,是一套生命周期纪律。 这一期从最容易被误解的 hooks 入手——它看起来像第八种扩展机制,其实是一座桥。
一、hooks 是桥,不是第八种机制
1.1 翻译层,不是扩展层
packages/hooks/README.md:5 定义得很清楚:hooks 子系统的职责是翻译外部 shell-hook 协议——把 Claude Code、Codex 的 hook 约定,「翻译」成 dsh 自己的类型化拦截点。它由两部分组成:一个共享线协议库(hook-protocol)+ 两个方言桥(hooks-claude-code / hooks-codex)。
dsh 的原生扩展面是 harness 自身的类型化拦截点(agent/pre-step、tools/pre-execute 等),所谓「原生 hook」只是挂在这些拦截点上的普通 Cordis 插件。hooks 桥做的一切,普通插件都能直接做,而且更强——设计笔记 agent/notes/implemented/feature/2026-06-30-interception-extension-points.md:9 把区别写得很直白:
| 维度 | 普通插件 | bridge |
|---|---|---|
| 返回值 | 类型化 Decision | 解析后的中性 HookOutput 再映射成 Decision |
| 上下文 | 全 ctx,无序列化边界 |
经 stdin/stdout 线协议 |
| 会话事件 | 不写 hook/* 事件 |
每次调用写 hook/invoked/hook/result 日志对 |
← 表格可左右滑动查看
为什么这个区分值得较真?因为「翻译层」和「扩展机制」的差别,决定了卸载时谁说了算——桥是普通插件,走标准装卸路径;特权机制才需要特殊照顾。dsh 这里没有特权核心。
1.2 两个方言桥
桥接双方各自只留方言差异(hooks-claude-code/src/config.ts:11-19、hooks-codex/src/config.ts:11):
- Claude Code 桥:支持 7 个事件;握手=解析
hooks.json的hooks键(src/index.ts:102-116),只跑type:'command';解析期做${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR}命令替换;PreToolUse 支持 deny/ask、PostToolUse 附additionalContexts、Stop 阻塞时agent.steer()强制续跑(:241-277)。 - Codex 桥:只支持 5 个事件;regex-only 匹配(
:131),无命令替换;payload 全 snake_case、每个事件带model、turn 事件带turn_id(:292-308);只认阻塞——PreToolUse deny 即拒,无 allow/ask。
同一个协议,两个世界:CC 的 hook 生态偏「护栏」,Codex 的偏「门禁」。
二、线协议:一段三段式的执行链
hooks 的线协议(packages/hooks/hook-protocol/)是四个模块的流水线:
- 匹配(
src/matcher.ts:57):absent /''/'*'全匹配;CC 模式对纯字面量按精确备选(|分隔),其余按非锚定正则;Codex 永远按正则。非法正则运行时当不匹配(:64),解析期由matcherDiagnostic抛SyntaxError拒配(:37-44)。 - 执行(
src/runner.ts:67):payload 经JSON.stringify写 stdin,经ctx.shell执行——继承凭据清洗、进程组取消、超时机制(:87);hook 级timeoutSec覆盖默认 10 分钟。基础设施失败被吞成exitCode: undefined的非阻塞错误,绝不 throw 进回合(:96-105)。 - 解码(
src/codec.ts:59):退出码 2 = 阻塞,stderr 即 reason(:66-69);仅退出码 0 解析以{开头的 stdout JSON,畸形 JSON 宽容降级为纯文本;expectedEventName守卫:事件字段不匹配整块丢弃(:122-124)。updatedInput只解析不执行——外部的数据永远只是数据。 - 合并(
src/merge.ts:62):同一点多个 hook 折叠成最严格结果,权限秩 deny(3) > ask(2) > allow(1)(:35-42),首个continue:false粘性停(:79-82)。
这段链的意义:外部协议被「翻译」进 dsh 时,错误边界、权限秩、类型校验都在翻译层完成,不是留给调用方碰运气。
三、卸载纪律一:生命周期即装卸
热卸载的第一条纪律:在 dsh 里,插件不是「安装/卸载」两个动作,而是「出生/死亡」两个时刻——出生时注册的一切能力,死亡时必须按逆序自动解除。
3.1 逆序 dispose
为什么逆序?因为依赖是有方向的:后注册的插件可能依赖先注册的服务,先卸掉被依赖的,依赖它的就会踩空。Cordis fiber 里所有监听经 ctx.on() 注册,listener 归当前 fiber 所有、返回 disposer(vendor/cordis/src/events.ts:90-97),fiber 卸载时按逆序自动移除——先卸最上层的,再卸底层的。
3.2 注册即撤销
这条纪律不是 hooks 特有的:上一期的 skill、更早的内核事件系统,全是同一套——registerProvider() 注册时返回「注销函数」,effect 销毁时自动注销(ep2 已拆)。一个框架敢说「一切皆插件」,前提是每个插件都带自己的撤销器,卸载即撤销,不靠运气。
四、卸载纪律二:静默——先杀、再等、才完成
第二条纪律才是「热卸载」里最硬核的部分。有些事件是「发出去就不管」的——比如会话启动信号(SessionStart),插件点了火,后续的异步链自己跑。问题来了:卸载的时候,这些尾巴还在跑怎么办?晚一步回调打进来,可能打在一个已经销毁的上下文上——那就是崩溃、数据错乱、幽灵进程(hook-protocol/README.md:26)。
dsh 的做法(hook-protocol/src/detached.ts:43):每个桥用 createDetachedRuns 追踪整条 run+continuation 链,并注册一个「排水器」drain():
drain():
1. abort 追踪信号 → 杀掉还在跑的 hook 进程
2. 循环 allSettled → 等所有异步链落定
3. 注册表空才 resolve → 卸载才算完成
drain() 作为 effect disposer 注册(hooks-claude-code/src/index.ts:126、hooks-codex/src/index.ts:105),卸载瞬间执行。注意这个顺序:先杀、再等、才宣告完成——不等直接卸 = 静默的炸弹;等了不杀 = 幽灵进程。两个都做,才是真静默。fiber.dispose() resolve 的那一刻,detached 工作已经彻底落定(hook-protocol/README.md:26)。
五、装卸的边界:什么不支持热更新
「热卸载」也有边界,说清楚才诚实。两个 hooks 桥的配置——hooks.json 那些——是进程级的:装载时读一次(hooks-claude-code/src/index.ts:102-116、hooks-codex/src/index.ts:86-97),之后换配置不生效,需要重载插件。代码里还留着 TODO:per-session 动态配置,还没做(hooks-claude-code/src/index.ts:50-51)。
这说明什么?dsh 的「热」是有范围的:
| 层 | 支持热卸载? | 机制 |
|---|---|---|
| 插件实例 | ✅ 动态装卸 | fiber 生命周期 + 逆序 dispose |
| 配置读取 | ❌ 装载时快照 | 换配置需重载插件,TODO per-session |
← 表格可左右滑动查看
插件实例可以动态装卸——这是生命周期机制;但配置读取是装载时快照——这是工程取舍。边界画得很清楚,不吹「全动态」。这个诚实,本身是工程成熟度的信号。
六、格局:卸载纪律是平台的分水岭
把三期的线索串起来:内核的注册即效果、skill 的卸载即撤销、今天的静默排水——是同一套纪律的三个面。
判断一个 agent 框架是不是真平台,别听它讲装得有多热闹,看它卸得干不干净:卸载会不会留垃圾、会不会崩、会不会留幽灵进程。dsh 连「卸载时正在跑的异步尾巴」都处理了——先杀、再等、才完成——这是冲着生产环境去的。玩具框架的典型特征恰好相反:装得热闹,卸靠重启。
四万星到十四万星,涨的是热度;热度背后的成色,是这种没人看见的纪律。
下一期:Typert——协议由编译器生成,不是人手写的文档。
来源表(引用红线)
| # | 论断 | 来源 |
|---|---|---|
| 1 | hooks 是翻译层不是第八种扩展机制 | packages/hooks/README.md:5;.agents/notes/implemented/feature/2026-06-30-interception-extension-points.md:9 |
| 2 | 原生拦截点 agent/pre-step、tools/pre-execute 类型化 | 仓库核心拦截点定义(ep1 已拆) |
| 3 | 桥接双方:CC 7 事件 / Codex 5 事件 | hooks-claude-code/src/config.ts:11-19、hooks-codex/src/config.ts:11 |
| 4 | 线协议四模块:matcher / runner / codec / merge | hook-protocol/src/matcher.ts:57、runner.ts:67、codec.ts:59、merge.ts:62 |
| 5 | 执行继承凭据清洗/进程组取消/超时;基础设施失败不 throw | hook-protocol/src/runner.ts:87,96-105 |
| 6 | 退出码 2 阻塞、畸形 JSON 降级、expectedEventName 守卫 | hook-protocol/src/codec.ts:59-89 |
| 7 | 权限秩 deny>ask>allow、continue:false 粘性停 | hook-protocol/src/merge.ts:35-42,79-82 |
| 8 | fiber 逆序 dispose / ctx.on 返回 disposer | vendor/cordis/src/events.ts:90-97(ep1 已拆) |
| 9 | detached drain:abort → 循环 allSettled → 注册表空才 resolve | hook-protocol/src/detached.ts:53-60 |
| 10 | drain 作为 effect disposer 注册(卸载瞬间执行) | hooks-claude-code/src/index.ts:126、hooks-codex/src/index.ts:105 |
| 11 | 晚回调打进已销毁 ctx 的防护 | hook-protocol/README.md:26 |
| 12 | 配置进程级、装载时读一次、TODO per-session | hooks-claude-code/src/index.ts:50-51,102-116、hooks-codex/src/index.ts:86-97 |
| 13 | CC 方言:命令替换/steer 续跑/PreToolUse deny-ask | hooks-claude-code/src/index.ts:147-180,241-277 |
| 14 | Codex 方言:snake_case/regex-only/仅阻塞 | hooks-codex/src/index.ts:131,146,229,292-308 |
| 15 | 玩具框架 vs 平台对比 | 分析层:由 [1]-[14] 机制证据归纳 |
← 表格可左右滑动查看
仓库版本:2026-08-17 clone,commit 99f6f02。制作前若隔日,重查 tags/commit 确认版本未漂移。
信号来源SIGNALS
信号来源
这一期从哪条雷达长出来(雷达 → 产出的双向可追溯):
node_links/radar_items.intent 的 feeds 指向关系当前为 0(M2 首轮回填统一先落 standalone,待人工指认)——回填后此处显示「雷达 YYYY-Www 条目 ③ → 已成为本期」。
来源表SOURCES