Claude Code Mods 不是 Hooks 升级版:进程内中间件的机制与实测
Claude Code 从 v2.1.287 起默认开启了一个新扩展点:mods。Claude Code 早就有 hooks——在 settings.json 里配个命令,工具调用前后跑一下——两者名字像,官方文档里也都叫 hook,很容易把 mod 当成 hooks 的升级版。我查了文档,又自己写了一个小 mod 跑了一遍,结论是:mod 是进程内的中间件,不是 hooks 的升级版。
这篇记下我是怎么得出这个结论的:mod 的机制、实测踩到的坑、它和 hooks 到底差在哪。
Mod 是什么
一句话:mod 是一种 Claude Code 插件,里面有一段 JS/TS 代码,Claude Code 在自己的进程里直接调用它的函数。
原来的 hooks(官方现在改叫 settings hook)是另一种思路:事件发生时,Claude Code 在外面跑一个 shell 命令(或发个 HTTP 请求),通过 stdin / stdout 传 JSON。
一个是进程内的中间件,一个是进程外的回调。后面所有的差别,都从这里来。
机制
三个文件
first-mod/
├── .claude-plugin/plugin.json # 普通插件清单
└── hooks/
├── hooks.json # { "modules": ["./register.js"] }
└── register.js # 你的代码
hooks.json 里有 modules 这个字段,这个插件就是 mod。不需要 Node、打包器或构建步骤,.js / .ts 直接加载。
on(事件, 过滤条件?, hook) 和 ($, e, next)
register.js 导出一个 register(on),每调一次 on 就给一个事件挂一个函数。每个函数都拿到三个参数:
$:Claude Code 给的接口。画界面、注册命令、调模型、读写文件、起进程,都只能通过它。代码运行的环境里没有 Node,也没有 DOM。e:事件本身,比如这次工具调用的工具名和参数。next(e):交给下一个 mod,最后交回 Claude Code 自己处理。
熟悉 Express / Koa 的话,这就是洋葱中间件。一个 hook 对事件能做三件事:
- 观察:记一下,
return next(e)原样放行; - 改写:
return next({ ...e, 改过的字段 }); - 作答:不调
next,自己给结果,比如返回{ deny: '原因' }拒绝这次工具调用。
能挂的事件很多:工具调用、提交的提示词、系统提示词的各个分段、对话里新增的每一行、回合的开始和结束,以及界面上每个组件的绘制(ui.render)。最后这一条是 hooks 完全做不到的。
实测:写一个 turn-meter
我写了一个小 mod,把上面几种用法都用上:
- 观察:统计每轮耗时和工具调用次数;
- 改写:在 Claude Code 自己的 spinner 后面加上「本轮工具调用 N 次」;
- 作答:不让 Claude 改
.env,再加一个/tally命令,不经过模型直接回答; - 画界面:回合结束后,在输入框上方显示一条状态。
// 观察 / 改写 / 作答 三种用法各演示一次
const PROTECTED = /(^|\/)\.env(\.|$)/
let calls = 0 // 本轮工具调用次数
let total = 0 // 本会话累计
let startedAt = 0
let last = null // 上一轮 { seconds, tools }
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'tally', description: '本会话工具调用统计' })
return next(e)
})
// 观察:记录本轮开始时间
on('prompt.submit', async ($, e, next) => {
startedAt = await $.clock.now()
calls = 0
return next(e)
})
// 观察 + 作答:计数;对 .env 的编辑不调 next,直接拒绝,工具不会执行
on('tool.call', async ($, e, next) => {
calls += 1
total += 1
$.ui.invalidate('ui.render')
if ((e.tool === 'Edit' || e.tool === 'Write') && PROTECTED.test(e.file_path)) {
return { deny: `turn-meter: ${e.file_path.split("/").pop()} 受保护,不允许修改` }
}
return next(e)
}).catch(($, e, next) => (next.called ? next(e) : { deny: 'turn-meter: 守卫出错,按拒绝处理' }))
on('turn.complete', async ($, e, next) => {
last = { seconds: Math.round(((await $.clock.now()) - startedAt) / 1000), tools: calls }
$.ui.invalidate('ui.render')
return next(e)
})
// 改写:在 Claude Code 自己的 spinner 后面加上计数
on('ui.render', { component: 'Spinner' }, async ($, e, next) =>
next({ ...e, props: { ...e.props, suffix: ` · 本轮工具调用 ${calls} 次…` } }),
)
// 画界面:输入框上方的状态条
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
if (e.props.hasSurvey || last === null) return next(e)
const { Box, Text } = $.ui.resolve(e)
return (
<Box>
<Text color="cyan">⏱ 上一轮 {last.seconds}s</Text>
<Text dimColor> · 工具调用 {last.tools} 次 · 本会话累计 {total} 次</Text>
</Box>
)
})
// 作答:/tally 命令,不经过模型
on('command.run', { command: 'tally' }, async () => ({
text: `本会话 Claude 共调用工具 ${total} 次`,
}))
}
写完先跑 claude plugin validate,它不运行代码,只做静态分析,列出这个 mod 挂了哪些事件、调了哪些接口:
❯ ./register.jsx hooks: session.start, prompt.submit, tool.call, turn.complete, ui.render{component=Spinner}, ui.render{component=AbovePrompt}, command.run{command=tally}
❯ ./register.jsx gating hook with .catch: tool.call
❯ ./register.jsx calls: $.clock.now, $.command.register, $.ui.invalidate, $.ui.resolve
✔ Validation passed
然后用 claude --plugin-dir ./turn-meter 起一个会话,在一个只有 README.md、calc.py、.env 的空项目里让 Claude「读一下 README 和 calc.py,给 calc.py 加个 sub 函数,再把 .env 里的 API_KEY 改成 test」。
回合进行中,spinner 后面多了计数;calc.py 正常改了,改 .env 那一步被 mod 拒绝:

回合结束,输入框上方出现状态条;/tally 立即返回:

.env 的内容没有变。
踩到的坑
- **写 JSX 的文件后缀必须是
.jsx/.tsx。**我第一次写成register.js,validate 直接报解析失败。 - **同一个事件不带过滤条件只能注册一次。**我本来把「拦
.env」和「计数」写成两个tool.callhook,validate 不让过,只好合并。 - **拒绝理由会原样显示在屏幕上,也会原样发给模型。**我第一版带了完整路径,结果路径直接上了屏幕,后来改成只显示文件名。
- **这个守卫有漏洞。**它只管 Edit 和 Write,模型要是用 Bash 跑
sed -i,照样能改.env。这次模型读到拒绝理由后主动说「没有用 Bash 绕过」——但那是模型自觉,不是 mod 拦住的。真要守住,Bash 也得拦。 - **计数放在模块变量里,mod 热重载后会归零。**要跨重载保存,得用
$.state。
和 Hooks 到底差在哪
| Mod | Settings hook | |
|---|---|---|
| 怎么运行 | 进程内的函数调用 | 进程外跑命令 / 发 HTTP |
| 能改什么 | 工具调用、提示词、命令、回合,还有界面 | 放行或阻止,改工具参数和结果,给 Claude 加上下文 |
| 能画界面 | 能 | 不能 |
| 状态 | 同一个文件里的 hook 共享变量 | 每次都是新进程 |
| 组合方式 | next 串成一条链 |
各自独立执行 |
| 用什么写 | JS / TS | 任何语言的脚本 |
怎么选,官方文档的建议是:
- 已经有个脚本,只想在工具调用前拦一下、记个日志——继续用 settings hook,简单,换语言也不受限。
- 要面板、要自定义命令、要改写事件、要在几个事件之间攒状态——用 mod。
两者也不冲突,可以同时存在,mod 甚至能挂到 settings hook 的事件上。
Anthropic 自己也在用 mod:/diff 面板、AGENTS.md 支持、埋点上报,都已经改成了内置 mod,源码是公开的。
社区现在怎么用
mod 没有单独的市场,就是插件 marketplace,任何带 marketplace.json 的 GitHub 仓库都能 /plugin marketplace add。精选的有 best-claude-code-mods(43 个,每个都过了 validate 并固定在审查过的 commit),大目录有 awesome-claude-code-mods,GitHub 话题 claude-code-mod 下有五百多个仓库。官方给了三个示例:token-weather、blast-radius、replay-theater。
按数量排,大家主要拿 mod 做这几类事:
- 看用量:上下文占用、5 小时和 7 天额度、缓存倒计时、费用,比如 cctop、claude-flightdeck;
- 看子 agent:agent-flow 用树状图显示正在跑的子 agent;
- 提效率:每轮结束给出几个下一步、不进主上下文的侧边聊天(aside)、上下文满了自动交接(ctx-handoff-mod);
- 阅读和渲染:Markdown 预览、Mermaid、回复重新排版(prismantis);
- 安全守卫:拦危险命令(blast-radius)、CI 没过不让合并、模型读到之前把密钥换成占位符(claude-code-redact);
- 等待时的娱乐:贪吃蛇、小游戏(cc-arcade)、像素宠物、呼吸练习。
装之前想清楚
mod 没有沙箱,以你的身份运行:能读写你的文件、起进程、联网、读环境变量里的密钥、看到你的每一条提示词,还能替你批准工具调用。有人扫过社区里的 359 个 mod,其中 127 个会启动进程、75 个会联网、111 个能看到每条提示词。内置的安全守卫只在组织托管设置下才加载,个人用户的 deny 规则挡不住 mod 自己去读 .env。
所以装之前,至少跑一遍 claude plugin validate,看它挂了哪些事件、调了哪些接口。一个显示时钟的状态条,没理由去读你的文件。
结论:mod 是进程内中间件,不是 hooks 升级版
把 mod 理解成「更强的 hooks」,会在三个地方想错:
- **它没有取代 hooks。**settings hook 还在,官方仍然推荐它做「用现成脚本拦一下、记个日志」这类事;mod 反过来还能挂到 settings hook 的事件上。两者是并列的两层,不是新旧两代。
- **它的位置不一样。**hooks 站在 Claude Code 外面,事件发生时被叫起来,回一个决定;mod 站在 Claude Code 里面,事件要从它手里经过——它可以只看、可以改了再往下传、也可以自己接下来不往下传,连界面怎么画都要过它一遍。这是中间件的位置,不是回调的位置。
- **它的信任模型不一样。**hooks 是你自己写、自己配的脚本;mod 是装进进程里、和 Claude Code 一样权限的代码,还能改写别的事件、替你批准工具调用。装一个 mod,更接近装一个 IDE 扩展,而不是加一条配置。
所以选型时别问「要不要从 hooks 升级到 mod」,而是问:**这件事需要站在 Claude Code 外面回个话,还是需要站在里面改它的流程和界面?**前者用 hooks,后者用 mod。