本教程聚焦 Claude Code 最容易被忽视的"上下文注入类"钩子——UserPromptSubmit、SessionStart、Notification、SubagentStop,把它们与 -p 无头模式、--output-format stream-json 机器可读输出、TodoWrite 自主规划组合成一条零干预自主流水线。学完你能让一个无头 Agent 在没有人在场时,自动加载环境、每轮注入新鲜上下文、改完代码自检、需要人时主动提醒、子代理产出先校验再放行。适合已写过 Skill/MCP、想从"交互式助手"迈向"无人值守 Agent"的开发者。
prompt_id、effort 等字段需要较新版本;terminalSequence 需 v2.1.141+)jq、bash、python3(解析钩子 stdin 的 JSON 与处理流式输出)PreToolUse/PostToolUse 基本用法(本篇不再重复安全钩子与格式化钩子的入门内容)近几轮教程已经把"安全钩子围栏""双钩格式化""Stop 钩子自愈跑测试""-p 流式批量引擎"讲透了。它们的共同点是:钩子作为"闸门"或"批处理器"——拦命令、跑格式、批量分发。
但真正决定一个无头 Agent 能不能"自己活下去"的,是另一组钩子:把新鲜上下文像活水一样持续灌进模型上下文窗口的那一类。官方文档把它们归为三种节奏:
SessionStart / SessionEndUserPromptSubmit / Stop / StopFailurePreToolUse / PostToolUseUserPromptSubmit 和 SessionStart 的关键能力是——stdout 与 additionalContext 会被直接塞进模型上下文(PreToolUse 的 stdout 只进调试日志,不给模型看)。这就是"注入"二字的来源。本篇的全部技巧都建立在这一点上。
与已覆盖主题的区别:本篇不写"批量分发"(那是并行/批量引擎)、不写"崩溃续传"(那是 --resume 断点)、不写"安全围栏"(那是 PreToolUse deny)。本篇写的是单一深度任务的"上下文供给系统":让一个 -p 调用从启动到停止,全程被钩子动态喂上下文、被钩子校验、被钩子通知。
所有钩子都写在 settings.json(用户级 ~/.claude/settings.json 或项目级 .claude/settings.json)的 hooks 字段下,结构是三层:
hooks
└── <事件名> # 如 UserPromptSubmit
└── [ matcher组 ] # 过滤何时触发
└── hooks: [ # 真正执行的 handler
{ type, command/args/url/... }
]
UserPromptSubmit、Stop、SubagentStop、Notification 这几个事件不支持 matcher,写了也会被静默忽略——它们每次都触发。这点很重要,省得你困惑"为什么 matcher 没生效"。
每个 handler 有五种类型:command(跑 shell)、http(POST 到端点)、mcp_tool(调 MCP 工具)、prompt(让模型单轮判定 yes/no)、agent(起子代理带工具去校验,实验性)。本篇主要用 command,因为它最可控、最易调试。
SessionStart 在会话开始或 --resume 恢复时触发。它的输入里有 source(startup/resume/clear/compact)、可选的 model、agent_type、session_title。最关键的是两个输出能力:
additionalContext:在第一轮 prompt 之前就塞进上下文initialUserMessage:在 -p 非交互模式下,直接成为"第一轮用户消息"——即使你没传 promptinitialUserMessage 是自主化的利器:你可以让 SessionStart 钩子读一个工单文件,把它转成第一句指令,Agent 就"自己开口干活"了。
另外,SessionStart 钩子能拿到 CLAUDE_ENV_FILE——一个文件路径,你往里写 export 语句,这些变量会持久化到本次会话后续所有 Bash 命令里。
先写一个 SessionStart 钩子脚本 .claude/hooks/on-start.sh:
#!/bin/bash
# .claude/hooks/on-start.sh —— 注入初始态势 + 持久化环境变量
input=$(cat)
source=$(jq -r '.source' <<< "$input")
# 1) 持久化环境变量到后续 Bash 命令
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"
fi
# 2) 采集初始态势(活水第一瓢)
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "no-git")
dirty=$(git status --porcelain 2>/dev/null | head -20)
last_ci=$(grep -rhoE "FAIL|ERROR" .github/workflows/*.yml 2>/dev/null | head -5)
# 3) 用 additionalContext 注入,source=resume 时刷新过期信息
jq -nc --arg ctx "环境态势 [source=$source]:
- 分支: $branch
- 未提交变更:
$dirty
- 近期 CI 关键词: ${last_ci:-无}
注意: 时间戳/SHA 在 resume 时会过期,本钩子已重新采集。" \
'{hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:$ctx}}'
预期结果:会话一启动,模型上下文里就有了当前分支、脏文件清单、CI 关键词,不需要你再说一遍"先看看现在在哪个分支"。
常见问题:
PostToolUse/UserPromptSubmit 注入的文本在 resume 时是"回放"而非重跑;只有 SessionStart 在 source=resume 时会重新执行。所以把"需要保鲜"的态势采集放在 SessionStart 里。sessionTitle 就必须用 JSON 形式。两者不能混用输出语义。这是本篇的主角。它在每次提交 prompt 后、模型处理前触发,输入里有 prompt 字段(用户原话)。默认超时 30 秒(比其它事件的 600 秒短),因为它阻塞模型处理——卡住就整个会话卡住。
它有两种"加上下文"的方式:
additionalContext:包在 hookSpecificOutput 里,作为系统提醒注入,模型读得到但 transcript 里不显示为消息自主场景推荐用 additionalContext:保持 transcript 干净,又能每轮把"此刻的真实状态"喂给模型。
写一个 .claude/hooks/inject-context.sh,每轮注入 git diff 统计 + 项目规约摘要:
#!/bin/bash
# .claude/hooks/inject-context.sh —— 每轮注入活水
# 超时 30s,只做轻量采集,重活留给 SessionStart 或子代理
diffstat=$(git diff --stat 2>/dev/null | tail -15)
todo_count=$(grep -rEc "TODO|FIXME" src/ 2>/dev/null | paste -sd+ | bc 2>/dev/null || echo 0)
convention=$(head -40 .claude/conventions.md 2>/dev/null || echo "无显式规约文件")
jq -nc --arg ctx "[每轮活水注入]
- 当前 diff 统计:
$diffstat
- 仓库内 TODO/FIXME 计数: $todo_count
- 必须遵守的规约摘要:
$convention
约束: 改动前先确认对应测试存在;禁止提交 .env" \
'{hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:$ctx}}'
把两个钩子注册进 .claude/settings.json:
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/on-start.sh" } ] }
],
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "timeout": 20,
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-context.sh" } ] }
]
}
}
预期结果:无论你(或 -p 脚本)发什么 prompt,模型每轮都能看到"此刻"的 diff、TODO 计数、规约。信息不再过期——这是"活水"相比写死在 CLAUDE.md 里"死水"的根本优势。
常见问题:
additionalContext 上限 10000 字符,超出会被存进文件、只传"预览+文件路径"给模型。所以 head/tail 限制行数是必须的。timeout 内完成,采集慢的数据放 SessionStart。{"decision":"block","reason":"..."} 会擦除该 prompt 不让模型处理——可用于"非工作时间禁止改生产分支"之类的策略。Stop 在模型每轮回答结束时触发。输入里有三个关键字段:
stop_hook_active:为 true 表示"本次继续就是因为上一个 Stop 钩子让继续的"——必须检查它,否则会卡在永不满足的条件上last_assistant_message:本轮最终回答的文本,别去读 transcript(transcript 异步写入,Stop 时不一定含最终消息)background_tasks / session_crons:区分"真结束"还是"在等后台任务唤醒"返回 decision:"block" 或 additionalContext 都能让会话继续(区别在于 transcript 标注与是否显示 hook 错误通知)。官方有连续 8 次阻断上限:超过 8 次强制结束,防死循环。
写一个自检 Stop 钩子 .claude/hooks/verify-and-continue.sh:
#!/bin/bash
# .claude/hooks/verify-and-continue.sh —— 改完跑校验,失败就让它继续修
input=$(cat)
active=$(jq -r '.stop_hook_active' <<< "$input")
# 已经是"被续命"状态,别再无脑 block
if [ "$active" = "true" ]; then
exit 0
fi
# 跑轻量校验(测试留给专门的 verify 步骤,这里只 lint)
lint_out=$(npx --no-install eslint src/ --format compact 2>&1 | tail -30)
if echo "$lint_out" | grep -qE "error|Error"; then
jq -nc --arg fb "[Stop 自检] lint 未通过,请修复后再结束:\n$lint_out" \
'{hookSpecificOutput:{hookEventName:"Stop",additionalContext:$fb}}'
else
exit 0
fi
注意:additionalContext 走的是和 block 相同的续命机制(受 stop_hook_active 与 8 次上限保护),但 transcript 标注为"Stop hook feedback"且不显示 hook 错误通知——所以"正常给指导"用 additionalContext,"硬拦"才用 decision:block。
Notification 在 Claude Code 发通知时触发(权限提示、空闲、鉴权成功、子代理需要输入等)。它不能 block,但能做两件事:把消息发外部(webhook/桌面通知),或用 terminalSequence 让终端响铃/弹通知。
钩子运行环境没有控制终端(v2.1.139 起 Linux/macOS 钩子在自己的会话里,打不开 /dev/tty),所以直接写转义序列会失败。正确做法是返回 terminalSequence,由 Claude Code 替你通过它的终端写入路径发出——这是 race-free 的,且在 tmux/screen/Windows 都能用。允许列表限 OSC 0/1/2/9/99/777 与 BEL。
.claude/hooks/notify.sh:
#!/bin/bash
# .claude/hooks/notify.sh —— 桌面通知 + 可选 webhook
input=$(cat)
body=$(jq -r '.message // "需要你注意" ' <<< "$input")
title=$(jq -r '.title // "Claude Code" ' <<< "$input")
# OSC 777: urxvt/Ghostty/Warp 通用通知
seq=$(printf '\033]777;notify;%s;%s\007' "$title" "$body")
# 可选:同时打 webhook(无人值守机房的转发渠道)
curl -s -m 3 -X POST "http://localhost:8080/notify" \
-H 'Content-Type: application/json' \
-d "{\"title\":\"$title\",\"body\":\"$body\"}" >/dev/null 2>&1 &
jq -nc --arg seq "$seq" '{terminalSequence:$seq}'
注册时记得加 notification_type 不需要 matcher(Notification 支持 matcher,值为 permission_prompt/idle_prompt/agent_completed 等):
"Notification": [
{ "matcher": "idle_prompt|agent_completed",
"hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.sh" } ] }
]
当你用 Agent 工具派子代理干活时,SubagentStop 在子代理结束时触发。输入里有 agent_id、agent_type、agent_transcript_path、last_assistant_message。它的决策格式与 Stop 一致:decision:"block" + reason 会让子代理继续运行并把 reason 作为下一条指令喂回去——等于"你这个产出不合格,重做"。
这是"自主但受控"的关键一环:让主 Agent 派子代理做调研,子代理回来前先用钩子检查"是否真带了证据"。
#!/bin/bash
# .claude/hooks/check-subagent.sh —— 子代理必须带回文件证据
input=$(cat)
msg=$(jq -r '.last_assistant_message' <<< "$input")
# 简单规则:回答里若没有任何 src/ 路径引用,视为没落地
if ! echo "$msg" | grep -qE "src/|test/"; then
jq -nc '{decision:"block", reason:"子代理产出未引用任何源码路径,请实际打开文件确认后再总结。"}'
else
exit 0
fi
文档提醒:若想把子代理结果注入父会话上下文,别用 SubagentStop(它只对子代理生效),改用对
Agent工具的PostToolUse钩子。
钩子搭好"供给系统"后,用一个 bash 脚本以无头方式驱动整条流水线。核心是 -p 与一组关键 flag:
#!/bin/bash
# run-agent.sh —— 无头驱动自主分支体检
set -euo pipefail
SESSION="polish-$(date +%s)"
PROMPT_FILE="${1:-.claude/task.md}"
claude -p \
--output-format stream-json \
--include-partial-messages \
--verbose \
--session-id "$SESSION" \
--permission-mode acceptEdits \
--max-turns 30 \
--allowedTools "Read,Write,Edit,Bash,Grep,Glob,TodoWrite" \
--disallowedTools "WebFetch" \
--add-dir "$(pwd)" \
--append-system-prompt "你是无人值守的分支体检 Agent。每轮会收到钩子注入的活水上下文。先用 TodoWrite 拆解任务,逐步执行,改完确保 lint 通过。" \
"$(cat "$PROMPT_FILE")" \
2>logs/agent-$SESSION.err \
| tee logs/agent-$SESSION.stream.jsonl
逐个 flag 解读:
| flag | 作用 | 自主化意义 |
|---|---|---|
-p / --print |
非交互,跑完即退 | 无人值守的前提 |
--output-format stream-json |
每行一个 JSON 事件 | 机器可解析,能编程判定成败 |
--include-partial-messages |
流式含增量文本 | 实时观察长任务进度 |
--session-id |
指定会话 ID | 配合 --resume 断点续做 |
--permission-mode acceptEdits |
自动接受文件编辑 | 避免 -p 卡在权限弹窗 |
--max-turns 30 |
上限轮数 | 防失控,与 Stop 8 次上限双保险 |
--allowedTools / --disallowedTools |
工具白/黑名单 | 收紧自主权限面 |
--append-system-prompt |
追加系统指令 | 注入角色与流程约束 |
--add-dir |
增加可访问目录 | 跨目录只读访问 |
stream-json 输出里,最末一条 type:"result" 事件含 subtype:"result"、result(最终文本)、is_error、duration_ms、total_cost_usd、num_turns。解析它就能判定这次自主任务到底成没成:
# 取最终结果
tail -1 logs/agent-$SESSION.stream.jsonl \
| jq '{ok: (.is_error|not), turns: .num_turns, cost: .total_cost_usd, text: .result}'
把上面七步拼成一个完整可运行项目。目录结构:
myrepo/
├── .claude/
│ ├── settings.json
│ ├── task.md # 给无头 Agent 的任务说明
│ ├── conventions.md # 项目规约(活水来源之一)
│ └── hooks/
│ ├── on-start.sh
│ ├── inject-context.sh
│ ├── verify-and-continue.sh
│ ├── notify.sh
│ └── check-subagent.sh
├── scripts/run-agent.sh
└── src/ (你的代码)
task.md(给 Agent 的任务):
目标: 对当前分支做"体检"并修复明显问题。
1. 用 TodoWrite 列出: lint、类型检查、TODO 清理、测试存在性核查。
2. 逐项执行;能安全修的(如未使用 import、格式)直接修。
3. 涉及逻辑的改动只记录到报告,不改。
4. 结束前确保 lint 通过(Stop 钩子会校验)。
完整 settings.json(合并五类钩子):
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/on-start.sh" } ] }
],
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "timeout": 20,
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-context.sh" } ] }
],
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "if": "Bash(rm -rf *)|Bash(git push --force*)",
"command": "jq -nc '{hookSpecificOutput:{hookEventName:\"PreToolUse\",permissionDecision:\"deny\",permissionDecisionReason:\"危险命令被自主围栏拦截\"}}'" } ] }
],
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [ { "type": "command",
"command": "npx --no-install prettier --write \"${tool_input.file_path}\" 2>/dev/null; exit 0" } ] }
],
"Stop": [
{ "hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-and-continue.sh" } ] }
],
"SubagentStop": [
{ "hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-subagent.sh" } ] }
],
"Notification": [
{ "matcher": "idle_prompt|agent_completed",
"hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.sh" } ] }
]
}
}
注意 PostToolUse 里 ${tool_input.file_path} 的用法——tool_input 是工具事件 stdin JSON 里的字段。但上面写法把变量直接拼进 command 字符串会拿不到值(钩子 command 里的 ${tool_input.file_path} 不是 shell 变量替换,而是需要脚本从 stdin 读)。正确做法是脚本里 jq -r '.tool_input.file_path'。修正后的格式化钩子应改为:
{ "matcher": "Edit|Write",
"hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh" } ] }
format.sh:
#!/bin/bash
f=$(jq -r '.tool_input.file_path' )
[ -f "$f" ] && npx --no-install prettier --write "$f" 2>/dev/null
exit 0
运行:chmod +x .claude/hooks/*.sh && bash scripts/run-agent.sh .claude/task.md
预期行为链:SessionStart 注入分支态势 → UserPromptSubmit 每轮注入 diff/规约 → Agent 用 TodoWrite 拆活 → 改文件触发 PostToolUse 自动 prettier → 危险命令被 PreToolUse 拦 → 每轮结束 Stop 跑 lint,不过就 additionalContext 续命让它修 → 子代理产出无证据被 SubagentStop 打回 → 完成或需要人时 Notification 响铃+webhook。全程无需你在场。
flowchart TD
A[run-agent.sh -p 无头启动] --> B[SessionStart 注入初始态势+环境变量]
B --> C[UserPromptSubmit 每轮注入活水 diff/规约/TODO]
C --> D[Agent 用 TodoWrite 拆解并执行]
D --> E{调用工具?}
E -->|是| F[PreToolUse 拦危险命令]
F --> G[工具执行]
G --> H[PostToolUse 自动格式化]
H --> D
E -->|派子代理| I[SubagentStart]
I --> J[SubagentStop 校验产出证据]
J -->|不合格| I
J -->|合格| D
E -->|否/本轮结束| K[Stop 自检 lint]
K -->|失败| L[additionalContext 续命]
L --> D
K -->|通过| M{需要人?}
M -->|是| N[Notification 响铃+webhook]
M -->|否| O[输出 stream-json result]
N --> O
O --> P[tail 解析 is_error/num_turns/cost]
/hooks 菜单确认已加载;UserPromptSubmit/Stop 等不支持 matcher,别在它们上面写 matcher。WorktreeCreate 外,只有 exit 2 才是阻断;exit 1 是非阻断错误。要 enforce 策略就用 exit 2 或 JSON decision:"block"。UserPromptSubmit/PostToolUse 注入的内容在 resume 时是回放(含过期时间戳/SHA);把"必须保鲜"的采集放进 SessionStart(resume 会重跑)。stop_hook_active,并知道 8 次连续阻断会被强制结束。-p 卡住不退:多半是权限弹窗——--permission-mode acceptEdits 或更严格的 --allowedTools 白名单能解决;不要为了省事直接 bypassPermissions。hookSpecificOutput 字段全表async/asyncRewake 后台钩子:让长任务(跑测试)不阻塞主循环,exit 2 时唤醒 Claudeprompt/agent 类型钩子:用模型本身做 yes/no 判定或带工具校验,适合"语义级围栏"--input-format stream-json:把 Claude Code 当作可编程对话引擎,往里灌结构化消息(含工具结果),构建更复杂的多阶段编排.claude/settings.json + hooks 脚本 + 一个 run-agent.sh 无头驱动器。Notification 钩子的 webhook 接到飞书/钉钉;把 SubagentStop 的校验规则升级为 agent 类型钩子做语义判定;用 --resume 把失败任务接续重跑。additionalContext/terminalSequence/hookSpecificOutput 字段与 exit code 行为的权威定义CLAUDE_ENV_FILE 的深入解读本文由 Claude Code 实战教程系列整理,聚焦"上下文注入类钩子 + -p 无头模式"的自主化路径。文中命令与 schema 基于 Claude Code v2.1.196+ 官方文档核对,实际使用请以本地版本为准。
[!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 等场景全覆盖。
