T15 — Hook lifecycle system (6 events, 4 types, hot reload)
Tổng quan Overview
Hook system là cơ chế mở rộng nằm ở layer giữa — sau khi agent quyết định làm gì nhưng trước/sau khi action thực sự xảy ra. OpenHarness định nghĩa 6 lifecycle events và 4 loại hook handler, đủ để thực hiện từ auto-formatting đến blocking lệnh nguy hiểm.
Phân tích code: hooks/executor.py Anatomy
HookExecutor — dispatch theo event + type
HookExecutor là entry point chính: nhận event và payload, lọc hooks phù hợp,
dispatch từng hook theo type, và dừng sớm nếu có block signal từ PRE_TOOL_USE.
hooks/executor.py — HookExecutor class
class HookExecutor:
def __init__(self, hooks: list[Hook]):
self._hooks = hooks
self._reloader = HookReloader(hooks)
async def execute(
self, event: HookEvent, payload: dict
) -> HookResult:
await self._reloader.reload_if_changed()
relevant = [h for h in self._hooks if event in h.events]
for hook in relevant:
result = await self._dispatch(hook, payload)
if result.blocked: # only PRE_TOOL_USE can block
return result
return HookResult(blocked=False)
async def _dispatch(self, hook: Hook, payload: dict) -> HookResult:
match hook.type:
case HookType.COMMAND:
return await _run_command(hook, payload)
case HookType.PROMPT:
return await _run_prompt_hook(hook, payload)
case HookType.HTTP:
return await _run_http_hook(hook, payload)
case HookType.AGENT:
return await _run_agent_hook(hook, payload)
Hooks chạy tuần tự theo thứ tự đăng ký —
"Hooks run sequentially in registration order — first match wins for blocking decisions."
Khi một hook trả về blocked=True, vòng lặp dừng ngay lập tức, các hooks
phía sau không được gọi.
HookReloader — hot reload bằng mtime
hooks/executor.py — HookReloader class
class HookReloader:
def __init__(self, hooks_path: Path):
self._path = hooks_path
self._mtime: float | None = None
async def reload_if_changed(self) -> bool:
try:
mtime = self._path.stat().st_mtime
except FileNotFoundError:
return False
if mtime != self._mtime:
self._mtime = mtime
return True # caller re-parses hooks file
return False
Cơ chế đơn giản: so sánh st_mtime trước mỗi execute. Nếu file thay đổi,
caller re-parse hooks file và nạp lại config. Không cần file watcher daemon hay
inotify — chỉ cần stat call mỗi hook execution.
6 HookEvents — vị trí trong lifecycle
Tương tác với các kỹ thuật khác Interaction
hooks_file path → T15 HookExecutor
nhận hook list → T18 permission check có thể xảy ra
trước hoặc sau PRE_TOOL_USE hook tùy config.
Failure modes Failures
Failure 1: PostToolUse hook fails → blocks workflow
Vấn đề: hook exit code != 0 blocking workflow
# BUG: PostToolUse hook formatter thoát với error code khi file bị lock
# → HookExecutor nhận returncode != 0
# → Workflow bị block mặc dù POST_TOOL_USE không được phép block
# Claude Code docs fix:
# "End commands with ; exit 0 for PostToolUse hooks
# so a hook failure doesn't block Claude's workflow unnecessarily"
# BAD hook command:
hooks:
- events: [POST_TOOL_USE]
command: "black {file} && isort {file}" # fails if file locked
# GOOD hook command:
hooks:
- events: [POST_TOOL_USE]
command: "black {file} && isort {file}; exit 0" # always exits 0Failure 2: Hot reload race condition
Race condition khi editor lưu hooks file
# Scenario: editor lưu hooks.yaml theo kiểu write-temp-then-rename
# 1. HookReloader.reload_if_changed() → mtime thay đổi → True
# 2. Caller bắt đầu re-parse hooks.yaml
# 3. Editor chưa rename xong → hooks.yaml bị truncated
# 4. json.loads / yaml.safe_load ném exception
# 5. HookExecutor falls back về empty hook list
# → tất cả hooks bị disabled silently trong 1 execution
# Mitigation: parse với try/except, giữ lại hooks cũ nếu parse fail
try:
new_hooks = parse_hooks_file(path)
self._hooks = new_hooks
except Exception as e:
logger.warning(f"Hot reload failed, keeping old hooks: {e}")
# _hooks unchanged — degrade gracefullyFailure 3: Prompt hook trong critical path → latency spike
Prompt hook (LLM-based) thêm latency mỗi tool call
# Prompt hook = gọi LLM để evaluate hook condition
# Nếu đặt vào PRE_TOOL_USE → mỗi tool call phải chờ LLM response
hooks:
- events: [PRE_TOOL_USE]
type: PROMPT
prompt: "Is this tool call safe? {tool_name} with args {args}"
# → Adds 500ms-2s latency mỗi tool call
# → Nested: tool calls bên trong hook cũng trigger hooks
# Pattern tốt hơn: dùng COMMAND hook với simple rule-based check
# Reserve PROMPT hook cho PostToolUse audit (non-blocking, async)
hooks:
- events: [PRE_TOOL_USE]
type: COMMAND
command: "check_tool_allowlist.sh {tool_name}; exit $?"
- events: [POST_TOOL_USE]
type: PROMPT
prompt: "Summarize what {tool_name} did: {result}"So sánh với các harness khác Compare
| Harness | Hook events | Hook types | Hot reload | Block capability |
|---|---|---|---|---|
| OpenHarness | 6 (SESSION_START/END · PRE/POST_COMPACT · PRE/POST_TOOL_USE) | Command / Prompt / HTTP / Agent | Yes — mtime polling | PRE_TOOL_USE only |
| Claude Code | 5 (PreToolUse · PostToolUse · Stop · SubagentStop · PreCompact) | Command only | No — requires restart | PreToolUse only |
| Aider | None | N/A | N/A | N/A |
| LangGraph | Before/After node | Python callable | Yes — dynamic graph | Yes — any node |
OpenHarness mở rộng Claude Code hook model theo 2 chiều: thêm HTTP và Agent hook types (ngoài Command), và thêm hot reload — tính năng Claude Code chưa có (phải restart để reload hooks). LangGraph linh hoạt hơn (block ở bất kỳ node nào) nhưng cần viết Python, không dùng được shell script hay HTTP endpoint.
Implementation recipe Recipe
Minimal hook executor với 6 events, Command type, và hot reload:
hook_executor.py — minimal implementation
import asyncio, subprocess, json
from enum import Enum
from dataclasses import dataclass
from pathlib import Path
class HookEvent(str, Enum):
SESSION_START = "SESSION_START"
SESSION_END = "SESSION_END"
PRE_COMPACT = "PRE_COMPACT"
POST_COMPACT = "POST_COMPACT"
PRE_TOOL_USE = "PRE_TOOL_USE"
POST_TOOL_USE = "POST_TOOL_USE"
@dataclass
class Hook:
events: list[HookEvent]
command: str # shell command for Command type
can_block: bool = False # only PRE_TOOL_USE hooks
@dataclass
class HookResult:
blocked: bool
reason: str = ""
async def run_command_hook(hook: Hook, payload: dict) -> HookResult:
env_payload = json.dumps(payload)
proc = await asyncio.create_subprocess_shell(
hook.command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(env_payload.encode()), timeout=30)
if proc.returncode != 0 and hook.can_block:
return HookResult(blocked=True, reason=stdout.decode())
return HookResult(blocked=False)can_block=True chỉ cho hooks đăng ký với
PRE_TOOL_USE. PostToolUse hooks nên luôn trả về
blocked=False bất kể exit code — hoặc append ; exit 0
vào command để đảm bảo.
Tham khảo Refs
- Anthropic — Claude Code Hooks docs · Punchlines: PreToolUse blocking, ; exit 0 pattern, 80% usage stats, sequential execution order
- Medium — Claude Code Extensions Explained · Hook types, lifecycle events, start-with pattern
- HumanLayer — Harness Engineering for Coding Agents · Framing hooks trong harness engineering, blocking vs non-blocking
- Anthropic Cookbook — Hooks examples · Ví dụ thực tế: auto-format, dangerous command block, notification
- LangGraph docs — Before/After node hooks · So sánh hook model với LangGraph node lifecycle