learn-claude-code/s04_hooks/README.md
gui-yue 1baf1aca5a
Some checks are pending
CI / build (push) Waiting to run
Test / python-smoke (push) Waiting to run
Test / web-build (push) Waiting to run
Follow up PR #265: refine chapters, diagrams, and add S20 (#283)
* feat: s01-s14 docs quality overhaul — tool pipeline, single-agent, knowledge & resilience

Rewrite code.py and README (zh/en/ja) for s01-s14, each chapter building
incrementally on the previous. Key fixes across chapters:

- s01-s04: agent loop, tool dispatch, permission pipeline, hooks
- s05-s08: todo write, subagent, skill loading, context compact
- s09-s11: memory system, system prompt assembly, error recovery
- s12-s14: task graph, background tasks, cron scheduler

All chapters CC source-verified. Code inherits fixes forward (PROMPT_SECTIONS,
json.dumps cache, real-state context, can_start dep protection, etc.).

* feat: s15-s19 docs quality overhaul — multi-agent platform: teams, protocols, autonomy, worktree, MCP tools

Rewrite code.py and README (zh/en/ja) for s15-s19, the multi-agent platform
chapters. Each chapter inherits all previous fixes and adds one mechanism:

- s15: agent teams (TeamCreate, teammate threads, shared task list)
- s16: team protocols (plan approval, shutdown handshake, consume_inbox)
- s17: autonomous agents (idle polling, auto-claim, consume_lead_inbox)
- s18: worktree isolation (git worktree, bind_task, cwd switching, safety)
- s19: MCP tools (MCPClient, normalize_mcp_name, assemble_tool_pool, no cache)

All appendix source code references verified against CC source. Config priority
corrected: claude.ai < plugin < user < project < local.

* fix: 5 regressions across s05-s19 — glob safety, todo validation, memory extraction, protocol types, dep crash

- s05-s09: glob results now filter with is_relative_to(WORKDIR) (inherited from s02)
- s06-s08: todo_write validates content/status required fields (inherited from s05)
- s09: extract_memories uses pre-compression snapshot instead of compacted messages
- s16: submit_plan docstring clarifies protocol-only (not code-level gate)
- s17-s19: match_response restores type mismatch validation (from s16)
- s17-s19: claim_task deps list handles missing dep files without crashing

* fix: s12 Todo V2 logic reversal, s14/s15 cron range validation, s18/s19 worktree name validation

- s12 README (zh/en/ja): fix Todo V2 direction — interactive defaults to Task,
  non-interactive/SDK defaults to TodoWrite. Fix env var name to
  CLAUDE_CODE_ENABLE_TASKS (not TODO_V2).
- s14/s15: add _validate_cron_field with per-field range checks (minute 0-59,
  hour 0-23, dom 1-31, month 1-12, dow 0-6), step > 0, range lo <= hi.
  Replace old try/except validation that only caught exceptions.
- s18/s19: add validate_worktree_name() to remove_worktree and keep_worktree,
  not just create_worktree.

* fix: align s16-s19 teaching tool consistency

* fix pr265 chapter diagrams

* Add comprehensive s20 harness chapter

* Fix chapter smoke test regressions

* Clarify README tutorial track transition

---------

Co-authored-by: Haoran <bill-billion@outlook.com>
2026-05-20 21:45:38 +08:00

11 KiB
Raw Permalink Blame History

s04: Hooks — 挂在循环上,不写进循环里

中文 · English · 日本語

s01 → s02 → s03 → s04s05 → s06 → ... → s20

"挂在循环上, 不写进循环里" — hook 在工具执行前后注入扩展逻辑。

Harness 层: hook — 扩展点不侵入循环。


问题

s03 的 Agent 有权限检查了。但每次加一个新检查,比如"记录每次 bash 调用"、"操作后自动 git add",都要修改 agent_loop 函数。

循环很快就变成了这样:

def agent_loop(messages):
    while True:
        # ... LLM call ...
        for block in response.content:
            if block.type == "tool_use":
                log_to_file(block)          # 加一行
                check_permission(block)     # 加一行
                notify_slack(block)         # 又加一行
                output = execute(block)
                auto_git_add(block)         # 再加一行
                # ... 很快循环就认不出来了

你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。


解决方案

Hooks Overview

s03 的循环和权限逻辑完全保留。唯一的变动是把 check_permission() 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 trigger_hooks("PreToolUse", block),由注册表决定跑什么。

四个事件,覆盖一个完整的 agent cycle

事件 触发时机 典型用途
UserPromptSubmit 用户输入提交后、进入 LLM 前 输入验证、注入上下文
PreToolUse 工具执行前 权限检查、日志记录
PostToolUse 工具执行后 副作用(自动 git add 等)、输出检查
Stop 循环即将退出时 收尾清理CC 还支持强制续跑)

扩展通过 register_hook() 添加,循环只调用 trigger_hooks()


工作原理

hook 注册表:一个字典,事件名映射到回调列表。

HOOKS = {
    "UserPromptSubmit": [],
    "PreToolUse": [],
    "PostToolUse": [],
    "Stop": [],
}

def register_hook(event: str, callback):
    HOOKS[event].append(callback)

def trigger_hooks(event: str, *args):
    for callback in HOOKS[event]:
        result = callback(*args)
        if result is not None:   # 返回值 ≠ None → hook 说"停"
            return result
    return None

教学版中PreToolUse 的非 None 返回值会阻止本次工具执行Stop 的非 None 返回值会强制续跑。UserPromptSubmit 和 PostToolUse 的返回值未被使用。

UserPromptSubmit,用户输入提交后、进入 LLM 前触发。CC 中可以拦截或修改输入,教学版只做日志演示:

def context_inject_hook(query: str) -> str | None:
    """Inject current working directory info into every prompt."""
    print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
    return None   # return None = no modification, let prompt through

register_hook("UserPromptSubmit", context_inject_hook)

在主循环中,用户输入后立即触发:

query = input("s04 >> ")
trigger_hooks("UserPromptSubmit", query)   # ← 进入 LLM 之前
history.append({"role": "user", "content": query})
agent_loop(history)

PreToolUse / PostToolUse,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook再加一个日志 hook 和一个大输出提醒:

# PreToolUse: 权限检查s03 的逻辑,从循环移到 hook
def permission_hook(block):
    if block.name == "bash":
        for pattern in DENY_LIST:
            if pattern in block.input.get("command", ""):
                return "Permission denied by deny list"
    if block.name in ("write_file", "edit_file"):
        path = block.input.get("path", "")
        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
            choice = input("   Allow? [y/N] ").strip().lower()
            if choice not in ("y", "yes"):
                return "Permission denied by user"
    return None

# PreToolUse: 日志
def log_hook(block):
    print(f"[HOOK] {block.name}(...)")

# PostToolUse: 大文件提醒
def large_output_hook(block, output):
    if len(str(output)) > 100000:
        print(f"[HOOK] ⚠ Large output from {block.name}")

register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)

Stop,循环即将退出时触发(stop_reason != "tool_use")。教学版用于打印收尾统计:

def summary_hook(messages: list) -> str | None:
    """Print a summary when the loop is about to stop."""
    tool_count = sum(1 for m in messages
                     for b in (m.get("content") if isinstance(m.get("content"), list) else [])
                     if isinstance(b, dict) and b.get("type") == "tool_result")
    print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
    return None   # return None = allow stop, return string = force continuation

register_hook("Stop", summary_hook)

在 agent_loop 中,退出前触发:

if response.stop_reason != "tool_use":
    force = trigger_hooks("Stop", messages)   # ← 退出之前
    if force:
        # hook returned a message → inject it and continue
        messages.append({"role": "user", "content": force})
        continue
    return

循环里只改了一处s03 直接调用 check_permission(block)s04 改为 trigger_hooks("PreToolUse", block)

for block in response.content:
    if block.type != "tool_use":
        continue

    # s03: if not check_permission(block): ...
    # s04: hook 替代硬编码
    blocked = trigger_hooks("PreToolUse", block)
    if blocked:
        results.append({"type": "tool_result", "tool_use_id": block.id,
                        "content": str(blocked)})
        continue

    handler = TOOL_HANDLERS.get(block.name)
    output = handler(**block.input) if handler else f"Unknown: {block.name}"

    trigger_hooks("PostToolUse", block, output)

    results.append({"type": "tool_result", "tool_use_id": block.id,
                    "content": output})

四个 hook 覆盖了 agent cycle 的关键节点:输入→执行前→执行后→退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。


相对 s03 的变更

组件 之前 (s03) 之后 (s04)
扩展方式 check_permission() 硬编码在循环里 HOOKS 注册表 + trigger_hooks()
新函数 register_hook, trigger_hooks
hook 回调 context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook
循环 直接调用 check_permission() 调用 trigger_hooks("PreToolUse", ...)
退出控制 trigger_hooks("Stop", ...) 可阻止退出
输入拦截 trigger_hooks("UserPromptSubmit", ...) 可注入上下文

试一下

cd learn-claude-code
python s04_hooks/code.py

试试这些 prompt

  1. Read the file README.md(应该直接通过,观察 hook 日志)
  2. Create a file called test.txt(通过后观察 PostToolUse 是否触发)
  3. Delete all temporary files in /tmpbash + rm 触发权限 hook

观察重点:每次工具执行前,是否出现了 [HOOK] 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?


接下来

Agent 现在能安全执行操作了。但它有没有停下来想过"我应该先做什么,再做什么"?给它一个复杂任务,它是一上来就动手,还是先列个计划?

s05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。

深入 CC 源码

以下基于 CC 源码 toolHooks.ts650 行)、hooks.tsstopHooks.tscoreTypes.ts 的完整分析。

一、Hook 事件:不止这 4 个,而是 27 个

教学版只讲了 PreToolUse 和 PostToolUse。CC 实际有 27 个 hook 事件(coreTypes.ts:25-53

类别 事件
工具相关 PreToolUse, PostToolUse, PostToolUseFailure
会话相关 SessionStart, SessionEnd, Stop, StopFailure, Setup
用户交互 UserPromptSubmit, Notification, PermissionRequest, PermissionDenied
子 Agent SubagentStart, SubagentStop
压缩相关 PreCompact, PostCompact
团队相关 TeammateIdle, TaskCreated, TaskCompleted
其他 Elicitation, ElicitationResult, ConfigChange, WorktreeCreate, WorktreeRemove, InstructionsLoaded, CwdChanged, FileChanged

教学版只讲 4 个核心事件UserPromptSubmit、PreToolUse、PostToolUse、Stop因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。

二、HookResult 常用字段摘录

CC 的 HookResulttypes/hooks.ts:260-275)有 14 个字段,以下是常用字段:

字段 类型 用途
message Message 可选 UI 消息
blockingError HookBlockingError 阻塞错误 → 注入对话让模型自纠
outcome success/blocking/non_blocking_error/cancelled 执行结果
preventContinuation boolean 阻止后续执行
stopReason string 停止原因描述
permissionBehavior allow/deny/ask/passthrough hook 返回权限决策
updatedInput Record 修改工具输入
additionalContext string 附加上下文
updatedMCPToolOutput unknown MCP 工具输出修改

三、关键不变式Hook 'allow' 不能绕过 deny/ask 规则

这是 CC 权限系统最重要的安全设计(toolHooks.ts:325-331hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则。即使用户的 hook 脚本说"允许",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。

教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。

四、stopHookActive 机制

CC 的 Stop hooks 有一个防无限循环机制(query.ts:212,1300stopHookActive 状态字段。当 stop hooks 产生 blockingError 时,循环带 stopHookActive: true 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错...

五、hook_stopped_continuation

PostToolUse hooks 返回 preventContinuation: true 时,会产生一个 hook_stopped_continuation 附件(toolHooks.ts:117-130。query.tsL1388-1393检测到后设置 shouldPreventContinuation = true,循环退出。这是 "hook 优雅地让 Agent 停机" 的机制,不是崩溃,是完成。

教学版的简化是刻意的

  • 27 个事件 → 4 个UserPromptSubmit/PreToolUse/PostToolUse/Stop覆盖 agent cycle 关键节点
  • 14 个字段 → 简单的返回值None = 继续,非 None = 阻止/续跑):心智负担降到最低
  • Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json 层
  • stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制