小态

Claude Code Mods 不是 Hooks 升级版:进程内中间件的机制与实测

#claude-code#ai

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 对事件能做三件事:

  1. 观察:记一下,return next(e) 原样放行;
  2. 改写:return next({ ...e, 改过的字段 });
  3. 作答:不调 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 拒绝:

回合进行中:spinner 显示本轮工具调用次数,对 .env 的修改被 turn-meter 拦下

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

回合结束:Claude 说明 .env 被拦截,/tally 输出累计次数,输入框上方显示上一轮耗时和工具调用次数

.env 的内容没有变。

踩到的坑

  • **写 JSX 的文件后缀必须是 .jsx / .tsx。**我第一次写成 register.js,validate 直接报解析失败。
  • **同一个事件不带过滤条件只能注册一次。**我本来把「拦 .env」和「计数」写成两个 tool.call hook,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 做这几类事:

  1. 看用量:上下文占用、5 小时和 7 天额度、缓存倒计时、费用,比如 cctop、claude-flightdeck;
  2. 看子 agent:agent-flow 用树状图显示正在跑的子 agent;
  3. 提效率:每轮结束给出几个下一步、不进主上下文的侧边聊天(aside)、上下文满了自动交接(ctx-handoff-mod);
  4. 阅读和渲染:Markdown 预览、Mermaid、回复重新排版(prismantis);
  5. 安全守卫:拦危险命令(blast-radius)、CI 没过不让合并、模型读到之前把密钥换成占位符(claude-code-redact);
  6. 等待时的娱乐:贪吃蛇、小游戏(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。