它是什么
Mod 是一个带 hooks module 的插件:hooks/hooks.json 里的 modules 指向一个 JS/TS 文件,Claude Code 在事件发生时调用其中的函数。每个 hook 拿到 ($, e, next),可以观察(Observe)、改写(Rewrite)或代答(Answer)。 官方文档
Mods 让插件里的函数直接跑在 Claude Code 进程内:能看、能改、能代答工具调用、提示词、命令、回合和界面。本页按「先判断要不要用 → 装之前查什么 → 怎么写 → 多个 Mod 与权限怎么裁决 → 坏了怎么查」的顺序整理,所有事实逐条对照官方页面,二手说法单独标注。
读完这一屏就能决定要不要继续往下看。
Mod 是一个带 hooks module 的插件:hooks/hooks.json 里的 modules 指向一个 JS/TS 文件,Claude Code 在事件发生时调用其中的函数。每个 hook 拿到 ($, e, next),可以观察(Observe)、改写(Rewrite)或代答(Answer)。 官方文档
settings hook、Skill、状态栏、MCP server 都在 Claude Code 外面工作;Mod 在里面,所以能画界面、改内置界面、按住一次工具调用去问用户、把一次请求换到别的模型、在没有 Claude 回合时响应 /命令。 官方文档
Mod 以安装者的用户权限读写文件、启动进程、发网络请求,能在权限弹窗出现之前批准或拒绝工具调用。Mod 启动的进程不受 Claude Code 沙箱约束。 官方文档
在没有托管设置、也没用 Team/Enterprise 登录的机器上,内置守卫不加载,此时 Mod 的 tool.check 能批准 deny 规则拒绝的调用。deny 规则压过 Mod 只在守卫加载的环境里成立。 官方文档
hook 抛错、超过 10 秒或返回形状不对时会被跳过,事件继续往下走。拦截类 hook 不加 .catch,一出错命令就照常执行。 官方文档
中文社区流传的解读里,有几处与官方文档不符或没有官方出处(源码「全部公开」、claude -p 不加载、提示词 token 减少 26.6% 等)。逐条对照见更正记录。
事件和方法会随版本变化。官方的说法是:文档与类型声明文件不一致时,以你本机生成的 .claude-plugin/types/claude-code/index.d.ts 为准。本页所有 API 名称以 v2.1.287 文档为基线。
先确认 Mod 是不是对的工具,再确认它在你用的那个界面里能不能跑、能不能画。
表格较宽,可在表格区域内左右滑动。
| 维度 | Mod | Settings hook | Skill | MCP server |
|---|---|---|---|---|
| 是什么 | 插件里的函数,Claude Code 在自己进程内调用 | 生命周期事件上运行的 shell 命令、HTTP 请求或 prompt | 一份 Claude 会读的 SKILL.md 指令 | 给 Claude 提供工具的外部进程或服务 |
| 能改什么 | 工具调用、提示词、命令、回合,以及界面画什么 | 工具调用或提示词是否放行、工具调用的参数与结果、给 Claude 追加的上下文 | Claude 知道什么、怎么做 | Claude 有哪些工具 |
| 能画界面吗 | 能 | 不能 | 不能 | 不能 |
| 你要写什么 | JavaScript 或 TypeScript | 一个脚本加一条 settings.json 配置 | Markdown | 任意语言的 server |
| 什么时候选它 | 想要一个面板、输入框上方的信息带、自定义命令,或要改写某个事件 | 已有脚本,只想拦截、放行或记录某个事件 | 总在反复粘贴同一段指令 | Claude 需要访问外部系统 |
注意第二行:settings hook 也能改工具调用的参数和结果、追加上下文,「旧 hook 只能拦截」的说法过头了。官方还补了一句:一个插件可以同时装下这四样。
开面板、在输入框上方画信息带、加按钮和输入框。
工具行、spinner、Claude 向你提问的对话框。权限弹窗除外,它不能被改样式。
按住工具调用去问用户、不跑工具直接给结果、把某一次请求发给另一个模型。
自定义 /命令 直接跑代码,不启动 Claude 回合,Claude 正在干活时也能用。
同一个文件里的变量,所有 hook 都能读写。
Bash(npm test)),不用写代码。「hook 是否执行」和「界面是否显示」是两件事,分开看。表格可左右滑动。
| 环境 | hook 是否执行 | 界面是否显示 |
|---|---|---|
| 终端 CLI | 是 | 是 |
| 桌面 App 的 Code 标签(非 WSL) | 是 | 是,仅终端可用的元素除外 |
| 桌面 App 的 WSL 会话 | 否 | 否 |
| VS Code 扩展聊天面板 | 是 | 否 |
claude -p 与 Agent SDK | 是 | 否 |
| Remote Control | 在本机执行 | 画在本机终端 |
| 云端会话 | 插件可达时执行 | 否 |
会画界面的 Mod 可以检查自己跑在哪个应用里,在画不出来的地方退回成 transcript 里的一行字或命令的文本回复。
装一个第三方 Mod,相当于让一段别人的代码以你的身份常驻在 Claude Code 里。先知道它能碰什么,再知道怎么让它停下。
Mod 只能通过 mods API(代码里写作 $)触达文件、进程和网络,而且必须把每个调用写全,所以 Claude Code 能在安装前静态列出来:
claude plugin validate ./some-mod
❯ ./register.js hooks: session.start, tool.call, ui.render{component=Pane}
❯ ./register.js calls: $.fs.read, $.http.fetch, $.store.set, $.ui.open
hooks: 是它接收的事件,calls: 是它调用的方法。读写环境变量会多出 env reads: / env writes: 行。写法让这条命令读不出来的 Mod,Claude Code 会拒绝加载。
$.fs.read / $.fs.write读写该用户能碰到的任何文件。即使 Read(.env) 被 deny,Mod 仍能用 $.fs.read 读它。
$.process.run / $.process.spawn以该用户身份启动程序,沙箱和网络策略都不覆盖这些程序。
$.http.fetch发网络请求。组织关闭 web fetch 时会被拒。
$.env.get / $.settings.read读环境变量与设置,里面可能有 API key。
$.env.set给 Claude Code 及其之后启动的每个命令、MCP server 设环境变量,能改变那些程序的行为。
$.mcp.call调用已连接 MCP server 的工具,受会话权限规则约束。
$.model.complete用你的套餐或 API key 调模型,会产生用量。
$.prompt.submit提交提示词,而且可以当成你本人说的话发出。
$.session.send发消息给另一个会话或子 Agent 的 Claude。
tool.call、prompt.submit:看得到每一次工具调用和每一条提示词,并且能改。tool.check:能在权限弹窗出现前批准或拒绝调用。session.append:能在对话每一行落盘前改写它。ui.render{component=AskUserQuestion}:能重画 Claude 向你提问的对话框。下面每一条都由上文的官方事实推出,顺序是我排的。
claude --version 不低于 2.1.287,否则装了也不加载。hooks: 与 calls: 两行,和它自称的功能对不对得上。一个「只显示 token 用量」的 Mod 不该出现 $.process.run。tool.check:有的话,它能替你批准调用。个人环境没有内置守卫,连 deny 规则都可能被它推翻。next 就返回结果的 tool.call hook:这类 hook 会让非托管的 PreToolUse hook 不再运行,Claude 只看得到它给的结果。.catch:没有的话,它出错时就等于不存在。/plugin → Installed 页里关闭。
claude --safe-mode。所有已安装的 Mod(含组织的)和其他自定义项都不加载;内置 Mod 照跑。适合判断问题是不是某个 Mod 引起的。
在 ~/.claude/settings.json 写 "disableAllHooks": true。副作用:settings hook 和自定义状态栏一起停。组织托管的 Mod 照跑,内置 Mod 照跑,插件里的 skill、命令、agent、MCP server 照常加载。
每个有自己的开关,在 /plugin → Installed → Built-in 里看。cc-plugin-sec-default 不能关。
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS 从 v2.1.287 起被忽略,设成 0 也关不掉 Mod。
Anthropic 可以远程关闭已安装的 Mod,此时本机没有任何设置能重新打开。
得到一个 Mod 有三条路:用内置的、装别人的、自己做。这一节讲前两条。
/plugin marketplace add your-org/my-mods
/plugin install token-weather@my-mods
/reload-plugins
局部片段 · 在 shell 里
claude plugin install token-weather@my-mods --scope user
在 shell 里安装后,已经开着的会话要执行 /reload-plugins,否则下次启动才加载。 官方文档
Anthropic 另有一个社区插件市场的只读镜像仓库 anthropics/claude-plugins-community,安装写法是 claude plugin install <插件名>@claude-community。README 称其中的插件经 claude.ai 提交、通过自动安全扫描并获批分发。 源码仓库 自动扫描不等于有人逐行审过代码,安装前七项检查照做。 编辑推导
claude --plugin-dir ./first-mod
这样加载的目录会被监视,保存文件即热重载。开发时一直用这种方式,不要改已安装的副本(原因见分发一节)。
/plugin:顶部会有一行暗色文字,如 1 mod active · first-mod。这一行不含内置 Mod。--debug 重开会话,在调试日志里找这样的加载行:hooks module first-mod@inline loaded (worker, environment 2, tier user); events: session.start,tool.call,command.run,ui.render
在一个不含 Mod 的目录里运行 claude plugin test,不需要会话。看它打印的消息:
no hooks module to loadMod 可以加载,只是当前目录没有可测的 Mod。
hooks modules are turned off here有设置挡住了:你自己的 disableAllHooks,或组织策略。
hooks modules are turned off in this processAnthropic 远程关闭了已安装的 Mod。
组织设了 allowManagedModsOnly 的情况,这条命令不会报告。
官方表里有六个内置条目,其中 plugin-authoring 只有 skill、没有 Mod 代码,所以不宜说成「六个内置 Mod」。
读取 AGENTS.md。除了读不到该文件的会话,每个会话都开。源码公开,带一个 userConfig 选项。
/diff 现在由它实现,交互式终端会话里开启;关掉后由内置的 /diff 接手。源码公开。
只含一个 skill,教 Claude 怎么写 Mod。除非 Anthropic 远程关闭已安装 Mod,否则开启。
内置守卫,不能关。只在有托管设置的机器或 Team/Enterprise 登录时加载。源码公开。
开了分析的地方就开;可用 DISABLE_TELEMETRY 关。它给其他 Mod 提供可调用的方法和类型。源码公开。
默认关闭;组织可用时出现在 /plugin → Installed → Show disabled。启用:/plugin enable cc-plugin-you-should-know@builtin。
不需要 Node.js、打包器或构建步骤,Claude Code 直接加载 .js 和 .ts。下面是官方教程的 first-mod:数工具调用次数,显示在 spinner 旁,并加一个 /tally 命令。
first-mod/
├── .claude-plugin/
│ └── plugin.json
└── hooks/
├── hooks.json
└── register.js
完整示例 · first-mod/.claude-plugin/plugin.json
{
"name": "first-mod",
"version": "0.1.0",
"description": "Counts Claude's tool calls, shows the count beside the spinner, and adds a /tally command",
"author": { "name": "Your Name" }
}
完整示例 · first-mod/hooks/hooks.json(modules 只列一个路径,有它才算 Mod)
{
"description": "The first-mod hooks module",
"modules": ["./register.js"]
}
完整示例 · first-mod/hooks/register.js(官方教程代码,注释为中文转写) · v2.1.287 文档
// 计数器,下面几个 hook 共用
let calls = 0
// Mod 加载时 Claude Code 调用一次
export function register(on) {
// 会话开始时(以及每次重载时)运行
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'tally',
description: 'Show how many tool calls Claude has made',
})
return next(e) // 观察:原样放行
})
// Claude 每次要用工具之前运行
on('tool.call', async ($, e, next) => {
calls += 1
$.ui.invalidate('ui.render') // 请求重画界面
return next(e) // 观察:原样放行
})
// 只有输入 /tally 时运行(第二个参数是 matcher)
on('command.run', { command: 'tally' }, async () => {
// 代答:不调 next,自己给结果
return { text: 'Claude has made ' + calls + ' tool calls since this mod loaded' }
})
// 每次画 spinner 时运行
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// 改写:把带新 suffix 的事件副本交给 next
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
}
claude --plugin-dir ./first-mod # 交互会话:spinner 显示 Thinking · tool calls: 2…
claude -p "/tally" --plugin-dir ./first-mod # 非交互:输出 first-mod: Claude has made 0 tool calls since this mod loaded
第二条命令本身就说明:用 --plugin-dir 或安装方式加载的 Mod,在 claude -p 里 hook 照常执行。保存 register.js 后,transcript 会出现一行说明 first-mod 已重载并列出它的 hook;每次重载都会重新执行 register,所以 calls 归零。
这个计数记的是「经过这个 hook 的 tool.call 次数」,不是成功执行的工具数:hook 在调用被权限检查之前就加了一,被拒绝的调用也算在内。 编辑推导
做完自己的事,return next(e)。要在事件之后做事,就 const r = await next(e),处理完再 return r。
事件对象是深度冻结的,赋值会抛错。把改过的副本传给 next;也可以改 next 返回的结果再交回去。
不调 next 直接返回结果,链条就此短路:后面的 Mod 和 Claude Code 自己的行为都不再运行。
官方介绍里也用过「watch the event, change it, or take it over」的说法,所以把 Answer 译成「接管」不算错;文档里的正式名称是 Observe / Rewrite / Answer。
on('tool.call', { tool: 'Bash' }, hook) // 字符串:只匹配一个值
on('tool.call', { tool: ['Edit', 'Write'] }, hook) // 数组:匹配其中任意一个
on('tool.call', { tool: /^mcp__github__/ }, hook) // 正则:按模式匹配
事件名可用通配:'classic.*' 匹配所有 settings hook 事件;'*' 匹配除 telemetry 之外的所有事件。同一个事件、同一个 matcher 只能注册一次,无 matcher 地给 session.start 注册两次会导致模块加载失败。
claude plugin validate ./first-mod
❯ ./register.js hooks: session.start, tool.call, command.run{command=tally}, ui.render{component=Spinner}
❯ ./register.js calls: $.command.register, $.ui.invalidate
✔ Validation passed
想挂的事件没出现在 hooks: 行,Claude Code 就不会调用它,常见原因是事件名拼错,报错形如 "tool.calls" is not an event。为了让静态分析读得出来,要遵守这些写法规则: 官方文档
$.store.get('notes')。可以把 $ 传给同文件顶层声明的函数;不能把 $ 或它的命名空间赋给变量、解构,或用计算出来的名字取成员。const ui = $.ui 会报 $.ui is used as a value。on 的事件名必须是字符串字面量,不能用变量或循环。register 里不能再声明名为 on 的变量或参数。claude-code。import();每个文件都写成 ES module,不用 require。从 --plugin-dir 目录加载或重载 Mod 时(Claude 代写的也一样),Claude Code 会在 Mod 目录的 .claude-plugin/types/ 下写入 .d.ts 文件:claude-code/index.d.ts(全部事件、方法和可画元素,每个方法带注释和示例)、claude-code-tools/index.d.ts(内置工具的输入输出)、claude-code-mcp/index.d.ts(已连接 MCP 工具的输入),以及一个 tsconfig.json。在线版本是仓库里的 mods/types/claude-code.d.ts,首行写着生成它的版本。
Claude 靠内置 skill plugin-authoring 来写 Mod,可以让它自动加载,也可以自己输入 /plugin-authoring。流程如下: 官方文档
~/.claude/dev-mods/<会话ID>/<mod名>/。在 default 和 acceptEdits 模式下,~/.claude 是受保护路径,每个文件都要你批准。/plugin 的 Installed 页里能看到它,也能在那里关掉。cleanupPeriodDays 后会被删除。拷到自己的目录,再用 --plugin-dir 加载或放进 marketplace。没人能批准的会话(claude -p、dontAsk 模式)、目录未被信任、Mod 被停用(--safe-mode、--bare、disableAllHooks 或组织策略)。这份清单只针对 Claude 当场写的 Mod,不是对所有 Mod 的限制。
想做某件事时,该挂哪个事件、调哪个方法。代码均取自官方 events 页,注释为中文转写。
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (/git push .*--force/.test(e.command)) {
// 不调 next:命令不执行,也不出现权限弹窗
return { deny: 'Force pushes are not allowed in this repository. Push to a new branch instead.' }
}
return next(e) // 其他命令继续走权限检查,然后执行
})
tool.call 覆盖子 Agent 的调用和 MCP 工具。deny 的文字会作为工具结果交给 Claude,所以要写成 Claude 能照做的指令。想自己给结果而不跑工具,返回 { result: '...' };想重试,就再调一次 next(e)。
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (!/\bgit\s+push\b.*--force/.test(e.command)) return next(e)
let answer = 'Refuse' // 默认答案是拒绝
try {
// 工具调用停在这里,直到用户选一项
answer = await $.ui.ask('Run this command? ' + e.command, ['Run it', 'Refuse'])
} catch {
// 用户关掉了提问,或这是没人可问的 claude -p
}
if (answer !== 'Run it') {
return { deny: 'The user declined this command. Ask before trying a different approach.' }
}
return next(e) // 用户同意后,常规权限检查照样进行
})
$.ui.ask 用的是 Claude 向你提问的那个对话框,选项之后还会多出「输入其他答案」和 Chat about this 两行。等待时间放在 mods API 调用里不计入 10 秒限制;自己 await 的 promise 会计入,而超时的 hook 会被跳过,被按住的命令就会执行。
on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
// 权限规则和 settings hook 已经得出的决定:'allow'、'ask' 或 'deny'
const decided = await next(e)
if (!e.input.command.includes('git push')) return decided
const branch = await $.process.run(['git', 'branch', '--show-current'])
if (branch.stdout.trim() !== 'main') return decided
// reason 是写给 Claude 的指令:改从 main 以外的分支推送
return { decision: 'deny', reason: 'Push from a branch other than main' }
})
这段代码只在当前分支是 main 时拒绝;reason 保留官方原文,它是一句祈使句,意思是「请从 main 以外的分支推送」。官方自己的提醒:这个 hook 只是匹配命令文本,应把它当成给 Claude 的提醒;要对所有人禁止推 main,去 Git 托管平台上保护分支。
next({ ...e, text: newText }),transcript 里显示新文本。
next({ ...e, context: [...(e.context ?? []), extraText] })
{ drop: 'the reason' }
其余 Claude 会读到的内容由别的事件管:prompt.section(系统提示词的每一段)、prompt.context(随第一条消息发送的上下文)、skill.prompt(skill 的正文)。这些 hook 产出的文字如果每次请求都变,会让 prompt cache 失效。
turn.step 是流式事件,hook 要写成异步生成器
on('turn.step', async function* ($, e, next) {
const result = yield* next(e) // 发出请求,边流边转发,拿到最终结果
if (result.usage) {
$.ui.log('cache read ' + result.usage.cache_read_input_tokens + ' · wrote ' + result.usage.cache_creation_input_tokens)
}
return result
})
一个带工具调用的回合有多次请求,每次都触发 turn.step,子 Agent 的请求也算(看 e.agentId)。把某次请求发给别的模型:next({ ...e, model });也可以不调模型直接代答。turn.complete 里返回 { text: '...' } 会在回答下方显示一行。
在 session.start 里 $.command.register(...),再用带 matcher 的 command.run 代答。
挂 ui.render 并指定渲染位(如 Pane、AbovePrompt),用 $.ui.open 打开面板,数据变了调 $.ui.invalidate。
对 Spinner、ToolUse、AskUserQuestion 等渲染位挂 ui.render,把改过 props 的事件交给 next。
$.model.complete({ model, system, prompt }),maxTokens 默认 1024。
$.clock.every、$.clock.after。hooks module 里没有 setTimeout。
$.ui.log(transcript 里的暗色行,Claude 不读)、$.ui.toast(默认 4 秒)、$.ui.status。
每个 settings hook 事件都有对应的 classic.<事件名>,如 classic.Stop;e 就是 settings hook 从 stdin 读到的 JSON。
把上下文占用画成「天气」信息带,阈值为 25%、50%、75%、90%。
在递归强制删除、git 硬重置、git clean、强制推送、数据库迁移这类命令执行前先做 dry run,再让用户选 Proceed 或 Cancel。教程原话是「安全网,不是权限系统」:命令替换、别名、脚本都能绕过。
记录 Edit 和 Write 并支持回放,只观察,不改任何东西。
同一个事件上的所有 hook 串成一条中间件链。谁在外层、谁的决定是终局,决定了你的守卫到底有没有用。
sec-default@builtin(加载时)、prependPlugins 列出的 Mod、其他算作组织的 Moddependencies 列出的 Mod 之前;同一模块内按 on 的调用顺序appendPluginsnext 到达这里事件由外向内传,结果由内向外回。最外层的 Mod 最先看到事件、最后看到结果,并决定里面的要不要运行;靠后的 Mod 无法阻止靠前的 Mod 看到事件。 官方文档
tool.call hook 按四层顺序运行。
终止分支:任何一个 hook 不调 next 就返回 { deny } 或 { result },后面的 Mod、非托管 PreToolUse hook、权限检查和工具本身都不再运行,也不出现权限弹窗。next,进入 Claude Code 自身行为:其他 settings 文件和插件 hooks/hooks.json 里的 PreToolUse hook 运行,权限规则给出 allow、ask 或 deny。tool.check 链运行,next(e) 拿到上一步的决定,hook 可以原样返回,也可以换成另一个决定。tool.call 链由内向外返回,每个 await next(e) 的 hook 都能看到并改写结果。tool.check 能推翻什么 官方文档能。Mod 可以直接批准本该弹窗询问的调用。
能。包括你自己在 settings.json 里写的 hook。
不能,它是终局。
Mod 批准的调用不经分类器检查就执行。
有托管设置的机器,或用 Team/Enterprise 登录时:deny 规则默认压过 Mod,组织可用 allowModsToOverrideDenyRules 放开。其他任何环境:Mod 可以批准 deny 规则拒绝的调用。
$.fs / $.processdeny 规则和托管 hook 只管 Claude 的工具调用,管不到 Mod 自己发起的文件和进程操作。
auto 模式里,如果 hook 在分类器审过之后又改了工具调用的输入,调用会被拒,理由是 a hook changed this call's input after the model wrote it。改输入的可能是 Mod 的 tool.call、turn.step hook,也可能是 PreToolUse settings hook,消息不会说是哪一个。 官方文档
tool.check 留给「取决于当时状态」的判断。 官方文档git push -f 这样的写法。真正的边界放在 Git 托管平台的分支保护、托管设置这类 Mod 之外的地方。 官方文档.catch 让它失败时拒绝,否则 hook 一出错就被跳过。$.ui.ask 里,并把默认答案设成拒绝。deny 文案写给 Claude 看:说清不能做什么、该改做什么。--safe-mode、worker 崩溃三次、Anthropic 远程关闭、hook 被跳过,都会让它消失。.catch 只保护那一个 hook,不是系统级保证。 编辑推导写法最简单(如 let calls = 0)。每次重载都重新初始化,热重载、/reload-plugins 后归零。
$.state会话内保留,重载不丢。/clear、/resume、/branch 会把它重置为默认值,而且 session.start 不会再触发,要在 classic.SessionStart hook 里把存过的值重新装回来。
$.store跨会话保留,落在 ~/.claude/plugins/store/,总量上限 4 MiB 的 JSON。超过 cleanupPeriodDays 没有读写会被清理。
所以「跨重载存数据」用 $.state 就够;「跨会话存数据」才需要 $.store。两者不要混为一谈。
$.clock.sleepnext(e) 里花的时间;其他 mods API 调用里花的时间(如 $.ui.ask 等用户、$.process.run 跑进程)另外:.catch 处理函数只有 1 秒;所有 session.end hook 合计 1.5 秒;$.process.run 默认 30 秒、最长 10 分钟。 官方文档
没有 .catch 的 hook 抛错、超时或返回形状不对时,取决于它是否已经调过 next:
next 之前失败这个 hook 被跳过,由下一个处理者接手。对拦截类 hook 来说,这等于放行。
next 返回之后失败next 的结果照常生效,不会有任何东西再跑一遍。
会话不会因此中断,只留一行记录,如 my-mod: tool.call hook skipped: threw Error: boom。要注意「放行」的准确含义:是这个失败的 hook 不参与了,其余 Mod、权限规则、settings hook 仍按原样运行。
guard 是你的 hook 函数)
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
// next.error.kind 是 'throw' 或 'timeout'
return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }
})
所有已安装的 Mod 共用一个 worker 线程。 官方文档
first-mod was unloaded: it crashed the hooks worker。hooks: mods that run in the hooks worker are off for this session: it crashed 3 times。执行 /reload-plugins 恢复。claude plugin test 在 shell 里运行,不需要会话、登录或网络。测试文件以 .test.ts 结尾,放在插件目录内任意位置,从 claude-code/testing 导入测试工具。有测试失败时退出码为 1,可以接进 CI。
import { expect, test } from 'claude-code/testing'
test('/tally reports the tool calls the mod has seen', async ($, on) => {
// 替 Claude Code 回答每次工具调用,所以不会真的跑工具
on('tool.call', () => ({ result: 'ok' }))
// 触发两次工具调用,Mod 的 tool.call hook 会计数
await $.tool.call({ tool: 'Bash', command: 'ls' })
await $.tool.call({ tool: 'Read', file_path: 'README.md' })
// 运行 /tally,检查 hook 返回的文字
const answer = await $.command.run({ command: 'tally', args: '' })
expect(answer.text).toBe('Claude has made 2 tool calls since this mod loaded')
})
写测试时最容易踩的规则: 官方文档
$ 站在 Claude Code 的位置上,不是 hook 拿到的那个 mods API;on 用来注册 stub,替 Claude Code 作答。$ 之前注册。session.start 不会自动触发。hook 依赖它做的准备时,要先手动触发,并给 command.register 之类的调用配上 stub。{ value: ... };事件的 stub 返回该事件自己的结果形状,如 { result: 'ok' }。让调用失败用 { deny: '原因' }。mock.clock(on) 拨时间;存储和环境变量有 mock.store、mock.env。$.ui.mount 画出某个渲染位,再用 press、input、select、find 操作和断言。它只检查 hook 返回的树是否合法,不检查实际画出来的样子,新布局还得进真实会话看一眼。/plugin 命令安装和更新;要给别人用就放进 marketplace。claude- 开头的名字过不了 claude plugin validate。--plugin-dir 的工作目录。Mod 的模块或 hook 出错时会被跳过、会话照常继续,所以「坏了」和「没反应」看起来一样。先做两个检查,再按症状找。
claude plugin validate ./first-mod。能查出拼错的事件名、有问题的 manifest、读不了的模块,不需要开会话。
热重载插件目录的交互会话(--plugin-dir,或为 Claude 代写的 Mod 开了热重载):transcript 里的暗色行。其他交互会话:只在调试日志里,用 claude --debug 启动。claude -p 加 --plugin-dir:stderr。
mods active 那一行里没有它hooks module <名字> not loaded:,冒号后面是理由。hooks: 行hooks/hooks.json 没有 modules 键或拼错了。"modules": ["./register.js"]。hooks module did not load:options do not fit plugin.json userConfiguserConfig 的定义,或必填项没填。settings.json 里对应的 pluginConfigs 条目。--safe-mode 启动。hooks modules are turned off for installed plugins in this processAnthropic 远程关闭,本机设置无法恢复。
disableAllHooks in managed settings组织关掉了已安装插件的 hook。
only managed plugins and built-in plugins run设了 allowManagedHooksOnly,或在托管设置之外的设置文件里设了 disableAllHooks。
installed plugins that are not managed load no hooks module in this mode (--bare)用了 --bare 启动。
another plugin of that name loads first两个插件重名,用的是托管的那个或先加载的那个。
mods are limited to your organization's by policy (allowManagedModsOnly)内置守卫的消息:组织只允许自己的 Mod。
hook skipped:registered /tally but no command.run hook answered itcommand.run hook、matcher 写的是别的命令、hook 返回了 next(e),或 hook 被跳过。command.run 的 hook skipped 行。was unloaded: it crashed the hooks workermods that run in the hooks worker are off for this session: it crashed 3 times/reload-plugins。a hook changed this call's input after the model wrote ittried to lift a deny rule in your settingstool.check 批准了 deny 规则拒绝的调用,守卫保持拒绝。the deny rules in your settings could not be checked for this call, so it is refusedui.render (Pane) refused: ...; the engine drew its own。$.ui.open 执行了但没有面板isPlaced。focus: true 打开。--plugin-dir 指向工作目录开发。$.state 或 $.store。/clear、/resume、/branch 之后数值重置$.state 重置为默认值,且不再触发 session.start。classic.SessionStart hook 里重新装载。reload failed, the previous version stays loaded:claude --debug-file ./mod-debug.log --plugin-dir ./first-mod
tail -f ./mod-debug.log | grep first-mod # 另开一个终端
想往调试日志里写自己的行:$.ui.log('message', { to: 'debug' })。
可折叠的速查表,以 v2.1.287 文档为基线。精确字段以本机类型声明文件为准。
next 的附属成员export function register(on, options);注册:on(event, matcher?, hook),返回值可接 .catch(handler);hook 形如 async ($, e, next) => ...。next.signal、next.origin(含 plugin、tier)、next.budget(含 ms、remainingMs)。next.to(e, tier):仅 prepend / append 层的 Mod 可用。next.error、next.called:仅在 .catch 处理函数里有。工具
tool.call、tool.check、tool.describe
提示词与 Claude 读到的内容
prompt.submit、prompt.fill、prompt.suggest、prompt.edit、prompt.compose、prompt.section、prompt.context、prompt.attachment、skill.prompt、attribution.text
命令与配置
command.run、command.describe、config.set、config.describe
回合
turn.start、turn.step(异步生成器,可换模型)、turn.complete
会话
session.start、session.end、session.compact、session.receive、session.send、session.append、session.attach、session.detach、session.measure
子 Agent
agent.offer、agent.spawn
界面
ui.render、ui.resolve、ui.press、ui.input、ui.select、ui.focus、ui.scroll、ui.close、ui.message
其他 Mod 与引擎
plugin.register、engine.create
遥测
telemetry.log、telemetry.mark。'*' 不含它们,要按名字或 'telemetry.*' 挂。
settings hook 事件
classic.<事件名>,如 classic.Stop、classic.SessionStart;通配 'classic.*'。
每个 mods API 调用
方法名去掉 $. 就是事件名,如 fs.write。组织的策略 Mod 可以据此记录或拒绝其他 Mod 的调用。
$ 的 21 个命名空间下面的分组是为了方便查找做的编辑归类,不是官方分类。 编辑推导
界面与交互
ui、command、prompt、audio
对话与 Agent
turn、session、agent、tool、mcp
模型
model(complete、fork、classify)
系统
fs、process、http、env、clock
状态与配置
state、store、config、settings
插件与遥测
plugin、telemetry
hooks module 的运行环境是受限的:没有 Node API、没有 setTimeout、没有自带的网络和文件访问,一切经由 $。这是运行环境的形态,不是安全沙箱。
渲染位
Pane、AbovePrompt、UserMessage、AssistantMessage、ToolUse、ToolResult、ToolGroup、CommandOutput、AskUserQuestion、Spinner、SessionMode、PromptHint
仅终端的渲染位
ToolProgress、TurnDuration、InfoNotice
元素
Box、Text、Button、Link、Code、Markdown、Input、Select、Client
平台专属元素
仅桌面:Svg。仅终端:Raster、Image。
权限弹窗不是渲染位,Mod 改不了。输入框本身不是「完全不能碰」:有 prompt.fill、prompt.suggest 事件,也有 Input 元素可画自己的输入框。
时间
hook 自身 10 秒;.catch 1 秒;session.end 合计 1.5 秒;$.process.run 默认 30 秒、最长 10 分钟;单个测试默认 5 秒、toast 默认显示 4 秒,两者都可用 timeoutMs 改。
大小
$.fs 单文件 4 MiB;$.store 合计 4 MiB JSON;会话消息取最新 4096 条;$.model.complete 的 maxTokens 默认 1024。
频率
重画每秒 10 次;可见的面板和信息带每秒 30 次。
终端宽度
自动打开面板需要至少 144 列;用户自己打开过一次之后是 110 列。这是终端列数,和本页的网页断点无关。
命名
命令、工具、子 Agent 类型和面板的名称不超过 64 个字符,只能用字母、数字、_ 和 -。这条不针对插件名。
allowManagedModsOnly
内置守卫的选项,写在托管设置的 pluginConfigs["cc-plugin-sec-default@builtin"].options 下。用户自带的 Mod(安装的、--plugin-dir 的、Claude 代写的)都不加载;他们的 settings hook、状态栏、/goal 不受影响。
allowModsToOverrideDenyRules
内置守卫的选项。不设时 deny 规则压过用户的 Mod;设为 true 后用户的 Mod 可以批准 deny 规则拒绝的调用。
allowManagedHooksOnly
范围更大:只有组织的 Mod 和内置 Mod 加载,同时挡掉用户自己设置文件里的 hook。
disableAllHooks
范围最大:写在托管设置里时,停掉所有已安装插件的 Mod(含组织自己的)和所有 settings hook,托管的 PreToolUse hook 也不再拦截;自定义状态栏和 /goal 一起停。
disableSideloadFlags
启动时拒绝 --plugin-dir、--plugin-url,并阻止 Claude 代写的 Mod 加载;同时拒绝 --agents 和 --mcp-config。
prependPlugins / appendPlugins
决定组织 Mod 在链上的位置。设了 prependPlugins 就要把 sec-default@builtin 也列进去,否则守卫不加载。只从托管设置读取;仓库的设置文件无效。
什么算「组织的 Mod」
三个条件同时满足:托管的 enabledPlugins 启用了它;托管设置把它的 marketplace 指到本机一个绝对路径的目录;marketplace 用相对路径列出它(就地加载)。从 GitHub、git、URL、npm 拷进缓存的插件一律算用户的。
守卫保护的内容
用户的 Mod 改不了:托管 hook 收到和决定的内容、系统提示词、托管的 CLAUDE.md 等指令、任何 Mod 读到的设置、托管 MCP server 的工具与描述。除此之外不加限制。守卫读不到托管设置时会拒绝所有用户 Mod。
策略 Mod
放在 prependPlugins 的 Mod 可以挂 plugin.register:每个 Mod 加载前都会把 validate 那份清单(e.uses.calls、e.tier)交给它,返回 { refuse: '理由' } 即可拒绝加载。这个检查同样要加 .catch,否则出错时放行。
环境变量与其他设置名:CLAUDE_CODE_PLUGIN_DIRS、CLAUDE_CODE_PLUGIN_DIR_WATCH、pluginConfigs。具体取值见官方 reference 页的 Settings and environment variables 一节。
claude plugin validate <目录>(可加 --strict、--json):静态检查并列出 hooks 与 calls。claude plugin test [目录]:跑测试;在无 Mod 的目录里可用来判断 Mod 能否加载。claude --plugin-dir <目录>(可重复):本次会话加载,保存即重载。/reload-plugins:重新加载插件。/plugin:查看、启用、停用;显示 mods active 行。claude --debug、claude --debug-file <路径>:写调试日志。claude --safe-mode:不加载已安装的 Mod 和其他自定义项。这次研究的起点是一篇流传较广的中文解读(公众号文章的 AI 摘要笔记)。下面逐条对照官方资料。每条标两个维度:依据的来源类型,和结论状态。
claude -p、dontAsk 等情况下不加载」--plugin-dir 的 Mod 在 claude -p 和 Agent SDK 里 hook 照常执行,只是不画界面。$.fs、$.process、$.http 以用户权限执行,没有安全沙箱。「没有沙箱」的结论正确。$.state」$.state 是会话级的,/clear、/resume、/branch 会重置它;跨会话要用 $.store。下面这些在这次打开的官方页面里没有找到明确答案,不要当成事实使用:
$.store 里的数据是否立即清除。next.signal 在链上如何传播。Mods reference(as of v2.1.287)
Manage mods for your organization
Configure permissions(Extend permissions with hooks 一节)
公众号文章(2026-10-01)及其 AI 摘要笔记
事实部分全部来自 2026-10-02 实际打开的官方页面;代码示例取自官方文档,只把注释转写成中文,个别做了缩写并已注明。标「编辑推导」的内容是基于官方事实做的归纳,标「二手转述」「待核实」的没有一手出处。全文没有在真实会话里跑过任何一个 Mod。