@@ -13,6 +13,9 @@ use tokio_util::sync::CancellationToken;
1313
1414pub struct Hooks {
1515 settings : HookSettingsFile ,
16+ /// Tracks which ContextFill thresholds have already fired this session.
17+ /// Prevents re-firing every turn while above the threshold.
18+ fired_context_thresholds : std:: sync:: Mutex < std:: collections:: HashSet < u32 > > ,
1619}
1720
1821impl Hooks {
@@ -21,7 +24,100 @@ impl Hooks {
2124 tracing:: debug!( "No hooks config loaded: {}" , e) ;
2225 HookSettingsFile :: default ( )
2326 } ) ;
24- Self { settings }
27+ Self {
28+ settings,
29+ fired_context_thresholds : std:: sync:: Mutex :: new ( std:: collections:: HashSet :: new ( ) ) ,
30+ }
31+ }
32+
33+ /// Check context fill and fire ContextFill hooks for any thresholds that have been crossed.
34+ /// Returns context to inject, if any.
35+ ///
36+ /// Call this once per turn in the agent loop with the current token count.
37+ pub async fn check_context_fill (
38+ & self ,
39+ session_id : & str ,
40+ current_tokens : usize ,
41+ context_limit : usize ,
42+ extension_manager : & crate :: agents:: extension_manager:: ExtensionManager ,
43+ working_dir : & Path ,
44+ cancel_token : CancellationToken ,
45+ ) -> Option < String > {
46+ if context_limit == 0 {
47+ return None ;
48+ }
49+
50+ let fill_pct = ( ( current_tokens as f64 / context_limit as f64 ) * 100.0 ) as u32 ;
51+
52+ // Get configured ContextFill thresholds from settings
53+ let event_configs = self
54+ . settings
55+ . get_hooks_for_event ( HookEventKind :: ContextFill ) ;
56+ if event_configs. is_empty ( ) {
57+ return None ;
58+ }
59+
60+ // Find thresholds that are newly crossed
61+ let mut new_thresholds = Vec :: new ( ) ;
62+ {
63+ let mut fired = self
64+ . fired_context_thresholds
65+ . lock ( )
66+ . unwrap_or_else ( |e| e. into_inner ( ) ) ;
67+ for config in event_configs {
68+ if let Some ( pattern) = & config. matcher {
69+ if let Ok ( threshold) = pattern. parse :: < u32 > ( ) {
70+ if fill_pct >= threshold && !fired. contains ( & threshold) {
71+ fired. insert ( threshold) ;
72+ new_thresholds. push ( threshold) ;
73+ }
74+ }
75+ }
76+ }
77+ }
78+
79+ if new_thresholds. is_empty ( ) {
80+ return None ;
81+ }
82+
83+ // Fire hooks for each newly crossed threshold.
84+ // fill_percentage is set to the threshold value (not the actual fill) so that
85+ // matches_config can use exact equality to route to the correct config entry.
86+ // The actual fill level is derivable from current_tokens / context_limit.
87+ let mut all_context = Vec :: new ( ) ;
88+ for threshold in new_thresholds {
89+ tracing:: info!(
90+ "Context fill {}% crossed threshold {}%, firing hooks" ,
91+ fill_pct,
92+ threshold
93+ ) ;
94+ let invocation = HookInvocation :: context_fill (
95+ session_id. to_string ( ) ,
96+ current_tokens,
97+ context_limit,
98+ threshold,
99+ working_dir. to_string_lossy ( ) . to_string ( ) ,
100+ ) ;
101+ if let Ok ( outcome) = self
102+ . run (
103+ invocation,
104+ extension_manager,
105+ working_dir,
106+ cancel_token. clone ( ) ,
107+ )
108+ . await
109+ {
110+ if let Some ( ctx) = outcome. context {
111+ all_context. push ( ctx) ;
112+ }
113+ }
114+ }
115+
116+ if all_context. is_empty ( ) {
117+ None
118+ } else {
119+ Some ( all_context. join ( "\n " ) )
120+ }
25121 }
26122
27123 pub async fn run (
@@ -320,19 +416,36 @@ impl Hooks {
320416 Notification => invocation
321417 . notification_type
322418 . as_ref ( )
323- . is_some_and ( |t| t . contains ( pattern) ) ,
419+ . is_some_and ( |t| Self :: regex_matches ( pattern, t ) ) ,
324420 PreCompact | PostCompact => {
325421 ( invocation. manual_compact && pattern == "manual" )
326422 || ( !invocation. manual_compact && pattern == "auto" )
327423 }
424+ ContextFill => {
425+ // Matcher is a threshold percentage (e.g., "70").
426+ // Exact equality: check_context_fill sets fill_percentage to the
427+ // specific threshold being fired (not the current fill level),
428+ // preventing double-execution when multiple thresholds cross at once.
429+ if let ( Ok ( threshold) , Some ( fill) ) =
430+ ( pattern. parse :: < u32 > ( ) , invocation. fill_percentage )
431+ {
432+ fill == threshold
433+ } else {
434+ false
435+ }
436+ }
328437 _ => true ,
329438 }
330439 }
331440
332441 /// Match a tool invocation against a Claude Code-style matcher pattern.
333- /// Supports:
334- /// "Bash" or "Bash(...)" — maps to developer__shell, optionally matching command content
335- /// "tool_name" — direct tool name match (goose-native)
442+ ///
443+ /// Supports regex patterns (Claude Code compat):
444+ /// "Bash" — maps to developer__shell or shell
445+ /// "Bash(regex)" — developer__shell/shell with command content regex
446+ /// "Edit|Write" — regex alternation matching tool names
447+ /// "mcp__memory__.*" — regex wildcard matching MCP tool names
448+ /// "developer__shell" — exact match (also valid regex)
336449 fn matches_tool ( pattern : & str , invocation : & HookInvocation ) -> bool {
337450 let tool_name = match & invocation. tool_name {
338451 Some ( name) => name,
@@ -341,34 +454,40 @@ impl Hooks {
341454
342455 // Claude Code "Bash" / "Bash(pattern)" syntax
343456 if pattern == "Bash" {
344- return tool_name == "developer__shell" ;
457+ return tool_name == "developer__shell" || tool_name == "shell" ;
345458 }
346459
347460 if let Some ( inner) = pattern
348461 . strip_prefix ( "Bash(" )
349462 . and_then ( |s| s. strip_suffix ( ')' ) )
350463 {
351- if tool_name != "developer__shell" {
464+ if tool_name != "developer__shell" && tool_name != "shell" {
352465 return false ;
353466 }
354- // Match the inner pattern against the command argument
355467 let command_str = invocation
356468 . tool_input
357469 . as_ref ( )
358470 . and_then ( |v| v. get ( "command" ) )
359471 . and_then ( |v| v. as_str ( ) )
360472 . unwrap_or ( "" ) ;
361473
362- return Self :: glob_match ( inner, command_str) ;
474+ return Self :: regex_matches ( inner, command_str) ;
363475 }
364476
365- // Direct tool name match (goose-native : "developer__shell ", "slack__post_message ", etc.)
366- tool_name == pattern
477+ // Regex match against tool name (Claude Code compat : "Edit|Write ", "mcp__.* ", etc.)
478+ Self :: regex_matches ( pattern, tool_name )
367479 }
368480
369- fn glob_match ( pattern : & str , text : & str ) -> bool {
370- glob:: Pattern :: new ( pattern)
371- . map ( |p| p. matches ( text) )
481+ /// Test if `text` matches `pattern` as a full-string regex.
482+ /// Anchors the pattern to match the entire string (not a substring).
483+ fn regex_matches ( pattern : & str , text : & str ) -> bool {
484+ let anchored = if pattern. starts_with ( '^' ) || pattern. ends_with ( '$' ) {
485+ pattern. to_string ( )
486+ } else {
487+ format ! ( "^(?:{})$" , pattern)
488+ } ;
489+ regex:: Regex :: new ( & anchored)
490+ . map ( |re| re. is_match ( text) )
372491 . unwrap_or ( false )
373492 }
374493}
@@ -479,4 +598,43 @@ mod tests {
479598 let result = Hooks :: parse_command_output ( output, HookEventKind :: PreToolUse ) . unwrap ( ) ;
480599 assert ! ( result. is_none( ) ) ;
481600 }
601+
602+ // -- regex_matches: Claude Code-compatible tool matching --
603+
604+ #[ test]
605+ fn regex_alternation_matches_either_tool ( ) {
606+ assert ! ( Hooks :: regex_matches( "Edit|Write" , "Edit" ) ) ;
607+ assert ! ( Hooks :: regex_matches( "Edit|Write" , "Write" ) ) ;
608+ assert ! ( !Hooks :: regex_matches( "Edit|Write" , "Read" ) ) ;
609+ }
610+
611+ #[ test]
612+ fn regex_wildcard_matches_mcp_tools ( ) {
613+ assert ! ( Hooks :: regex_matches(
614+ "mcp__memory__.*" ,
615+ "mcp__memory__create_entities"
616+ ) ) ;
617+ assert ! ( Hooks :: regex_matches(
618+ "mcp__memory__.*" ,
619+ "mcp__memory__search"
620+ ) ) ;
621+ assert ! ( !Hooks :: regex_matches(
622+ "mcp__memory__.*" ,
623+ "mcp__filesystem__read"
624+ ) ) ;
625+ }
626+
627+ #[ test]
628+ fn regex_exact_string_still_works ( ) {
629+ assert ! ( Hooks :: regex_matches( "developer__shell" , "developer__shell" ) ) ;
630+ assert ! ( !Hooks :: regex_matches(
631+ "developer__shell" ,
632+ "developer__shell_extra"
633+ ) ) ;
634+ }
635+
636+ #[ test]
637+ fn regex_invalid_pattern_returns_false ( ) {
638+ assert ! ( !Hooks :: regex_matches( "[invalid" , "anything" ) ) ;
639+ }
482640}
0 commit comments