使用 mod 响应事件
从 mod 处理 Claude Code 事件:观察、重写或回答工具调用、提示和轮次,过滤 hook 处理的事件,并为其他 mod 做计划。
hook 是一个事件处理程序:Claude Code 在命名事件发生时运行的函数。Claude Code 在即将采取行动的每个点触发事件,例如当它运行工具、提交提示、向模型发送请求或启动或结束会话时。你的 hook 在 Claude Code 采取行动之前运行,因此它可以观察事件、重写事件或代替 Claude Code 回答事件。你使用 on(eventName, handler) 注册 hook。
在开始之前,请构建你的第一个 mod。对于每个事件及其确切字段,请参阅参考或阅读你的构建的类型。
hook 如何处理事件
hook 位于事件和 Claude Code 对其采取的行动之间,因此它可以观察事件、重写事件或自己回答事件。它接收三个参数:mods API 作为 $、事件作为 e 和下一个处理程序作为 next。事件的处理程序形成中间件链。next(e) 调用下一个处理程序,这是另一个 mod 的 hook 或链末端的 Claude Code 自己的行为,它解析为结果。你的 hook 对 next 做什么决定了它做以下三件事中的哪一件。
观察事件
要观察事件而不改变它,请执行你的工作并返回 next(e)。此 hook 记录 Claude 即将使用的每个工具:
on('tool.call', async ($, e, next) => {
// 在工具运行之前运行
$.ui.log('Claude is about to use ' + e.tool)
// 原样传递事件
return next(e)
})
在每个工具运行之前,转录中会出现一条暗线,例如 ● my-mod: Claude is about to use Bash,其中 my-mod 是你的插件的名称。工具的运行方式与没有 mod 时相同。
要在事件后采取行动,请 await next(e)、执行你的工作并返回结果。此 hook 在每个工具运行后记录它:
on('tool.call', async ($, e, next) => {
// 让工具运行,并等待其结果
const result = await next(e)
// 在工具运行后运行
$.ui.log(e.tool + ' finished')
// 原样返回结果
return result
})
该行现在出现在每个工具完成后。Claude 读取相同的结果,因为 hook 返回 next(e) 解析的内容。
重写事件
要更改 Claude Code 作用的内容,例如提示的文本,请使用修改后的事件副本调用 next。事件本身是不可变的:它在每个深度都被冻结,分配给字段会抛出错误。此 hook 在发送前修剪每个提示:
on('prompt.submit', async ($, e, next) => {
// 传递事件的副本,其文本已更改
return next({ ...e, text: e.text.trim() })
})
后续处理程序和 Claude Code 接收修剪后的提示,永远看不到原始提示。你也可以更改结果:await next(e),然后返回替换了字段的结果副本。
回答事件
要自己处理事件,请返回结果而不调用 next。这会短路链,因此后续 mod 和 Claude Code 自己的行为不会运行。此 hook 拒绝每个 Bash 命令:
on('tool.call', { tool: 'Bash' }, async () => {
// 没有调用 next,所以命令永远不会运行
return { deny: 'Bash is turned off in this project. Use the file tools.' }
})
当 Claude 尝试 Bash 命令时,命令不会运行,Claude 将 deny 文本读作工具的结果。每个事件都有自己的结果形状,事件参考列出了这些。
过滤 hook 处理的事件
要仅为某些事件运行 hook,请将过滤器作为第二个参数传递给 on。Claude Code 将过滤器称为 matcher。它是一个对象,其字段与事件的字段进行比较,只有当每个字段都匹配时,hook 才会运行。字段可以是值、允许值的数组或正则表达式。
此示例中的每一行都为更窄的工具调用集合注册相同的函数 hook:
// 字符串匹配一个值:仅 Bash 调用
on('tool.call', { tool: 'Bash' }, hook)
// 数组匹配其中任何值:Edit 调用和 Write 调用
on('tool.call', { tool: ['Edit', 'Write'] }, hook)
// 正则表达式按模式匹配:来自一个 MCP 服务器的每个工具
on('tool.call', { tool: /^mcp__github__/ }, hook)
hook 为 Bash、Edit 或 Write 调用各运行一次,为名称以 mcp__github__ 开头的工具调用运行一次。对任何其他工具(如 Read)的调用都不匹配这三个中的任何一个,因此 hook 不会为它运行。
事件名称可以是通配符。'classic.*' 匹配每个设置 hook 事件。'*' 匹配除遥测事件之外的每个事件,你可以按名称或作为 'telemetry.*' 来 hook 这些事件。
为每个 matcher 注册一次事件。如果你为 session.start 调用 on 两次而没有 matcher,模块将无法加载,错误为 on("session.start") is registered twice without a matcher。将你的 mod 在会话启动时执行的所有操作放在一个 hook 中。
Hook Claude 正在做的事情
Hook 这些事件以查看或更改工具调用、提示或轮次。对于每个事件以及 hook 可以返回的内容,请参阅事件参考。
保护或更改工具调用
tool.call hook 看到 Claude 即将使用的每个工具,因此它可以拒绝调用、更改其参数或让其通过。tool.call 在 Claude Code 即将运行工具时触发,包括子代理进行的调用和对 MCP 工具的调用。e.tool 是工具的名称,工具的参数是 e 的字段,例如 Bash 的 e.command。当你调用 next(e) 时,Claude Code 运行权限检查,然后运行工具。
此 hook 拒绝强制推送的 Bash 命令,并告诉 Claude 原因:
// matcher 将 hook 限制为 Bash 调用,因此 e.command 是 shell 命令
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.' }
}
// 每个其他命令都会进行权限检查,然后进行 Bash
return next(e)
})
当 Claude 尝试 git push --force 时,命令不会运行,也不会出现权限提示,因为 hook 永远不会调用 next。Claude 将 deny 文本读作工具的结果,因此将其写成 Claude 可以采取行动的指令。每个其他 Bash 命令的运行方式与没有 mod 时相同。
要在工具运行后采取行动,请 await next(e)、执行你的工作并返回 next 给你的内容。此 hook 记录 Claude 更改的每个 .mdx 文件,使用 $.ui.log,它向转录中添加一条暗线,Claude 不会读取:
on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
// 等待权限检查和工具,并保留它们生成的内容
const result = await next(e)
// 被拒绝的调用返回为 { deny },失败的调用设置了 isError
const changed = !result.deny && !result.isError
if (changed && e.file_path.endsWith('.mdx')) $.ui.log('Claude changed ' + e.file_path)
// 原样返回结果,所以 Claude 读取工具返回的内容
return result
})
Claude 编辑或写入 .mdx 文件后,转录中的暗线会命名该文件。对于另一种文件或被拒绝或失败的调用,不会记录任何内容。Claude 对调用的看法不会改变,因为 hook 返回它接收的结果。
要更改调用,请将更改的参数传递给 next。要重试调用,请再次调用 next(e):看到第一个结果上的 isError 的 hook 可以第二次运行工具并返回该结果。要自己回答调用,请返回带有 result 字段的对象,例如 { result: 'Skipped by my-mod' },而不调用 next。当你这样做时,不会出现权限提示,工具不会运行,因此你返回的结果是 Claude 了解发生了什么的全部内容。
你的组织的托管设置中的 hooks 在任何 mod 的 tool.call hook 之前运行,其中一个的块是最终的。
保持工具调用直到用户决定
hook 可以暂停工具调用并在继续之前询问用户该怎么做。tool.call hook 可以在调用 next 或返回之前 await,工具调用保持待处理状态直到那时。要向用户提出问题,请调用 $.ui.ask。它在 Claude 用来问你的对话框中的编号列表上方显示你的问题,并解析为用户选择的标签。在你的选项之后,对话框添加一行用于输入不同的答案和一个聊天此问题行。
此示例中的 RISKY 模式匹配 rm -r、rm -rf、git reset --hard 和带有 --force 的 git push,它会错过其他拼写,例如 git push -f。此模块在运行与模式匹配的 Bash 命令之前询问:
const RISKY = /\brm\s+-rf?\b|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*--force/
export function register(on) {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
// 让每个其他命令通过而不提问
if (!RISKY.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') {
// 回答而不调用 next,所以命令不会运行
return { deny: 'The user declined this command. Ask before trying a different approach.' }
}
return next(e)
})
}
当 Claude 尝试诸如 rm -rf build 的命令时,问题会出现,命令会等待答案:
- 用户选择 Run it:hook 调用
next(e),通常的权限检查仍然在之后运行 - 用户选择 Refuse:命令不会运行,Claude 读取
deny文本 - 用户输入答案:
$.ui.ask解析为输入的文本。hook 将其与Run it进行比较,因此任何其他文本都会拒绝命令。 - 没有人回答:当用户关闭问题或选择聊天此问题时,
$.ui.ask会拒绝,在claude -p运行中也是如此,因此catch块将答案保留在Refuse
将等待保持在 mods API 调用(如 $.ui.ask)内,因为该时间不计入 hook 的10 秒时间限制。花在等待你自己的承诺上的时间确实计入。Claude Code 跳过超时的 hook,因此保持的命令会运行。
重写或添加到提示
prompt.submit hook 在轮次开始之前看到每个提示,因此它可以重写文本或添加到其中。e.text 是输入的内容。
| 要执行此操作 | 返回此内容 |
|---|---|
| 重写提示。转录中的消息显示新文本。 | next({ ...e, text: newText }) |
| 仅添加 Claude 读取的文本,在提示之后 | next({ ...e, context: [...(e.context ?? []), extraText] }) |
| 停止发送提示 | { drop: 'the reason' } |
此 hook 在提示提及拉取请求时为 Claude 添加当前分支名称:
on('prompt.submit', async ($, e, next) => {
// 原样传递不提及拉取请求的提示
if (!/\bPR\b|pull request/i.test(e.text)) return next(e)
const git = await $.process.run(['git', 'branch', '--show-current'])
// 在 git 存储库外,命令失败,因此没有分支可添加
if (git.exitCode !== 0) return next(e)
// 保留早期 hook 添加的任何上下文,并为 Claude 添加一行
return next({ ...e, context: [...(e.context ?? []), 'Current branch: ' + git.stdout.trim()] })
})
当你发送诸如 open a PR for this change 的提示时,你的消息在转录中看起来相同,Claude 也会在其后读取诸如 Current branch: feature/auth 的行。不提及拉取请求的提示会原样通过,git 不会运行。
其他事件涵盖 Claude 读取的其余内容:prompt.section 用于系统提示的每个部分,prompt.context 用于与第一条消息一起发送的上下文,skill.prompt 用于技能的文本。来自这些 hook 的文本在请求之间更改时会使提示缓存失效。
跟踪轮次
轮次是 Claude 为回答一个提示而做的所有事情。Hook turn.start、turn.step 和 turn.complete 来跟踪一个:
| 事件 | 何时触发 | hook 可以做什么 |
|---|---|---|
turn.start |
轮次开始 | 观察。e.turnId 在其他两个事件中标识轮次。 |
turn.step |
Claude Code 即将向模型发送一个请求。具有工具调用的轮次有多个。e.agentId 为子代理的请求设置。 |
读取每个请求的令牌使用情况,使用 next({ ...e, model }) 将其发送到不同的模型,或在不调用模型的情况下回答 |
turn.complete |
轮次结束,包括用户中断的轮次,其中 e.isAborted 为 true。e.answer 是 Claude 的最终文本,e.durationMs 是花费的时间,e.usage 是轮次的令牌总数。子代理的轮次使用 e.agentId 设置触发它。 |
观察,或返回带有 text 字段的对象,例如 { text: 'Done in 12 seconds' },以在答案下显示一行 |
将 turn.step hook 写成异步生成器,因为事件流。yield* next(e) 在流式传输时转发响应并评估为完成的结果。此 hook 记录每个请求中 Claude API 从提示缓存提供的数量:
// function* 使 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
})
Claude 的响应流式传输到屏幕,就像没有 mod 时一样。每个请求完成后,转录中的暗线给出从缓存读取的令牌数和写入的令牌数。具有工具调用的轮次有多个请求,因此它添加多行。
result.usage 保存 Claude API 为请求报告的四个令牌计数,加上回答的 model:input_tokens、output_tokens、cache_read_input_tokens 和 cache_creation_input_tokens。hook 也为子代理的请求运行,因此当你只想要主对话时检查 e.agentId。
Hook 设置 hook 事件
设置 hooks 是你在设置文件中配置的命令、HTTP、提示和代理 hooks。每个设置 hook 事件,例如 Stop、SessionEnd 或 PostToolUse,也是一个名为 classic. 后跟设置 hook 事件名称的事件,例如 classic.Stop。e 是设置 hook 在 stdin 上接收的 JSON,包括 transcript_path。
此 hook 使用 Stop(在 Claude 完成响应时触发)来记录会话的转录保存位置:
on('classic.Stop', async ($, e, next) => {
// e 具有设置文件中的 Stop hook 从 stdin 读取的相同字段
$.ui.log('Transcript saved at ' + e.transcript_path)
// 传递事件,所以你的设置文件中的 Stop hooks 仍然运行
return next(e)
})
每次 Claude 完成响应时,转录中的暗线都会给出转录文件的路径。hook 返回 next(e),因此它观察事件并不改变轮次的结束方式。
与其他 mod 一起运行
多个 mod 可以 hook 同一事件,其中任何一个都可能失败。如果你的 mod 阻止工具调用,请检查它在链中的位置以及当其 hook 失败时会发生什么。
mod 运行的顺序
同一事件上的 hooks 形成一个中间件链。每个 mod 的 next 调用以下 mod 的 hook,最后的 next 到达 Claude Code 自己的行为。第一个 mod 是最外层的:它在其他 mod 之前看到事件,在它们之后看到结果,并决定其他 mod 是否运行。后续 mod 无法阻止早期 mod 看到事件。
Claude Code 按每个 mod 的来源对链进行排序:
- 内置保护
sec-default@builtin,一个内置于 Claude Code 的 mod,/plugin列为cc-plugin-sec-default,其中它加载,你的组织在prependPlugins中列出的 mod,然后是任何其他计为你的组织的 mod,不在appendPlugins中 - 你安装的 mod
- 你的组织在
appendPlugins中列出的 mod - 其他内置于 Claude Code 的 mod
在你安装的 mod 中,mod 在它在清单中的 dependencies 下列出的 mod 之前运行。在一个模块中,hooks 按 register 调用 on 的顺序运行。
设置 hooks 在顺序中运行的位置
在设置文件中配置的 PreToolUse hooks 也在工具调用期间运行,在 mod 链中的固定点:
- 来自托管设置的
PreToolUsehooks:在第一个 mod 的tool.callhook 之前运行,其中一个的块是最终的,因此没有 mod 看到调用。 - 来自每个其他设置文件和插件的
hooks/hooks.json的PreToolUsehooks:在最后一个 mod 调用next后运行,作为 Claude Code 自己的行为的一部分。回答tool.call而不调用next的 mod 会阻止它们运行,调用next的 mod 在它返回的结果中看到它们的决定。
tool.check 是 Claude Code 决定是否允许工具调用运行的事件。它在这些 hooks 和权限规则决定后触发,next(e) 解析为它们的决定。tool.check 上的 hook 可以返回不同的决定,例如 { decision: 'allow' },因此它可以批准第二组中的 hook 阻止的调用。使用 hooks 扩展权限列出哪些决定对 mod 有效。
处理失败的 hook
失败的 hook 不会破坏会话,你可以决定接下来会发生什么。当没有 .catch 处理程序的 hook 抛出、超时或返回错误形状的结果时,接下来会发生什么取决于它是否调用了 next:
- 它在调用
next之前失败:Claude Code 跳过它,下一个处理程序代替运行 - 它在
next解析后失败:该结果成立,没有任何东西运行第二次
一行命名 mod、事件和原因,例如 my-mod: tool.call hook skipped: threw Error: boom。你读取它的位置取决于会话,如找出 mod 为什么不做任何事列出的。其绘图不验证的 ui.render hook 的报告方式不同,如从元素构建树所述。
要使阻止调用的 hook 失败关闭,请添加一个 .catch 错误处理程序来代替回答。这里,guard 是你的 hook 函数:
// on 返回一个注册,.catch 将处理程序附加到该 hook
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
// next.error.kind 是 'throw' 或 'timeout',说明 guard 如何失败
return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }
})
当 guard 工作时,处理程序永远不会运行。当 guard 在 Bash 调用上抛出或超时时,Claude Code 使用相同的事件调用处理程序。处理程序返回 { deny },所以命令不会运行,Claude 读取末尾带有 throw 或 timeout 的文本。没有处理程序,Claude Code 会跳过 guard 并运行命令。处理程序有一秒来回答。
后续步骤
- 使用 mods API:添加命令和工具、调用模型并在计时器上运行工作
- 在界面中绘制:在窗格中或提示上方显示你的 hooks 收集的内容
- 测试 mod:从测试中触发这些事件中的任何一个
- Mods 参考:每个事件、每个 mods API 方法和限制