ripperdoc 0.2.7__py3-none-any.whl → 0.2.9__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 (87) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +33 -115
  3. ripperdoc/cli/commands/__init__.py +70 -6
  4. ripperdoc/cli/commands/agents_cmd.py +6 -3
  5. ripperdoc/cli/commands/clear_cmd.py +1 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/help_cmd.py +11 -1
  10. ripperdoc/cli/commands/hooks_cmd.py +610 -0
  11. ripperdoc/cli/commands/models_cmd.py +26 -9
  12. ripperdoc/cli/commands/permissions_cmd.py +57 -37
  13. ripperdoc/cli/commands/resume_cmd.py +6 -4
  14. ripperdoc/cli/commands/status_cmd.py +4 -4
  15. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  16. ripperdoc/cli/ui/file_mention_completer.py +64 -8
  17. ripperdoc/cli/ui/interrupt_handler.py +3 -4
  18. ripperdoc/cli/ui/message_display.py +5 -3
  19. ripperdoc/cli/ui/panels.py +13 -10
  20. ripperdoc/cli/ui/provider_options.py +247 -0
  21. ripperdoc/cli/ui/rich_ui.py +196 -77
  22. ripperdoc/cli/ui/spinner.py +25 -1
  23. ripperdoc/cli/ui/tool_renderers.py +8 -2
  24. ripperdoc/cli/ui/wizard.py +215 -0
  25. ripperdoc/core/agents.py +9 -3
  26. ripperdoc/core/config.py +49 -12
  27. ripperdoc/core/custom_commands.py +412 -0
  28. ripperdoc/core/default_tools.py +11 -2
  29. ripperdoc/core/hooks/__init__.py +99 -0
  30. ripperdoc/core/hooks/config.py +301 -0
  31. ripperdoc/core/hooks/events.py +535 -0
  32. ripperdoc/core/hooks/executor.py +496 -0
  33. ripperdoc/core/hooks/integration.py +344 -0
  34. ripperdoc/core/hooks/manager.py +745 -0
  35. ripperdoc/core/permissions.py +40 -8
  36. ripperdoc/core/providers/anthropic.py +548 -68
  37. ripperdoc/core/providers/gemini.py +70 -5
  38. ripperdoc/core/providers/openai.py +60 -5
  39. ripperdoc/core/query.py +140 -39
  40. ripperdoc/core/query_utils.py +2 -0
  41. ripperdoc/core/skills.py +9 -3
  42. ripperdoc/core/system_prompt.py +4 -2
  43. ripperdoc/core/tool.py +9 -5
  44. ripperdoc/sdk/client.py +2 -2
  45. ripperdoc/tools/ask_user_question_tool.py +5 -3
  46. ripperdoc/tools/background_shell.py +2 -1
  47. ripperdoc/tools/bash_output_tool.py +1 -1
  48. ripperdoc/tools/bash_tool.py +30 -20
  49. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  50. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  51. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  52. ripperdoc/tools/file_edit_tool.py +8 -4
  53. ripperdoc/tools/file_read_tool.py +9 -5
  54. ripperdoc/tools/file_write_tool.py +9 -5
  55. ripperdoc/tools/glob_tool.py +3 -2
  56. ripperdoc/tools/grep_tool.py +3 -2
  57. ripperdoc/tools/kill_bash_tool.py +1 -1
  58. ripperdoc/tools/ls_tool.py +1 -1
  59. ripperdoc/tools/mcp_tools.py +13 -10
  60. ripperdoc/tools/multi_edit_tool.py +8 -7
  61. ripperdoc/tools/notebook_edit_tool.py +7 -4
  62. ripperdoc/tools/skill_tool.py +1 -1
  63. ripperdoc/tools/task_tool.py +5 -4
  64. ripperdoc/tools/todo_tool.py +2 -2
  65. ripperdoc/tools/tool_search_tool.py +3 -2
  66. ripperdoc/utils/conversation_compaction.py +11 -7
  67. ripperdoc/utils/file_watch.py +8 -2
  68. ripperdoc/utils/json_utils.py +2 -1
  69. ripperdoc/utils/mcp.py +11 -3
  70. ripperdoc/utils/memory.py +4 -2
  71. ripperdoc/utils/message_compaction.py +21 -7
  72. ripperdoc/utils/message_formatting.py +11 -7
  73. ripperdoc/utils/messages.py +105 -66
  74. ripperdoc/utils/path_ignore.py +38 -12
  75. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  76. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  77. ripperdoc/utils/safe_get_cwd.py +2 -1
  78. ripperdoc/utils/session_history.py +13 -6
  79. ripperdoc/utils/todo.py +2 -1
  80. ripperdoc/utils/token_estimation.py +6 -1
  81. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/METADATA +24 -3
  82. ripperdoc-0.2.9.dist-info/RECORD +123 -0
  83. ripperdoc-0.2.7.dist-info/RECORD +0 -113
  84. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/WHEEL +0 -0
  85. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/entry_points.txt +0 -0
  86. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/licenses/LICENSE +0 -0
  87. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,745 @@
1
+ """Hook manager for coordinating hook execution.
2
+
3
+ This module provides the main interface for triggering hooks
4
+ throughout the application lifecycle.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from ripperdoc.core.hooks.config import (
12
+ HooksConfig,
13
+ HookDefinition,
14
+ get_merged_hooks_config,
15
+ )
16
+ from ripperdoc.core.hooks.events import (
17
+ HookEvent,
18
+ HookDecision,
19
+ HookOutput,
20
+ PreToolUseInput,
21
+ PermissionRequestInput,
22
+ PostToolUseInput,
23
+ UserPromptSubmitInput,
24
+ NotificationInput,
25
+ StopInput,
26
+ SubagentStopInput,
27
+ PreCompactInput,
28
+ SessionStartInput,
29
+ SessionEndInput,
30
+ )
31
+ from ripperdoc.core.hooks.executor import HookExecutor, LLMCallback
32
+ from ripperdoc.utils.log import get_logger
33
+
34
+ logger = get_logger()
35
+
36
+
37
+ class HookResult:
38
+ """Result of running hooks for an event.
39
+
40
+ Aggregates results from all hooks and provides convenience methods
41
+ for checking the overall decision.
42
+ """
43
+
44
+ def __init__(self, outputs: List[HookOutput]):
45
+ self.outputs = outputs
46
+
47
+ @property
48
+ def should_block(self) -> bool:
49
+ """Check if any hook returned a blocking decision."""
50
+ return any(o.decision in (HookDecision.DENY, HookDecision.BLOCK) for o in self.outputs)
51
+
52
+ @property
53
+ def should_allow(self) -> bool:
54
+ """Check if any hook returned an allow decision."""
55
+ return any(o.decision == HookDecision.ALLOW for o in self.outputs)
56
+
57
+ @property
58
+ def should_ask(self) -> bool:
59
+ """Check if any hook returned an ask decision."""
60
+ return any(o.decision == HookDecision.ASK for o in self.outputs)
61
+
62
+ @property
63
+ def should_continue(self) -> bool:
64
+ """Check if execution should continue (no hook set continue=false)."""
65
+ return all(o.should_continue for o in self.outputs)
66
+
67
+ @property
68
+ def block_reason(self) -> Optional[str]:
69
+ """Get the reason for blocking, if any."""
70
+ for o in self.outputs:
71
+ if o.decision in (HookDecision.DENY, HookDecision.BLOCK) and o.reason:
72
+ return o.reason
73
+ return None
74
+
75
+ @property
76
+ def stop_reason(self) -> Optional[str]:
77
+ """Get the stop reason from hooks, if any."""
78
+ for o in self.outputs:
79
+ if o.stop_reason:
80
+ return o.stop_reason
81
+ return None
82
+
83
+ @property
84
+ def additional_context(self) -> Optional[str]:
85
+ """Get combined additional context from all hooks."""
86
+ contexts = []
87
+ for o in self.outputs:
88
+ if o.additional_context:
89
+ contexts.append(o.additional_context)
90
+ return "\n".join(contexts) if contexts else None
91
+
92
+ @property
93
+ def system_message(self) -> Optional[str]:
94
+ """Get system message from hooks, if any."""
95
+ for o in self.outputs:
96
+ if o.system_message:
97
+ return o.system_message
98
+ return None
99
+
100
+ @property
101
+ def updated_input(self) -> Optional[Dict[str, Any]]:
102
+ """Get updated tool input from PreToolUse hooks, if any."""
103
+ for o in self.outputs:
104
+ if o.updated_input:
105
+ return o.updated_input
106
+ return None
107
+
108
+ @property
109
+ def has_errors(self) -> bool:
110
+ """Check if any hook had an error."""
111
+ return any(o.error for o in self.outputs)
112
+
113
+ @property
114
+ def errors(self) -> List[str]:
115
+ """Get all error messages."""
116
+ return [o.error for o in self.outputs if o.error]
117
+
118
+
119
+ class HookManager:
120
+ """Manages hook configuration and execution.
121
+
122
+ This is the main interface for triggering hooks in the application.
123
+ It loads configuration, finds matching hooks, and executes them.
124
+ """
125
+
126
+ def __init__(
127
+ self,
128
+ project_dir: Optional[Path] = None,
129
+ session_id: Optional[str] = None,
130
+ transcript_path: Optional[str] = None,
131
+ permission_mode: str = "default",
132
+ llm_callback: Optional[LLMCallback] = None,
133
+ ):
134
+ """Initialize the hook manager.
135
+
136
+ Args:
137
+ project_dir: The project directory
138
+ session_id: Current session ID for hook input
139
+ transcript_path: Path to the conversation transcript JSON
140
+ permission_mode: Current permission mode (default, plan, acceptEdits, bypassPermissions)
141
+ llm_callback: Async callback for prompt-based hooks. Takes prompt string,
142
+ returns LLM response string. If not set, prompt hooks will
143
+ be skipped with a warning.
144
+ """
145
+ self.project_dir = project_dir
146
+ self.session_id = session_id
147
+ self.transcript_path = transcript_path
148
+ self.permission_mode = permission_mode
149
+ self.llm_callback = llm_callback
150
+ self._config: Optional[HooksConfig] = None
151
+ self._executor: Optional[HookExecutor] = None
152
+
153
+ @property
154
+ def config(self) -> HooksConfig:
155
+ """Get the hooks configuration (lazy loaded)."""
156
+ if self._config is None:
157
+ self._config = get_merged_hooks_config(self.project_dir)
158
+ return self._config
159
+
160
+ @property
161
+ def executor(self) -> HookExecutor:
162
+ """Get the hook executor (lazy created)."""
163
+ if self._executor is None:
164
+ self._executor = HookExecutor(
165
+ project_dir=self.project_dir,
166
+ session_id=self.session_id,
167
+ transcript_path=self.transcript_path,
168
+ llm_callback=self.llm_callback,
169
+ )
170
+ return self._executor
171
+
172
+ def reload_config(self) -> None:
173
+ """Reload hooks configuration from files."""
174
+ self._config = None
175
+ logger.debug("Hooks configuration will be reloaded on next access")
176
+
177
+ def set_project_dir(self, project_dir: Optional[Path]) -> None:
178
+ """Update the project directory and reload config."""
179
+ self.project_dir = project_dir
180
+ self._config = None
181
+ self._executor = None
182
+
183
+ def set_session_id(self, session_id: Optional[str]) -> None:
184
+ """Update the session ID."""
185
+ self.session_id = session_id
186
+ if self._executor:
187
+ self._executor.session_id = session_id
188
+
189
+ def set_transcript_path(self, transcript_path: Optional[str]) -> None:
190
+ """Update the transcript path."""
191
+ self.transcript_path = transcript_path
192
+ if self._executor:
193
+ self._executor.transcript_path = transcript_path
194
+
195
+ def set_permission_mode(self, mode: str) -> None:
196
+ """Update the permission mode."""
197
+ self.permission_mode = mode
198
+
199
+ def set_llm_callback(self, callback: Optional[LLMCallback]) -> None:
200
+ """Update the LLM callback for prompt hooks."""
201
+ self.llm_callback = callback
202
+ if self._executor:
203
+ self._executor.llm_callback = callback
204
+
205
+ def _get_cwd(self) -> Optional[str]:
206
+ """Get current working directory."""
207
+ try:
208
+ return os.getcwd()
209
+ except OSError:
210
+ return str(self.project_dir) if self.project_dir else None
211
+
212
+ def _get_hooks(self, event: HookEvent, tool_name: Optional[str] = None) -> List[HookDefinition]:
213
+ """Get hooks that should run for an event."""
214
+ return self.config.get_hooks_for_event(event, tool_name)
215
+
216
+ def cleanup(self) -> None:
217
+ """Clean up resources (call on session end)."""
218
+ if self._executor:
219
+ self._executor.cleanup_env_file()
220
+
221
+ # --- Pre Tool Use ---
222
+
223
+ def run_pre_tool_use(
224
+ self,
225
+ tool_name: str,
226
+ tool_input: Dict[str, Any],
227
+ tool_use_id: Optional[str] = None,
228
+ ) -> HookResult:
229
+ """Run PreToolUse hooks synchronously.
230
+
231
+ Args:
232
+ tool_name: Name of the tool being called
233
+ tool_input: Input parameters for the tool
234
+ tool_use_id: Unique ID for this tool use
235
+
236
+ Returns:
237
+ HookResult with decision information
238
+ """
239
+ hooks = self._get_hooks(HookEvent.PRE_TOOL_USE, tool_name)
240
+ if not hooks:
241
+ return HookResult([])
242
+
243
+ input_data = PreToolUseInput(
244
+ tool_name=tool_name,
245
+ tool_input=tool_input,
246
+ tool_use_id=tool_use_id,
247
+ session_id=self.session_id,
248
+ transcript_path=self.transcript_path,
249
+ cwd=self._get_cwd(),
250
+ permission_mode=self.permission_mode,
251
+ )
252
+
253
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
254
+ return HookResult(outputs)
255
+
256
+ async def run_pre_tool_use_async(
257
+ self,
258
+ tool_name: str,
259
+ tool_input: Dict[str, Any],
260
+ tool_use_id: Optional[str] = None,
261
+ ) -> HookResult:
262
+ """Run PreToolUse hooks asynchronously."""
263
+ hooks = self._get_hooks(HookEvent.PRE_TOOL_USE, tool_name)
264
+ if not hooks:
265
+ return HookResult([])
266
+
267
+ input_data = PreToolUseInput(
268
+ tool_name=tool_name,
269
+ tool_input=tool_input,
270
+ tool_use_id=tool_use_id,
271
+ session_id=self.session_id,
272
+ transcript_path=self.transcript_path,
273
+ cwd=self._get_cwd(),
274
+ permission_mode=self.permission_mode,
275
+ )
276
+
277
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
278
+ return HookResult(outputs)
279
+
280
+ # --- Permission Request ---
281
+
282
+ def run_permission_request(
283
+ self,
284
+ tool_name: str,
285
+ tool_input: Dict[str, Any],
286
+ tool_use_id: Optional[str] = None,
287
+ ) -> HookResult:
288
+ """Run PermissionRequest hooks synchronously.
289
+
290
+ Args:
291
+ tool_name: Name of the tool requesting permission
292
+ tool_input: Input parameters for the tool
293
+ tool_use_id: Unique ID for this tool use
294
+
295
+ Returns:
296
+ HookResult with decision information
297
+ """
298
+ hooks = self._get_hooks(HookEvent.PERMISSION_REQUEST, tool_name)
299
+ if not hooks:
300
+ return HookResult([])
301
+
302
+ input_data = PermissionRequestInput(
303
+ tool_name=tool_name,
304
+ tool_input=tool_input,
305
+ tool_use_id=tool_use_id,
306
+ session_id=self.session_id,
307
+ transcript_path=self.transcript_path,
308
+ cwd=self._get_cwd(),
309
+ permission_mode=self.permission_mode,
310
+ )
311
+
312
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
313
+ return HookResult(outputs)
314
+
315
+ async def run_permission_request_async(
316
+ self,
317
+ tool_name: str,
318
+ tool_input: Dict[str, Any],
319
+ tool_use_id: Optional[str] = None,
320
+ ) -> HookResult:
321
+ """Run PermissionRequest hooks asynchronously."""
322
+ hooks = self._get_hooks(HookEvent.PERMISSION_REQUEST, tool_name)
323
+ if not hooks:
324
+ return HookResult([])
325
+
326
+ input_data = PermissionRequestInput(
327
+ tool_name=tool_name,
328
+ tool_input=tool_input,
329
+ tool_use_id=tool_use_id,
330
+ session_id=self.session_id,
331
+ transcript_path=self.transcript_path,
332
+ cwd=self._get_cwd(),
333
+ permission_mode=self.permission_mode,
334
+ )
335
+
336
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
337
+ return HookResult(outputs)
338
+
339
+ # --- Post Tool Use ---
340
+
341
+ def run_post_tool_use(
342
+ self,
343
+ tool_name: str,
344
+ tool_input: Dict[str, Any],
345
+ tool_response: Any = None,
346
+ tool_use_id: Optional[str] = None,
347
+ ) -> HookResult:
348
+ """Run PostToolUse hooks synchronously."""
349
+ hooks = self._get_hooks(HookEvent.POST_TOOL_USE, tool_name)
350
+ if not hooks:
351
+ return HookResult([])
352
+
353
+ input_data = PostToolUseInput(
354
+ tool_name=tool_name,
355
+ tool_input=tool_input,
356
+ tool_response=tool_response,
357
+ tool_use_id=tool_use_id,
358
+ session_id=self.session_id,
359
+ transcript_path=self.transcript_path,
360
+ cwd=self._get_cwd(),
361
+ permission_mode=self.permission_mode,
362
+ )
363
+
364
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
365
+ return HookResult(outputs)
366
+
367
+ async def run_post_tool_use_async(
368
+ self,
369
+ tool_name: str,
370
+ tool_input: Dict[str, Any],
371
+ tool_response: Any = None,
372
+ tool_use_id: Optional[str] = None,
373
+ ) -> HookResult:
374
+ """Run PostToolUse hooks asynchronously."""
375
+ hooks = self._get_hooks(HookEvent.POST_TOOL_USE, tool_name)
376
+ if not hooks:
377
+ return HookResult([])
378
+
379
+ input_data = PostToolUseInput(
380
+ tool_name=tool_name,
381
+ tool_input=tool_input,
382
+ tool_response=tool_response,
383
+ tool_use_id=tool_use_id,
384
+ session_id=self.session_id,
385
+ transcript_path=self.transcript_path,
386
+ cwd=self._get_cwd(),
387
+ permission_mode=self.permission_mode,
388
+ )
389
+
390
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
391
+ return HookResult(outputs)
392
+
393
+ # --- User Prompt Submit ---
394
+
395
+ def run_user_prompt_submit(self, prompt: str) -> HookResult:
396
+ """Run UserPromptSubmit hooks synchronously."""
397
+ hooks = self._get_hooks(HookEvent.USER_PROMPT_SUBMIT)
398
+ if not hooks:
399
+ return HookResult([])
400
+
401
+ input_data = UserPromptSubmitInput(
402
+ prompt=prompt,
403
+ session_id=self.session_id,
404
+ transcript_path=self.transcript_path,
405
+ cwd=self._get_cwd(),
406
+ permission_mode=self.permission_mode,
407
+ )
408
+
409
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
410
+ return HookResult(outputs)
411
+
412
+ async def run_user_prompt_submit_async(self, prompt: str) -> HookResult:
413
+ """Run UserPromptSubmit hooks asynchronously."""
414
+ hooks = self._get_hooks(HookEvent.USER_PROMPT_SUBMIT)
415
+ if not hooks:
416
+ return HookResult([])
417
+
418
+ input_data = UserPromptSubmitInput(
419
+ prompt=prompt,
420
+ session_id=self.session_id,
421
+ transcript_path=self.transcript_path,
422
+ cwd=self._get_cwd(),
423
+ permission_mode=self.permission_mode,
424
+ )
425
+
426
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
427
+ return HookResult(outputs)
428
+
429
+ # --- Notification ---
430
+
431
+ def run_notification(self, message: str, notification_type: str = "info") -> HookResult:
432
+ """Run Notification hooks synchronously.
433
+
434
+ Args:
435
+ message: The notification message
436
+ notification_type: Type of notification (permission_prompt, idle_prompt, auth_success, elicitation_dialog)
437
+ """
438
+ hooks = self._get_hooks(HookEvent.NOTIFICATION)
439
+ if not hooks:
440
+ return HookResult([])
441
+
442
+ input_data = NotificationInput(
443
+ message=message,
444
+ notification_type=notification_type,
445
+ session_id=self.session_id,
446
+ transcript_path=self.transcript_path,
447
+ cwd=self._get_cwd(),
448
+ permission_mode=self.permission_mode,
449
+ )
450
+
451
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
452
+ return HookResult(outputs)
453
+
454
+ async def run_notification_async(
455
+ self, message: str, notification_type: str = "info"
456
+ ) -> HookResult:
457
+ """Run Notification hooks asynchronously."""
458
+ hooks = self._get_hooks(HookEvent.NOTIFICATION)
459
+ if not hooks:
460
+ return HookResult([])
461
+
462
+ input_data = NotificationInput(
463
+ message=message,
464
+ notification_type=notification_type,
465
+ session_id=self.session_id,
466
+ transcript_path=self.transcript_path,
467
+ cwd=self._get_cwd(),
468
+ permission_mode=self.permission_mode,
469
+ )
470
+
471
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
472
+ return HookResult(outputs)
473
+
474
+ # --- Stop ---
475
+
476
+ def run_stop(
477
+ self,
478
+ stop_hook_active: bool = False,
479
+ reason: Optional[str] = None,
480
+ stop_sequence: Optional[str] = None,
481
+ ) -> HookResult:
482
+ """Run Stop hooks synchronously.
483
+
484
+ Args:
485
+ stop_hook_active: True if already continuing from a stop hook
486
+ reason: Reason for stopping
487
+ stop_sequence: Stop sequence that triggered the stop
488
+ """
489
+ hooks = self._get_hooks(HookEvent.STOP)
490
+ if not hooks:
491
+ return HookResult([])
492
+
493
+ input_data = StopInput(
494
+ stop_hook_active=stop_hook_active,
495
+ reason=reason,
496
+ stop_sequence=stop_sequence,
497
+ session_id=self.session_id,
498
+ transcript_path=self.transcript_path,
499
+ cwd=self._get_cwd(),
500
+ permission_mode=self.permission_mode,
501
+ )
502
+
503
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
504
+ return HookResult(outputs)
505
+
506
+ async def run_stop_async(
507
+ self,
508
+ stop_hook_active: bool = False,
509
+ reason: Optional[str] = None,
510
+ stop_sequence: Optional[str] = None,
511
+ ) -> HookResult:
512
+ """Run Stop hooks asynchronously."""
513
+ hooks = self._get_hooks(HookEvent.STOP)
514
+ if not hooks:
515
+ return HookResult([])
516
+
517
+ input_data = StopInput(
518
+ stop_hook_active=stop_hook_active,
519
+ reason=reason,
520
+ stop_sequence=stop_sequence,
521
+ session_id=self.session_id,
522
+ transcript_path=self.transcript_path,
523
+ cwd=self._get_cwd(),
524
+ permission_mode=self.permission_mode,
525
+ )
526
+
527
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
528
+ return HookResult(outputs)
529
+
530
+ # --- Subagent Stop ---
531
+
532
+ def run_subagent_stop(self, stop_hook_active: bool = False) -> HookResult:
533
+ """Run SubagentStop hooks synchronously.
534
+
535
+ Args:
536
+ stop_hook_active: True if already continuing from a stop hook
537
+ """
538
+ hooks = self._get_hooks(HookEvent.SUBAGENT_STOP)
539
+ if not hooks:
540
+ return HookResult([])
541
+
542
+ input_data = SubagentStopInput(
543
+ stop_hook_active=stop_hook_active,
544
+ session_id=self.session_id,
545
+ transcript_path=self.transcript_path,
546
+ cwd=self._get_cwd(),
547
+ permission_mode=self.permission_mode,
548
+ )
549
+
550
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
551
+ return HookResult(outputs)
552
+
553
+ async def run_subagent_stop_async(self, stop_hook_active: bool = False) -> HookResult:
554
+ """Run SubagentStop hooks asynchronously."""
555
+ hooks = self._get_hooks(HookEvent.SUBAGENT_STOP)
556
+ if not hooks:
557
+ return HookResult([])
558
+
559
+ input_data = SubagentStopInput(
560
+ stop_hook_active=stop_hook_active,
561
+ session_id=self.session_id,
562
+ transcript_path=self.transcript_path,
563
+ cwd=self._get_cwd(),
564
+ permission_mode=self.permission_mode,
565
+ )
566
+
567
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
568
+ return HookResult(outputs)
569
+
570
+ # --- Pre Compact ---
571
+
572
+ def run_pre_compact(self, trigger: str, custom_instructions: str = "") -> HookResult:
573
+ """Run PreCompact hooks synchronously.
574
+
575
+ Args:
576
+ trigger: "manual" or "auto"
577
+ custom_instructions: Custom instructions passed to /compact
578
+ """
579
+ hooks = self._get_hooks(HookEvent.PRE_COMPACT)
580
+ if not hooks:
581
+ return HookResult([])
582
+
583
+ input_data = PreCompactInput(
584
+ trigger=trigger,
585
+ custom_instructions=custom_instructions,
586
+ session_id=self.session_id,
587
+ transcript_path=self.transcript_path,
588
+ cwd=self._get_cwd(),
589
+ permission_mode=self.permission_mode,
590
+ )
591
+
592
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
593
+ return HookResult(outputs)
594
+
595
+ async def run_pre_compact_async(
596
+ self, trigger: str, custom_instructions: str = ""
597
+ ) -> HookResult:
598
+ """Run PreCompact hooks asynchronously."""
599
+ hooks = self._get_hooks(HookEvent.PRE_COMPACT)
600
+ if not hooks:
601
+ return HookResult([])
602
+
603
+ input_data = PreCompactInput(
604
+ trigger=trigger,
605
+ custom_instructions=custom_instructions,
606
+ session_id=self.session_id,
607
+ transcript_path=self.transcript_path,
608
+ cwd=self._get_cwd(),
609
+ permission_mode=self.permission_mode,
610
+ )
611
+
612
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
613
+ return HookResult(outputs)
614
+
615
+ # --- Session Start ---
616
+
617
+ def run_session_start(self, source: str) -> HookResult:
618
+ """Run SessionStart hooks synchronously.
619
+
620
+ Args:
621
+ source: "startup", "resume", "clear", or "compact"
622
+ """
623
+ hooks = self._get_hooks(HookEvent.SESSION_START)
624
+ if not hooks:
625
+ return HookResult([])
626
+
627
+ input_data = SessionStartInput(
628
+ source=source,
629
+ session_id=self.session_id,
630
+ transcript_path=self.transcript_path,
631
+ cwd=self._get_cwd(),
632
+ permission_mode=self.permission_mode,
633
+ )
634
+
635
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
636
+ return HookResult(outputs)
637
+
638
+ async def run_session_start_async(self, source: str) -> HookResult:
639
+ """Run SessionStart hooks asynchronously."""
640
+ hooks = self._get_hooks(HookEvent.SESSION_START)
641
+ if not hooks:
642
+ return HookResult([])
643
+
644
+ input_data = SessionStartInput(
645
+ source=source,
646
+ session_id=self.session_id,
647
+ transcript_path=self.transcript_path,
648
+ cwd=self._get_cwd(),
649
+ permission_mode=self.permission_mode,
650
+ )
651
+
652
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
653
+ return HookResult(outputs)
654
+
655
+ # --- Session End ---
656
+
657
+ def run_session_end(
658
+ self,
659
+ reason: str,
660
+ duration_seconds: Optional[float] = None,
661
+ message_count: Optional[int] = None,
662
+ ) -> HookResult:
663
+ """Run SessionEnd hooks synchronously.
664
+
665
+ Args:
666
+ reason: "clear", "logout", "prompt_input_exit", or "other"
667
+ duration_seconds: How long the session lasted
668
+ message_count: Number of messages in the session
669
+ """
670
+ hooks = self._get_hooks(HookEvent.SESSION_END)
671
+ if not hooks:
672
+ return HookResult([])
673
+
674
+ input_data = SessionEndInput(
675
+ reason=reason,
676
+ duration_seconds=duration_seconds,
677
+ message_count=message_count,
678
+ session_id=self.session_id,
679
+ transcript_path=self.transcript_path,
680
+ cwd=self._get_cwd(),
681
+ permission_mode=self.permission_mode,
682
+ )
683
+
684
+ outputs = self.executor.execute_hooks_sync(hooks, input_data)
685
+ return HookResult(outputs)
686
+
687
+ async def run_session_end_async(
688
+ self,
689
+ reason: str,
690
+ duration_seconds: Optional[float] = None,
691
+ message_count: Optional[int] = None,
692
+ ) -> HookResult:
693
+ """Run SessionEnd hooks asynchronously."""
694
+ hooks = self._get_hooks(HookEvent.SESSION_END)
695
+ if not hooks:
696
+ return HookResult([])
697
+
698
+ input_data = SessionEndInput(
699
+ reason=reason,
700
+ duration_seconds=duration_seconds,
701
+ message_count=message_count,
702
+ session_id=self.session_id,
703
+ transcript_path=self.transcript_path,
704
+ cwd=self._get_cwd(),
705
+ permission_mode=self.permission_mode,
706
+ )
707
+
708
+ outputs = await self.executor.execute_hooks_async(hooks, input_data)
709
+ return HookResult(outputs)
710
+
711
+
712
+ # Global instance for convenience
713
+ hook_manager = HookManager()
714
+
715
+
716
+ def get_hook_manager() -> HookManager:
717
+ """Get the global hook manager instance."""
718
+ return hook_manager
719
+
720
+
721
+ def init_hook_manager(
722
+ project_dir: Optional[Path] = None,
723
+ session_id: Optional[str] = None,
724
+ transcript_path: Optional[str] = None,
725
+ permission_mode: str = "default",
726
+ llm_callback: Optional[LLMCallback] = None,
727
+ ) -> HookManager:
728
+ """Initialize the global hook manager with project context.
729
+
730
+ Args:
731
+ project_dir: The project directory
732
+ session_id: Current session ID
733
+ transcript_path: Path to the conversation transcript JSON
734
+ permission_mode: Current permission mode
735
+ llm_callback: Async callback for prompt-based hooks
736
+
737
+ Returns:
738
+ The initialized global hook manager
739
+ """
740
+ hook_manager.set_project_dir(project_dir)
741
+ hook_manager.set_session_id(session_id)
742
+ hook_manager.set_transcript_path(transcript_path)
743
+ hook_manager.set_permission_mode(permission_mode)
744
+ hook_manager.set_llm_callback(llm_callback)
745
+ return hook_manager