1pub mod config;
40pub mod external;
41
42pub use config::{load_hooks_config, HookCommand, HookCommandType, HookMatcher, HooksConfig};
43pub use external::{
44 ExternalHookRunner, HookInput, HookResult, PermissionDecision, SdkHookForwarder,
45};
46
47use std::sync::Arc;
48
49use serde_json::Value;
50
51use crate::runtime::RuntimeOutcome;
52use std::collections::HashMap;
53use std::time::Instant;
54
55#[derive(Debug, Clone)]
57pub enum HookAction {
58 Continue,
60 Skip,
62 Error(String),
64}
65
66#[derive(Debug, Clone)]
71#[non_exhaustive]
72pub enum HookEvent<'a> {
73 SessionStart {
75 goal: &'a str,
77 },
78 PreToolCall {
80 name: &'a str,
82 args: &'a Value,
84 },
85 PostToolCall {
87 name: &'a str,
89 args: &'a Value,
91 result: &'a str,
93 duration_ms: u64,
95 },
96 PreCompact {
98 transcript_len: usize,
100 },
101 PostCompact {
103 removed: usize,
105 summary_chars: usize,
107 },
108 SessionEnd {
112 outcome: &'a RuntimeOutcome,
114 },
115 UserPromptSubmit {
117 content: &'a str,
119 },
120 Stop {
122 outcome: &'a RuntimeOutcome,
124 },
125 SubagentStart {
127 goal: &'a str,
129 depth: usize,
131 },
132 SubagentStop {
134 outcome: &'a RuntimeOutcome,
136 depth: usize,
138 },
139 PostToolCallFailure {
141 name: &'a str,
143 args: &'a Value,
145 error: &'a str,
147 },
148 PermissionDenied {
150 tool_name: &'a str,
152 reason: &'a str,
154 },
155 Notification {
157 message: &'a str,
159 },
160 Setup,
162}
163
164pub trait Hook: Send + Sync {
166 fn on_event(&self, event: HookEvent) -> HookAction;
173}
174
175#[derive(Clone, Default)]
182pub struct HookRegistry {
183 hooks: Vec<Arc<dyn Hook>>,
184}
185
186impl HookRegistry {
187 pub fn new() -> Self {
189 Self::default()
190 }
191
192 pub fn register(&mut self, hook: Arc<dyn Hook>) {
194 self.hooks.push(hook);
195 }
196
197 pub fn dispatch(&self, event: HookEvent) -> HookAction {
203 let is_pre_tool = matches!(event, HookEvent::PreToolCall { .. });
204 for hook in &self.hooks {
205 match hook.on_event(event.clone()) {
210 HookAction::Skip if is_pre_tool => return HookAction::Skip,
211 HookAction::Error(msg) if is_pre_tool => return HookAction::Error(msg),
212 _ => continue,
213 }
214 }
215 HookAction::Continue
216 }
217
218 pub fn has_hooks(&self) -> bool {
221 !self.hooks.is_empty()
222 }
223
224 pub fn is_empty(&self) -> bool {
226 self.hooks.is_empty()
227 }
228
229 pub fn len(&self) -> usize {
231 self.hooks.len()
232 }
233}
234
235pub struct ToolTimingHook {
240 start_times: std::sync::Mutex<HashMap<String, Instant>>,
241}
242
243impl ToolTimingHook {
244 pub fn new() -> Self {
245 Self {
246 start_times: std::sync::Mutex::new(HashMap::new()),
247 }
248 }
249
250 #[cfg(test)]
252 fn has_start(&self, name: &str) -> bool {
253 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
254 self.start_times.lock().unwrap().contains_key(name)
255 }
256}
257
258impl Default for ToolTimingHook {
259 fn default() -> Self {
260 Self::new()
261 }
262}
263
264impl Hook for ToolTimingHook {
265 fn on_event(&self, event: HookEvent) -> HookAction {
266 match event {
267 HookEvent::PreToolCall { name, .. } => {
268 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
269 let mut map = self.start_times.lock().unwrap();
270 map.insert(name.to_string(), Instant::now());
271 HookAction::Continue
272 }
273 HookEvent::PostToolCall {
274 name, duration_ms, ..
275 } => {
276 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
279 {
280 let mut map = self.start_times.lock().unwrap();
281 map.remove(name);
282 }
283 eprintln!("[hook] {name} took {duration_ms}ms");
284 HookAction::Continue
285 }
286 _ => HookAction::Continue,
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use std::sync::atomic::{AtomicUsize, Ordering};
295
296 struct SkipHook;
297
298 impl Hook for SkipHook {
299 fn on_event(&self, event: HookEvent) -> HookAction {
300 match event {
301 HookEvent::PreToolCall { .. } => HookAction::Skip,
302 _ => HookAction::Continue,
303 }
304 }
305 }
306
307 struct ErrorHook;
308
309 impl Hook for ErrorHook {
310 fn on_event(&self, event: HookEvent) -> HookAction {
311 match event {
312 HookEvent::PreToolCall { .. } => HookAction::Error("nope".into()),
313 _ => HookAction::Continue,
314 }
315 }
316 }
317
318 #[test]
319 fn empty_registry_returns_continue() {
320 let reg = HookRegistry::new();
321 let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
322 assert!(matches!(action, HookAction::Continue));
323 }
324
325 #[test]
326 fn session_start_fires_with_correct_goal() {
327 let captured = Arc::new(std::sync::Mutex::new(String::new()));
328 let c = captured.clone();
329 struct GoalCapture(Arc<std::sync::Mutex<String>>);
330 impl Hook for GoalCapture {
331 fn on_event(&self, event: HookEvent) -> HookAction {
332 if let HookEvent::SessionStart { goal } = event {
333 *self.0.lock().unwrap() = goal.to_string();
334 }
335 HookAction::Continue
336 }
337 }
338 let mut reg = HookRegistry::new();
339 reg.register(Arc::new(GoalCapture(c)));
340 reg.dispatch(HookEvent::SessionStart { goal: "my goal" });
341 assert_eq!(*captured.lock().unwrap(), "my goal");
342 }
343
344 #[test]
345 fn pre_tool_call_skip_prevents_execution() {
346 let mut reg = HookRegistry::new();
347 reg.register(Arc::new(SkipHook));
348 let action = reg.dispatch(HookEvent::PreToolCall {
349 name: "Write",
350 args: &serde_json::json!({"path": "foo.txt"}),
351 });
352 assert!(matches!(action, HookAction::Skip));
353 }
354
355 #[test]
356 fn pre_tool_call_error_returns_message() {
357 let mut reg = HookRegistry::new();
358 reg.register(Arc::new(ErrorHook));
359 let action = reg.dispatch(HookEvent::PreToolCall {
360 name: "Write",
361 args: &serde_json::json!({"path": "foo.txt"}),
362 });
363 assert!(matches!(action, HookAction::Error(ref msg) if msg == "nope"));
364 }
365
366 #[test]
367 fn skip_and_error_on_non_pre_tool_are_continue() {
368 let mut reg = HookRegistry::new();
369 reg.register(Arc::new(SkipHook));
370 reg.register(Arc::new(ErrorHook));
371 let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
373 assert!(matches!(action, HookAction::Continue));
374 let action = reg.dispatch(HookEvent::PostToolCall {
376 name: "Read",
377 args: &serde_json::json!({"path": "foo.txt"}),
378 result: "ok",
379 duration_ms: 5,
380 });
381 assert!(matches!(action, HookAction::Continue));
382 }
383
384 #[test]
385 fn multiple_hooks_fire_in_order() {
386 let order = Arc::new(std::sync::Mutex::new(Vec::new()));
387 let o1 = order.clone();
388 let o2 = order.clone();
389
390 struct OrdHook(usize, Arc<std::sync::Mutex<Vec<usize>>>);
391 impl Hook for OrdHook {
392 fn on_event(&self, _event: HookEvent) -> HookAction {
393 self.1.lock().unwrap().push(self.0);
394 HookAction::Continue
395 }
396 }
397
398 let mut reg = HookRegistry::new();
399 reg.register(Arc::new(OrdHook(1, o1)));
400 reg.register(Arc::new(OrdHook(2, o2)));
401 reg.dispatch(HookEvent::SessionStart { goal: "test" });
402 assert_eq!(*order.lock().unwrap(), vec![1, 2]);
403 }
404
405 #[test]
406 fn continue_action_does_not_short_circuit() {
407 let count = Arc::new(AtomicUsize::new(0));
412 let c1 = count.clone();
413 let c2 = count.clone();
414
415 struct CountingContinue(Arc<AtomicUsize>);
416 impl Hook for CountingContinue {
417 fn on_event(&self, _event: HookEvent) -> HookAction {
418 self.0.fetch_add(1, Ordering::SeqCst);
419 HookAction::Continue
420 }
421 }
422
423 let mut reg = HookRegistry::new();
424 reg.register(Arc::new(CountingContinue(c1)));
425 reg.register(Arc::new(CountingContinue(c2)));
426 let action = reg.dispatch(HookEvent::SessionStart { goal: "g" });
427 assert!(matches!(action, HookAction::Continue));
428 assert_eq!(
429 count.load(Ordering::SeqCst),
430 2,
431 "both Continue hooks must run"
432 );
433 }
434
435 #[test]
436 fn first_skip_short_circuits_remaining_hooks() {
437 let count = Arc::new(AtomicUsize::new(0));
438 let c1 = count.clone();
439 let c2 = count.clone();
440
441 struct FirstSkip(Arc<AtomicUsize>);
442 impl Hook for FirstSkip {
443 fn on_event(&self, event: HookEvent) -> HookAction {
444 match event {
445 HookEvent::PreToolCall { .. } => HookAction::Skip,
446 _ => {
447 self.0.fetch_add(1, Ordering::SeqCst);
448 HookAction::Continue
449 }
450 }
451 }
452 }
453
454 struct SecondCounter(Arc<AtomicUsize>);
455 impl Hook for SecondCounter {
456 fn on_event(&self, _event: HookEvent) -> HookAction {
457 self.0.fetch_add(1, Ordering::SeqCst);
458 HookAction::Continue
459 }
460 }
461
462 let mut reg = HookRegistry::new();
463 reg.register(Arc::new(FirstSkip(c1)));
464 reg.register(Arc::new(SecondCounter(c2.clone())));
465 let action = reg.dispatch(HookEvent::PreToolCall {
466 name: "Write",
467 args: &serde_json::json!({"path": "foo.txt"}),
468 });
469 assert!(matches!(action, HookAction::Skip));
470 assert_eq!(c2.load(Ordering::SeqCst), 0);
472 }
473
474 #[test]
475 fn post_tool_call_receives_correct_fields() {
476 let captured = Arc::new(std::sync::Mutex::new(None::<(String, String, u64)>));
477 let c = captured.clone();
478 struct CaptureHook(Arc<std::sync::Mutex<Option<(String, String, u64)>>>);
479 impl Hook for CaptureHook {
480 fn on_event(&self, event: HookEvent) -> HookAction {
481 if let HookEvent::PostToolCall {
482 name,
483 result,
484 duration_ms,
485 ..
486 } = event
487 {
488 *self.0.lock().unwrap() =
489 Some((name.to_string(), result.to_string(), duration_ms));
490 }
491 HookAction::Continue
492 }
493 }
494 let mut reg = HookRegistry::new();
495 reg.register(Arc::new(CaptureHook(c)));
496 reg.dispatch(HookEvent::PostToolCall {
497 name: "Read",
498 args: &serde_json::json!({"path": "foo.txt"}),
499 result: "file contents",
500 duration_ms: 42,
501 });
502 let captured = captured.lock().unwrap().clone().unwrap();
503 assert_eq!(captured.0, "Read");
504 assert_eq!(captured.1, "file contents");
505 assert_eq!(captured.2, 42);
506 }
507
508 #[test]
509 fn session_end_receives_outcome() {
510 let captured = Arc::new(std::sync::Mutex::new(None));
511 let c = captured.clone();
512 struct CaptureOutcome(Arc<std::sync::Mutex<Option<RuntimeOutcome>>>);
513 impl Hook for CaptureOutcome {
514 fn on_event(&self, event: HookEvent) -> HookAction {
515 if let HookEvent::SessionEnd { outcome } = event {
516 *self.0.lock().unwrap() = Some(outcome.clone());
517 }
518 HookAction::Continue
519 }
520 }
521 let outcome = RuntimeOutcome {
522 final_text: Some("done".into()),
523 finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
524 total_usage: crate::llm::TokenUsage::default(),
525 steps: 3,
526 llm_latency_ms: 100,
527 checkpoint_id: None,
528 };
529 let mut reg = HookRegistry::new();
530 reg.register(Arc::new(CaptureOutcome(c)));
531 reg.dispatch(HookEvent::SessionEnd { outcome: &outcome });
532 let captured = captured.lock().unwrap().take().unwrap();
533 assert_eq!(captured.final_text.as_deref(), Some("done"));
534 assert_eq!(captured.steps, 3);
535 }
536
537 #[test]
538 fn pre_compact_receives_transcript_len() {
539 let captured = Arc::new(std::sync::Mutex::new(0usize));
540 let c = captured.clone();
541 struct CaptureLen(Arc<std::sync::Mutex<usize>>);
542 impl Hook for CaptureLen {
543 fn on_event(&self, event: HookEvent) -> HookAction {
544 if let HookEvent::PreCompact { transcript_len } = event {
545 *self.0.lock().unwrap() = transcript_len;
546 }
547 HookAction::Continue
548 }
549 }
550 let mut reg = HookRegistry::new();
551 reg.register(Arc::new(CaptureLen(c)));
552 reg.dispatch(HookEvent::PreCompact {
553 transcript_len: 5000,
554 });
555 assert_eq!(*captured.lock().unwrap(), 5000);
556 }
557
558 #[test]
559 fn post_compact_receives_removed_and_summary_chars() {
560 let captured = Arc::new(std::sync::Mutex::new((0usize, 0usize)));
561 let c = captured.clone();
562 struct CaptureCompact(Arc<std::sync::Mutex<(usize, usize)>>);
563 impl Hook for CaptureCompact {
564 fn on_event(&self, event: HookEvent) -> HookAction {
565 if let HookEvent::PostCompact {
566 removed,
567 summary_chars,
568 } = event
569 {
570 *self.0.lock().unwrap() = (removed, summary_chars);
571 }
572 HookAction::Continue
573 }
574 }
575 let mut reg = HookRegistry::new();
576 reg.register(Arc::new(CaptureCompact(c)));
577 reg.dispatch(HookEvent::PostCompact {
578 removed: 10,
579 summary_chars: 200,
580 });
581 assert_eq!(captured.lock().unwrap().0, 10);
582 assert_eq!(captured.lock().unwrap().1, 200);
583 }
584
585 #[test]
586 fn hook_event_is_non_exhaustive() {
587 let _ = HookEvent::SessionStart { goal: "test" };
589 }
590
591 #[test]
594 fn new_events_compile_and_dispatch() {
595 let reg = HookRegistry::new();
596 let outcome = RuntimeOutcome {
597 final_text: None,
598 finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
599 total_usage: crate::llm::TokenUsage::default(),
600 steps: 0,
601 llm_latency_ms: 0,
602 checkpoint_id: None,
603 };
604 let empty_args = serde_json::json!({});
605
606 let action = reg.dispatch(HookEvent::UserPromptSubmit { content: "hello" });
607 assert!(matches!(action, HookAction::Continue));
608 let action = reg.dispatch(HookEvent::Stop { outcome: &outcome });
609 assert!(matches!(action, HookAction::Continue));
610 let action = reg.dispatch(HookEvent::SubagentStart {
611 goal: "sub-goal",
612 depth: 1,
613 });
614 assert!(matches!(action, HookAction::Continue));
615 let action = reg.dispatch(HookEvent::SubagentStop {
616 outcome: &outcome,
617 depth: 1,
618 });
619 assert!(matches!(action, HookAction::Continue));
620 let action = reg.dispatch(HookEvent::PostToolCallFailure {
621 name: "Bash",
622 args: &empty_args,
623 error: "err",
624 });
625 assert!(matches!(action, HookAction::Continue));
626 let action = reg.dispatch(HookEvent::PermissionDenied {
627 tool_name: "Write",
628 reason: "not allowed",
629 });
630 assert!(matches!(action, HookAction::Continue));
631 let action = reg.dispatch(HookEvent::Notification { message: "hi" });
632 assert!(matches!(action, HookAction::Continue));
633 let action = reg.dispatch(HookEvent::Setup);
634 assert!(matches!(action, HookAction::Continue));
635 }
636
637 #[test]
638 fn post_tool_call_failure_dispatched_to_hook() {
639 let captured = Arc::new(std::sync::Mutex::new(None::<(String, String)>));
640 let c = captured.clone();
641 struct CaptureFailure(Arc<std::sync::Mutex<Option<(String, String)>>>);
642 impl Hook for CaptureFailure {
643 fn on_event(&self, event: HookEvent) -> HookAction {
644 if let HookEvent::PostToolCallFailure { name, error, .. } = event {
645 *self.0.lock().unwrap() = Some((name.to_string(), error.to_string()));
646 }
647 HookAction::Continue
648 }
649 }
650 let mut reg = HookRegistry::new();
651 reg.register(Arc::new(CaptureFailure(c)));
652 let args = serde_json::json!({"command": "rm -rf /"});
653 reg.dispatch(HookEvent::PostToolCallFailure {
654 name: "Bash",
655 args: &args,
656 error: "permission denied",
657 });
658 let cap = captured.lock().unwrap().clone().unwrap();
659 assert_eq!(cap.0, "Bash");
660 assert_eq!(cap.1, "permission denied");
661 }
662
663 #[test]
664 fn user_prompt_submit_received_by_hook() {
665 let captured = Arc::new(std::sync::Mutex::new(String::new()));
666 let c = captured.clone();
667 struct CapturePrompt(Arc<std::sync::Mutex<String>>);
668 impl Hook for CapturePrompt {
669 fn on_event(&self, event: HookEvent) -> HookAction {
670 if let HookEvent::UserPromptSubmit { content } = event {
671 *self.0.lock().unwrap() = content.to_string();
672 }
673 HookAction::Continue
674 }
675 }
676 let mut reg = HookRegistry::new();
677 reg.register(Arc::new(CapturePrompt(c)));
678 reg.dispatch(HookEvent::UserPromptSubmit {
679 content: "test prompt",
680 });
681 assert_eq!(*captured.lock().unwrap(), "test prompt");
682 }
683
684 #[test]
685 fn tool_timing_hook_prints_to_stderr_on_post_tool_call() {
686 let hook = ToolTimingHook::new();
688 let action = hook.on_event(HookEvent::PostToolCall {
689 name: "Read",
690 args: &serde_json::json!({"path": "foo.txt"}),
691 result: "ok",
692 duration_ms: 42,
693 });
694 assert!(matches!(action, HookAction::Continue));
695 }
696
697 #[test]
698 fn tool_timing_hook_returns_continue_for_non_tool_events() {
699 let hook = ToolTimingHook::new();
700 let action = hook.on_event(HookEvent::SessionStart { goal: "test" });
701 assert!(matches!(action, HookAction::Continue));
702
703 let action = hook.on_event(HookEvent::PreCompact {
704 transcript_len: 100,
705 });
706 assert!(matches!(action, HookAction::Continue));
707
708 let action = hook.on_event(HookEvent::PostCompact {
709 removed: 5,
710 summary_chars: 50,
711 });
712 assert!(matches!(action, HookAction::Continue));
713
714 let outcome = RuntimeOutcome {
715 final_text: Some("done".into()),
716 finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
717 total_usage: crate::llm::TokenUsage::default(),
718 steps: 1,
719 llm_latency_ms: 0,
720 checkpoint_id: None,
721 };
722 let action = hook.on_event(HookEvent::SessionEnd { outcome: &outcome });
723 assert!(matches!(action, HookAction::Continue));
724 }
725
726 #[test]
727 fn tool_timing_hook_pre_tool_call_returns_continue() {
728 let hook = ToolTimingHook::new();
729 let action = hook.on_event(HookEvent::PreToolCall {
730 name: "Write",
731 args: &serde_json::json!({"path": "foo.txt"}),
732 });
733 assert!(matches!(action, HookAction::Continue));
734 assert!(
736 hook.has_start("Write"),
737 "PreToolCall must record a start time for the tool name"
738 );
739 }
740
741 #[test]
742 fn tool_timing_hook_post_clears_recorded_start() {
743 let hook = ToolTimingHook::new();
744 let _ = hook.on_event(HookEvent::PreToolCall {
745 name: "Read",
746 args: &serde_json::json!({}),
747 });
748 assert!(hook.has_start("Read"));
749 let action = hook.on_event(HookEvent::PostToolCall {
750 name: "Read",
751 args: &serde_json::json!({}),
752 result: "ok",
753 duration_ms: 12,
754 });
755 assert!(matches!(action, HookAction::Continue));
756 assert!(
758 !hook.has_start("Read"),
759 "PostToolCall must clear the matching Pre start"
760 );
761 }
762
763 struct AlwaysSkipHook;
769 impl Hook for AlwaysSkipHook {
770 fn on_event(&self, _event: HookEvent) -> HookAction {
771 HookAction::Skip
772 }
773 }
774
775 struct AlwaysErrorHook;
777 impl Hook for AlwaysErrorHook {
778 fn on_event(&self, _event: HookEvent) -> HookAction {
779 HookAction::Error("always".into())
780 }
781 }
782
783 #[test]
784 fn skip_on_non_pre_tool_call_event_is_treated_as_continue() {
785 let mut reg = HookRegistry::new();
791 reg.register(Arc::new(AlwaysSkipHook));
792 let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
793 assert!(
794 matches!(action, HookAction::Continue),
795 "Skip from a non-PreToolCall hook must be treated as Continue"
796 );
797 }
798
799 #[test]
800 fn error_on_non_pre_tool_call_event_is_treated_as_continue() {
801 let mut reg = HookRegistry::new();
807 reg.register(Arc::new(AlwaysErrorHook));
808 let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
809 assert!(
810 matches!(action, HookAction::Continue),
811 "Error from a non-PreToolCall hook must be treated as Continue"
812 );
813 }
814
815 #[test]
816 fn has_hooks_true_when_registered() {
817 let mut reg = HookRegistry::new();
819 assert!(!reg.has_hooks(), "empty registry has no hooks");
820 reg.register(Arc::new(AlwaysSkipHook));
821 assert!(reg.has_hooks(), "non-empty registry has hooks");
822 }
823
824 #[test]
825 fn is_empty_and_len_reflect_registration() {
826 let mut reg = HookRegistry::new();
828 assert!(reg.is_empty(), "fresh registry must be empty");
829 assert_eq!(reg.len(), 0, "fresh registry len must be 0");
830 reg.register(Arc::new(AlwaysSkipHook));
831 assert!(
832 !reg.is_empty(),
833 "after register, registry must not be empty"
834 );
835 assert_eq!(reg.len(), 1, "after one register, len must be 1");
836 }
837}