openhands-sdk 1.7.0__py3-none-any.whl → 1.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. openhands/sdk/agent/agent.py +31 -1
  2. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +1 -2
  3. openhands/sdk/agent/utils.py +9 -4
  4. openhands/sdk/context/condenser/base.py +11 -6
  5. openhands/sdk/context/condenser/llm_summarizing_condenser.py +167 -18
  6. openhands/sdk/context/condenser/no_op_condenser.py +2 -1
  7. openhands/sdk/context/condenser/pipeline_condenser.py +10 -9
  8. openhands/sdk/context/condenser/utils.py +149 -0
  9. openhands/sdk/context/skills/skill.py +85 -0
  10. openhands/sdk/context/view.py +234 -37
  11. openhands/sdk/conversation/conversation.py +6 -0
  12. openhands/sdk/conversation/impl/local_conversation.py +33 -3
  13. openhands/sdk/conversation/impl/remote_conversation.py +36 -0
  14. openhands/sdk/conversation/state.py +41 -1
  15. openhands/sdk/hooks/__init__.py +30 -0
  16. openhands/sdk/hooks/config.py +180 -0
  17. openhands/sdk/hooks/conversation_hooks.py +227 -0
  18. openhands/sdk/hooks/executor.py +155 -0
  19. openhands/sdk/hooks/manager.py +170 -0
  20. openhands/sdk/hooks/types.py +40 -0
  21. openhands/sdk/io/cache.py +85 -0
  22. openhands/sdk/io/local.py +39 -2
  23. openhands/sdk/llm/mixins/fn_call_converter.py +61 -16
  24. openhands/sdk/llm/mixins/non_native_fc.py +5 -1
  25. openhands/sdk/tool/schema.py +10 -0
  26. {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/METADATA +1 -1
  27. {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/RECORD +29 -21
  28. {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/WHEEL +0 -0
  29. {openhands_sdk-1.7.0.dist-info → openhands_sdk-1.7.1.dist-info}/top_level.txt +0 -0
@@ -94,6 +94,18 @@ class ConversationState(OpenHandsModel):
94
94
  description="List of activated knowledge skills name",
95
95
  )
96
96
 
97
+ # Hook-blocked actions: action_id -> blocking reason
98
+ blocked_actions: dict[str, str] = Field(
99
+ default_factory=dict,
100
+ description="Actions blocked by PreToolUse hooks, keyed by action ID",
101
+ )
102
+
103
+ # Hook-blocked messages: message_id -> blocking reason
104
+ blocked_messages: dict[str, str] = Field(
105
+ default_factory=dict,
106
+ description="Messages blocked by UserPromptSubmit hooks, keyed by message ID",
107
+ )
108
+
97
109
  # Conversation statistics for LLM usage tracking
98
110
  stats: ConversationStats = Field(
99
111
  default_factory=ConversationStats,
@@ -167,7 +179,9 @@ class ConversationState(OpenHandsModel):
167
179
  Else: create fresh (agent required), persist base, and return.
168
180
  """
169
181
  file_store = (
170
- LocalFileStore(persistence_dir) if persistence_dir else InMemoryFileStore()
182
+ LocalFileStore(persistence_dir, cache_limit_size=max_iterations)
183
+ if persistence_dir
184
+ else InMemoryFileStore()
171
185
  )
172
186
 
173
187
  try:
@@ -276,6 +290,32 @@ class ConversationState(OpenHandsModel):
276
290
  f"State change callback failed for field {name}", exc_info=True
277
291
  )
278
292
 
293
+ def block_action(self, action_id: str, reason: str) -> None:
294
+ """Persistently record a hook-blocked action."""
295
+ self.blocked_actions = {**self.blocked_actions, action_id: reason}
296
+
297
+ def pop_blocked_action(self, action_id: str) -> str | None:
298
+ """Remove and return a hook-blocked action reason, if present."""
299
+ if action_id not in self.blocked_actions:
300
+ return None
301
+ updated = dict(self.blocked_actions)
302
+ reason = updated.pop(action_id)
303
+ self.blocked_actions = updated
304
+ return reason
305
+
306
+ def block_message(self, message_id: str, reason: str) -> None:
307
+ """Persistently record a hook-blocked user message."""
308
+ self.blocked_messages = {**self.blocked_messages, message_id: reason}
309
+
310
+ def pop_blocked_message(self, message_id: str) -> str | None:
311
+ """Remove and return a hook-blocked message reason, if present."""
312
+ if message_id not in self.blocked_messages:
313
+ return None
314
+ updated = dict(self.blocked_messages)
315
+ reason = updated.pop(message_id)
316
+ self.blocked_messages = updated
317
+ return reason
318
+
279
319
  @staticmethod
280
320
  def get_unmatched_actions(events: Sequence[Event]) -> list[ActionEvent]:
281
321
  """Find actions in the event history that don't have matching observations.
@@ -0,0 +1,30 @@
1
+ """
2
+ OpenHands Hooks System - Event-driven hooks for automation and control.
3
+
4
+ Hooks are event-driven scripts that execute at specific lifecycle events
5
+ during agent execution, enabling deterministic control over agent behavior.
6
+ """
7
+
8
+ from openhands.sdk.hooks.config import HookConfig, HookDefinition, HookMatcher
9
+ from openhands.sdk.hooks.conversation_hooks import (
10
+ HookEventProcessor,
11
+ create_hook_callback,
12
+ )
13
+ from openhands.sdk.hooks.executor import HookExecutor, HookResult
14
+ from openhands.sdk.hooks.manager import HookManager
15
+ from openhands.sdk.hooks.types import HookDecision, HookEvent, HookEventType
16
+
17
+
18
+ __all__ = [
19
+ "HookConfig",
20
+ "HookDefinition",
21
+ "HookMatcher",
22
+ "HookExecutor",
23
+ "HookResult",
24
+ "HookManager",
25
+ "HookEvent",
26
+ "HookEventType",
27
+ "HookDecision",
28
+ "HookEventProcessor",
29
+ "create_hook_callback",
30
+ ]
@@ -0,0 +1,180 @@
1
+ """Hook configuration loading and management."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from openhands.sdk.hooks.types import HookEventType
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class HookType(str, Enum):
19
+ """Types of hooks that can be executed."""
20
+
21
+ COMMAND = "command" # Shell command executed via subprocess
22
+ PROMPT = "prompt" # LLM-based evaluation (future)
23
+
24
+
25
+ class HookDefinition(BaseModel):
26
+ """A single hook definition."""
27
+
28
+ type: HookType = HookType.COMMAND
29
+ command: str
30
+ timeout: int = 60
31
+
32
+
33
+ class HookMatcher(BaseModel):
34
+ """Matches events to hooks based on patterns.
35
+
36
+ Supports exact match, wildcard (*), and regex (auto-detected or /pattern/).
37
+ """
38
+
39
+ matcher: str = "*"
40
+ hooks: list[HookDefinition] = Field(default_factory=list)
41
+
42
+ # Regex metacharacters that indicate a pattern should be treated as regex
43
+ _REGEX_METACHARACTERS = set("|.*+?[]()^$\\")
44
+
45
+ def matches(self, tool_name: str | None) -> bool:
46
+ """Check if this matcher matches the given tool name."""
47
+ # Wildcard matches everything
48
+ if self.matcher == "*" or self.matcher == "":
49
+ return True
50
+
51
+ if tool_name is None:
52
+ return self.matcher in ("*", "")
53
+
54
+ # Check for explicit regex pattern (enclosed in /)
55
+ is_regex = (
56
+ self.matcher.startswith("/")
57
+ and self.matcher.endswith("/")
58
+ and len(self.matcher) > 2
59
+ )
60
+ if is_regex:
61
+ pattern = self.matcher[1:-1]
62
+ try:
63
+ return bool(re.fullmatch(pattern, tool_name))
64
+ except re.error:
65
+ return False
66
+
67
+ # Auto-detect regex: if matcher contains metacharacters, treat as regex
68
+ if any(c in self.matcher for c in self._REGEX_METACHARACTERS):
69
+ try:
70
+ return bool(re.fullmatch(self.matcher, tool_name))
71
+ except re.error:
72
+ # Invalid regex, fall through to exact match
73
+ pass
74
+
75
+ # Exact match
76
+ return self.matcher == tool_name
77
+
78
+
79
+ class HookConfig(BaseModel):
80
+ """Configuration for all hooks, loaded from .openhands/hooks.json."""
81
+
82
+ hooks: dict[str, list[HookMatcher]] = Field(default_factory=dict)
83
+
84
+ @classmethod
85
+ def load(
86
+ cls, path: str | Path | None = None, working_dir: str | Path | None = None
87
+ ) -> "HookConfig":
88
+ """Load config from path or search .openhands/hooks.json locations.
89
+
90
+ Args:
91
+ path: Explicit path to hooks.json file. If provided, working_dir is ignored.
92
+ working_dir: Project directory for discovering .openhands/hooks.json.
93
+ Falls back to cwd if not provided.
94
+ """
95
+ if path is None:
96
+ # Search for hooks.json in standard locations
97
+ base_dir = Path(working_dir) if working_dir else Path.cwd()
98
+ search_paths = [
99
+ base_dir / ".openhands" / "hooks.json",
100
+ Path.home() / ".openhands" / "hooks.json",
101
+ ]
102
+ for search_path in search_paths:
103
+ if search_path.exists():
104
+ path = search_path
105
+ break
106
+
107
+ if path is None:
108
+ return cls()
109
+
110
+ path = Path(path)
111
+ if not path.exists():
112
+ return cls()
113
+
114
+ try:
115
+ with open(path) as f:
116
+ data = json.load(f)
117
+ return cls.from_dict(data)
118
+ except (json.JSONDecodeError, OSError) as e:
119
+ # Log warning but don't fail - just return empty config
120
+ logger.warning(f"Failed to load hooks from {path}: {e}")
121
+ return cls()
122
+
123
+ @classmethod
124
+ def from_dict(cls, data: dict[str, Any]) -> "HookConfig":
125
+ """Create HookConfig from a dictionary."""
126
+ hooks_data = data.get("hooks", {})
127
+ hooks: dict[str, list[HookMatcher]] = {}
128
+
129
+ for event_type, matchers in hooks_data.items():
130
+ if not isinstance(matchers, list):
131
+ continue
132
+
133
+ hooks[event_type] = []
134
+ for matcher_data in matchers:
135
+ if isinstance(matcher_data, dict):
136
+ # Parse hooks within the matcher
137
+ hook_defs = []
138
+ for hook_data in matcher_data.get("hooks", []):
139
+ if isinstance(hook_data, dict):
140
+ hook_defs.append(HookDefinition(**hook_data))
141
+
142
+ hooks[event_type].append(
143
+ HookMatcher(
144
+ matcher=matcher_data.get("matcher", "*"),
145
+ hooks=hook_defs,
146
+ )
147
+ )
148
+
149
+ return cls(hooks=hooks)
150
+
151
+ def get_hooks_for_event(
152
+ self, event_type: HookEventType, tool_name: str | None = None
153
+ ) -> list[HookDefinition]:
154
+ """Get all hooks that should run for an event."""
155
+ event_key = event_type.value
156
+ matchers = self.hooks.get(event_key, [])
157
+
158
+ result: list[HookDefinition] = []
159
+ for matcher in matchers:
160
+ if matcher.matches(tool_name):
161
+ result.extend(matcher.hooks)
162
+
163
+ return result
164
+
165
+ def has_hooks_for_event(self, event_type: HookEventType) -> bool:
166
+ """Check if there are any hooks configured for an event type."""
167
+ return event_type.value in self.hooks and len(self.hooks[event_type.value]) > 0
168
+
169
+ def to_dict(self) -> dict[str, Any]:
170
+ """Convert to dictionary format for serialization."""
171
+ hooks_dict = {k: [m.model_dump() for m in v] for k, v in self.hooks.items()}
172
+ return {"hooks": hooks_dict}
173
+
174
+ def save(self, path: str | Path) -> None:
175
+ """Save hook configuration to a JSON file."""
176
+ path = Path(path)
177
+ path.parent.mkdir(parents=True, exist_ok=True)
178
+
179
+ with open(path, "w") as f:
180
+ json.dump(self.to_dict(), f, indent=2)
@@ -0,0 +1,227 @@
1
+ """Hook integration for conversations."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from openhands.sdk.event import ActionEvent, Event, MessageEvent, ObservationEvent
6
+ from openhands.sdk.hooks.config import HookConfig
7
+ from openhands.sdk.hooks.manager import HookManager
8
+ from openhands.sdk.hooks.types import HookEventType
9
+ from openhands.sdk.logger import get_logger
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from openhands.sdk.conversation.state import ConversationState
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class HookEventProcessor:
19
+ """Processes events and runs hooks at appropriate points.
20
+
21
+ Call set_conversation_state() after creating Conversation for blocking to work.
22
+
23
+ Note on persistence: HookEvent/HookResult are ephemeral (for hook script I/O).
24
+ If hook execution traces need to be persisted (e.g., for observability), create
25
+ a HookExecutionObservation inheriting from Observation and emit it through the
26
+ event stream, rather than modifying these hook classes.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ hook_manager: HookManager,
32
+ original_callback: Any = None,
33
+ ):
34
+ self.hook_manager = hook_manager
35
+ self.original_callback = original_callback
36
+ self._conversation_state: ConversationState | None = None
37
+
38
+ def set_conversation_state(self, state: "ConversationState") -> None:
39
+ """Set conversation state for blocking support."""
40
+ self._conversation_state = state
41
+
42
+ def on_event(self, event: Event) -> None:
43
+ """Process an event and run appropriate hooks."""
44
+ # Run PreToolUse hooks for action events
45
+ if isinstance(event, ActionEvent) and event.action is not None:
46
+ self._handle_pre_tool_use(event)
47
+
48
+ # Run PostToolUse hooks for observation events
49
+ if isinstance(event, ObservationEvent):
50
+ self._handle_post_tool_use(event)
51
+
52
+ # Run UserPromptSubmit hooks for user messages
53
+ if isinstance(event, MessageEvent) and event.source == "user":
54
+ self._handle_user_prompt_submit(event)
55
+
56
+ # Call original callback
57
+ if self.original_callback:
58
+ self.original_callback(event)
59
+
60
+ def _handle_pre_tool_use(self, event: ActionEvent) -> None:
61
+ """Handle PreToolUse hooks. Blocked actions are marked in conversation state."""
62
+ if not self.hook_manager.has_hooks(HookEventType.PRE_TOOL_USE):
63
+ return
64
+
65
+ tool_name = event.tool_name
66
+ tool_input = {}
67
+
68
+ # Extract tool input from action
69
+ if event.action is not None:
70
+ try:
71
+ tool_input = event.action.model_dump()
72
+ except Exception as e:
73
+ logger.debug(f"Could not extract tool input: {e}")
74
+
75
+ should_continue, results = self.hook_manager.run_pre_tool_use(
76
+ tool_name=tool_name,
77
+ tool_input=tool_input,
78
+ )
79
+
80
+ if not should_continue:
81
+ reason = self.hook_manager.get_blocking_reason(results)
82
+ logger.warning(f"Hook blocked action {tool_name}: {reason}")
83
+
84
+ # Mark this action as blocked in the conversation state
85
+ # The Agent will check this and emit a rejection instead of executing
86
+ if self._conversation_state is not None:
87
+ block_reason = reason or "Blocked by hook"
88
+ self._conversation_state.block_action(event.id, block_reason)
89
+ else:
90
+ logger.warning(
91
+ "Cannot block action: conversation state not set. "
92
+ "Call processor.set_conversation_state(conversation.state) "
93
+ "after creating the Conversation."
94
+ )
95
+
96
+ def _handle_post_tool_use(self, event: ObservationEvent) -> None:
97
+ """Handle PostToolUse hooks after an action completes."""
98
+ if not self.hook_manager.has_hooks(HookEventType.POST_TOOL_USE):
99
+ return
100
+
101
+ # O(1) lookup of corresponding action from state events
102
+ action_event = None
103
+ if self._conversation_state is not None:
104
+ try:
105
+ idx = self._conversation_state.events.get_index(event.action_id)
106
+ event_at_idx = self._conversation_state.events[idx]
107
+ if isinstance(event_at_idx, ActionEvent):
108
+ action_event = event_at_idx
109
+ except KeyError:
110
+ pass # action not found
111
+
112
+ if action_event is None:
113
+ return
114
+
115
+ tool_name = event.tool_name
116
+ tool_input: dict[str, Any] = {}
117
+ tool_response: dict[str, Any] = {}
118
+
119
+ # Extract tool input from action
120
+ if action_event.action is not None:
121
+ try:
122
+ tool_input = action_event.action.model_dump()
123
+ except Exception as e:
124
+ logger.debug(f"Could not extract tool input: {e}")
125
+
126
+ # Extract structured tool response from observation
127
+ if event.observation is not None:
128
+ try:
129
+ tool_response = event.observation.model_dump()
130
+ except Exception as e:
131
+ logger.debug(f"Could not extract tool response: {e}")
132
+
133
+ results = self.hook_manager.run_post_tool_use(
134
+ tool_name=tool_name,
135
+ tool_input=tool_input,
136
+ tool_response=tool_response,
137
+ )
138
+
139
+ # Log any hook errors
140
+ for result in results:
141
+ if result.error:
142
+ logger.warning(f"PostToolUse hook error: {result.error}")
143
+
144
+ def _handle_user_prompt_submit(self, event: MessageEvent) -> None:
145
+ """Handle UserPromptSubmit hooks before processing a user message."""
146
+ if not self.hook_manager.has_hooks(HookEventType.USER_PROMPT_SUBMIT):
147
+ return
148
+
149
+ # Extract message text
150
+ message = ""
151
+ if event.llm_message and event.llm_message.content:
152
+ from openhands.sdk.llm import TextContent
153
+
154
+ for content in event.llm_message.content:
155
+ if isinstance(content, TextContent):
156
+ message += content.text
157
+
158
+ should_continue, additional_context, results = (
159
+ self.hook_manager.run_user_prompt_submit(message=message)
160
+ )
161
+
162
+ if not should_continue:
163
+ reason = self.hook_manager.get_blocking_reason(results)
164
+ logger.warning(f"Hook blocked user message: {reason}")
165
+
166
+ # Mark this message as blocked in the conversation state
167
+ # The Agent will check this and skip processing the message
168
+ if self._conversation_state is not None:
169
+ block_reason = reason or "Blocked by hook"
170
+ self._conversation_state.block_message(event.id, block_reason)
171
+ else:
172
+ logger.warning(
173
+ "Cannot block message: conversation state not set. "
174
+ "Call processor.set_conversation_state(conversation.state) "
175
+ "after creating the Conversation."
176
+ )
177
+
178
+ # TODO: Inject additional_context into the message
179
+ if additional_context:
180
+ logger.info(f"Hook injected context: {additional_context[:100]}...")
181
+
182
+ def is_action_blocked(self, action_id: str) -> bool:
183
+ """Check if an action was blocked by a hook."""
184
+ if self._conversation_state is None:
185
+ return False
186
+ return action_id in self._conversation_state.blocked_actions
187
+
188
+ def is_message_blocked(self, message_id: str) -> bool:
189
+ """Check if a message was blocked by a hook."""
190
+ if self._conversation_state is None:
191
+ return False
192
+ return message_id in self._conversation_state.blocked_messages
193
+
194
+ def run_session_start(self) -> None:
195
+ """Run SessionStart hooks. Call after conversation is created."""
196
+ results = self.hook_manager.run_session_start()
197
+ for r in results:
198
+ if r.error:
199
+ logger.warning(f"SessionStart hook error: {r.error}")
200
+
201
+ def run_session_end(self) -> None:
202
+ """Run SessionEnd hooks. Call before conversation is closed."""
203
+ results = self.hook_manager.run_session_end()
204
+ for r in results:
205
+ if r.error:
206
+ logger.warning(f"SessionEnd hook error: {r.error}")
207
+
208
+
209
+ def create_hook_callback(
210
+ hook_config: HookConfig | None = None,
211
+ working_dir: str | None = None,
212
+ session_id: str | None = None,
213
+ original_callback: Any = None,
214
+ ) -> tuple[HookEventProcessor, Any]:
215
+ """Create a hook-enabled event callback. Returns (processor, callback)."""
216
+ hook_manager = HookManager(
217
+ config=hook_config,
218
+ working_dir=working_dir,
219
+ session_id=session_id,
220
+ )
221
+
222
+ processor = HookEventProcessor(
223
+ hook_manager=hook_manager,
224
+ original_callback=original_callback,
225
+ )
226
+
227
+ return processor, processor.on_event
@@ -0,0 +1,155 @@
1
+ """Hook executor - runs shell commands with JSON I/O."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from openhands.sdk.hooks.config import HookDefinition
10
+ from openhands.sdk.hooks.types import HookDecision, HookEvent
11
+
12
+
13
+ class HookResult(BaseModel):
14
+ """Result from executing a hook.
15
+
16
+ Exit code 0 = success, exit code 2 = block operation.
17
+ """
18
+
19
+ success: bool = True
20
+ blocked: bool = False
21
+ exit_code: int = 0
22
+ stdout: str = ""
23
+ stderr: str = ""
24
+ decision: HookDecision | None = None
25
+ reason: str | None = None
26
+ additional_context: str | None = None
27
+ error: str | None = None
28
+
29
+ @property
30
+ def should_continue(self) -> bool:
31
+ """Whether the operation should continue after this hook."""
32
+ if self.blocked:
33
+ return False
34
+ if self.decision == HookDecision.DENY:
35
+ return False
36
+ return True
37
+
38
+
39
+ class HookExecutor:
40
+ """Executes hook commands with JSON I/O."""
41
+
42
+ def __init__(self, working_dir: str | None = None):
43
+ self.working_dir = working_dir or os.getcwd()
44
+
45
+ def execute(
46
+ self,
47
+ hook: HookDefinition,
48
+ event: HookEvent,
49
+ env: dict[str, str] | None = None,
50
+ ) -> HookResult:
51
+ """Execute a single hook."""
52
+ # Prepare environment
53
+ hook_env = os.environ.copy()
54
+ hook_env["OPENHANDS_PROJECT_DIR"] = self.working_dir
55
+ hook_env["OPENHANDS_SESSION_ID"] = event.session_id or ""
56
+ hook_env["OPENHANDS_EVENT_TYPE"] = event.event_type
57
+ if event.tool_name:
58
+ hook_env["OPENHANDS_TOOL_NAME"] = event.tool_name
59
+
60
+ if env:
61
+ hook_env.update(env)
62
+
63
+ # Serialize event to JSON for stdin
64
+ event_json = event.model_dump_json()
65
+
66
+ try:
67
+ # Execute the hook command
68
+ result = subprocess.run(
69
+ hook.command,
70
+ shell=True,
71
+ cwd=self.working_dir,
72
+ env=hook_env,
73
+ input=event_json,
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=hook.timeout,
77
+ )
78
+
79
+ # Parse the result
80
+ hook_result = HookResult(
81
+ success=result.returncode == 0,
82
+ blocked=result.returncode == 2,
83
+ exit_code=result.returncode,
84
+ stdout=result.stdout,
85
+ stderr=result.stderr,
86
+ )
87
+
88
+ # Try to parse JSON from stdout
89
+ if result.stdout.strip():
90
+ try:
91
+ output_data = json.loads(result.stdout)
92
+ if isinstance(output_data, dict):
93
+ # Parse decision
94
+ if "decision" in output_data:
95
+ decision_str = output_data["decision"].lower()
96
+ if decision_str == "allow":
97
+ hook_result.decision = HookDecision.ALLOW
98
+ elif decision_str == "deny":
99
+ hook_result.decision = HookDecision.DENY
100
+ hook_result.blocked = True
101
+
102
+ # Parse other fields
103
+ if "reason" in output_data:
104
+ hook_result.reason = str(output_data["reason"])
105
+ if "additionalContext" in output_data:
106
+ hook_result.additional_context = str(
107
+ output_data["additionalContext"]
108
+ )
109
+ if "continue" in output_data:
110
+ if not output_data["continue"]:
111
+ hook_result.blocked = True
112
+
113
+ except json.JSONDecodeError:
114
+ # Not JSON, that's okay - just use stdout as-is
115
+ pass
116
+
117
+ return hook_result
118
+
119
+ except subprocess.TimeoutExpired:
120
+ return HookResult(
121
+ success=False,
122
+ exit_code=-1,
123
+ error=f"Hook timed out after {hook.timeout} seconds",
124
+ )
125
+ except FileNotFoundError as e:
126
+ return HookResult(
127
+ success=False,
128
+ exit_code=-1,
129
+ error=f"Hook command not found: {e}",
130
+ )
131
+ except Exception as e:
132
+ return HookResult(
133
+ success=False,
134
+ exit_code=-1,
135
+ error=f"Hook execution failed: {e}",
136
+ )
137
+
138
+ def execute_all(
139
+ self,
140
+ hooks: list[HookDefinition],
141
+ event: HookEvent,
142
+ env: dict[str, str] | None = None,
143
+ stop_on_block: bool = True,
144
+ ) -> list[HookResult]:
145
+ """Execute multiple hooks in order, optionally stopping on block."""
146
+ results: list[HookResult] = []
147
+
148
+ for hook in hooks:
149
+ result = self.execute(hook, event, env)
150
+ results.append(result)
151
+
152
+ if stop_on_block and result.blocked:
153
+ break
154
+
155
+ return results