扩展机制对照:Cordis 插件、Pi extensions 与 Claude Code 的 hooks/plugins
先把问题落到地上:你想在一次工具调用真正执行之前把它拦下来,顺便把入参改掉——比如给 bash 命令前面塞一行环境初始化。这个诉求足够普通,普通到三家都提供了机制。但真正会咬人的不是「有没有」,而是「拦截返回值到底算不算数」。这三家在这一行上的答案不一样。
需要先说清限定:DeepSeek Harness 仓库 README 自述处于 developer preview 阶段,原文写着 THERE WILL BE COMPATIBILITY-BREAKING CHANGES,仓库版本 0.1.0-rc.5。下文提到的配置字段、默认值、包名都可能变。
dsh 这边:正典是 Cordis 插件,不是 hook
docs/cordis-primer.md 开头就把 Cordis 概括成五条,其中两条决定了扩展长什么样:插件是一个实现 Service 的对象,可以是带 inject 与 apply(ctx) 字段的函数;服务从 context 里认领一个稳定的 ctx.<key>(ctx.tools、ctx.llm、ctx.sessions),别的插件按 key 找服务而不是 import 具体实现。依赖靠 inject 声明,声明了就等到服务出现再挂载——加载顺序是「我需要谁」而不是手写启动序列。
拦截点在事件上。同一份文档给了四种派发模式表:emit(不 await、无返回)、waterfall(不 await、有返回)、parallel(await、并行)、serial(await、按注册顺序、有返回)。要拦工具调用,用的是 waterfall。原文对 waterfall 的定义是 around-middleware:监听器收到 (...args, next),调用 next() 把可能被包装过的结果交给下一个服务,不调用 next() 直接返回就是短路。文档接着写明,对于单一决策类事件,短路就是设计本身——一个持有决策权的策略监听器可以不调 next() 直接返回,而只做标注或观察的监听器必须委托下去。
具体是哪条流水线,docs/tool-execution-pipeline.md 那张生成的 Mermaid 图画得很直白:tools/pre-execute waterfall 跑在最前(图上标注的职责就是 hooks、permission、sandbox),然后是注册的 monotonic guards,再是 tools/execute 与 tools/post-execute 两道 waterfall。pre 的三个出边分别是 allow / deny / ask,ask 会走到 ctx.approval 的一次性询问。
仓库里真的有一座「Claude Code 桥」
翻到 packages/hooks/,这个组下面是三个包:hook-protocol/(共享协议库,README 开头就写明它不是 cordis 插件、什么都不注册也什么都不注入)、hooks-claude-code/、hooks-codex/。也就是说,dsh 把 Claude Code 与 Codex 的 shell hook 协议当成一种外部方言,写了两座桥把它们翻译到自己的拦截点上。
packages/hooks/README.md 自述得很干脆:正典的扩展面是 harness 自己的类型化拦截点,「原生 hook」不过是挂在这些点上的一个普通 Cordis 插件;这几个包是桥。hooks-claude-code/README.md 更进一步,原文写着一个原生 cordis 插件能做到这座桥做的一切、而且更强(有类型化返回、没有序列化边界),桥的存在只是为了已映射的那一小撮 CC command hook 的兼容路径。
它的映射表就七行:SessionStart → agent/session-start(emit,只能注入 context,拦不住)、UserPromptSubmit → agent/pre-step(waterfall)、PreToolUse → tools/pre-execute(waterfall,deny/ask 都能映射)、PostToolUse → tools/post-execute、Stop → agent/turn-stopping(serial)、SubagentStart / SubagentStop → subagent/start 与 subagent/end。
桥的边界,比映射表本身更值得读
这座桥的 README 把「不支持什么」写得比「支持什么」还长,几条是真会绊人的:
- 只跑 shell 形式的
type: 'command'。http/mcp_tool/prompt/agent四种处理器会被解析后跳过并告警。 configPath是进程级的,且只在加载时解析一次。相对路径按进程启动 cwd 解析,README 里挂着TODO(per-session-hook-config),明说还没有按会话发现配置的能力。- 多个命中同一点的钩子串行跑、按配置顺序、且不去重;README 自己对照写明 Claude Code 那边是并行跑并且会对相同 handler 去重。
PreToolUse是部分支持:deny和ask生效,allow不做预批准,defer不支持,additionalContext被忽略,updatedInput只记录加告警、不采纳。Stop的连锁保护没做(TODO(stop-loop-guard)),README 写明一个无条件阻断的 Stop 钩子会把每一步都强制续跑,除非它自己限流。- 超时默认值和 CC 对不上:
hook-protocol里DEFAULT_HOOK_TIMEOUT_MS源码取值是600_000(packages/hooks/hook-protocol/src/runner.ts),桥在UserPromptSubmit上沿用这个 600 秒默认,而不是 Claude Code 给这个事件单独定的 30 秒。
顺带记一处数字对不上:桥的 README 写的是「不支持 Claude Code 当前 30 个事件中的 23 个」,并把那 23 个逐一列了出来;而我们读的 Claude Code 官方文档里,事件汇总表是 31 行,正文对应的小节也正好 31 个,且桥那份 23 条清单里没有出现 DirectoryAdded。两处口径不一致,数量以各自源文件为准;这里只陈述差异。
Claude Code 侧:契约全在退出码和超时上
Claude Code 是闭源产品,能依据的只有官方文档。文档写明 hook 处理器有五种 type:command、http、mcp_tool、prompt、agent。命令型钩子从 stdin 收事件 JSON,用退出码和 stdout 回话。
这里有两条最容易踩:
其一,对多数事件而言,只有 exit 2 靠退出码本身阻断。文档专门挂了个 Warning,用的就是「for most hook events」这个限定:stdout 上没有合法 JSON 时,exit 1 被当成非阻断错误,动作照样进行——尽管 1 是 Unix 惯例上的失败码;要执行策略就用 exit 2。文档给的例外是 WorktreeCreate,那里任何非零退出都会中止创建;反方向的例外是 PermissionRequest,文档写明 exit 2 在这个事件上不被采纳,要拒就走 decision 对象。
其二,超时的钩子不阻断。文档原文的立场是:command / http / mcp_tool 钩子达到 timeout 会被取消、输出被丢弃、不产生任何决策;在 PreToolUse 上,超时的这类钩子不会挡住工具调用,调用继续走正常权限流程——别指望一个卡住的钩子充当闸门。默认值本身也不是一个数:command/http/mcp_tool 是 600 秒,prompt 是 30 秒,agent 是 60 秒;UserPromptSubmit 把前三类降到 30 秒,MessageDisplay 降到 10 秒,SessionEnd 是 1.5 秒的共享预算,配置里设了更长的每钩子 timeout 会把预算抬上去、上限 60 秒。
信任这一层文档也讲了口径差异:交互式会话里,Claude Code 在你接受工作区信任对话框之前不会跑任何来自设置文件的钩子——包括你自己的 ~/.claude/settings.json;而 -p 或 SDK 会话不显示对话框、直接当作受信任,于是仓库 .claude/settings.json 里提交的钩子会在一个你从没信任过的目录里跑起来。
插件那侧是文件系统结构:.claude-plugin/plugin.json 是清单,skills/、commands/、agents/、hooks/、.mcp.json 这些必须放在插件根目录。官方文档把「把它们塞进 .claude-plugin/ 里」直接标成了 Common mistake,并补了一句插件根永远不是 ~/.claude/。
Pi 侧:扩展就是一个 TypeScript 模块
Pi 的 packages/coding-agent/docs/extensions.md 里,扩展是导出一个默认工厂函数、接收 ExtensionAPI 的 TypeScript 模块,用 jiti 加载所以不用编译。自动发现位置文档给了四行表格:~/.pi/agent/extensions/*.ts、~/.pi/agent/extensions/*/index.ts 是全局,.pi/extensions/*.ts、.pi/extensions/*/index.ts 是项目级;文档写明项目级那两行要等项目被信任之后才加载,而 -e ./path.ts 只建议用于快速测试、放在自动发现位置的扩展才能用 /reload 热重载。
拦工具调用用 pi.on("tool_call", ...),返回 { block: true, reason } 就是拦。关键在紧跟着的三条行为保证,文档是逐条列出来的:对 event.input 的就地修改会影响真实的工具执行;后面的 tool_call handler 看得到前面 handler 的修改;修改之后不做重新校验。
同一个字段,三种答案
把「改入参」这件事拉到一起看,就是本文一开始说的那一行:
| 改入参的写法 | 文档写明的效果 | |
|---|---|---|
| DeepSeek Harness(CC 桥) | 钩子输出 updatedInput | 解析了,但记录加告警、不采纳 |
| Pi | 就地改 event.input | 生效,且改完不再做 schema 校验 |
| Claude Code | hookSpecificOutput.updatedInput | 替换整个 input 对象,未改的字段也要一并带上 |
Claude Code 文档还额外写了一条:PermissionRequest 里的 updatedInput 只对 "allow" 生效,并且改过的 input 会重新过一遍 deny 与 ask 规则。三家在同一个动作上给的语义不同,所以一套靠改入参兜底的策略脚本换个 harness 就可能静默失效——不是报错,是照常放行。
沙箱这一栏,三家口径出奇一致
packages/extensions/ 下是四个包,其中 cordis-host-runner 提供 ctx.dynamicCordisRunner,用 node:vm 跑模型自己写的动态包。它的 README 与 tool-cordis 的 README 都写了同一句话:这个沙箱隔离了全局对象,但不是安全边界;宿主 realm 的辅助函数可达,因此加载它要像授予 bash 权限一样审慎。配置表只有一个字段 vmTimeoutMs,默认 5000(源码 packages/extensions/cordis-host-runner/src/index.ts 里是 z.number().min(1).default(5000)),而且 README 的 Known Limitations 自己写明它只约束同步求值,异步的宿主半体会逃出这个界。
Claude Code 文档在命令钩子上挂的 Warning 是同一个意思:命令钩子以你的完整用户权限执行 shell,能改、能删、能读你账号可及的任何文件。Pi 的扩展位置表上面那条 Security 提示也一样:扩展以你的完整系统权限运行、可以执行任意代码,只装可信来源的。三家都没打包票,读者也不该替它们打。
顺手记一处仓库内不一致:packages/extensions/tool-cordis/README.md 的 Config 段说 vm 求值边界 vmTimeoutMs 与浏览器确认窗口 ackTimeoutMs 都属于 runner 服务;而 cordis-host-runner/README.md 的 Config 表只有 vmTimeoutMs 一行,并明写「One field is all there is」,理由是运行请求要等人回应、这趟往返没有自己的截止时间。我们在仓库里 grep ackTimeoutMs,只命中那两处 README 文本,源码里没有对应实现。以实读源码为准,这里说完就停。
所以什么时候会咬到你
手上已经有一套 .claude/hooks.json,想搬到 dsh 上跑:先拿四条对一遍自己的脚本——是不是全是 shell command 型、有没有依赖 updatedInput 生效、有没有依赖 allow 做预批准、有没有依赖同一事件上多个钩子并行。四条里中任意一条,桥就不是等价替换。
要在 dsh 上写新东西:桥的 README 已经把话说死了,走原生 Cordis 插件挂到同一批拦截点,别绕 shell。
想让模型自己临时造一个扩展:这条路只在 dsh 的 packages/extensions/ 里看到对应机制:tool-cordis 的 README 列的模型可见工具是 cordis_define / cordis_run / cordis_stop / cordis_undefine 四个动作动词,外加一个只用来查看的 cordis_inspect。同一份 README 写明动态包只活在共享的 DSH 进程内存里,不创建 Plugin 文件、不安装包、不改 cordis.yml 或个人/项目配置,重启不留存也不会自动提升为正式插件;每个动词都是 session 作用域的,包只在定义它的那个会话里可见可控。这一点我们在 Claude Code 官方文档与 Pi 文档里没有找到对应说明,不比。
在意策略钩子会不会失效:Claude Code 那两条「exit 1 不阻断」「超时不阻断」是它自己文档写出来的行为,不是缺陷推断;Pi 那条「改完不再校验」也是它自己写的。这类句子才是三家文档里最值钱的部分——它们划的就是各自愿意负责到哪里。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
文中涉及的 Claude Code 内容依据其官方文档(code.claude.com/docs)整理,该产品闭源,本文不推断其实现;
Codex 依据 github.com/openai/codex 快照 c6058cc、Pi 依据 github.com/earendil-works/pi 快照 027a5847 整理。
本文只对照各方公开写明的机制,不对三者做优劣排名。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。