# Hooks

> features/externalHooks 的 20 个生命周期事件、App/Session/Agent 三层分工、退出码 2 的阻断语义，以及 Skills 与 Hooks「建议 vs 规则」的分工。

- 作者：David（道雾轩）
- 专栏：Kimi Code 源码解读（https://daiw.org/manual/kimi-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/kimi-code/hooks
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

[上一篇](https://daiw.org/manual/kimi-code/skills)的 Skills 用 Markdown 改变模型**怎么想**。这一篇的 Hooks 是另一条扩展路径——**在生命周期节点跑本地命令**，改变系统**会发生什么**。

## 怎么用

hook 写在 `config.toml`（位于 `~/.kimi-code/` 或 `KIMI_CODE_HOME`）的 `[[hooks]]` 数组里，schema 见 `features/externalHooks/configSection.ts:10`：

```ts
export const HookDefSchema = z
  .object({
    event: z.enum(HOOK_EVENT_TYPES),
    matcher: z.string().optional(),
    command: z.string().min(1),
    timeout: z.number().int().min(1).max(600).optional(),
  })
  .strict();
```

`.strict()` 意味着多写一个字段，配置就通不过校验。一个最小例子（示意）：

```toml
[[hooks]]
event = "PreToolUse"
matcher = "Bash"
command = "node ~/.kimi-code/hooks/guard-bash.mjs"
timeout = 5
```

插件也能在清单里声明 hook，加载时与配置里的合并在一起。

## 目录结构：三层作用域

v0.29 时代 hook 挂在 `session/hooks/`（`HookEngine` 那一套）；现在是 agent-core-v2 的一个 Feature：

```text
features/externalHooks/
├── app/externalHooksRunnerService.ts       App：读配置与插件 hook、按事件索引、执行
├── session/sessionExternalHooksService.ts  Session：会话起停、心跳、子代理起停
├── agent/agentExternalHooksService.ts      Agent：工具、权限、输入、回合、压缩、任务
└── internal/                               匹配、起进程、解析结果
```

名字里的 **external** 是为了和引擎内部的 hook 区分：`src/hooks.ts` 的 `OrderedHookSlot` 是一条可按 `before`/`after` 排序的中间件链，循环、工具执行器等服务都用它开放扩展点（如 `loop.hooks.onDidFinishStep`）。用户配置的外部 hook，正是挂在这些内部扩展点和事件总线上的。

## 20 个事件

`internal/types.ts:3`：

```ts
export const HOOK_EVENT_TYPES = [
  'PreToolUse',
  'PostToolUse',
  'PostToolUseFailure',
  'PermissionRequest',
  'PermissionResult',
  'UserPromptSubmit',
  'UserPromptQueued',
  'TurnStarted',
  'Stop',
  'StopFailure',
  'Interrupt',
  'SessionStart',
  'SessionEnd',
  'SessionHeartbeat',
  'SubagentStart',
  'SubagentStop',
  'TaskStarted',
  'PreCompact',
  'PostCompact',
  'Notification',
] as const;
```

v0.29 时是 16 个，v0.32.0 加了 `UserPromptQueued`、`TurnStarted`、`SessionHeartbeat`、`TaskStarted` 四个。

| 组 | 事件 | 能干什么 |
| --- | --- | --- |
| 工具 | `PreToolUse` / `PostToolUse` / `PostToolUseFailure` | 调用前拦截、调用后审计、失败时告警 |
| 权限 | `PermissionRequest` / `PermissionResult` | 审批前后通知，记录谁批了什么 |
| 输入 | `UserPromptSubmit` / `UserPromptQueued` | 输入进模型前注入或拦截；观察排队 |
| 回合 | `TurnStarted` / `Stop` / `StopFailure` / `Interrupt` | 区分正常收敛、异常中止、用户取消 |
| 会话 | `SessionStart` / `SessionEnd` / `SessionHeartbeat` | 起停时准备与清理；每 60 秒心跳 |
| 子代理与任务 | `SubagentStart` / `SubagentStop` / `TaskStarted` | 观测[子代理](https://daiw.org/manual/kimi-code/subagents)与[后台任务](https://daiw.org/manual/kimi-code/background) |
| 压缩 | `PreCompact` / `PostCompact` | 在[压缩](https://daiw.org/manual/kimi-code/compaction)前后落存档 |
| 其它 | `Notification` | 后台任务状态变化时通知外部系统 |

**终止事件仍然分开**，呼应[回合收敛](https://daiw.org/manual/kimi-code/turn-convergence)那条要求：`Stop` 在模型正常准备收尾时触发，`StopFailure` 在回合失败时触发，`Interrupt` 在回合被取消（如用户按 Esc）时触发。合成一个事件，外部脚本就没法分辨「用户喊停」和「模型跑飞了被掐断」。

## 能阻断，不只是旁观

脚本从 stdin 读到一份 JSON（键名由 `camelToSnake` 转成 `hook_event_name`、`session_id`、`tool_name` 这类形状），靠退出码表态。`internal/runHook.ts:140`：

```ts
function resultFromExitCode(exitCode: number, stdout: string, stderr: string): HookResult {
  if (exitCode === 2) {
    const message = stderr.trim();
    return {
      action: 'block',
      message,
      reason: message,
      stdout,
      stderr,
      exitCode,
    };
  }
```

退出码 `2` 表示阻断，stderr 就是理由；退出码 `0` 时 stdout 若是 JSON 且 `hookSpecificOutput.permissionDecision` 为 `deny`，同样算阻断。能真正改变流程的只有三个事件：

- **`PreToolUse`**：在工具执行前的否决链上投票。
- **`UserPromptSubmit`**：阻断则本轮不调模型；放行时 stdout 包成 `<hook_result>` 附进上下文。
- **`Stop`**：阻断理由作为一条 user 消息追加，模型接着干；每个回合只给一次机会，防止无限续跑。

`PreToolUse` 的接线在 `agent/agentExternalHooksService.ts:160`：

```ts
private registerToolHooks(toolExecutor: IAgentToolExecutorService): void {
  this._register(
    toolExecutor.onBeforeExecuteTool(async (event) => {
      const reason = await this.runPreToolUse(event);
      if (reason !== undefined) {
        event.veto(denyToolExecution(reason));
      }
    }),
  );
```

权限闸门、计划模式守卫也监听同一个 `onBeforeExecuteTool`，任何一个 `veto` 都会让工具不执行。需要用户审批的请求经 `waitUntil` 推迟到所有监听器跑完之后才弹出，所以 **`PreToolUse` 总在审批弹窗之前给出结论**。v1 的 `pre-tool-call-hook.ts` 权限策略已不复存在。

其余事件都是观察型的，脚本返回什么都不改变主流程。

## 匹配与执行

`matcher` 是正则（`new RegExp(pattern).test(value)`），不填则全匹配，正则写错视为不匹配。匹配对象随事件而定：工具事件是工具名，`UserPromptSubmit` 是输入里各段文本拼成的字符串，`SessionStart` 是 `startup` 或 `resume`。

执行上有几条约定：命中的 hook **并行**运行；同一 `cwd` 下命令相同的只跑一次；默认超时 30 秒，超时先 `SIGTERM`、100ms 后 `SIGKILL`；起进程失败、超时、被取消都按放行处理——**fail-open**。这决定了 hook 适合做提醒和轻量拦截，不该是唯一的安全防线。

## 两条扩展路径的分工

| | [Skills](https://daiw.org/manual/kimi-code/skills) | Hooks |
| --- | --- | --- |
| 形态 | Markdown 指令 | 本地命令 |
| 作用对象 | **模型**(改变它怎么想) | **系统**(改变会发生什么) |
| 能否阻断 | 否 | **能**(三个事件) |
| 来源 | 各级目录与插件 | 用户配置与已装插件 |

<Callout type="info">
  **Skill 是建议，Hook 是规则。**

  这个区分是安全模型上的分水岭：**模型可以不听 skill，但绕不过 hook**。skill 写「不要删除生产数据」，模型大概率会听，但它本质上只是一段提示词——上下文一长、指令一冲突，就可能被稀释。hook 写同样的约束，是在工具真正执行前由一段代码判定，模型没有任何余地。

  所以判据很清楚：**希望模型「通常这么做」用 skill；要求它「绝不那样做」用 hook。** 把硬约束写进 skill，是这类系统里最常见的安全误区。
</Callout>

来源那一行也值得注意：hook 是会在本机执行的命令，除了用户自己写的，还可能来自插件。这正是[插件与市场](https://daiw.org/manual/kimi-code/plugins)要在安装前把信任级别摆出来的原因。

下一篇：[子代理与 swarm](https://daiw.org/manual/kimi-code/subagents)。
