Skip to content

Commit c1182a4

Browse files
authored
feat: add include_hook_events option (#917)
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`.
1 parent f5a1b67 commit c1182a4

6 files changed

Lines changed: 143 additions & 0 deletions

File tree

src/claude_agent_sdk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
DeferredToolUse,
6868
HookCallback,
6969
HookContext,
70+
HookEventMessage,
7071
HookInput,
7172
HookJSONOutput,
7273
HookMatcher,
@@ -579,6 +580,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
579580
"HookCallback",
580581
"HookContext",
581582
"HookInput",
583+
"HookEventMessage",
582584
"BaseHookInput",
583585
"PreToolUseHookInput",
584586
"PostToolUseHookInput",

src/claude_agent_sdk/_internal/message_parser.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
AssistantMessage,
99
ContentBlock,
1010
DeferredToolUse,
11+
HookEventMessage,
1112
Message,
1213
MirrorErrorMessage,
1314
RateLimitEvent,
@@ -49,6 +50,28 @@ def parse_message(data: dict[str, Any]) -> Message | None:
4950
data,
5051
)
5152

53+
# Hook events (emitted when ``include_hook_events`` is enabled) arrive as
54+
# ``system`` messages with ``subtype`` of ``hook_started`` or
55+
# ``hook_response``. Route them to ``HookEventMessage`` before the generic
56+
# ``SystemMessage`` handling below.
57+
if data.get("type") == "system" and data.get("subtype") in (
58+
"hook_started",
59+
"hook_response",
60+
):
61+
hook_event_name = (
62+
data.get("hook_event")
63+
or data.get("hook_name")
64+
or data.get("hook_event_name")
65+
or ""
66+
)
67+
return HookEventMessage(
68+
subtype=data["subtype"],
69+
hook_event_name=hook_event_name,
70+
data=data,
71+
session_id=data.get("session_id"),
72+
uuid=data.get("uuid"),
73+
)
74+
5275
message_type = data.get("type")
5376
if not message_type:
5477
raise MessageParseError("Message missing 'type' field", data)

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,9 @@ def _build_command(self) -> list[str]:
334334
if self._options.include_partial_messages:
335335
cmd.append("--include-partial-messages")
336336

337+
if self._options.include_hook_events:
338+
cmd.append("--include-hook-events")
339+
337340
if self._options.strict_mcp_config:
338341
cmd.append("--strict-mcp-config")
339342

src/claude_agent_sdk/types.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,40 @@ class RateLimitEvent:
11971197
session_id: str
11981198

11991199

1200+
@dataclass
1201+
class HookEventMessage(SystemMessage):
1202+
"""Hook event emitted by the CLI when ``include_hook_events`` is enabled.
1203+
1204+
When ``ClaudeAgentOptions.include_hook_events`` is ``True``, the CLI emits
1205+
hook lifecycle events (PreToolUse, PostToolUse, Stop, etc.) into the
1206+
message stream. Each event is identified by ``hook_event_name`` and the
1207+
full raw payload is available in ``data``.
1208+
1209+
These arrive on the wire as ``{"type": "system", "subtype":
1210+
"hook_started" | "hook_response", "hook_event": "PreToolUse", ...}``.
1211+
1212+
Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and
1213+
``case SystemMessage()`` checks continue to match. The base ``subtype``
1214+
and ``data`` fields remain populated with the raw payload.
1215+
1216+
Attributes:
1217+
subtype: Lifecycle phase — ``"hook_started"`` when a hook begins
1218+
executing, ``"hook_response"`` when it completes (the latter
1219+
carries ``output``, ``exit_code``, and ``outcome`` keys in
1220+
``data``).
1221+
hook_event_name: Name of the hook event (e.g. ``"PreToolUse"``,
1222+
``"PostToolUse"``, ``"Stop"``).
1223+
data: Full raw event dict from the CLI, including any
1224+
event-specific fields not modeled here.
1225+
session_id: Session ID the event belongs to, if present.
1226+
uuid: Unique ID of the event, if present.
1227+
"""
1228+
1229+
hook_event_name: str = ""
1230+
session_id: str | None = None
1231+
uuid: str | None = None
1232+
1233+
12001234
Message = (
12011235
UserMessage
12021236
| AssistantMessage
@@ -1707,6 +1741,14 @@ class ClaudeAgentOptions:
17071741
When true, ``SDKPartialAssistantMessage`` events are emitted during streaming.
17081742
"""
17091743

1744+
include_hook_events: bool = False
1745+
"""Include hook lifecycle events in the message stream.
1746+
1747+
When true, the CLI emits hook events (PreToolUse, PostToolUse, Stop,
1748+
etc.) as ``HookEventMessage`` objects in the message stream. Matches the
1749+
TypeScript SDK's ``includeHookEvents``.
1750+
"""
1751+
17101752
fork_session: bool = False
17111753
"""When true, resumed sessions fork to a new session ID rather than
17121754
continuing the previous session. Use with ``resume``."""

tests/test_message_parser.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from claude_agent_sdk.types import (
88
AssistantMessage,
99
DeferredToolUse,
10+
HookEventMessage,
1011
RateLimitEvent,
1112
ResultMessage,
1213
ServerToolResultBlock,
@@ -989,3 +990,63 @@ def test_parse_result_message_success_no_errors(self):
989990
assert isinstance(message, ResultMessage)
990991
assert message.errors is None
991992
assert message.result == "Task completed successfully"
993+
994+
def test_parse_hook_event_message(self):
995+
"""Hook started events (system/hook_started) parse into HookEventMessage."""
996+
data = {
997+
"type": "system",
998+
"subtype": "hook_started",
999+
"hook_event": "PreToolUse",
1000+
"hook_name": "PreToolUse",
1001+
"session_id": "sess-123",
1002+
"uuid": "uuid-456",
1003+
"tool_name": "Bash",
1004+
"tool_input": {"command": "ls"},
1005+
}
1006+
message = parse_message(data)
1007+
assert isinstance(message, HookEventMessage)
1008+
assert message.subtype == "hook_started"
1009+
assert message.hook_event_name == "PreToolUse"
1010+
assert message.session_id == "sess-123"
1011+
assert message.uuid == "uuid-456"
1012+
assert message.data == data
1013+
1014+
def test_parse_hook_event_message_response(self):
1015+
"""Hook response events (system/hook_response) parse into HookEventMessage."""
1016+
data = {
1017+
"type": "system",
1018+
"subtype": "hook_response",
1019+
"hook_event": "PostToolUse",
1020+
"hook_name": "PostToolUse",
1021+
"session_id": "sess-123",
1022+
"uuid": "uuid-789",
1023+
"output": "",
1024+
"exit_code": 0,
1025+
"outcome": "success",
1026+
}
1027+
message = parse_message(data)
1028+
assert isinstance(message, HookEventMessage)
1029+
assert message.subtype == "hook_response"
1030+
assert message.hook_event_name == "PostToolUse"
1031+
assert message.session_id == "sess-123"
1032+
assert message.uuid == "uuid-789"
1033+
assert message.data["output"] == ""
1034+
assert message.data["exit_code"] == 0
1035+
assert message.data["outcome"] == "success"
1036+
1037+
def test_parse_hook_event_message_isinstance_system(self):
1038+
"""HookEventMessage is a SystemMessage subclass for backward compat."""
1039+
data = {"type": "system", "subtype": "hook_started", "hook_event": "PreToolUse"}
1040+
message = parse_message(data)
1041+
assert isinstance(message, HookEventMessage)
1042+
assert isinstance(message, SystemMessage)
1043+
1044+
def test_parse_hook_event_message_minimal(self):
1045+
"""Hook events without session_id/uuid/hook_event still parse."""
1046+
data = {"type": "system", "subtype": "hook_started", "hook_name": "Stop"}
1047+
message = parse_message(data)
1048+
assert isinstance(message, HookEventMessage)
1049+
assert message.subtype == "hook_started"
1050+
assert message.hook_event_name == "Stop"
1051+
assert message.session_id is None
1052+
assert message.uuid is None

tests/test_transport.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ def test_build_command_basic(self):
7878
assert "--system-prompt" in cmd
7979
assert cmd[cmd.index("--system-prompt") + 1] == ""
8080

81+
def test_build_command_include_hook_events(self):
82+
"""Test that include_hook_events emits the --include-hook-events flag."""
83+
transport = SubprocessCLITransport(
84+
prompt="Hello", options=make_options(include_hook_events=True)
85+
)
86+
cmd = transport._build_command()
87+
assert "--include-hook-events" in cmd
88+
89+
transport_off = SubprocessCLITransport(prompt="Hello", options=make_options())
90+
cmd_off = transport_off._build_command()
91+
assert "--include-hook-events" not in cmd_off
92+
8193
def test_build_command_strict_mcp_config(self):
8294
"""Test that --strict-mcp-config is emitted only when enabled."""
8395
transport = SubprocessCLITransport(

0 commit comments

Comments
 (0)