Hook 设计的两种答案:跨进程与同栈
前置阅读:Claude Code 的 Hook 体系设计(讲"这套系统内部怎么运转")、Hook 设计机制(讲"契约为什么是双保险")。
本篇不再重复那两篇的内容,只回答一个新问题:同样是"在生命周期节点插入第三方逻辑",为什么 Claude Code 和 DeepSeek Harness 做出了几乎相反的选择?
引言
先看两个配置。
Claude Code 的 .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/usr/local/bin/guard.sh", "timeout": 5 }
]
}
]
}
}
DeepSeek Harness 的 cordis.patch.yml:
- insert:
- id: tool-call-timeout-policy
name: '@deepseek-ai/dsh-tool-call-timeout-policy'
两段配置想干的事差不多:在某个生命周期节点上,插一段不是宿主写的逻辑。
但往下走一步,它们就分道扬镳了:
- Claude Code 的
guard.sh是一个独立进程。它从 stdin 读一行 JSON,往 stdout 写一行 JSON,用退出码表达"同意/阻断/我错了"。 - DeepSeek Harness 的 timeout-policy 是一个同进程的 TypeScript 函数。它拿到的是活的对象,可以
await next(),甚至能在调用中途把exec.signal换掉。
这中间的差别,不是风格,不是品味,甚至不是工程能力——是物理。
本文的核心隐喻:Claude Code 的 hook 像隔着窗口递申请表,窗口后面的人决定办不办;DeepSeek Harness 的插件像站在同一条传送带上,可以伸手把件拿下来改一改再放回去。
隔着窗口,你只能"申报意图";站在传送带旁,你才能"包裹行为"。
而这个区别,是从"能不能共享调用栈"这一件事推出来的。
一、先把差异摊平:十二个维度
在进入原因之前,先看结果。下面是两边在同一位置上的十二项对比——每一行都不是"谁更好",而是"谁更适合自己的定位"。
| 维度 | Claude Code hooks | DeepSeek Harness 插件 |
|---|---|---|
| 扩展载体 | 配置里的一条命令(shell/http/prompt/agent) | npm 包(Cordis 插件) |
| 运行位置 | 独立子进程 | 同进程 |
| 接口形态 | stdin 一行 JSON → stdout JSON + 退出码 | 类型化事件监听 + next() |
| 语言 | 任意(bash / Python / Go / Java…) | TypeScript(ESM) |
| 声明方式 | settings.json 的 hooks 段 | YAML 分层 patch + id |
| 分发方式 | marketplace 打包 + 手工配置 | pnpm 安装 + profile 组合 |
| 分发语义 | 并行、无序、无上限 | waterfall、严格保序 |
| 阻断方式 | exit 2 或 {"decision":"block"} | 不调用 next() |
| 并发治理 | 无上限 | 工具级 maxParallelToolCalls: 10 |
| 失败影响 | 隔离(hook 崩了只丢一个 hook) | 插件异常关闭当前轮次 |
| 状态 | 每次全新进程,无状态 | 可持有服务级单例状态 |
| 扩展范围 | 只能在预定义的事件点插逻辑 | 可贡献服务、工具、UI、模型提供方 |
这张表的下半部分(分发语义到失败影响)几乎全部由上半部分(运行位置)决定。
这就是本文要论证的核心命题:
不是"设计得好不好",而是"扩展者站在哪里"。位置决定了接口,接口决定了能力,能力决定了治理方式。
二、物理根源:能不能共享调用栈
2.1 Claude Code 的二十行
src/utils/hooks.ts:1019 起,是整套 hook 系统的物理起点:
child = spawn(finalCommand, [], {
env: envVars,
cwd: safeCwd,
shell,
windowsHide: true,
})
spawn 只做一件事:让内核把某个可执行文件加载成新进程。它对程序内容零假设——bash、python3、java 在它眼里完全同类。
之后是三根管道加一个数字(hooks.ts:1252、1110、1209、1276):
host ──write──► hook.stdin 一行 JSON + EOF
host ◄──read─── hook.stdout 一行 JSON(或纯文本)
host ◄──read─── hook.stderr 日志 / 给模型的报错
host ◄──wait─── exit code 0 成功 / 2 阻断 / 其他非阻断
注意这三条通道里没有一条能承载"函数返回值"。 进程之间的边界是一堵墙,墙上只有三个洞:两根字节流,一个整数。
2.2 DeepSeek Harness 的二十行
Cordis 的 waterfall(瀑布式中间件)实现,在 @deepseek-ai/cordis 的 src/events.ts 里:
waterfall(...args: any[]) {
const cbs = this.dispatch('waterfall', args) // 取该事件的所有监听器
const inner = args.pop() // 最后一个参数 = 内置行为
const next = () => {
const cb = cbs.shift() ?? inner // 队列非空 → 下一个;空了 → 内置行为
return cb(...args)
}
args.push(next) // 把 next 交给最外层
return next()
}
整个机制的魔法只在 cbs.shift() ?? inner 这一句。但它成立的前提,是这三个条件同时满足:
- 同一个调用栈:监听器能在"调用还没返回"的时候,把控制权交出去(
await next()) - 同一个堆:参数是对象引用,A 改的字段 B 和内置行为都看得见
- 同一个事件循环:组合是同步的,异步只在内部
于是 DSH 的 timeout 插件(@deepseek-ai/dsh-tool-call-timeout-policy)可以这么写:
ctx.on("tools/execute", async (exec, next) => {
const timeoutMs = ctx.tools.get(exec.name, exec.agent)?.timeoutMs
if (timeoutMs === undefined) return next()
const d = deadline(exec.signal, timeoutMs, "TOOL_TIMEOUT")
const upstream = exec.signal
exec.signal = d.signal // ← 直接把参数对象里的字段换掉
try {
const result = await next() // ← 事情还没发生,先让它发生
if (timedOut(d.signal)) return toolTimeoutResult(timeoutMs) // ← 事后还能换结果
return result
} finally {
exec.signal = upstream
}
})
它有"事前"和"事后"两段,中间由 next() 分开。 这是 Claude Code 的 hook 永远做不到的——跨越进程边界,你既不能在调用中途改参数,也不能在调用返回后换结果。
2.3 一个反证:就算同进程,也不行
有人会说:那 Claude Code 干嘛不把 hook 跑在 worker_threads 里?同一个进程,总行了吧。
不行。 worker 之间传对象要结构化克隆(structured clone),你依然拿不到"引用",也依然不能在调用栈上挂续延。await next() 会退化成 RPC——也就是 Claude Code 现在这套。
所以准确的判据不是"同进程",而是:
同栈,才能包裹;跨栈,只能传消息。
这条判据不只适用于这两个系统。Koa/Express 的中间件、eBPF 的内核 hook,都是"同栈"所以能包裹;gRPC interceptor、git hook、浏览器扩展、LSP,都是"跨栈"所以只能传消息。
同一个物理事实,长出了同一个形状。
2.4 所以形态不是选择,是后果
顺序要摆正:
① 扩展者的代码可信吗?
├─ 不可信 → 必须进程隔离 → 只能传消息 → 只能表达意图 → fail-open 兜底
└─ 可信 → 可以同进程 → 能共享引用 → 才能包裹行为 → 异常即失败
第一步的选择决定了后面全部。 这就是为什么我说"位置决定接口"——不是先设计了接口,再去挑运行位置。
三、契约:意图契约 vs 类型契约
3.1 Claude Code:一份"申请表"
CC 的返回值长这样(src/types/hooks.ts:50 起,节选):
z.object({
continue: z.boolean().optional(),
suppressOutput:z.boolean().optional(),
stopReason: z.string().optional(),
decision: z.enum(['approve', 'block']).optional(),
reason: z.string().optional(),
systemMessage: z.string().optional(),
hookSpecificOutput: z.union([ /* 按 hookEventName 判别的 15 个分支 */ ]).optional(),
})
两个特征值得注意:
(1)几乎全都是 optional。
因为这是"意图申报",不是"命令下达"。hook 可以只说一句 systemMessage,也可以什么都不说;宿主必须能接受"你什么都没申报"。
(2)事件特化字段走判别式联合。
hookSpecificOutput 用 hookEventName 这个字面量做判别键:PreToolUse 的 permissionDecision、PostToolUse 的 additionalContext、PostToolUseFailure 的 updatedMCPToolOutput、SessionStart 的 watchPaths、PermissionRequest 的 decision……同一个字段名,在不同事件下含义完全不同,靠判别键分开。
而 HookInputSchema(src/entrypoints/sdk/coreSchemas.ts:767)也是同一套做法:公共信封(session_id/transcript_path/cwd/permission_mode/agent_id/agent_type)加事件专属字段,27 个成员拼成一个判别式联合。
(3)最关键的一个字段:z.unknown()。
PreToolUseHookInputSchema(coreSchemas.ts:414)里:
z.object({
hook_event_name: z.literal('PreToolUse'),
tool_name: z.string(),
tool_input: z.unknown(), // ← 故意不定义形状
tool_use_id:z.string(),
})
工具参数是变化最快的东西(新工具、MCP 动态注册的工具、宿主自己都不知道的参数)。把它定义成固定结构,每加一个工具就要改 hook 接口——必然崩溃。 所以这里留白:弱类型载荷 + 强类型信封。
这就是 CC 契约通用性的全部秘密:
把变化最快的(工具参数)装进
unknown,把变化最慢的(身份信息)提成信封,中间用判别键串成联合。
(4)解释权在宿主。
processHookJSONOutput(src/utils/hooks.ts:528)是唯一的翻译官,把所有事件的特化字段压成统一的 HookResult。而 expectedHookEvent 校验(hooks.ts:622)防止串台——PreToolUse 的 hook 返回 hookEventName: "Stop",直接报错。
hook 只是"申报",宿主决定"后果"。 这条原则的具体表现是:同一句 permissionDecision: "ask",在 PreToolUse 里是"弹窗问用户",在别的事件里根本没有这个字段。
3.2 DeepSeek Harness:一份"函数签名"
DSH 这边的"契约"是 TypeScript 的类型声明合并:
declare module 'cordis' {
interface Events {
'tools/pre-execute'(exec: ToolExec, next: () => Promise<Result>): Promise<Decision>
}
}
没有 schema,没有运行时校验,没有"申报"这层缓冲——因为参数就是对象引用,监听器能直接改它。
这带来两个后果,一好一坏:
好处:表达力极强。上面那段 timeout 插件能换 exec.signal,能拿到 ctx.tools.get() 查注册表,能访问任意服务。这是"意图契约"给不了的。
坏处:版本锁死。exec 的字段一旦改名,所有插件同时崩。所以 DSH 才需要 engines.dsh 版本兼容检查、version-exemptions 豁免机制、以及一整套 plugin-manager——同进程扩展的代价,全在生态治理上。
3.3 一张表看清"校验方向"
| Claude Code | DeepSeek Harness | |
|---|---|---|
| 入参形状由谁保证 | 生产者构造 + 类型(同仓库) | 生产者构造 + 类型(同仓库) |
| 出参形状由谁校验 | 消费方运行时校验(Zod safeParse) | 不需要(同进程类型) |
| 数据可信度来源 | 进程边界(不可信) | 加载期信任(engines.dsh) |
| 跨版本演化的手段 | 可选字段、判别键、expectedHookEvent | 声明合并 + 版本范围 + 豁免 |
| 演化失败的代价 | 一条 hook 报错,流程继续 | 插件加载失败,profile 起不来 |
规律是:谁接收不可信数据,谁负责校验。
CC 的宿主收到的是"另一个进程吐出来的字符串",所以必须 Zod;DSH 的宿主调的是"自己进程里的函数",所以交给 TypeScript。
信任方向反过来,校验方向就反过来。
四、裁决:优先级规则 vs 中间件顺序
有了契约,下一个问题就来了:多个扩展者同时想说话时,听谁的?
这是我认为最能看出"框架"与"机制"分界的一节——因为裁决不属于任何一个模块,它只存在于"相邻模块之间"。
4.1 Claude Code:并行 + 规则
CC 的多个 hook 是并发跑的(hooks.ts:2792):
for await (const result of all(hookPromises)) { ... }
all() 是并发扇入合并器,谁先完成谁先到。顺序没有保证,因为顺序在跨进程世界里本来就没有便宜的保证方式。
那冲突怎么办?用规则而不是顺序(hooks.ts:2868):
deny > ask > allow
而且是"最严格的赢",不是"最后一个赢":
deny绝对优先ask优先于allowallow只在不冲突时生效passthrough什么都不设置
这个规则的价值在于:它把"顺序不确定"这个事实,从语义里消掉了。 五个 hook 并发跑,无论谁先返回,最终结论都一样。
4.2 一个容易被忽略的边界:allow 到底能干什么
这里有个细节,我读到时愣了下(src/services/tools/toolHooks.ts:492):
// Hook allow skips the interactive prompt, but deny/ask rules still apply.
也就是说:hook 的 allow 只能"免掉弹窗",不能"越过用户配置的 deny 规则"。
再往下还有一层更微妙的:如果 hook 同时改写了参数,那么规则检查的是改写后的版本(toolHooks.ts:466):
if (hookPermissionResult?.behavior === 'allow') {
// 决策与输入成对取用:钩子若改写过输入,批准绑定的是改写后的版本
const hookInput = hookPermissionResult.updatedInput ?? input
注释里"批准绑定的是改写后的版本"这几个字,就是防 TOCTOU(Time-of-Check to Time-of-Use):如果批准绑定旧版本、执行用新版本,hook 就能在批准之后把 ls 改成 rm -rf。
这是"意图契约"必须付的税:一旦允许扩展者改写参数,就必须让"批准"和"执行"绑定同一份数据。
4.3 DeepSeek Harness:顺序即语义
DSH 这边反过来(@deepseek-ai/cordis):
listener A(外层)
└─ listener B(内层)
└─ 内置行为
- 顺序由注册顺序决定,
prepend可以插队 - 不调用
next()就等于否决,后面全部不执行 - 谁能否决,取决于"谁在外层"——位置本身就是权限
而 @deepseek-ai/dsh-tools 还额外加了一层单调守卫(ctx.tools.guard()):
在可扩展的
tools/pre-executewaterfall 之后注册单调同步守卫:返回的理由会拒绝调用,后续监听器无法把该拒绝重新变为允许。
4.4 两边独立收敛到同一条
把两边的裁决放在一起,会发现它们用完全不同的机制,得出了同一个结论:
| CC | DSH | |
|---|---|---|
| 谁先说话 | 并发,无顺序 | 严格顺序 |
| 冲突怎么解 | 规则(严格度) | 位置(外层优先) |
| 拒绝能否被翻案 | 不能(deny 最高) | 不能(guard 单调) |
拒绝必须单调。 这是安全语义的硬要求——否则"多扩展点"就等于"没有任何扩展点",因为最后一个说话的永远能洗掉前面的判决。
两边用不同路径走到同一个地方,说明这不是偏好,是约束。
五、失效:fail-open 与"关轮次"
这一节是我认为整套设计里最容易被忽略、也最重要的一段。因为它决定了一件根本的事:这套机制到底是"护栏",还是"建议"。
5.1 Claude Code 的四条失效路径
一个 hook 有四种"没能正常给判断"的方式:
| 失效形态 | 具体表现 | 系统反应 |
|---|---|---|
| 崩溃 | 脚本语法错、依赖没装、exit 1/127 | non_blocking_error,继续执行 |
| 超时 / 被杀 | 跑太久被 SIGTERM,或用户按 Esc | cancelled,继续执行 |
| 管道断了 | 没读 stdin 就退出(EPIPE) | status: 1,继续执行 |
| 根本没跑 | 工作区不信任、disableAllHooks、CLAUDE_CODE_SIMPLE | 静默跳过,继续执行 |
对应的代码:
// hooks.ts:2718
// Any other non-zero exit code is a non-critical error that should just
// be shown to the user.
// hooks.ts:2521
if (result.aborted) {
yield { message: createAttachmentMessage({ type: 'hook_cancelled', ... }), outcome: 'cancelled' }
return
}
四种全指向同一个方向。CC 是 fail-open 的,而且没有开关改成 fail-closed。
5.2 为什么"静默"比"放行"更危险
fail-open 本身不是问题——很多系统都 fail-open。问题是它同时是静默的。
设想一个场景:
你写了个 hook:检测到 rm -rf 就 block。
某天机器上 jq 没装 → hook exit 127
→ 宿主记一条 non-blocking error(很可能滚过去)
→ 工具照常执行
→ rm -rf 真的跑了
最坏的不是它没拦住,而是你以为它拦住了。
所以判据不是"它能不能拦",而是:
它失效的时候,你会不会知道?
护栏 = fail-closed + 失效可感知。 两个条件缺一不可。缺了可感知,你连"现在没有保护"都不知道;缺了 fail-closed,知道了也晚了。
按这个标准,CC 的 hook 更接近"建议":平时有效,出事沉默。
5.3 为什么 CC 要这么选
三个理由,都很硬:
- hook 是用户自己写的代码。 一个语法错误就让整个产品不可用,没人受得了。
- hook 的典型用途是锦上添花:格式化、通知、记日志、跑 lint。这些挂了大不了没做。
- CC 真正的硬边界在别处:权限系统的
deny规则、fs-sandbox/bash-sandbox的沙箱。hook 是"用户自定义策略"层,不是安全层。
所以这不是缺陷,是分工。用错了地方,才是缺陷。
5.4 DSH:另一条路
DSH 的插件跑在同进程,没有进程边界兜底,所以它的失效语义完全不同(@deepseek-ai/dsh-agent-loop/README.zh.md):
Middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次——插件失败结束的是轮次,不是循环。
注意最后半句:失败的是轮次,不是整个进程。 这已经是权衡后的结果——插件崩了,至少循环还活着,能开下一轮。
但代价依然存在:一个插件抛异常,当前这一轮就废了。
5.5 选型表
| 场景 | 漏过的代价 | 误拦的代价 | 该选 |
|---|---|---|---|
| 代码格式化检查 | 低 | 中 | fail-open |
| 通知 / 日志 | 低 | 低 | fail-open |
| 提交前跑测试 | 中 | 低 | 倾向 fail-closed |
| 禁止删库 / 禁止推 main | 极高 | 中 | 必须 fail-closed |
| 合规审计(每操作必须有记录) | 极高 | 中 | 必须 fail-closed |
判据一句话:当"漏过"的代价远大于"误拦"的代价时,才值得 fail-closed。而且前提是——你能检测到失效。
六、治理:限流到底该约束谁
你可能会问:hook 一次跑很多个,不会把机器跑崩吗?
答案有点意外:工具调用有上限,hook 没有。
// src/services/tools/toolOrchestration.ts:56
/**
* 并发上限:默认 10,可用环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 覆盖。
*/
function getMaxToolUseConcurrency(): number {
return parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
}
而 hook 这边(src/utils/hooks.ts:2792):
for await (const result of all(hookPromises)) { // ← 没有第二个参数
all() 的签名是 all(generators, concurrencyCap = Infinity)。同一个函数,工具那边传了 10,hook 这边没传。
6.1 这不是疏忽,是信任边界的体现
| 谁生成 | 可预测性 | 策略 | |
|---|---|---|---|
| 工具调用 | 模型 | 不可控 | 必须限流 |
| hook 配置 | 用户 | 可控 | 可以不限流 |
限流是为了约束"你控制不了的东西"。
用户自己写的 50 个 hook,他自己知道。模型一次发 20 个工具调用,谁也不确定。
6.2 但这里确实有一处缺口
这个推理在"用户装了第三方插件"时就不成立了——那时 hook 数量也不可控,但上限依然没有。
而且有个乘法效应,才是真正的风险:
模型一次发 10 个只读工具调用(工具层上限 10)
× 每个调用触发 5 个 PreToolUse hook
= 峰值 50 个进程同时在飞
如果这 50 个是 bash 脚本(每个 25MB),总共 100250MB,毫无压力;如果是 50 个 JVM(每个 100500MB),就是 525GB——真的会把机器跑崩。
真正的瓶颈不是进程数,是每个进程的体重。
所以这是我认为 CC 这套设计最该补的一块:一个没有上限的 all(),去扇出一个用户可配置数量的外部进程,并发风险完全交给配置者。工具那边显然知道该这么做(注释都写了"有界并发的扇入合并器"),hook 这边没跟上。
对比之下,DSH 在 @deepseek-ai/dsh-agent-loop 里把 maxParallelToolCalls 默认设为 10——同一个数字。 大概是"10 个并行调用"这个量级在实践里被反复验证过。
七、能力:四条通道 vs 直接调用
最后一个维度:扩展者怎么"用上宿主的能力"?
这个问题在 CC 里特别难,因为它跨进程——hook 摸不到宿主的 UI、内存、事件循环。所以 CC 只有"借",没有"用"。
7.1 CC 的四条通道
| 通道 | 做什么 | 谁收到 | 强度 |
|---|---|---|---|
| stdout 广播 | 写 systemMessage 或纯文本 | 用户的眼睛 | 弱,可能被忽略 |
| 灌回模型上下文 | additionalContext(→ hook_additional_context 附件,在 messages.ts:4810 包成系统提示);exit 2 的 stderr 直接作为工具结果 | 模型的脑子 | 强,会引发行动 |
| PromptRequest 反向 RPC | hook 往 stdout 打一行 {"prompt":"id","message":...,"options":[...]},宿主弹选择框,把 {"prompt_response":"id","selected":"a"} 写回 stdin | 用户的嘴 | 强,但阻塞、只能单选 |
| Elicitation 代理 | 返回 {action:'accept',content:{...}},弹窗根本不出现 | 用户的名义 | 最强也最险 |
第四条的实现最能说明"代理"的含义(src/services/mcp/elicitationHandler.ts:91):
// Run elicitation hooks first - they can provide a response programmatically
const hookResponse = await runElicitationHooks(serverName, request.params, extra.signal)
if (hookResponse) {
return hookResponse // ← 直接返回,后面的弹窗代码根本没执行
}
hook 替用户签了字,用户毫不知情。
7.2 一个精妙的协议细节:反向 RPC 怎么不污染结果
PromptRequest 有一个工程难题:提问行本身也是 JSON,混在 stdout 里,宿主怎么区分"这是提问"还是"这是最终结果"?
看 hooks.ts:1281:
const finalStdout =
processedPromptLines.size === 0
? stdout
: stdout.split('\n')
.filter(line => !processedPromptLines.has(line.trim()))
.join('\n')
处理过的请求行被"消费"了,就从最终 stdout 里剔掉。 而且注释特意说明:用内容匹配而不是下标,避免 index 漂移。
于是同一条流里,信令靠行特征识别,结果靠剔除后剩下的部分。干净。
7.3 DSH:不需要通道,因为有 ctx
在同进程里,插件根本不用"申请"——直接调服务:
ctx.tools.get(exec.name, exec.agent) // 查工具注册表
ctx.fs.read(...) // 读文件
ctx.agent.create(...) // 开子 agent
UI 呢?DSH 的插件是双侧的:host 插件在 Node 进程,client 插件打成 bundle 在浏览器里跑(@deepseek-ai/dsh-client-modules),中间用 RPC 通信。
7.4 一条共同的设计原则
把两边放在一起看,会看到同一个约束在起作用:
不可信代码只能提交"意图声明",不能提交"渲染指令"。
CC 的 PromptRequest 只能"从一组 option 里选一个"——不支持任意 UI。这不是做不到,是主动收窄:一旦允许 hook 自由渲染,它就成了 XSS 的入口。
DSH 的 client 插件能渲染任意 UI,但它是被安装、被打包的代码,属于"可信扩展",信任点在"装不装",不在"跑不跑"。
能力的大小,永远和"信任的粒度"绑定。
八、收束:八个原型
把前面所有差异往上提一层,会落到 8 个问题上。每个管理系统扩展点的框架,都必须回答这 8 个问题;框架之间的差别,只是答案不同。
| # | 原型 | 它回答的问题 | Claude Code | DeepSeek Harness |
|---|---|---|---|---|
| 1 | 契约 | 双方怎么交换信息? | 子进程 stdin/stdout 一行 JSON + 退出码;判别式联合;返回全 optional | 同进程 TS 函数 + next();类型声明合并;参数共享引用 |
| 2 | 路由 | 什么时候触发、触发谁? | matcher + if 预过滤(不匹配连进程都不起) | 插件装不装(部署期选择)+ 事件名 |
| 3 | 编排 | 多个执行者怎么并发/依赖? | all() 并行、无上限、谁先完成谁先到 | waterfall 严格串行、外层包内层 |
| 4 | 失效 | 坏了怎么办? | fail-open:崩溃/超时/取消/没跑都继续 | 抛异常直接关闭当前轮次 |
| 5 | 隔离 | 边界画在哪? | 子进程 + 工作区信任门 | 同进程 + 安装期信任(engines.dsh 版本检查) |
| 6 | 观测 | 怎么知道它在干嘛? | hook_started / hook_progress / hook_response 事件 + debug 日志 | tools/result 仅观测事件 + fiber 诊断 |
| 7 | 裁决 | 冲突时谁赢? | deny > ask > allow;allow 只能免弹窗 | 监听器顺序(prepend 可插队)+ ctx.tools.guard() 单调 |
| 8 | 演化 | 怎么加东西不破坏旧的? | 全 optional 字段 + 按 hookEventName 判别 + expectedHookEvent | interface Events 声明合并 + engines.dsh 兼容检查 |
这张表的价值:它把"40 个细节"压成"8 个问题"。
而且注意——8 行里没有一行是"功能",全是"取舍"。 这就是为什么它可以跨系统迁移:功能和产品绑定,取舍和约束绑定。
九、怎么选:四个问题
如果你要在自己的系统里设计扩展点,按顺序问四个问题,答案基本就定了:
① 扩展点给谁写?
├─ 终端用户(写脚本) → 跨进程、任意语言、意图契约、fail-open
└─ 开发者(写插件) → 同进程、单语言、类型契约、异常即失败
② 扩展者的代码可信吗?
├─ 不可信 → 必须进程隔离 → 放弃"包裹"能力
└─ 可信 → 可以同进程 → 可以给"改对象"的能力
③ 需要"否决"还是只需"通知"?
├─ 需要否决 → 必须串行/有序(否则裁决不可控)
└─ 只需通知 → 可以并行、可以 fire-and-forget
④ 出错了谁承担?
├─ 可用性优先 → fail-open + 可观测
└─ 安全优先 → fail-closed + 可观测(缺一不可)
四个问题里,第①题最重要——它同时决定了语言、分发、门槛、和后面三项的默认值。
CC 的答案是「终端用户 / 不可信 / 需要否决 / 可用性优先」;
DSH 的答案是「开发者 / 可信 / 需要否决 / 安全优先」。
同样的第③④题,因为第①②题不同,长出了完全不同的实现。
十、结语
回到开头那个问题:为什么同样是"插入第三方逻辑",两边做出了相反的选择?
因为它俩要解决的根本不是同一个问题:
- CC 要解决的是:"怎么让一个不写 TypeScript 的用户,用二十行 bash 就改掉我的行为,而且他写崩了不能把我的产品搞挂。"
- DSH 要解决的是:"怎么让一个开发者把逻辑嵌进我的执行链中间,改参数、换结果、加服务,而且整个生态还能版本兼容。"
前者要求"低门槛 + 高隔离",后者要求"高能力 + 强治理"。两个目标在根上冲突,所以答案必然相反。
所以真正的结论不是"谁更先进",而是:
扩展点的形态,是"扩展者是谁"的投影。
你选不了形态,你只能选"给谁用"。选完之后,形态已经被决定了。
这也是为什么我后来越来越觉得:读源码真正读到的不是"它怎么写的",而是"它被迫只能这么写"。前者是知识,后者才是判断力。
附:本文引用的位置
| 主题 | Claude Code | DeepSeek Harness |
|---|---|---|
| 事件清单 | src/entrypoints/sdk/coreTypes.ts:25 | interface Events 声明合并(各包自行扩展) |
| 入参 schema | src/entrypoints/sdk/coreSchemas.ts:387 / :414 / :767 | TS 类型(declare module 'cordis') |
| 返回值 schema | src/types/hooks.ts:50 | 函数返回类型 |
| 唯一翻译官 | src/utils/hooks.ts:528 | 无(不需要) |
| 分发器 | src/utils/hooks.ts:2000(executeHooks) | cordis/src/events.ts(waterfall) |
| 并发合并 | src/utils/hooks.ts:2792 | cordis 的 waterfall(串行) |
| 权限裁决 | src/utils/hooks.ts:2868 | ctx.tools.guard() + 调用顺序 |
| 失败语义 | src/utils/hooks.ts:2718 / :2521 | dsh-agent-loop 抛异常关轮次 |
| 反向 RPC | src/types/hooks.ts:28 + hooks.ts:1114 | 直接调 ctx 服务 |
| 观测量 | src/utils/hooks/hookEvents.ts | tools/result |
DSH 部分基于
@deepseek-ai/dsh0.2.1-rc.5/ Cordis4.0.5。其源码打包在app.asar内,无法用常规编辑器直接跳转,因此正文用"包名 + 文件名"定位,不引未核实的行号。
评论