@@ -179,7 +179,7 @@ fn run_gobby_owned(args: &Args) -> ExitCode {
179179 ) ) )
180180 } ;
181181
182- let mut input_data = match parsed {
182+ let input_data = match parsed {
183183 Ok ( v) => v,
184184 Err ( e) => {
185185 let _ = transport:: quarantine_malformed ( & stdin_raw, & e. to_string ( ) , is_critical) ;
@@ -188,29 +188,7 @@ fn run_gobby_owned(args: &Args) -> ExitCode {
188188 }
189189 } ;
190190
191- // Terminal-context enrichment, gated by per-CLI set.
192- if cfg. wants_terminal_context ( hook_type) {
193- terminal_context:: inject ( & mut input_data) ;
194- }
195-
196- // Headers: omit on missing (never empty string).
197- let mut headers: BTreeMap < String , String > = BTreeMap :: new ( ) ;
198- if let Some ( pid) = project_id {
199- headers. insert ( "X-Gobby-Project-Id" . into ( ) , pid) ;
200- }
201- if let Some ( sid) = input_data. get ( "session_id" ) . and_then ( |v| v. as_str ( ) )
202- && !sid. is_empty ( )
203- {
204- headers. insert ( "X-Gobby-Session-Id" . into ( ) , sid. to_string ( ) ) ;
205- }
206-
207- let env = Envelope :: new (
208- is_critical,
209- hook_type. to_string ( ) ,
210- input_data,
211- detect_source ( & cfg) ,
212- headers,
213- ) ;
191+ let env = build_dispatch_envelope ( & cfg, hook_type, input_data, project_id. as_deref ( ) ) ;
214192
215193 // Enqueue first (atomic write to ~/.gobby/hooks/inbox/).
216194 let inbox = match transport:: inbox_dir ( ) {
@@ -297,6 +275,34 @@ fn hooks_disabled_by_env() -> bool {
297275 std:: env:: var_os ( "GOBBY_HOOKS_DISABLED" ) . is_some_and ( |v| v == "1" )
298276}
299277
278+ fn build_dispatch_envelope (
279+ cfg : & CliConfig ,
280+ hook_type : & str ,
281+ mut input_data : Value ,
282+ project_id : Option < & str > ,
283+ ) -> Envelope {
284+ terminal_context:: inject ( & mut input_data) ;
285+
286+ // Headers: omit on missing (never empty string).
287+ let mut headers: BTreeMap < String , String > = BTreeMap :: new ( ) ;
288+ if let Some ( pid) = project_id {
289+ headers. insert ( "X-Gobby-Project-Id" . into ( ) , pid. to_string ( ) ) ;
290+ }
291+ if let Some ( sid) = input_data. get ( "session_id" ) . and_then ( |v| v. as_str ( ) )
292+ && !sid. is_empty ( )
293+ {
294+ headers. insert ( "X-Gobby-Session-Id" . into ( ) , sid. to_string ( ) ) ;
295+ }
296+
297+ Envelope :: new (
298+ cfg. is_critical_hook ( hook_type) ,
299+ hook_type. to_string ( ) ,
300+ input_data,
301+ detect_source ( cfg) ,
302+ headers,
303+ )
304+ }
305+
300306fn detect_source ( cfg : & CliConfig ) -> String {
301307 if cfg. source != "claude" {
302308 return cfg. source . to_string ( ) ;
@@ -571,6 +577,100 @@ mod tests {
571577 use super :: * ;
572578 use crate :: transport:: DeliveryFailureKind ;
573579 use serde_json:: json;
580+ use std:: ffi:: OsString ;
581+ use std:: sync:: { Mutex , MutexGuard } ;
582+
583+ static ENV_LOCK : Mutex < ( ) > = Mutex :: new ( ( ) ) ;
584+
585+ fn with_tmux_env < T > ( tmux : Option < & str > , tmux_pane : Option < & str > , f : impl FnOnce ( ) -> T ) -> T {
586+ let _env = TmuxEnv :: set ( tmux, tmux_pane) ;
587+ f ( )
588+ }
589+
590+ struct TmuxEnv {
591+ _guard : MutexGuard < ' static , ( ) > ,
592+ original_tmux : Option < OsString > ,
593+ original_tmux_pane : Option < OsString > ,
594+ }
595+
596+ impl TmuxEnv {
597+ fn set ( tmux : Option < & str > , tmux_pane : Option < & str > ) -> Self {
598+ let guard = ENV_LOCK . lock ( ) . unwrap ( ) ;
599+ let original_tmux = std:: env:: var_os ( "TMUX" ) ;
600+ let original_tmux_pane = std:: env:: var_os ( "TMUX_PANE" ) ;
601+
602+ set_env_var ( "TMUX" , tmux. map ( OsString :: from) ) ;
603+ set_env_var ( "TMUX_PANE" , tmux_pane. map ( OsString :: from) ) ;
604+
605+ Self {
606+ _guard : guard,
607+ original_tmux,
608+ original_tmux_pane,
609+ }
610+ }
611+ }
612+
613+ impl Drop for TmuxEnv {
614+ fn drop ( & mut self ) {
615+ set_env_var ( "TMUX" , self . original_tmux . take ( ) ) ;
616+ set_env_var ( "TMUX_PANE" , self . original_tmux_pane . take ( ) ) ;
617+ }
618+ }
619+
620+ fn set_env_var ( key : & str , value : Option < OsString > ) {
621+ // SAFETY: tests that mutate TMUX/TMUX_PANE serialize those mutations
622+ // with ENV_LOCK and restore the original values before releasing it.
623+ unsafe {
624+ match value {
625+ Some ( value) => std:: env:: set_var ( key, value) ,
626+ None => std:: env:: remove_var ( key) ,
627+ }
628+ }
629+ }
630+
631+ #[ test]
632+ fn dispatch_envelope_injects_valid_tmux_pane_for_any_cli ( ) {
633+ with_tmux_env ( Some ( "/tmp/tmux-501/default,12345,0" ) , Some ( "%17" ) , || {
634+ let cfg = CliConfig :: for_dispatch ( "grok" ) ;
635+ let envelope = build_dispatch_envelope (
636+ & cfg,
637+ "SessionStart" ,
638+ json ! ( { "session_id" : "sess-1" } ) ,
639+ None ,
640+ ) ;
641+
642+ assert_eq ! ( envelope. input_data[ "terminal_context" ] [ "tmux_pane" ] , "%17" ) ;
643+ } ) ;
644+ }
645+
646+ #[ test]
647+ fn dispatch_envelope_omits_terminal_context_for_missing_or_invalid_tmux_pane ( ) {
648+ for pane in [ None , Some ( "" ) , Some ( "17" ) , Some ( "%" ) , Some ( "%x" ) ] {
649+ with_tmux_env ( Some ( "/tmp/tmux-501/default,12345,0" ) , pane, || {
650+ let cfg = CliConfig :: for_dispatch ( "gemini" ) ;
651+ let envelope = build_dispatch_envelope (
652+ & cfg,
653+ "SessionStart" ,
654+ json ! ( { "session_id" : "sess-1" } ) ,
655+ None ,
656+ ) ;
657+
658+ assert ! ( envelope. input_data. get( "terminal_context" ) . is_none( ) ) ;
659+ } ) ;
660+ }
661+
662+ with_tmux_env ( None , Some ( "%17" ) , || {
663+ let cfg = CliConfig :: for_dispatch ( "gemini" ) ;
664+ let envelope = build_dispatch_envelope (
665+ & cfg,
666+ "SessionStart" ,
667+ json ! ( { "session_id" : "sess-1" } ) ,
668+ None ,
669+ ) ;
670+
671+ assert ! ( envelope. input_data. get( "terminal_context" ) . is_none( ) ) ;
672+ } ) ;
673+ }
574674
575675 #[ test]
576676 fn action_from_success_forwards_sessionstart_context_json ( ) {
0 commit comments