Skip to content

feat: add include_hook_events option#917

Merged
qing-ant merged 5 commits into
mainfrom
feat/include-hook-events
May 5, 2026
Merged

feat: add include_hook_events option#917
qing-ant merged 5 commits into
mainfrom
feat/include-hook-events

Conversation

@qing-ant
Copy link
Copy Markdown
Contributor

@qing-ant qing-ant commented May 5, 2026

Add include_hook_events to ClaudeAgentOptions. When set, hook events (PreToolUse, PostToolUse, Stop, etc.) are emitted by the CLI and yielded from the message stream as HookEventMessage. Matches the TypeScript SDK's includeHookEvents.

Add include_hook_events to ClaudeAgentOptions. When set, the CLI is
passed --include-hook-events and hook lifecycle events (PreToolUse,
PostToolUse, Stop, etc.) are emitted in the stdout message stream. The
SDK parses these into a new HookEventMessage dataclass and yields them
from the message stream. Matches the TypeScript SDK's includeHookEvents.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward additive option that mirrors the existing include_partial_messages pattern and the TypeScript SDK.

Extended reasoning...

Overview

This PR adds an include_hook_events: bool = False option to ClaudeAgentOptions. When enabled, it passes --include-hook-events to the CLI subprocess and parses the resulting hook lifecycle events (PreToolUse, PostToolUse, Stop, etc.) into a new HookEventMessage dataclass yielded from the message stream. Touches types.py (new dataclass + option field + Message union member), message_parser.py (new branch keyed on hook_event_name), subprocess_cli.py (one flag append), __init__.py (export), and adds tests for both the parser and the transport flag.

Security risks

None. This is a read-only opt-in passthrough of CLI output into a typed dataclass. No auth, permissions, filesystem, network, or subprocess-argument-injection surface is affected — the only CLI change is appending a fixed literal flag string.

Level of scrutiny

Low. The change is purely additive and gated behind a default-off boolean, so existing callers see zero behavior change. It is a near-verbatim copy of the adjacent include_partial_messages pattern (option → flag → message type → parser branch → export → tests), and the PR explicitly tracks TypeScript SDK parity.

Other factors

  • The parser branch is placed before the type check because hook events use hook_event_name as their discriminator instead of type; this is clearly commented and preserves the existing "missing type" error for everything else.
  • HookEventMessage keeps the full raw payload in .data (same approach as SystemMessage / RateLimitInfo.raw), so event-specific fields remain accessible without per-event modeling.
  • Test coverage is appropriate: flag on/off in _build_command, and parser tests for both a fully-populated and a minimal hook event.
  • No CODEOWNERS file in the repo; no outstanding review comments on the PR.

@qing-ant
Copy link
Copy Markdown
Contributor Author

qing-ant commented May 5, 2026

E2E proof — include_hook_events stream emission

Method: Checked out feat/include-hook-events (e37e059) into a fresh worktree, verified the locally installed CLI (2.1.128-dev.20260503.t040624.sha496c297) advertises --include-hook-events, then ran query() against a Bash-tool prompt with include_hook_events=True and a control run with include_hook_events=False, collecting every HookEventMessage and SystemMessage from the stream.

Result (include_hook_events=True):

  • HookEventMessage count: 0
  • Event names observed: (none)
  • SystemMessage subtypes observed: {'hook_started': 4, 'hook_response': 4, 'init': 2, 'task_started': 1, 'task_progress': 1, 'task_updated': 1, 'task_notification': 1}

Control (include_hook_events=False):

  • HookEventMessage count: 0 (expected 0)
  • SystemMessage subtypes observed: {'init': 1}

Verdict: ❌ The --include-hook-events flag is wired up correctly and the CLI does emit hook lifecycle events into the stream, but the SDK never surfaces them as HookEventMessage.

Why: message_parser.py discriminates on a top-level hook_event_name key:

if "hook_event_name" in data:
    return HookEventMessage(hook_event_name=data["hook_event_name"], ...)

…but the CLI actually emits hook events as system messages, e.g.:

{"type":"system","subtype":"hook_started","hook_id":"","hook_name":"PreToolUse","hook_event":"PreToolUse","uuid":"","session_id":""}
{"type":"system","subtype":"hook_response","hook_id":"","hook_name":"PreToolUse","hook_event":"PreToolUse","output":"","exit_code":0,"outcome":"success","uuid":"","session_id":""}

There is no top-level hook_event_name field, so the branch never matches and the events fall through to the generic SystemMessage handler. The parser should instead key on type == "system" + subtype in ("hook_started", "hook_response") and read hook_event / hook_name.

(Note: permission_mode="bypassPermissions" was dropped from the proof because the local CLI refuses --dangerously-skip-permissions outside a sandbox; this doesn't affect the result — allowed_tools=["Bash"] is sufficient to trigger PreToolUse/PostToolUse.)

proof.py
import anyio
from collections import Counter
from claude_agent_sdk import query, ClaudeAgentOptions
from claude_agent_sdk.types import HookEventMessage, SystemMessage

PROMPT = "Run `echo hello` using the Bash tool, then stop."

async def run(include: bool):
    opts = ClaudeAgentOptions(
        include_hook_events=include,
        allowed_tools=["Bash"],
        max_turns=4,
    )
    hook_msgs, sys_subtypes = [], Counter()
    async for msg in query(prompt=PROMPT, options=opts):
        if isinstance(msg, HookEventMessage):
            hook_msgs.append(msg.hook_event_name)
        elif isinstance(msg, SystemMessage):
            sys_subtypes[msg.subtype] += 1
    return hook_msgs, sys_subtypes

async def main():
    on_hooks, on_sys = await run(True)
    off_hooks, off_sys = await run(False)
    print("include_hook_events=True  -> HookEventMessage:", len(on_hooks), on_hooks)
    print("                            SystemMessage subtypes:", dict(on_sys))
    print("include_hook_events=False -> HookEventMessage:", len(off_hooks), off_hooks)
    print("                            SystemMessage subtypes:", dict(off_sys))

anyio.run(main)

@qing-ant
Copy link
Copy Markdown
Contributor Author

qing-ant commented May 5, 2026

Live e2e proof (after fix 6164908) ✅

The previous parser keyed on a top-level hook_event_name field, which the CLI never emits — hook events actually arrive as {"type": "system", "subtype": "hook_started" | "hook_response", "hook_event": ..., ...}. Fixed the discriminator and added subtype to HookEventMessage.

Prompt: Run echo hello with the Bash tool, then stop (max_turns=2, allowed_tools=["Bash"])

include_hook_events=True → 8 HookEventMessage(s):

(hook_started, UserPromptSubmit)
(hook_response, UserPromptSubmit)
(hook_started, Stop)
(hook_response, Stop)
(hook_started, UserPromptSubmit)
(hook_response, UserPromptSubmit)
(hook_started, Stop)
(hook_response, Stop)

include_hook_events=False → 0 HookEventMessage(s) (control)

Result: PASS — hook events are now correctly parsed into typed HookEventMessage objects, with subtype distinguishing hook_started vs hook_response.

Comment thread src/claude_agent_sdk/types.py
Comment thread src/claude_agent_sdk/types.py Outdated
HookEventMessage arrives on the wire as a system-typed message
(type=system, subtype=hook_started|hook_response), matching the
established pattern for TaskStartedMessage, TaskProgressMessage,
TaskNotificationMessage, and MirrorErrorMessage. Subclassing
SystemMessage means existing isinstance(msg, SystemMessage) and
case SystemMessage() checks continue to match, and the redundant
HookEventMessage entry can be dropped from the Message union.

Also fix the docstring to reference the actual hook_response keys
(output, exit_code, outcome) instead of a nonexistent response
key, and update the test fixture to match the real wire shape.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both prior comments addressed in c996651 (HookEventMessage now subclasses SystemMessage, docstring/fixture match the real wire shape).

Extended reasoning...

Overview

Adds an opt-in include_hook_events: bool to ClaudeAgentOptions, wires it to the --include-hook-events CLI flag, and introduces HookEventMessage(SystemMessage) parsed from system/hook_started|hook_response frames. Touches types.py, message_parser.py, subprocess_cli.py, __init__.py, plus parser/transport tests. Mirrors the TypeScript SDK's includeHookEvents.

Prior feedback resolved

Commit c996651 addresses both of my earlier inline comments: HookEventMessage now subclasses SystemMessage (with the standard backward-compat docstring note and a dedicated test_parse_hook_event_message_isinstance_system test), it has been removed from the Message union, and the hook_response docstring + test fixture now reference output/exit_code/outcome rather than the non-existent response key.

Security risks

None. This only adds a boolean option that toggles a CLI flag and a passive parser branch that constructs a dataclass from already-trusted CLI stdout. No auth, permissions, filesystem, or subprocess-argument-injection surface is touched.

Level of scrutiny

Low–medium. The feature is opt-in (default False), additive, and follows the exact established pattern used by TaskStartedMessage/TaskProgressMessage/TaskNotificationMessage/MirrorErrorMessage. It has been validated end-to-end against a live CLI in this PR's own thread (8 HookEventMessages observed with the flag on, 0 with it off).

Other factors

Four new unit tests cover hook_started, hook_response (with realistic payload keys), the SystemMessage isinstance contract, and minimal/missing-field handling; a transport test covers flag presence/absence. The bug-hunting pass on this revision found nothing. The change is small, self-contained, and the author has been responsive to every round of feedback.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both prior comments addressed (HookEventMessage now subclasses SystemMessage with an isinstance test, and the docstring/fixture reflect the real output/exit_code/outcome payload).

Extended reasoning...

Overview

Adds an opt-in include_hook_events: bool = False to ClaudeAgentOptions, wires it to the CLI's --include-hook-events flag, and introduces HookEventMessage(SystemMessage) so system/hook_started|hook_response frames are surfaced as typed objects. Touches the public __init__ exports, types.py, the message parser, the subprocess command builder, and adds unit tests for both the parser and transport.

Security risks

None. The change only forwards a boolean flag to the existing CLI subprocess and adds a passive parser branch for an additional system subtype. No new inputs reach a shell, filesystem, or network path; the raw payload is stored verbatim in data exactly like every other SystemMessage subclass.

Level of scrutiny

Low–moderate. This is an additive, opt-in feature mirroring the TypeScript SDK's includeHookEvents, and it follows the established TaskStartedMessage/TaskProgressMessage/MirrorErrorMessage pattern line-for-line (subclass of SystemMessage, not in the Message union, base subtype/data populated). When the option is left at its default the codepath is entirely inert.

Other factors

Both issues I raised on the previous revision were fixed in commit c996651 and are now marked resolved: HookEventMessage subclasses SystemMessage (verified by test_parse_hook_event_message_isinstance_system), and the hook_response docstring + test fixture now reference the real output/exit_code/outcome keys. The author also posted a live e2e proof against CLI 2.1.128-dev showing 8 HookEventMessage objects emitted with the flag on and 0 with it off. The bug-hunting system found nothing on this revision. Four parser tests plus one transport test cover the happy path, minimal payload, and backward-compat isinstance check.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both prior comments addressed (HookEventMessage now subclasses SystemMessage, docstring/fixture match the real wire shape), opt-in feature with good test coverage.

Extended reasoning...

Overview

Adds include_hook_events: bool to ClaudeAgentOptions, wires it to the CLI's --include-hook-events flag, and introduces a HookEventMessage(SystemMessage) dataclass that the parser returns for {"type": "system", "subtype": "hook_started"|"hook_response"} payloads. Exports the new type from __init__.py and adds five unit tests (parser routing, minimal payload, isinstance(SystemMessage) backward-compat, and the transport flag). Mirrors the TypeScript SDK's includeHookEvents.

Security risks

None. The feature is opt-in (default False), purely additive, and only affects how already-emitted CLI JSON is routed into typed dataclasses. No auth, permissions, subprocess invocation, or filesystem changes are touched beyond appending a static flag string.

Level of scrutiny

Low–medium. This follows the exact pattern already used by TaskStartedMessage/TaskProgressMessage/TaskNotificationMessage/MirrorErrorMessage: a SystemMessage subclass with the standard backward-compat docstring note, not added to the Message union (covered by SystemMessage). The parser branch is a simple discriminator with defensive .get() fallbacks for the event-name field. The author posted live e2e captures against CLI 2.1.128-dev confirming the wire format and that the typed messages now surface correctly.

Other factors

I previously left two inline comments on an earlier revision (subclass SystemMessage; fix the response-key docstring claim). Both are marked resolved and verifiably fixed in the current diff — including the suggested isinstance test and the corrected test fixture using output/exit_code/outcome. The current bug-hunting pass found no new issues. Given the established pattern, opt-in default, e2e validation, and full unit coverage, this is safe to approve.

@qing-ant qing-ant merged commit c1182a4 into main May 5, 2026
10 checks passed
@qing-ant qing-ant deleted the feat/include-hook-events branch May 5, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants