← openharness report

T15 — Hook lifecycle system (6 events, 4 types, hot reload)

OpenHarness hook system cho phép inject logic tùy chỉnh vào 6 điểm trong session lifecycle — với 4 loại hook handler và hot reload không cần restart.
Nhóm: D — Extension EcosystemFile: hooks/events.py, hooks/executor.pyEvents: 6 (SESSION_START · SESSION_END · PRE/POST_COMPACT · PRE/POST_TOOL_USE)ID: D.2

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.

Punchline từ Claude Code hooks docs: "Start with auto-formatting on PostToolUse, dangerous command blocking on PreToolUse, and desktop notifications on Stop." Ba use-case này capture 80% lý do người dùng cần hooks — và tất cả đều có trong 6 events của OpenHarness.
Điểm khác biệt quan trọng: "PreToolUse is the only hook that can block actions." Đây là thiết kế có chủ đích — chỉ PRE_TOOL_USE mới có khả năng interrupt execution flow. Tất cả hooks khác chỉ observe hoặc notify, không block. OpenHarness giữ nguyên nguyên tắc này từ Claude Code: PostToolUse hooks không được phép block workflow.
Thực tế sử dụng: "80% of hooks use PreToolUse/PostToolUse; SessionStart is the next most useful." PRE/POST_COMPACT và SESSION_END phục vụ use-case niche hơn (audit trail, context summary).

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

PY
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

PY
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

Session Lifecycle: SESSION_START ──────────────────────────────────────────────┐ (agent khởi động, env vars available) │ │ ┌─── PRE_TOOL_USE ◄─── tool call request │ │ [CÓ THỂ BLOCK — check allowlist, dangerous cmd] │ │ │ │ Agent executes tool │ │ │ └─── POST_TOOL_USE ─── tool result │ [observe only — format, log, notify] │ │ PRE_COMPACT ────────────────────────────────────────────────┤ (trước khi context được compacted/summarized) │ │ POST_COMPACT ───────────────────────────────────────────────┤ (sau compact — useful để log summary stats) │ │ SESSION_END ────────────────────────────────────────────────┘ (cleanup, audit trail, final notification)

Tương tác với các kỹ thuật khác Interaction

T15 (Hook system) tương tác với: T14 (Skill system) └─ POST_TOOL_USE hook fires sau khi skill tool được execute Payload chứa tool_name, result, duration T16 (Plugin manifest) └─ Plugin đóng góp hooks_file qua manifest load_plugins() → extract hooks_file path → feed vào HookExecutor T18 (Permission mode) └─ PRE_TOOL_USE hook có thể block TRƯỚC permission check hoặc SAU — tùy vị trí đăng ký trong pipeline Hook không bypass permission; chúng là layer bổ sung T21 (Async approval) └─ Approval response có thể trigger POST hooks Ví dụ: approved tool call → POST_TOOL_USE với approved=True
Plugin workflow điển hình: T16 load plugin manifest → extract 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

MD
# 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 0

Failure 2: Hot reload race condition

Race condition khi editor lưu hooks file

MD
# 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 gracefully

Failure 3: Prompt hook trong critical path → latency spike

Prompt hook (LLM-based) thêm latency mỗi tool call

MD
# 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

HarnessHook eventsHook typesHot reloadBlock capability
OpenHarness6 (SESSION_START/END · PRE/POST_COMPACT · PRE/POST_TOOL_USE)Command / Prompt / HTTP / AgentYes — mtime pollingPRE_TOOL_USE only
Claude Code5 (PreToolUse · PostToolUse · Stop · SubagentStop · PreCompact)Command onlyNo — requires restartPreToolUse only
AiderNoneN/AN/AN/A
LangGraphBefore/After nodePython callableYes — dynamic graphYes — 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

PY
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)
Nhớ set 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

Nguồn chính