首页 › tutorials › 动态注入钩子跑通无头Agent

动态注入钩子跑通无头 Agent:用全生命周期钩子驱动 -p 自主流水线

概述

本教程聚焦 Claude Code 最容易被忽视的"上下文注入类"钩子——UserPromptSubmit、SessionStart、Notification、SubagentStop,把它们与 -p 无头模式、--output-format stream-json 机器可读输出、TodoWrite 自主规划组合成一条零干预自主流水线。学完你能让一个无头 Agent 在没有人在场时,自动加载环境、每轮注入新鲜上下文、改完代码自检、需要人时主动提醒、子代理产出先校验再放行。适合已写过 Skill/MCP、想从"交互式助手"迈向"无人值守 Agent"的开发者。

前置条件

为什么换一个角度:四类"活水钩子"才是自主化的关键

近几轮教程已经把"安全钩子围栏""双钩格式化""Stop 钩子自愈跑测试""-p 流式批量引擎"讲透了。它们的共同点是:钩子作为"闸门"或"批处理器"——拦命令、跑格式、批量分发。

但真正决定一个无头 Agent 能不能"自己活下去"的,是另一组钩子:把新鲜上下文像活水一样持续灌进模型上下文窗口的那一类。官方文档把它们归为三种节奏:

UserPromptSubmit 和 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——会话启动即注入"初始态势"

SessionStart 在会话开始或 --resume 恢复时触发。它的输入里有 source(startup/resume/clear/compact)、可选的 model、agent_type、session_title。最关键的是两个输出能力:

  1. additionalContext:在第一轮 prompt 之前就塞进上下文
  2. initialUserMessage:在 -p 非交互模式下,直接成为"第一轮用户消息"——即使你没传 prompt

initialUserMessage 是自主化的利器:你可以让 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 关键词,不需要你再说一遍"先看看现在在哪个分支"。

常见问题:

第三步:UserPromptSubmit——每轮注入"活水"

这是本篇的主角。它在每次提交 prompt 后、模型处理前触发,输入里有 prompt 字段(用户原话)。默认超时 30 秒(比其它事件的 600 秒短),因为它阻塞模型处理——卡住就整个会话卡住。

它有两种"加上下文"的方式:

自主场景推荐用 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 里"死水"的根本优势。

常见问题:

第四步:Stop——自主校验与"不许停"

Stop 在模型每轮回答结束时触发。输入里有三个关键字段:

返回 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 与 terminalSequence——无人值守时主动叫人

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" } ] }
]

第六步:SubagentStop——子代理产出先校验再放行

当你用 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 钩子。

第七步:-p 无头驱动 + stream-json 机器可读输出

钩子搭好"供给系统"后,用一个 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。全程无需你在场。

Mermaid 流程图:全生命周期自主流水线

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]

调试与常见坑

延伸阅读

训练营适用性评估

参考来源

  1. Hooks reference — Claude Code Docs — 官方钩子事件全表、JSON 输入输出 schema、additionalContext/terminalSequence/hookSpecificOutput 字段与 exit code 行为的权威定义
  2. Automate actions with hooks — Claude Code Docs — 官方快速上手指南,讲解三层配置与 matcher/if 过滤
  3. Claude Code Hooks: Complete Guide with 20+ Ready-to-Use Examples (2026) — aiorg.dev — 覆盖 UserPromptSubmit 上下文注入、Stop 续命、Notification 通知等实战配置范例
  4. Session and Context Hooks — DeepWiki (disler/claude-code-hooks-mastery) — 社区对 SessionStart/UserPromptSubmit 等"会话与上下文钩子"生命周期与 CLAUDE_ENV_FILE 的深入解读
  5. Claude Code: Hooks, Subagents, and Skills — Complete Guide (dev.to) — 把钩子、子代理、Skill 串成"可编程 AI"视角的社区教程,SubagentStop 校验思路参考自此

本文由 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 等场景全覆盖。

51学通信知识星球海报-扫码加入