1pub mod config;
40pub mod external;
41
42pub use config::{load_hooks_config, HookCommand, HookCommandType, HookMatcher, HooksConfig};
43pub use external::{ExternalHookRunner, HookResult, PermissionDecision};
44
45use std::sync::Arc;
46
47use serde_json::Value;
48
49use crate::runtime::RuntimeOutcome;
50use std::collections::HashMap;
51use std::time::Instant;
52
53#[derive(Debug, Clone)]
55pub enum HookAction {
56 Continue,
58 Skip,
60 Error(String),
62}
63
64#[derive(Debug, Clone)]
69#[non_exhaustive]
70pub enum HookEvent<'a> {
71 SessionStart {
73 goal: &'a str,
75 },
76 PreToolCall {
78 name: &'a str,
80 args: &'a Value,
82 },
83 PostToolCall {
85 name: &'a str,
87 args: &'a Value,
89 result: &'a str,
91 duration_ms: u64,
93 },
94 PreCompact {
96 transcript_len: usize,
98 },
99 PostCompact {
101 removed: usize,
103 summary_chars: usize,
105 },
106 SessionEnd {
111 outcome: &'a RuntimeOutcome,
113 },
114 UserPromptSubmit {
116 content: &'a str,
118 },
119 Stop {
121 outcome: &'a RuntimeOutcome,
123 },
124 SubagentStart {
126 goal: &'a str,
128 depth: usize,
130 },
131 SubagentStop {
133 outcome: &'a RuntimeOutcome,
135 depth: usize,
137 },
138 PostToolCallFailure {
140 name: &'a str,
142 args: &'a Value,
144 error: &'a str,
146 },
147 PermissionDenied {
149 tool_name: &'a str,
151 reason: &'a str,
153 },
154 Notification {
156 message: &'a str,
158 },
159 Setup,
161}
162
163pub trait Hook: Send + Sync {
165 fn on_event(&self, event: HookEvent) -> HookAction;
172}
173
174#[derive(Clone, Default)]
181pub struct HookRegistry {
182 hooks: Vec<Arc<dyn Hook>>,
183}
184
185impl HookRegistry {
186 pub fn new() -> Self {
188 Self::default()
189 }
190
191 pub fn register(&mut self, hook: Arc<dyn Hook>) {
193 self.hooks.push(hook);
194 }
195
196 pub fn dispatch(&self, event: HookEvent) -> HookAction {
202 let is_pre_tool = matches!(event, HookEvent::PreToolCall { .. });
203 for hook in &self.hooks {
204 match hook.on_event(event.clone()) {
205 HookAction::Continue => continue,
206 HookAction::Skip if is_pre_tool => return HookAction::Skip,
207 HookAction::Error(msg) if is_pre_tool => return HookAction::Error(msg),
208 _ => continue,
210 }
211 }
212 HookAction::Continue
213 }
214
215 pub fn has_hooks(&self) -> bool {
218 !self.hooks.is_empty()
219 }
220
221 pub fn is_empty(&self) -> bool {
223 self.hooks.is_empty()
224 }
225
226 pub fn len(&self) -> usize {
228 self.hooks.len()
229 }
230}
231
232pub struct ToolTimingHook {
237 start_times: std::sync::Mutex<HashMap<String, Instant>>,
238}
239
240impl ToolTimingHook {
241 pub fn new() -> Self {
242 Self {
243 start_times: std::sync::Mutex::new(HashMap::new()),
244 }
245 }
246}
247
248impl Default for ToolTimingHook {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254impl Hook for ToolTimingHook {
255 fn on_event(&self, event: HookEvent) -> HookAction {
256 match event {
257 HookEvent::PreToolCall { name, .. } => {
258 let mut map = self.start_times.lock().unwrap();
259 map.insert(name.to_string(), Instant::now());
260 HookAction::Continue
261 }
262 HookEvent::PostToolCall {
263 name, duration_ms, ..
264 } => {
265 eprintln!("[hook] {name} took {duration_ms}ms");
266 HookAction::Continue
267 }
268 _ => HookAction::Continue,
269 }
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use std::sync::atomic::{AtomicUsize, Ordering};
277
278 struct SkipHook;
279
280 impl Hook for SkipHook {
281 fn on_event(&self, event: HookEvent) -> HookAction {
282 match event {
283 HookEvent::PreToolCall { .. } => HookAction::Skip,
284 _ => HookAction::Continue,
285 }
286 }
287 }
288
289 struct ErrorHook;
290
291 impl Hook for ErrorHook {
292 fn on_event(&self, event: HookEvent) -> HookAction {
293 match event {
294 HookEvent::PreToolCall { .. } => HookAction::Error("nope".into()),
295 _ => HookAction::Continue,
296 }
297 }
298 }
299
300 #[test]
301 fn empty_registry_returns_continue() {
302 let reg = HookRegistry::new();
303 let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
304 assert!(matches!(action, HookAction::Continue));
305 }
306
307 #[test]
308 fn session_start_fires_with_correct_goal() {
309 let captured = Arc::new(std::sync::Mutex::new(String::new()));
310 let c = captured.clone();
311 struct GoalCapture(Arc<std::sync::Mutex<String>>);
312 impl Hook for GoalCapture {
313 fn on_event(&self, event: HookEvent) -> HookAction {
314 if let HookEvent::SessionStart { goal } = event {
315 *self.0.lock().unwrap() = goal.to_string();
316 }
317 HookAction::Continue
318 }
319 }
320 let mut reg = HookRegistry::new();
321 reg.register(Arc::new(GoalCapture(c)));
322 reg.dispatch(HookEvent::SessionStart { goal: "my goal" });
323 assert_eq!(*captured.lock().unwrap(), "my goal");
324 }
325
326 #[test]
327 fn pre_tool_call_skip_prevents_execution() {
328 let mut reg = HookRegistry::new();
329 reg.register(Arc::new(SkipHook));
330 let action = reg.dispatch(HookEvent::PreToolCall {
331 name: "write_file",
332 args: &serde_json::json!({"path": "foo.txt"}),
333 });
334 assert!(matches!(action, HookAction::Skip));
335 }
336
337 #[test]
338 fn pre_tool_call_error_returns_message() {
339 let mut reg = HookRegistry::new();
340 reg.register(Arc::new(ErrorHook));
341 let action = reg.dispatch(HookEvent::PreToolCall {
342 name: "write_file",
343 args: &serde_json::json!({"path": "foo.txt"}),
344 });
345 assert!(matches!(action, HookAction::Error(ref msg) if msg == "nope"));
346 }
347
348 #[test]
349 fn skip_and_error_on_non_pre_tool_are_continue() {
350 let mut reg = HookRegistry::new();
351 reg.register(Arc::new(SkipHook));
352 reg.register(Arc::new(ErrorHook));
353 let action = reg.dispatch(HookEvent::SessionStart { goal: "test" });
355 assert!(matches!(action, HookAction::Continue));
356 let action = reg.dispatch(HookEvent::PostToolCall {
358 name: "read_file",
359 args: &serde_json::json!({"path": "foo.txt"}),
360 result: "ok",
361 duration_ms: 5,
362 });
363 assert!(matches!(action, HookAction::Continue));
364 }
365
366 #[test]
367 fn multiple_hooks_fire_in_order() {
368 let order = Arc::new(std::sync::Mutex::new(Vec::new()));
369 let o1 = order.clone();
370 let o2 = order.clone();
371
372 struct OrdHook(usize, Arc<std::sync::Mutex<Vec<usize>>>);
373 impl Hook for OrdHook {
374 fn on_event(&self, _event: HookEvent) -> HookAction {
375 self.1.lock().unwrap().push(self.0);
376 HookAction::Continue
377 }
378 }
379
380 let mut reg = HookRegistry::new();
381 reg.register(Arc::new(OrdHook(1, o1)));
382 reg.register(Arc::new(OrdHook(2, o2)));
383 reg.dispatch(HookEvent::SessionStart { goal: "test" });
384 assert_eq!(*order.lock().unwrap(), vec![1, 2]);
385 }
386
387 #[test]
388 fn first_skip_short_circuits_remaining_hooks() {
389 let count = Arc::new(AtomicUsize::new(0));
390 let c1 = count.clone();
391 let c2 = count.clone();
392
393 struct FirstSkip(Arc<AtomicUsize>);
394 impl Hook for FirstSkip {
395 fn on_event(&self, event: HookEvent) -> HookAction {
396 match event {
397 HookEvent::PreToolCall { .. } => HookAction::Skip,
398 _ => {
399 self.0.fetch_add(1, Ordering::SeqCst);
400 HookAction::Continue
401 }
402 }
403 }
404 }
405
406 struct SecondCounter(Arc<AtomicUsize>);
407 impl Hook for SecondCounter {
408 fn on_event(&self, _event: HookEvent) -> HookAction {
409 self.0.fetch_add(1, Ordering::SeqCst);
410 HookAction::Continue
411 }
412 }
413
414 let mut reg = HookRegistry::new();
415 reg.register(Arc::new(FirstSkip(c1)));
416 reg.register(Arc::new(SecondCounter(c2.clone())));
417 let action = reg.dispatch(HookEvent::PreToolCall {
418 name: "write_file",
419 args: &serde_json::json!({"path": "foo.txt"}),
420 });
421 assert!(matches!(action, HookAction::Skip));
422 assert_eq!(c2.load(Ordering::SeqCst), 0);
424 }
425
426 #[test]
427 fn post_tool_call_receives_correct_fields() {
428 let captured = Arc::new(std::sync::Mutex::new(None::<(String, String, u64)>));
429 let c = captured.clone();
430 struct CaptureHook(Arc<std::sync::Mutex<Option<(String, String, u64)>>>);
431 impl Hook for CaptureHook {
432 fn on_event(&self, event: HookEvent) -> HookAction {
433 if let HookEvent::PostToolCall {
434 name,
435 result,
436 duration_ms,
437 ..
438 } = event
439 {
440 *self.0.lock().unwrap() =
441 Some((name.to_string(), result.to_string(), duration_ms));
442 }
443 HookAction::Continue
444 }
445 }
446 let mut reg = HookRegistry::new();
447 reg.register(Arc::new(CaptureHook(c)));
448 reg.dispatch(HookEvent::PostToolCall {
449 name: "read_file",
450 args: &serde_json::json!({"path": "foo.txt"}),
451 result: "file contents",
452 duration_ms: 42,
453 });
454 let captured = captured.lock().unwrap().clone().unwrap();
455 assert_eq!(captured.0, "read_file");
456 assert_eq!(captured.1, "file contents");
457 assert_eq!(captured.2, 42);
458 }
459
460 #[test]
461 fn session_end_receives_outcome() {
462 let captured = Arc::new(std::sync::Mutex::new(None));
463 let c = captured.clone();
464 struct CaptureOutcome(Arc<std::sync::Mutex<Option<RuntimeOutcome>>>);
465 impl Hook for CaptureOutcome {
466 fn on_event(&self, event: HookEvent) -> HookAction {
467 if let HookEvent::SessionEnd { outcome } = event {
468 *self.0.lock().unwrap() = Some(outcome.clone());
469 }
470 HookAction::Continue
471 }
472 }
473 let outcome = RuntimeOutcome {
474 final_text: Some("done".into()),
475 finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
476 total_usage: crate::llm::TokenUsage::default(),
477 steps: 3,
478 llm_latency_ms: 100,
479 checkpoint_id: None,
480 };
481 let mut reg = HookRegistry::new();
482 reg.register(Arc::new(CaptureOutcome(c)));
483 reg.dispatch(HookEvent::SessionEnd { outcome: &outcome });
484 let captured = captured.lock().unwrap().take().unwrap();
485 assert_eq!(captured.final_text.as_deref(), Some("done"));
486 assert_eq!(captured.steps, 3);
487 }
488
489 #[test]
490 fn pre_compact_receives_transcript_len() {
491 let captured = Arc::new(std::sync::Mutex::new(0usize));
492 let c = captured.clone();
493 struct CaptureLen(Arc<std::sync::Mutex<usize>>);
494 impl Hook for CaptureLen {
495 fn on_event(&self, event: HookEvent) -> HookAction {
496 if let HookEvent::PreCompact { transcript_len } = event {
497 *self.0.lock().unwrap() = transcript_len;
498 }
499 HookAction::Continue
500 }
501 }
502 let mut reg = HookRegistry::new();
503 reg.register(Arc::new(CaptureLen(c)));
504 reg.dispatch(HookEvent::PreCompact {
505 transcript_len: 5000,
506 });
507 assert_eq!(*captured.lock().unwrap(), 5000);
508 }
509
510 #[test]
511 fn post_compact_receives_removed_and_summary_chars() {
512 let captured = Arc::new(std::sync::Mutex::new((0usize, 0usize)));
513 let c = captured.clone();
514 struct CaptureCompact(Arc<std::sync::Mutex<(usize, usize)>>);
515 impl Hook for CaptureCompact {
516 fn on_event(&self, event: HookEvent) -> HookAction {
517 if let HookEvent::PostCompact {
518 removed,
519 summary_chars,
520 } = event
521 {
522 *self.0.lock().unwrap() = (removed, summary_chars);
523 }
524 HookAction::Continue
525 }
526 }
527 let mut reg = HookRegistry::new();
528 reg.register(Arc::new(CaptureCompact(c)));
529 reg.dispatch(HookEvent::PostCompact {
530 removed: 10,
531 summary_chars: 200,
532 });
533 assert_eq!(captured.lock().unwrap().0, 10);
534 assert_eq!(captured.lock().unwrap().1, 200);
535 }
536
537 #[test]
538 fn hook_event_is_non_exhaustive() {
539 let _ = HookEvent::SessionStart { goal: "test" };
541 }
542
543 #[test]
546 fn new_events_compile_and_dispatch() {
547 let reg = HookRegistry::new();
548 let outcome = RuntimeOutcome {
549 final_text: None,
550 finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
551 total_usage: crate::llm::TokenUsage::default(),
552 steps: 0,
553 llm_latency_ms: 0,
554 checkpoint_id: None,
555 };
556 let empty_args = serde_json::json!({});
557
558 let action = reg.dispatch(HookEvent::UserPromptSubmit { content: "hello" });
559 assert!(matches!(action, HookAction::Continue));
560 let action = reg.dispatch(HookEvent::Stop { outcome: &outcome });
561 assert!(matches!(action, HookAction::Continue));
562 let action = reg.dispatch(HookEvent::SubagentStart {
563 goal: "sub-goal",
564 depth: 1,
565 });
566 assert!(matches!(action, HookAction::Continue));
567 let action = reg.dispatch(HookEvent::SubagentStop {
568 outcome: &outcome,
569 depth: 1,
570 });
571 assert!(matches!(action, HookAction::Continue));
572 let action = reg.dispatch(HookEvent::PostToolCallFailure {
573 name: "run_shell",
574 args: &empty_args,
575 error: "err",
576 });
577 assert!(matches!(action, HookAction::Continue));
578 let action = reg.dispatch(HookEvent::PermissionDenied {
579 tool_name: "write_file",
580 reason: "not allowed",
581 });
582 assert!(matches!(action, HookAction::Continue));
583 let action = reg.dispatch(HookEvent::Notification { message: "hi" });
584 assert!(matches!(action, HookAction::Continue));
585 let action = reg.dispatch(HookEvent::Setup);
586 assert!(matches!(action, HookAction::Continue));
587 }
588
589 #[test]
590 fn post_tool_call_failure_dispatched_to_hook() {
591 let captured = Arc::new(std::sync::Mutex::new(None::<(String, String)>));
592 let c = captured.clone();
593 struct CaptureFailure(Arc<std::sync::Mutex<Option<(String, String)>>>);
594 impl Hook for CaptureFailure {
595 fn on_event(&self, event: HookEvent) -> HookAction {
596 if let HookEvent::PostToolCallFailure { name, error, .. } = event {
597 *self.0.lock().unwrap() = Some((name.to_string(), error.to_string()));
598 }
599 HookAction::Continue
600 }
601 }
602 let mut reg = HookRegistry::new();
603 reg.register(Arc::new(CaptureFailure(c)));
604 let args = serde_json::json!({"command": "rm -rf /"});
605 reg.dispatch(HookEvent::PostToolCallFailure {
606 name: "run_shell",
607 args: &args,
608 error: "permission denied",
609 });
610 let cap = captured.lock().unwrap().clone().unwrap();
611 assert_eq!(cap.0, "run_shell");
612 assert_eq!(cap.1, "permission denied");
613 }
614
615 #[test]
616 fn user_prompt_submit_received_by_hook() {
617 let captured = Arc::new(std::sync::Mutex::new(String::new()));
618 let c = captured.clone();
619 struct CapturePrompt(Arc<std::sync::Mutex<String>>);
620 impl Hook for CapturePrompt {
621 fn on_event(&self, event: HookEvent) -> HookAction {
622 if let HookEvent::UserPromptSubmit { content } = event {
623 *self.0.lock().unwrap() = content.to_string();
624 }
625 HookAction::Continue
626 }
627 }
628 let mut reg = HookRegistry::new();
629 reg.register(Arc::new(CapturePrompt(c)));
630 reg.dispatch(HookEvent::UserPromptSubmit {
631 content: "test prompt",
632 });
633 assert_eq!(*captured.lock().unwrap(), "test prompt");
634 }
635
636 #[test]
637 fn tool_timing_hook_prints_to_stderr_on_post_tool_call() {
638 let hook = ToolTimingHook::new();
640 let action = hook.on_event(HookEvent::PostToolCall {
641 name: "read_file",
642 args: &serde_json::json!({"path": "foo.txt"}),
643 result: "ok",
644 duration_ms: 42,
645 });
646 assert!(matches!(action, HookAction::Continue));
647 }
648
649 #[test]
650 fn tool_timing_hook_returns_continue_for_non_tool_events() {
651 let hook = ToolTimingHook::new();
652 let action = hook.on_event(HookEvent::SessionStart { goal: "test" });
653 assert!(matches!(action, HookAction::Continue));
654
655 let action = hook.on_event(HookEvent::PreCompact {
656 transcript_len: 100,
657 });
658 assert!(matches!(action, HookAction::Continue));
659
660 let action = hook.on_event(HookEvent::PostCompact {
661 removed: 5,
662 summary_chars: 50,
663 });
664 assert!(matches!(action, HookAction::Continue));
665
666 let outcome = RuntimeOutcome {
667 final_text: Some("done".into()),
668 finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
669 total_usage: crate::llm::TokenUsage::default(),
670 steps: 1,
671 llm_latency_ms: 0,
672 checkpoint_id: None,
673 };
674 let action = hook.on_event(HookEvent::SessionEnd { outcome: &outcome });
675 assert!(matches!(action, HookAction::Continue));
676 }
677
678 #[test]
679 fn tool_timing_hook_pre_tool_call_returns_continue() {
680 let hook = ToolTimingHook::new();
681 let action = hook.on_event(HookEvent::PreToolCall {
682 name: "write_file",
683 args: &serde_json::json!({"path": "foo.txt"}),
684 });
685 assert!(matches!(action, HookAction::Continue));
686 }
687}