s04: Hooks — Hang on the Loop, Don't Write into It
s01 → s02 → s03 → s04 → s05 → s06 → ... → s20
"Hang on the loop, don't write into it" — Hooks inject extension logic before and after tool execution.
Harness Layer: Hooks — Extension points that don't invade the loop.
The Problem
The s03 Agent has permission checks. But every new check, "log every bash call", "auto git add after writes", requires modifying the agent_loop function.
The loop quickly becomes this:
What you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside.
The Solution
)
The s03 loop and permission logic are fully preserved. The only change is moving check_permission() from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls trigger_hooks("PreToolUse", block), and the registry decides what to run.
Four events, covering a complete agent cycle:
Extensions are added via register_hook(). The loop only calls trigger_hooks().
How It Works
Hook registry: a dict mapping event names to callback lists.
In the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused.
UserPromptSubmit, triggers after user input, before entering the LLM. CC can intercept or modify input; the teaching version only logs:
In the main loop, triggered right after user input:
PreToolUse / PostToolUse, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder:
Stop, triggers when the loop is about to exit (stop_reason != "tool_use"). The teaching version prints a cleanup summary:
In agent_loop, triggered before exit:
Only one change in the loop: s03 directly called check_permission(block), s04 replaces it with trigger_hooks("PreToolUse", block):
Four hooks cover the critical nodes of the agent cycle: input → before execution → after execution → exit. The loop only calls trigger_hooks(); all logic lives in hook callbacks.
Changes from s03
Try It
Try these prompts:
Read the file README.md(should pass directly, observe hook logs)Create a file called test.txt(after creation, observe if PostToolUse fires)Delete all temporary files in /tmp(bash + rm triggers permission hook)
What to watch for: Before each tool execution, does the [HOOK] log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop?
What's Next
The Agent can now safely execute operations. But does it ever stop to think "what should I do first, and what next?" Given a complex task, does it jump straight in, or plan first?
→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.
Dive into CC Source Code
The following is based on a complete analysis of CC source code
toolHooks.ts(650 lines),hooks.ts,stopHooks.ts, andcoreTypes.ts.
1. Hook Events: Not Just 4, but 27
The teaching version covers only PreToolUse and PostToolUse. CC actually has 27 hook events (coreTypes.ts:25-53):
The teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern.
2. HookResult Common Fields
CC's HookResult (types/hooks.ts:260-275) has 14 fields. Common ones:
3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules
This is the most important security design in CC's permission system (toolHooks.ts:325-331): when a hook returns allow, it still checks settings.json deny/ask rules. Even if the user's hook script says "allow", if the tool is disabled in settings.json, the operation is still blocked.
The teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production.
4. stopHookActive Mechanism
CC's Stop hooks have an infinite-loop prevention mechanism (query.ts:212,1300): the stopHookActive state field. When stop hooks produce a blockingError, the loop re-enters with stopHookActive: true. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again...
5. hook_stopped_continuation
When PostToolUse hooks return preventContinuation: true, a hook_stopped_continuation attachment is produced (toolHooks.ts:117-130). query.ts (L1388-1393) detects it and sets shouldPreventContinuation = true, causing the loop to exit. This is the mechanism for "hooks gracefully shut down the Agent" — not a crash, but a completion.
Teaching Version Simplifications Are Intentional
- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes
- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load
- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer
- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed
