本教程带你用 Claude Code 的生命周期钩子(Lifecycle Hooks)从零搭出一个"会话中枢":会话一开自动注入项目状态、每条提问自动带上当前焦点、长任务跑完桌面提醒、压缩前抢救关键上下文、子代理产出自动留痕、会话结束自动归档。学完你会掌握 SessionStart、UserPromptSubmit、Notification、PreCompact、SubagentStop、SessionEnd 六类钩子的接线方式与 stdin/stdout 的 JSON 握手协议。适合已经写过一两个 Skill 或 MCP、想把 Claude Code 改造成"无人值守 + 自动记忆"工作台的进阶玩家。
claude --version)bash、jq、git(Linux/macOS 基本自带;缺 jq 用 brew install jq 或 apt install jq)~/.claude/settings.json 的基本结构本教程刻意避开已被反复讲过的
PreToolUse(命令护栏)和Stop/PostToolUse(质量自愈)两条线,专注其余六类"会话级"钩子,补齐全景。
钩子本质是你在 settings.json 里登记的 shell 命令,Claude Code 在特定生命周期节点把它们唤醒,通过 stdin 喂进一段 JSON,再根据退出码与 stdout 的 JSON决定后续行为。所有事件共享同一套握手协议,区别只在于"什么时候触发"和"stdout 能影响什么"。
| 事件 | 触发时机 | 典型用途 | 本教程是否装配 |
|---|---|---|---|
SessionStart |
会话开启/恢复/清屏后 | 注入项目状态、环境摘要 | ✅ |
UserPromptSubmit |
用户按下回车提交提问 | 注入动态上下文、留痕、拦截 | ✅ |
Notification |
Claude 需要输入或等待确认 | 桌面提醒、响铃 | ✅ |
PreCompact |
上下文压缩前 | 抢救关键信息、回灌要点 | ✅ |
SubagentStop |
子代理(Task/Agent)结束 | 产出留痕、强制复核 | ✅ |
SessionEnd |
会话结束 | 归档、索引、清理 | ✅ |
PreToolUse |
工具执行前 | 拦截危险命令、改写参数 | (已有护栏教程) |
PostToolUse |
工具执行后 | 自动格式化、自愈 | (已有自愈教程) |
关键握手规则记住三条:
UserPromptSubmit/SessionStart/PreCompact 这类"可注入"事件,stdout(或结构化 JSON)会被加进上下文。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;
先建一个集中存放钩子脚本和产出的目录,养成"脚本与数据分离"的习惯:
mkdir -p ~/.claude/hooks # 钩子脚本
mkdir -p ~/.claude/sessions # 钩子产出的快照、日志、归档
所有脚本共用同一个套路:cat 读 stdin → jq 取字段 → 干活 → 用 jq -n 拼结构化输出。先写一个取字段的小约定,后面六个脚本都按它来:
INPUT="$(cat)"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // "."')"
SESSION_ID="$(printf '%s' "$INPUT" | jq -r '.session_id // "-"')"
注意:
jq -r '.cwd // "."'里的//是 jq 的"空值兜底"运算符,字段缺失时回退到默认值,避免脚本因为字段缺失而崩在set -e上。
新建 ~/.claude/hooks/on-session-start.sh:
#!/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 是"高频注入点"——你每按一次回车它就跑一次。新建 on-prompt.sh:
#!/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 在 Claude 需要你输入或长时间等待时触发。新建 on-notify.sh:
#!/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 报错中断。钩子的健壮性原则——副作用类钩子要"能跑就跑,不能跑就静默"。
上下文快满时 Claude Code 会自动压缩(compact),但压缩有丢失细节的风险。PreCompact 给你最后一次机会把"绝对不能丢的东西"落盘并回灌。新建 on-precompact.sh:
#!/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/ 留一个带时间戳的快照,同时给摘要器一句"别丢未完成事项"的提醒。事后翻快照就能还原"压缩那一刻我们在干嘛"。
主代理派出去的子代理(Task/Agent 工具)干完活就消失,产出混在大对话里很难复盘。SubagentStop 让你给每个子代理的结尾留个"小票"。新建 on-subagent-stop.sh:
#!/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 会让主代理"不急着收工",先复核子代理结果,相当于给并行任务挂了一道人工复核闸。
会话退出时(关终端、/exit、超时)触发,适合写索引、做清理。新建 on-session-end.sh:
#!/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 能完整复盘任何一天的交互。
把六个钩子登记到 ~/.claude/settings.json 的 hooks 段。注意:非工具类事件不写 matcher(或留空串),matcher 只对 PreToolUse/PostToolUse 有意义(用来匹配工具名)。
{
"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/hooks/test-hook.sh:
#!/usr/bin/env bash
# usage: ./test-hook.sh <script> <sample.json>
cat "$2" | bash "$1"; echo "——exit=$?"
再准备几份样本 JSON(模拟 Claude 真实喂进来的 stdin)。sample-session-start.json:
{
"session_id": "test-001",
"transcript_path": "",
"cwd": "/home/gprshome/myproject",
"hook_event_name": "SessionStart",
"source": "startup"
}
sample-prompt.json:
{
"session_id": "test-001",
"transcript_path": "",
"cwd": "/home/gprshome/myproject",
"hook_event_name": "UserPromptSubmit",
"prompt": "帮我把登录逻辑拆成 service"
}
sample-notify.json:
{ "session_id":"test-001", "cwd":"/home/gprshome/myproject",
"hook_event_name":"Notification", "message":"Claude is waiting for your input" }
跑一遍:
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:
# 当前焦点
- 目标:把 auth 模块拆成独立的 AuthService
- 红线:不要动 src/legacy/ 下的任何文件
- 验收:所有 auth 相关测试通过
然后启动会话:
cd ~/myproject
claude
体验闭环:
feat/auth 分支,有 5 个未提交改动"。FOCUS.md 内容被自动带上,它直接奔"拆 AuthService"而去,且自觉绕开 src/legacy/。sessions/subagents/ 留了尾部产出。PreCompact 先把最近提问和焦点落盘,并提醒摘要器别丢未完成事项。/exit 退出,index.log 多了一行归档记录。整套流程的端到端时序:
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% 的"接好了但不响"。
https://code.claude.com/docs/en/hookshttps://code.claude.com/docs/en/hooks-guideNotification 接到手机:用 curl 推到 ntfy/Bark/Telegram Bot,把 on-notify.sh 里 notify-send 换成推送即可SessionEnd 写出的 session_id 可直接喂给 claude --resume <id> 恢复-p 批量:SessionStart 注入 + SessionEnd 归档,能让无人值守批处理也带上记忆闭环test-hook.sh 离线验证 stdin/stdout 握手;能解释退出码 0/2/其它的行为差异PreCompact 抢救的快照接入向量库,实现"跨会话长期记忆"[!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 等场景全覆盖。
