<!-- title: 造会话中枢：生命周期钩子全装配 -->

## 概述

本教程带你用 Claude Code 的**生命周期钩子（Lifecycle Hooks）**从零搭出一个"会话中枢"：会话一开自动注入项目状态、每条提问自动带上当前焦点、长任务跑完桌面提醒、压缩前抢救关键上下文、子代理产出自动留痕、会话结束自动归档。学完你会掌握 `SessionStart`、`UserPromptSubmit`、`Notification`、`PreCompact`、`SubagentStop`、`SessionEnd` 六类钩子的接线方式与 stdin/stdout 的 JSON 握手协议。适合已经写过一两个 Skill 或 MCP、想把 Claude Code 改造成"无人值守 + 自动记忆"工作台的进阶玩家。

## 前置条件

- 已安装 Claude Code（终端能跑 `claude --version`）
- 系统带 `bash`、`jq`、`git`（Linux/macOS 基本自带；缺 `jq` 用 `brew install jq` 或 `apt install jq`）
- 了解 `~/.claude/settings.json` 的基本结构
- 一个可测试的 git 项目目录（任意仓库都行）

> 本教程刻意避开已被反复讲过的 `PreToolUse`（命令护栏）和 `Stop`/`PostToolUse`（质量自愈）两条线，专注其余六类"会话级"钩子，补齐全景。

## 一、先搞懂：钩子事件全景

钩子本质是**你在 `settings.json` 里登记的 shell 命令**，Claude Code 在特定生命周期节点把它们唤醒，通过 **stdin 喂进一段 JSON**，再根据**退出码与 stdout 的 JSON**决定后续行为。所有事件共享同一套握手协议，区别只在于"什么时候触发"和"stdout 能影响什么"。

| 事件 | 触发时机 | 典型用途 | 本教程是否装配 |
|---|---|---|---|
| `SessionStart` | 会话开启/恢复/清屏后 | 注入项目状态、环境摘要 | ✅ |
| `UserPromptSubmit` | 用户按下回车提交提问 | 注入动态上下文、留痕、拦截 | ✅ |
| `Notification` | Claude 需要输入或等待确认 | 桌面提醒、响铃 | ✅ |
| `PreCompact` | 上下文压缩前 | 抢救关键信息、回灌要点 | ✅ |
| `SubagentStop` | 子代理（Task/Agent）结束 | 产出留痕、强制复核 | ✅ |
| `SessionEnd` | 会话结束 | 归档、索引、清理 | ✅ |
| `PreToolUse` | 工具执行前 | 拦截危险命令、改写参数 | （已有护栏教程） |
| `PostToolUse` | 工具执行后 | 自动格式化、自愈 | （已有自愈教程） |

关键握手规则记住三条：

1. **退出码 0** = 正常。对 `UserPromptSubmit`/`SessionStart`/`PreCompact` 这类"可注入"事件，stdout（或结构化 JSON）会被加进上下文。
2. **退出码 2** = 阻断错误，stderr 会被回喂给 Claude，让它知道"被打断了，原因是什么"。
3. **其它非零** = 非阻断错误，stderr 只在 UI 里提示，不打断流程。

```mermaid
flowchart LR
    A[会话开启]:::start --> HS[SessionStart 注入项目状态]:::hook
    HS --> U[等待用户提问]
    U --> HU[UserPromptSubmit 注入焦点+留痕]:::hook
    HU --> C[Claude 思考/调工具]
    C -->|需输入/等待| HN[Notification 桌面提醒]:::hook
    C -->|上下文将满| HC[PreCompact 抢救要点]:::hook
    C -->|派子代理| HSU[SubagentStop 产出留痕]:::hook
    C -->|结束| HE[SessionEnd 归档索引]:::hook
    HE --> Z[(~/.claude/sessions)]:::store
    classDef start fill:#d4edda,stroke:#28a745;
    classDef hook fill:#cce5ff,stroke:#007bff;
    classDef store fill:#fff3cd,stroke:#ffc107;
```

## 二、搭建会话中枢骨架

先建一个集中存放钩子脚本和产出的目录，养成"脚本与数据分离"的习惯：

```bash
mkdir -p ~/.claude/hooks          # 钩子脚本
mkdir -p ~/.claude/sessions       # 钩子产出的快照、日志、归档
```

所有脚本共用同一个套路：`cat` 读 stdin → `jq` 取字段 → 干活 → 用 `jq -n` 拼结构化输出。先写一个**取字段的小约定**，后面六个脚本都按它来：

```bash
INPUT="$(cat)"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // "."')"
SESSION_ID="$(printf '%s' "$INPUT" | jq -r '.session_id // "-"')"
```

> 注意：`jq -r '.cwd // "."'` 里的 `//` 是 jq 的"空值兜底"运算符，字段缺失时回退到默认值，避免脚本因为字段缺失而崩在 `set -e` 上。

## 三、SessionStart：启动即注入项目状态

新建 `~/.claude/hooks/on-session-start.sh`：

```bash
#!/usr/bin/env bash
# SessionStart: feed Claude a one-shot project status on every session open
set -euo pipefail
INPUT="$(cat)"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // "."')"
SRC="$(printf '%s' "$INPUT" | jq -r '.source // "startup"')"
PROJECT="$(basename "$CWD")"
mkdir -p "$HOME/.claude/sessions"
RECAP="$HOME/.claude/sessions/${PROJECT}.recap.md"

lines="[会话中枢·SessionStart source=$SRC]\n- 项目：$PROJECT\n"

if command -v git >/dev/null 2>&1 && git -C "$CWD" rev-parse >/dev/null 2>&1; then
  BRANCH="$(git -C "$CWD" branch --show-current 2>/dev/null || echo '-')"
  DIRTY="$(git -C "$CWD" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
  AHEAD="$(git -C "$CWD" rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)"
  lines+="- Git：分支 $BRANCH / 未提交 $DIRTY / 领先远端 $AHEAD\n"
fi

if [[ -f "$RECAP" ]]; then
  lines+="- 上次小结：\n$(sed 's/^/  /' "$RECAP" | head -20)\n"
fi

jq -n --arg ctx "$(printf '%b' "$lines")" \
  '{hookSpecificOutput:{hookEventName:"SessionStart", hookAdditionalContext:$ctx}}'
```

`source` 字段会告诉你这次会话是怎么来的：`startup`（全新启动）、`resume`（恢复历史会话）、`clear`（清屏）、`compact`（压缩后重建）。你可以据此决定要不要重新喂一大段上下文——压缩重建时其实不需要再灌一遍。

**预期结果**：会话一开，Claude 的上下文里就多了项目名、分支、脏文件数、上次小结，它第一句话往往就能直接接上"上次我们在改 X"。

## 四、UserPromptSubmit：每条提问自动带上焦点

`UserPromptSubmit` 是"高频注入点"——你每按一次回车它就跑一次。新建 `on-prompt.sh`：

```bash
#!/usr/bin/env bash
# UserPromptSubmit: log every prompt + inject FOCUS.md if present
set -euo pipefail
INPUT="$(cat)"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // "."')"
PROMPT="$(printf '%s' "$INPUT" | jq -r '.prompt // ""')"
PROJECT="$(basename "$CWD")"
mkdir -p "$HOME/.claude/sessions"

# 1) leave a trail of every prompt
printf '[%s] %s\n' "$(date '+%F %T')" "$(printf '%s' "$PROMPT" | head -c 120)" \
  >> "$HOME/.claude/sessions/${PROJECT}.prompts.log"

# 2) inject focus file so the model never loses the thread
FOCUS="$CWD/.claude/FOCUS.md"
if [[ -f "$FOCUS" ]]; then
  ctx="[当前焦点·FOCUS.md]\n$(sed 's/^/  /' "$FOCUS" | head -30)\n"
  jq -n --arg c "$(printf '%b' "$ctx")" \
    '{hookSpecificOutput:{hookEventName:"UserPromptSubmit", hookAdditionalContext:$c}}'
fi
```

在项目里放一个 `.claude/FOCUS.md`，写上"本周目标：把 auth 模块拆成 service"之类。之后无论你问什么，Claude 都会先把这条焦点记在脑子里。

**进阶玩法**：`UserPromptSubmit` 也支持**阻断**。如果你检测到提问里粘了疑似密钥（如 `AKIA` 开头的 AWS Key），直接 `echo "提问里疑似含密钥，请删去后再发" >&2; exit 2`，Claude 会收到这条 stderr 并主动停下。这比 PreToolUse 拦命令更"前置"——在提问阶段就掐断。

## 五、Notification：长任务跑完桌面提醒

长任务（重构、跑测试、批量生成）最烦的就是盯着屏幕等。`Notification` 在 Claude 需要你输入或长时间等待时触发。新建 `on-notify.sh`：

```bash
#!/usr/bin/env bash
# Notification: ring the terminal bell + desktop toast
set -uo pipefail
INPUT="$(cat)"
MSG="$(printf '%s' "$INPUT" | jq -r '.message // "Claude Code 需要你")'"
printf '\a'                       # terminal bell
if command -v notify-send >/dev/null 2>&1; then
  notify-send "Claude Code" "$MSG" 2>/dev/null || true
elif command -v osascript >/dev/null 2>&1; then
  osascript -e "display notification \"$MSG\" with title \"Claude Code\"" 2>/dev/null || true
fi
exit 0
```

**预期结果**：你切去别的窗口干活，Claude 卡在"需要确认"或"任务完成"时，桌面右下角弹通知 + 终端响铃，回来一看就知道。

> 这里**故意不写 `set -e`**：通知是"锦上添花"，绝不能因为 `notify-send` 不存在就让 Claude 报错中断。钩子的健壮性原则——副作用类钩子要"能跑就跑，不能跑就静默"。

## 六、PreCompact：压缩前抢救关键上下文

上下文快满时 Claude Code 会自动压缩（compact），但压缩有丢失细节的风险。`PreCompact` 给你最后一次机会把"绝对不能丢的东西"落盘并回灌。新建 `on-precompact.sh`：

```bash
#!/usr/bin/env bash
# PreCompact: snapshot recent prompts + focus before context is summarized
set -euo pipefail
INPUT="$(cat)"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // "."')"
TP="$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""')"
PROJECT="$(basename "$CWD")"
mkdir -p "$HOME/.claude/sessions"
DUMP="$HOME/.claude/sessions/${PROJECT}.compact.$(date '+%s').md"

{
  echo "# PreCompact 抢救快照"
  echo "- 时间：$(date '+%F %T')  触发：$(printf '%s' "$INPUT" | jq -r '.trigger // "auto"')"
  echo -e "\n## 最近用户提问（保意图）"
  if [[ -f "$TP" ]]; then
    jq -r 'select(.type=="user") | .message.content // empty' "$TP" 2>/dev/null | tail -40 || true
  fi
  echo -e "\n## 仍打开的焦点"
  [[ -f "$CWD/.claude/FOCUS.md" ]] && cat "$CWD/.claude/FOCUS.md" || echo "(无)"
} > "$DUMP"

# re-inject a reminder so the summarizer keeps unfinished items
jq -n --arg c "压缩前已把最近提问与焦点存档到 $DUMP，请务必在摘要中保留所有未完成事项。" \
  '{hookSpecificOutput:{hookEventName:"PreCompact", hookAdditionalContext:$c}}'
```

**预期结果**：每次压缩都会在 `~/.claude/sessions/` 留一个带时间戳的快照，同时给摘要器一句"别丢未完成事项"的提醒。事后翻快照就能还原"压缩那一刻我们在干嘛"。

## 七、SubagentStop：子代理产出自动留痕

主代理派出去的子代理（Task/Agent 工具）干完活就消失，产出混在大对话里很难复盘。`SubagentStop` 让你给每个子代理的结尾留个"小票"。新建 `on-subagent-stop.sh`：

```bash
#!/usr/bin/env bash
# SubagentStop: archive each subagent's tail output
set -uo pipefail
INPUT="$(cat)"
TP="$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""')"
mkdir -p "$HOME/.claude/sessions/subagents"
LOG="$HOME/.claude/sessions/subagents/$(date '+%F_%H%M%S').tail.md"

{
  echo "## SubagentStop @ $(date '+%F %T')"
  if [[ -f "$TP" ]]; then
    jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text' \
      "$TP" 2>/dev/null | tail -30 || true
  fi
} > "$LOG"

# uncomment to force the main agent to review subagent output before moving on:
# printf '{"decision":"block","reason":"请复核子代理产出（留痕：%s）"}\n' "$LOG"
exit 0
```

**预期结果**：每个子代理结束都会落一份尾部产出快照。需要更严格时，解开最后两行注释——`decision:block` 会让主代理"不急着收工"，先复核子代理结果，相当于给并行任务挂了一道人工复核闸。

## 八、SessionEnd：会话结束自动归档

会话退出时（关终端、`/exit`、超时）触发，适合写索引、做清理。新建 `on-session-end.sh`：

```bash
#!/usr/bin/env bash
# SessionEnd: append one index line per session
set -uo pipefail
INPUT="$(cat)"
SID="$(printf '%s' "$INPUT" | jq -r '.session_id // "-"')"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // "."')"
REASON="$(printf '%s' "$INPUT" | jq -r '.reason // "unknown"')"
PROJECT="$(basename "$CWD")"
mkdir -p "$HOME/.claude/sessions"
printf '[%s] project=%s session=%s reason=%s\n' \
  "$(date '+%F %T')" "$PROJECT" "$SID" "$REASON" \
  >> "$HOME/.claude/sessions/index.log"
exit 0
```

跑久了 `index.log` 就是一份"我跟哪些项目、哪些会话聊过"的流水账，配合 `prompts.log` 能完整复盘任何一天的交互。

## 九、一键装配 settings.json

把六个钩子登记到 `~/.claude/settings.json` 的 `hooks` 段。注意：**非工具类事件不写 `matcher`（或留空串）**，`matcher` 只对 `PreToolUse`/`PostToolUse` 有意义（用来匹配工具名）。

```json
{
  "hooks": {
    "SessionStart": [
      { "hooks": [ { "type": "command", "command": "bash $HOME/.claude/hooks/on-session-start.sh" } ] }
    ],
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "bash $HOME/.claude/hooks/on-prompt.sh" } ] }
    ],
    "Notification": [
      { "hooks": [ { "type": "command", "command": "bash $HOME/.claude/hooks/on-notify.sh" } ] }
    ],
    "PreCompact": [
      { "hooks": [ { "type": "command", "command": "bash $HOME/.claude/hooks/on-precompact.sh" } ] }
    ],
    "SubagentStop": [
      { "hooks": [ { "type": "command", "command": "bash $HOME/.claude/hooks/on-subagent-stop.sh" } ] }
    ],
    "SessionEnd": [
      { "hooks": [ { "type": "command", "command": "bash $HOME/.claude/hooks/on-session-end.sh" } ] }
    ]
  }
}
```

> 项目级覆盖写在 `<项目>/.claude/settings.json`，本地不入库的写在 `.claude/settings.local.json`。三者按"企业策略 → 用户 → 项目 → 本地"的优先级合并。

## 十、脱机自测：不进 Claude 也能验钩子

钩子最怕"接好了但不响，又不知道哪错了"。写一个最小测试器 `~/.claude/hooks/test-hook.sh`：

```bash
#!/usr/bin/env bash
# usage: ./test-hook.sh <script> <sample.json>
cat "$2" | bash "$1"; echo "——exit=$?"
```

再准备几份样本 JSON（模拟 Claude 真实喂进来的 stdin）。`sample-session-start.json`：

```json
{
  "session_id": "test-001",
  "transcript_path": "",
  "cwd": "/home/gprshome/myproject",
  "hook_event_name": "SessionStart",
  "source": "startup"
}
```

`sample-prompt.json`：

```json
{
  "session_id": "test-001",
  "transcript_path": "",
  "cwd": "/home/gprshome/myproject",
  "hook_event_name": "UserPromptSubmit",
  "prompt": "帮我把登录逻辑拆成 service"
}
```

`sample-notify.json`：

```json
{ "session_id":"test-001", "cwd":"/home/gprshome/myproject",
  "hook_event_name":"Notification", "message":"Claude is waiting for your input" }
```

跑一遍：

```bash
chmod +x ~/.claude/hooks/*.sh
cd ~/.claude/hooks
./test-hook.sh on-session-start.sh sample-session-start.json
./test-hook.sh on-prompt.sh sample-prompt.json
./test-hook.sh on-notify.sh sample-notify.json    # 应听到响铃
```

**预期结果**：前两条会打印一段 `{"hookSpecificOutput":{...}}` JSON（这就是会被注入上下文的内容），第三条终端响铃且 `exit=0`。看到这些就说明握手正确，可以放心进真实会话。

## 实战案例：会话中枢完整接线

把上面所有零件串成一个可复现的小项目。目录结构：

```
~/.claude/
├── settings.json              # hooks 登记
└── hooks/
    ├── on-session-start.sh
    ├── on-prompt.sh
    ├── on-notify.sh
    ├── on-precompact.sh
    ├── on-subagent-stop.sh
    ├── on-session-end.sh
    └── test-hook.sh
```

在你的业务仓库里加一个 `.claude/FOCUS.md`：

```markdown
# 当前焦点
- 目标：把 auth 模块拆成独立的 AuthService
- 红线：不要动 src/legacy/ 下的任何文件
- 验收：所有 auth 相关测试通过
```

然后启动会话：

```bash
cd ~/myproject
claude
```

体验闭环：

1. **开场**：Claude 上下文里已经有项目名、分支、脏文件数，第一句就能说"当前在 `feat/auth` 分支，有 5 个未提交改动"。
2. **提问**：你问"继续昨天的活"，`FOCUS.md` 内容被自动带上，它直接奔"拆 AuthService"而去，且自觉绕开 `src/legacy/`。
3. **等待**：它跑完一轮要你确认，你正在看别的窗口——桌面通知 + 响铃把你叫回来。
4. **派活**：它派子代理并行查 3 个文件，每个结束都在 `sessions/subagents/` 留了尾部产出。
5. **压缩**：聊了很久上下文将满，`PreCompact` 先把最近提问和焦点落盘，并提醒摘要器别丢未完成事项。
6. **收工**：`/exit` 退出，`index.log` 多了一行归档记录。

整套流程的端到端时序：

```mermaid
sequenceDiagram
    participant U as 用户
    participant CC as Claude Code
    participant H as 钩子脚本
    participant S as sessions 目录

    U->>CC: 启动会话
    CC->>H: SessionStart (stdin JSON)
    H->>CC: hookAdditionalContext 注入状态
    CC->>U: "在 feat/auth，5 个改动"

    U->>CC: 提问
    CC->>H: UserPromptSubmit
    H->>S: 写 prompts.log
    H->>CC: 注入 FOCUS.md
    CC->>H: SubagentStop (×N)
    H->>S: 留子代理产出

    Note over CC: 上下文将满
    CC->>H: PreCompact
    H->>S: 落快照 *.compact.*.md
    H->>CC: 回灌"别丢未完成事项"

    CC->>H: Notification (等输入)
    H->>U: 响铃 + 桌面通知

    U->>CC: /exit
    CC->>H: SessionEnd
    H->>S: 追加 index.log
```

## 常见坑与对策

| 现象 | 根因 | 解决 |
|---|---|---|
| 钩子死活不触发 | 事件名拼错（如 `OnPromptSubmit`） | 严格用官方事件名，大小写敏感 |
| 注入的上下文没出现 | stdout 不是合法 JSON 或没退出码 0 | 用 `test-hook.sh` 离线验证 `jq -n` 输出 |
| 脚本一跑就 `set -e` 退出 | 某字段缺失，`jq` 取到 `null` 后续命令失败 | 全程用 `.x // "默认值"` 兜底 |
| 副作用钩子（通知）偶发报错 | 用了 `set -e` 且依赖命令不存在 | 副作用钩子去掉 `set -e`，命令后加 `\|\| true` |
| 改了 settings.json 没生效 | 改的是项目级但启动在别的目录 | 用 `claude --debug` 启动可看到钩子加载与执行轨迹 |
| `matcher` 写了却没匹配 | 只对 `PreToolUse`/`PostToolUse` 有效 | 生命周期事件不要写 `matcher` |
| 钩子卡住拖慢响应 | 脚本里有网络/长任务且同步阻塞 | 拆出后台进程 `nohup ... &`，或用 `timeout` 字段限时 |

> 调试金句：**所有"注入型"钩子先离线喂样本 JSON 验 stdout，再进真实会话**。这能拦下 90% 的"接好了但不响"。

## 延伸阅读

- 官方钩子参考（事件、schema、退出码全表）：`https://code.claude.com/docs/en/hooks`
- 官方钩子上手指南（含 matcher 用法）：`https://code.claude.com/docs/en/hooks-guide`
- 想把 `Notification` 接到手机：用 `curl` 推到 ntfy/Bark/Telegram Bot，把 `on-notify.sh` 里 `notify-send` 换成推送即可
- 想做"会话续跑"：`SessionEnd` 写出的 `session_id` 可直接喂给 `claude --resume <id>` 恢复
- 钩子 + `-p` 批量：`SessionStart` 注入 + `SessionEnd` 归档，能让无人值守批处理也带上记忆闭环

## 训练营适用性评估

- **适合**：Claude Code 进阶训练营、DevOps 自动化训练营、AI 编程效率训练营
- **教学时长**：约 2.5–3 小时（讲透握手协议 30min + 六脚本精讲 90min + 现场接线调试 60min）
- **前置模块**：建议放在"Skill 开发"和"MCP 服务器"之后，作为"把 Claude Code 改造成专属工作站"的收口实验
- **考核点**：学员能独立写出任意一类生命周期钩子，并用 `test-hook.sh` 离线验证 stdin/stdout 握手；能解释退出码 0/2/其它的行为差异
- **延伸作业**：把 `PreCompact` 抢救的快照接入向量库，实现"跨会话长期记忆"

## 参考来源

1. [Hooks reference - Claude Code Docs](https://code.claude.com/docs/en/hooks) — 官方钩子事件、配置 schema、JSON 输入输出格式与退出码权威参考
2. [Automate actions with hooks - Claude Code Docs](https://code.claude.com/docs/en/hooks-guide) — 官方上手指南，含 matcher 匹配与多处理器写法
3. [The Complete Guide to Claude Code Hooks - timewell.jp](https://timewell.jp/en/columns/claude-code-hooks-complete-guide) — 逐事件机制深读，覆盖 PreToolUse/PostToolUse 等关键事件
4. [Claude Code Hooks: Complete Guide with 20+ Ready-to-Use Examples (2026) - aiorg.dev](https://aiorg.dev/blog/claude-code-hooks) — 20+ 可复制配置，自动格式化、通知等生产级模式
5. [Claude Code Hooks — Input & Output Schemas (Reference) - GitHub Gist](https://gist.github.com/FrancisBourre/50dca37124ecc43eaf08328cdcccdb34) — 各事件 stdin/stdout 的实现级 schema 速查

> [!tip] 51学通信知识星球
> **星主：爱卫生**（微信：gprshome201101）——公众号「51学通信」创办者，19 年通信行业运维与讲师经验，著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书（各大电商平台有售）。
> **星球定位**：深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域，主打运维岗位与信令分析。
> **当前规模**：5200 篇主题 / 990 个回答，198 元/年。
> **加入福利**：加入星球后请加爱卫生微信，拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题，另有群内分享。
> **AI 加持**：51学通信知识星球已作为 skill 接入 WorkBuddy，使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例，做总结、找文档和 PPT 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
