跪拜 Guibai
← Back to the summary

Hooks Are the Agent's Programmable Spine: 11 Lifecycle Events, 4 Execution Engines


theme: channing-cyan

Understanding Hooks: Programmable Aspects Across the Agent's Full Lifecycle, and How the Four Engines Connect

What This Covers

Previous articles covered what the agent actively does — tools, compression, sub-agents. This one turns it around and looks at the moments when external code can intervene in the agent's lifecycle.

An agent doesn't run as a black box — it has a series of key nodes: session start, user input, before and after tool execution, stop, before and after compression, permission requests. If custom logic can be "hooked" into these nodes, users can extend behavior without modifying the agent's source code: for example, automatically running prettier after every edit_file, filtering sensitive words before every user input, or spawning a sub-agent for intelligent review before every high-risk tool call. This is Hooks — programmable aspects across the agent's full lifecycle.

This article dissects hooks/, four files — types.ts (11 event types + types), registry.ts (registration and dispatch), loader.ts (config → compile → register), shellExecutor.ts (command execution). The core is "one dispatch skeleton, four execution engines": command (spawn shell), http (POST webhook), prompt (inject text), agent (spawn sub-agent for review). After reading, you'll see that slicing the lifecycle into mountable event points and abstracting execution methods into replaceable engines is the key to making an agent extensible without losing control.

1. First, the Big Picture: Which Events Can Be Hooked, and With Which Engines

The key to reading this is the following "lifecycle event × interceptable?" table. 11 event types cover the agent's entire run, but only two can "block":

Event Trigger Point Interceptable? Typical Use
SessionStart Session start, after obtaining sessionId Observe Initialize logging, prepare environment
UserPromptSubmit Before user input enters the agent Interceptable Sensitive word filtering, inject additional context
PreToolUse Before all safety gates (rewrite version) / after approval (compat version) Interceptable + can rewrite args Lint, security scan, second review of high-risk commands, argument normalization
PostToolUse After tool execute Observe (can rewrite model view) Auto-format after edit_file, compress verbose output
Stop Agent main loop exits Observe Exit notification, result reporting
SessionEnd Request ends, before SSE closes Observe Cleanup, statistics
SubagentStart/Stop Sub-agent start/end Observe Monitor sub-agents
PreCompact/PostCompact Before/after context compression Observe Record token changes
PermissionRequest Before permission request is issued Observe Permission audit

Note the "Interceptable" column — among the 11 event types, only PreToolUse and UserPromptSubmit can deny (block); the rest are all "observation" types (look but don't block). This dichotomy is the core of Hooks security: the only points that can block are "before tool execution" and "before user input" — the two points where it's still possible to stop something. Everything else is post-hoc observation and doesn't affect the flow. However, "observation" doesn't mean "powerless" — PreToolUse can now also rewrite args, and PostToolUse can rewrite the result the model sees (resultOverride). This is a third capability added beyond "block/don't block": modify.

Next, "which engine to hook with" — one dispatch skeleton, four execution engines, see the table below:

Engine type What it does deny Decision Basis Applicable Events
command command (default) spawn shell command Non-zero exit code → deny; stdout JSON can carry rewrites All
http http POST context JSON to url Response JSON {deny:true} or non-2xx (JSON can carry rewrites) All
prompt prompt Inject text into agent Does not deny (inject only) UserPromptSubmit only
agent agent spawn sub-agent for intelligent review Sub-agent returns DENY:/ALLOW PreToolUse only

Let's bring this to life with a few scenarios:

Once you understand these two tables, looking at the four files makes the main thread clear: types defines "what events, what engines", loader compiles config into HookRule (one set of run logic per engine), registry's dispatch uniformly distributes, and shellExecutor executes commands. Let's break it down.

2. Event Classification and Dispatch Model

The Interceptable vs Observation Dichotomy

types.ts solidifies this dichotomy with two sets (:168):

export const INTERCEPTABLE_EVENTS = new Set(['PreToolUse', 'UserPromptSubmit']);  // Only these two can deny
export const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);                // Only these two consume matcher

INTERCEPTABLE_EVENTS — only before tool execution and before user input can block. Why these two? Because they are the last two points in the flow where it's "still possible to stop": blocking user input prevents it from entering the agent, blocking tool execution prevents it from writing to disk. Other points (after tool execution, stop, after compression) are fait accompli — blocking is meaningless, only observation and recording are possible.

TOOL_EVENTS — matcher (tool name matching) only makes sense for tool events. Configuring a matcher for SessionStart is meaningless (it has no toolName), and the loader will warn and ignore it.

dispatch: Serial Short-Circuit vs Concurrent

registry.ts's dispatch selects between two execution models based on event type (:74):

This branching is critical: interceptable events must be serial (need ordered judgment, short-circuit, waterfall), but observation events have no order dependency, so concurrency saves time. Also worth noting is the single-point gate design: rewrite capability only takes effect when appConfig.hookRewrite is enabled (determined inside dispatch), when the switch is off, both fields are constantly undefined — callers don't need to check the switch everywhere, and behavior automatically returns to the old world of "only block, no rewrite".

matcher Matching

matcher only takes effect for tool events, filtered by ctx.toolName inside dispatch (:79). matcher supports three types (:27): exact string ("run_command"), wildcard ("*"), regex (/edit|write/), predicate function. This allows hooks to precisely attach to "a certain class of tools" (only edit_file, only run_command), rather than one-size-fits-all for all tools.

3. The Four Execution Engines

The differences among the four engines all lie in loader.ts's compileRule (:241) — the same piece of config, compiled into four different HookRule.run based on type.

command: spawn shell (default)

The most commonly used, existing behavior. compileRule compiles a command into a run, calling shellExecutor (:331):

run: async (ctx) => {
    const res = await executeHookCommand({ command, cwd: ctx.cwd, env: ctx.env, timeoutMs, stdinPayload: ctx });
    if (res.timedOut) return denyOnNonZero ? { deny: true, ... } : { deny: false };
    if (denyOnNonZero && res.exitCode !== 0) return { deny: true, ... };
    if (res.exitCode === 0) {
        const out = parseStdoutDecision(res.stdout);   // ★ stdout JSON decision protocol
        if (out) return out;
    }
    return { deny: false };
}

deny decision basis is exit code: non-zero exit (script determines failure/danger) → deny. denyOnNonZero controls whether this is enabled, default true for PreToolUse (non-zero means block), default false for other events.

Beyond exit code, there's a second decision channel — stdout JSON protocol (parseStdoutDecision at loader.ts :230). When exitCode is 0, the script can output a line of JSON on stdout to participate in blocking/rewriting:

{ "deny": true, "reason": "...", "argsOverride": { ... }, "resultOverride": "..." }

Parsing is very picky: non-JSON, non-object, or containing no known fields → returns undefined silently ignored. This is deliberate backward compatibility — existing hooks that output plain text (like prettier, lint, "Formatted 3 files") are completely unaffected; only scripts that explicitly output decision JSON gain a voice. Exit code handles "block or not", stdout JSON handles "rewrite what" — scripts can participate in fine-grained argument normalization without changing their exit convention.

http: POST webhook

JSON-ifies the entire hook context and POSTs it to a url, deciding based on the response (:296). Decision basis has two layers:

This suits "centralized policy" — a company has an audit service, all agent tool calls are POSTed there, and the service makes unified judgments (after upgrading, it can also uniformly do argument normalization). validateRule forces url to be http/https (prevents protocol injection like file://).

prompt: inject text (does not deny)

This is the most special one — it doesn't execute external commands, doesn't deny, only injects text into the agent context (:249):

if (raw.type === 'prompt') {
    const text = raw.text!;
    return { ...base, run: async () => ({ contextAdditions: [text] }) };
}

Returns contextAdditions, spliced into user input via the UserPromptSubmit seam. validateRule restricts prompt to only be configured on UserPromptSubmit (injection only makes sense "before entering the agent"; injecting after entry is too late). It's essentially "declarative system prompt supplementation" — without modifying agent code, configuring a line of text can influence the model's behavior every turn.

agent: spawn sub-agent for intelligent review

The most powerful and also the most expensive (:255). Every time a tool call is hit, a full sub-agent is spawned, given a review task, and asked to decide according to the DECISION protocol (last line DENY: <reason> or ALLOW):

run: async (ctx) => {
    const tc = ctx?.toolContext;
    if (!tc || (typeof tc.depth === "number" && tc.depth > 0)) return { deny: false };  // ★ depth gate
    const fullTask = ["Review the upcoming tool call...", "Decision protocol: last line DENY:/ALLOW", ...].join("\n");
    const res = await runSubagent({ task: fullTask }, tc, () => agentTools);
    const decision = parseAgentDecision(res.output);  // parse last line DENY/ALLOW
    return decision.deny ? { deny: true, ... } : { deny: false };
}

This is "replacing rigid rules with model intelligence" — command/http rules are rigid (exit code, status code), but agent can understand semantics ("this git push contains untested code, dangerous"). The cost is spawning a sub-agent every time it's hit, significant cost (loader comments explicitly warn "configure cautiously"). validateRule restricts agent to only be configured on PreToolUse (PostToolUse is observation type, deny is ignored, spawning a sub-agent is pure waste).

4. Four Design Decisions

Decision 1: Hook failure must not crash the main flow (fault-tolerance iron law)

This is the highest principle of the Hooks system (registry.ts file header). Two types of events, two types of fault tolerance:

Core idea: hooks are "icing on the cake add-ons", not "essential components of the agent". A bad hook (script error, service timeout) must never prevent the entire agent from running. This iron law runs through the entire system.

Decision 2: Interceptable vs Observation dichotomy (rewrite lands along the same boundary)

11 event types, only 2 can deny. This isn't laziness, it's responsibility boundary — the only points that can block are the two where it's "still possible to stop" (before tool execution, before user input); the rest can only observe. Forcing deny onto "after tool execution" is meaningless (the file has already been modified). This dichotomy strictly limits the strong capability of "interception" to reasonable timing points.

The later-added rewrite capability also lands along this boundary, without opening new openings: PreToolUse rewrites args (the action hasn't happened yet, what's being rewritten is "what will be done"), PostToolUse rewrites result (fait accompli, what's being rewritten is only "the view the model sees" — disk and execution result unchanged, user still sees original output). The three rewrite fields (argsOverride/resultOverride) each guard their own event positions, and there are three safety boundaries: argsOverride must be plain-object (non-object warns and ignores, validation point unified in dispatch — programmatic and declarative hooks share one set); when deny short-circuits, unconsumed overrides are directly discarded (block and rewrite are mutually exclusive, a rejected call won't execute); the master switch DEEP_SEEK_HOOK_REWRITE defaults to off, when off both fields are constantly undefined, the entire system returns to the old world of "only block, no rewrite".

Decision 3: Four engines unified dispatch skeleton

command/http/prompt/agent — four completely different execution methods, externally all are the same HookRule.run. Differences are entirely encapsulated in compileRule's compilation — dispatch is completely unaware of "what engine this hook is". This completely decouples dispatch logic (serial short-circuit/concurrent) from execution logic (four engines). Adding a fifth engine (like a future event evaluator) only touches compileRule, dispatch unchanged by a single line. This is the abstraction of "replaceable execution backend".

Decision 4: Tiered timeout + hard cap

Hooks cannot hang indefinitely (would block the main flow). Timeout is designed as tiered + hard cap (types.ts :186):

SessionStart / UserPromptSubmit: 10_000,   // Block startup and first byte, must be fast
PreToolUse / PostToolUse / Stop: 30_000,   // Single tool-level check
SessionEnd: 60_000,                         // Wrap-up cleanup, most lenient
// Hard cap HARD_TIMEOUT_CAP_MS = 300_000 (5 minutes)

The tiering basis is "event sensitivity to user-perceived latency" — startup/first byte must be fast (user is waiting), wrap-up is most lenient (user is already waiting for response to finish). User-explicitly-configured timeoutMs takes priority, but is still constrained by the 300s hard cap. Hooks are add-ons; their time consumption must not drag down the agent's responsiveness.

5. Six Technical Difficulties

Difficulty 1: The fail-open vs fail-closed trade-off

What happens when a hook itself crashes (throws error, distinct from "normal non-zero exit" denyOnNonZero)? Two choices: let through (fail-open) or deny (fail-closed). Default fail-open — because most hooks are auxiliary (lint, format), their crash shouldn't mistakenly block the agent. But security hooks (high-risk command detection) should be fail-closed — their crash means protection has failed, better to deny than let a high-risk operation through. This trade-off is delegated to each rule via the onError field. No one-size-fits-all, because a hook's "importance" varies by rule.

Difficulty 2: Catching synchronous throws requires Promise.resolve().then()

Concurrent execution of observation events has a hidden pitfall (registry.ts :96 comment):

// ★ Don't write Promise.resolve(rule.run(ctx)), write Promise.resolve().then(() => rule.run(ctx))
Promise.resolve().then(() => rule.run(ctx)).catch(...)

Why? Promise.resolve(rule.run(ctx)) evaluates the argument first (executes run); if run throws synchronously, the exception escapes before .catch is attached. Changing to Promise.resolve().then(() => run(ctx)) — defers run execution into the then callback, at which point catch is already attached, and synchronous exceptions can be caught. This is a JS microtask detail pitfall; writing it wrong causes a synchronously throwing hook to crash the main flow (violating the fault-tolerance iron law).

Difficulty 3: Agent hook's depth gate prevents recursive explosion

The agent engine spawns a sub-agent to review tool calls. But the sub-agent itself will also call tools, and its tool calls will trigger PreToolUse → spawn sub-agent again → tree-like fan-out explosion. Hence the depth gate (:264):

if (!tc || (typeof tc.depth === "number" && tc.depth > 0)) return { deny: false };

Only the main agent's (depth=0) tool calls trigger agent hooks; sub-agent (depth>0) tool calls skip. Note: only the agent engine is subject to this gate; command/http/prompt still trigger at full depth (they don't spawn sub-agents, won't recurse). This is "agent engine-specific recursive risk, agent engine-specific protection".

Difficulty 4: Cross-platform process group killing

Same pitfall as Article 9's MCP. Hook commands execute via shell (spawn({shell:true})); when killing on timeout, just child.kill() only kills sh -c, and the actual command spawned by the shell becomes an orphan and continues running. So (shellExecutor.ts :117):

detached: !isWin,   // Non-Win independent process group
// On timeout:
void killTree(child);   // Win taskkill /T/F, non-Win process.kill(-pid) kill entire group

This is an inherent hassle of the "shell wrapper layer" — the command is in a shell child process, killing the shell doesn't kill the actual command. killTree cross-platform kills the entire tree.

Difficulty 5: Environment variable whitelist + field truncation

Hook commands are user-configured shells; during execution, process.env cannot be fully passed through (contains DEEPSEEKER_CODE_TOKEN/API key). So whitelist pass-through (shellExecutor.ts :29), consistent with Article 9 MCP's MCP_ENV_WHITELISTany place that spawns a child process must prevent secret leakage.

Also stdin payload field truncation (:60): edit_file's old_str/new_str can be very large; passing them to stdin without truncation can stall pipe buffers, causing backpressure slowdown. capFieldStrings recursively truncates fields to 4KB, limiting total size while maintaining valid JSON. This MAX_STDIN_FIELD is also reused by registry.ts for PostToolUse result truncation — single source of truth, avoiding the inconsistency of "nominal 8K actual 4K".

Difficulty 6: HOOK_FILE_PATH injection, simple scripts can work without reading stdin

Hook context is passed as JSON via stdin, but some simple scripts (like npx prettier --write "$HOOK_FILE_PATH") don't read stdin, only use environment variables. So shellExecutor syncs key fields to HOOK_* environment variables (:104): HOOK_SESSION_ID, HOOK_TOOL_NAME, HOOK_PROMPT; tool events additionally inject HOOK_FILE_PATH (extracted from args.path/file_path, resolved to absolute path). This lets "one-liner format hooks" get the target file path without parsing stdin — lowering the barrier to writing simple hooks.

6. Recommended Source Reading Order

  1. Read types.ts in full first: Build the panorama of "11 event types + four engines + interceptable/observation dichotomy". Focus on the two sets INTERCEPTABLE_EVENTS / TOOL_EVENTS, and the DEFAULT_TIMEOUT_BY_EVENT tiers.
  2. Read registry.ts's dispatch (:74): Understand the serial short-circuit (interception) vs concurrent (observation) branching, and the two rewrite pathways — PreToolUse argsOverride waterfall (:127, hook returns → update ctx.args in-place), PostToolUse resultOverride last-wins (:111, preserve registration order concurrent collection). Focus on the Promise.resolve().then() synchronous throw pitfall (:96).
  3. Read loader.ts's compileRule (:241): This is the core of the four engines. Read branch by branch per type, see how the same config compiles into four different runs. Focus on agent's depth gate (:264) and DECISION protocol parsing.
  4. Read validateRule (:129): See the legality constraints for each engine (prompt only UserPromptSubmit, agent only PreToolUse, http forces http/https).
  5. Read shellExecutor.ts in full: See the execution details of the command engine — environment variable whitelist, field truncation, HOOK_FILE_PATH injection, cross-platform process group killing.
  6. Read httpExecutor.ts: http engine execution (POST + response parsing + timeout/abort), compare with shellExecutor to see similarities and differences between the two external executions.

7. Connections: Hooks' Position in the Whole System

Hooks are the agent's "extensible aspects", interlocking with several systems:

You'll see that Hooks isn't an isolated "event system"; it's the interface through which the agent opens up its key nodes — 11 event types are the "open points", four engines are "what can be done at these points", dispatch is "how to do it safely". The fault-tolerance iron law ensures add-ons don't drag down the main body; the interceptable/observation dichotomy ensures strong capabilities are reasonably constrained.

Finally

The most direct idea for making an agent extensible is "expose a bunch of configuration options". But truly flexible extension is "expose a bunch of mountable lifecycle event points, and give each point multiple execution methods". Hooks is exactly this design — all 11 nodes from SessionStart to SessionEnd can have rules mounted, and each rule can be running a command, sending a webhook, injecting a piece of text, or even spawning an intelligent sub-agent to review.

Reading this source code, the two ideas most worth taking away: one is "aspect" thinking — the agent's key nodes are mountable, external logic can intervene non-invasively (lint, audit, inject context); the second is "engine-replaceable" abstraction — four completely different execution methods, externally the same run, differences encapsulated in the compilation layer, dispatch completely unaware. Add to that the fault-tolerance iron law of "hook failure must not crash the main flow", and this Hooks system truly achieves "extensible without losing control".

Next article, we'll read the memory system — see how the agent remembers things across sessions, and the system prompt injection strategy.

The project source is open at github.com/xnk/deepSeekCode; the files mentioned in the article are all under src/core/src/hooks/. Feel free to read along with the source. If you find this guided reading series interesting, a star is my greatest encouragement.

Summary

  1. 11 event types cover the agent's full lifecycle: SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/Stop/SessionEnd + Subagent/Compact/Permission series; only PreToolUse and UserPromptSubmit are interceptable (deny), the rest are all observation types — the only points that can block are the two where it's "still possible to stop";
  2. Four engines unified dispatch skeleton: command (spawn shell, exit code decision + stdout JSON decision protocol) / http (POST webhook, response JSON or status code decision, rewrites only adopted on 2xx) / prompt (inject text, does not deny, UserPromptSubmit only) / agent (spawn sub-agent intelligent review, DECISION protocol, PreToolUse only), differences fully encapsulated in compileRule compilation layer, dispatch branches by "interceptable serial short-circuit / observation concurrent";
  3. Rewrite protocol lands along intercept/observation boundary: PreToolUse returns argsOverride to update arguments in-place (waterfall, subsequent hooks and safety gates all see rewritten values), PostToolUse returns resultOverride to modify model view (last-wins, concurrent not serialized); command via stdout JSON, http via response JSON, non-JSON silently ignored (zero impact on existing hooks); DEEP_SEEK_HOOK_REWRITE defaults off, when off both fields constantly undefined;
  4. Four design decisions: hook failure must not crash main flow (fault-tolerance iron law, interceptable throws decided by onError default allow to prevent mistaken blocking, observation silently swallowed), interceptable vs observation dichotomy (strong capability limited to reasonable timing, rewrite along same boundary), four engines unified skeleton (replaceable execution backend), tiered timeout + hard cap (10/30/60s tiered by latency sensitivity, hard cap 300s);
  5. Six technical difficulties: fail-open vs fail-closed trade-off (onError delegated), Promise.resolve().then() catching synchronous throws (don't Promise.resolve(run())), agent hook depth gate preventing recursive explosion (only depth=0 triggers), cross-platform process group killing (detached + killTree), environment variable whitelist + stdin field truncation (prevent secret leakage + prevent backpressure), HOOK_FILE_PATH injection (simple scripts can work without reading stdin).