← 返回文章列表

Hook 设计的两种答案:跨进程与同栈

Hook 设计的两种答案:跨进程与同栈 前置阅读:Claude Code 的 Hook 体系设计(讲"这套系统内部怎么运转")、Hook 设计机制(讲"契约为什么是双保险")。 本篇不再重复那两篇的内容,只回答一个新问题:同样是"在生命周期节点插入第三方逻辑",为什么 Claude Code 和 D

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 hooksDeepSeek 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 这一句。但它成立的前提,是这三个条件同时满足:

  1. 同一个调用栈:监听器能在"调用还没返回"的时候,把控制权交出去(await next())
  2. 同一个堆:参数是对象引用,A 改的字段 B 和内置行为都看得见
  3. 同一个事件循环:组合是同步的,异步只在内部

于是 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 CodeDeepSeek 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 优先于 allow
  • allow 只在不冲突时生效
  • 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-execute waterfall 之后注册单调同步守卫:返回的理由会拒绝调用,后续监听器无法把该拒绝重新变为允许。

4.4 两边独立收敛到同一条

把两边的裁决放在一起,会发现它们用完全不同的机制,得出了同一个结论:

CCDSH
谁先说话并发,无顺序严格顺序
冲突怎么解规则(严格度)位置(外层优先)
拒绝能否被翻案不能(deny 最高)不能(guard 单调)

拒绝必须单调。 这是安全语义的硬要求——否则"多扩展点"就等于"没有任何扩展点",因为最后一个说话的永远能洗掉前面的判决。

两边用不同路径走到同一个地方,说明这不是偏好,是约束。


五、失效:fail-open 与"关轮次"

这一节是我认为整套设计里最容易被忽略、也最重要的一段。因为它决定了一件根本的事:这套机制到底是"护栏",还是"建议"。

5.1 Claude Code 的四条失效路径

一个 hook 有四种"没能正常给判断"的方式:

失效形态具体表现系统反应
崩溃脚本语法错、依赖没装、exit 1/127non_blocking_error,继续执行
超时 / 被杀跑太久被 SIGTERM,或用户按 Esccancelled,继续执行
管道断了没读 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 要这么选

三个理由,都很硬:

  1. hook 是用户自己写的代码。 一个语法错误就让整个产品不可用,没人受得了。
  2. hook 的典型用途是锦上添花:格式化、通知、记日志、跑 lint。这些挂了大不了没做。
  3. 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 反向 RPChook 往 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 CodeDeepSeek 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 判别 + expectedHookEventinterface Events 声明合并 + engines.dsh 兼容检查

这张表的价值:它把"40 个细节"压成"8 个问题"。

而且注意——8 行里没有一行是"功能",全是"取舍"。 这就是为什么它可以跨系统迁移:功能和产品绑定,取舍和约束绑定。


九、怎么选:四个问题

如果你要在自己的系统里设计扩展点,按顺序问四个问题,答案基本就定了:

① 扩展点给谁写?
     ├─ 终端用户(写脚本)      → 跨进程、任意语言、意图契约、fail-open
     └─ 开发者(写插件)        → 同进程、单语言、类型契约、异常即失败

② 扩展者的代码可信吗?
     ├─ 不可信 → 必须进程隔离 → 放弃"包裹"能力
     └─ 可信   → 可以同进程   → 可以给"改对象"的能力

③ 需要"否决"还是只需"通知"?
     ├─ 需要否决 → 必须串行/有序(否则裁决不可控)
     └─ 只需通知 → 可以并行、可以 fire-and-forget

④ 出错了谁承担?
     ├─ 可用性优先 → fail-open + 可观测
     └─ 安全优先   → fail-closed + 可观测(缺一不可)

四个问题里,第①题最重要——它同时决定了语言、分发、门槛、和后面三项的默认值。

CC 的答案是「终端用户 / 不可信 / 需要否决 / 可用性优先」;
DSH 的答案是「开发者 / 可信 / 需要否决 / 安全优先」。

同样的第③④题,因为第①②题不同,长出了完全不同的实现。


十、结语

回到开头那个问题:为什么同样是"插入第三方逻辑",两边做出了相反的选择?

因为它俩要解决的根本不是同一个问题:

  • CC 要解决的是:"怎么让一个不写 TypeScript 的用户,用二十行 bash 就改掉我的行为,而且他写崩了不能把我的产品搞挂。"
  • DSH 要解决的是:"怎么让一个开发者把逻辑嵌进我的执行链中间,改参数、换结果、加服务,而且整个生态还能版本兼容。"

前者要求"低门槛 + 高隔离",后者要求"高能力 + 强治理"。两个目标在根上冲突,所以答案必然相反。

所以真正的结论不是"谁更先进",而是:

扩展点的形态,是"扩展者是谁"的投影。

你选不了形态,你只能选"给谁用"。选完之后,形态已经被决定了。

这也是为什么我后来越来越觉得:读源码真正读到的不是"它怎么写的",而是"它被迫只能这么写"。前者是知识,后者才是判断力。


附:本文引用的位置

主题Claude CodeDeepSeek Harness
事件清单src/entrypoints/sdk/coreTypes.ts:25interface Events 声明合并(各包自行扩展)
入参 schemasrc/entrypoints/sdk/coreSchemas.ts:387 / :414 / :767TS 类型(declare module 'cordis')
返回值 schemasrc/types/hooks.ts:50函数返回类型
唯一翻译官src/utils/hooks.ts:528无(不需要)
分发器src/utils/hooks.ts:2000(executeHooks)cordis/src/events.ts(waterfall)
并发合并src/utils/hooks.ts:2792cordis 的 waterfall(串行)
权限裁决src/utils/hooks.ts:2868ctx.tools.guard() + 调用顺序
失败语义src/utils/hooks.ts:2718 / :2521dsh-agent-loop 抛异常关轮次
反向 RPCsrc/types/hooks.ts:28 + hooks.ts:1114直接调 ctx 服务
观测量src/utils/hooks/hookEvents.tstools/result

DSH 部分基于 @deepseek-ai/dsh 0.2.1-rc.5 / Cordis 4.0.5。其源码打包在 app.asar 内,无法用常规编辑器直接跳转,因此正文用"包名 + 文件名"定位,不引未核实的行号。

评论