From 3e15ccb8824a36f6d6a857ea65481318161df592 Mon Sep 17 00:00:00 2001 From: "Dakota Fabro, B. Psy, M.Ed, M.Th" Date: Mon, 10 Aug 2026 10:26:39 -0700 Subject: [PATCH] Skip hook loading and lifecycle events for subagents (#10596) --- crates/goose-cli/src/session/mod.rs | 8 + crates/goose-cli/src/session/output.rs | 8 + crates/goose/src/agents/agent.rs | 36 +++- .../src/agents/platform_extensions/summon.rs | 6 +- crates/goose/src/hooks/mod.rs | 160 ++++++++++++++++++ 5 files changed, 211 insertions(+), 7 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 87b1cd569..3f269c33c 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -535,6 +535,14 @@ impl CliSession { /// Start an interactive session, optionally with an initial message pub async fn interactive(&mut self, prompt: Option) -> Result<()> { + let banners = self + .agent + .emit_hook_with_banners(goose::hooks::HookEvent::SessionStart, &self.session_id) + .await; + if !banners.is_empty() { + output::display_banner(&banners); + } + let result = self.run_interactive(prompt).await; self.agent diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 55e6535ab..6c289531b 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -1435,6 +1435,14 @@ fn set_terminal_title() { let _ = std::io::stdout().flush(); } +pub fn display_banner(banners: &[String]) { + for banner in banners { + for line in banner.lines() { + println!("{}", line); + } + } +} + pub fn display_context_usage(total_tokens: usize, context_limit: usize) { use console::style; diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index e040b76b0..323de68d6 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use anyhow::{anyhow, Context, Result}; @@ -195,6 +196,7 @@ pub struct AgentConfig { pub mcp_host_info: Option, pub session_name_update_tx: Option>, pub use_login_shell_path: Option, + pub is_subagent: bool, } impl AgentConfig { @@ -216,6 +218,7 @@ impl AgentConfig { mcp_host_info: None, session_name_update_tx: None, use_login_shell_path: None, + is_subagent: false, } } @@ -265,6 +268,7 @@ pub struct Agent { pub(super) retry_manager: RetryManager, pub(super) tool_inspection_manager: ToolInspectionManager, pub(super) hook_manager: crate::hooks::HookManager, + session_start_emitted: AtomicBool, #[cfg(test)] pub(super) stop_hook_block_cap_override: Option, container: Mutex>, @@ -399,6 +403,7 @@ impl Agent { let inspection_session_manager = Arc::clone(&config.session_manager); let permission_manager = Arc::clone(&config.permission_manager); let use_login_shell_path = config.resolve_use_login_shell_path(); + let is_subagent = config.is_subagent; Self { provider: provider.clone(), config, @@ -425,10 +430,15 @@ impl Agent { provider.clone(), inspection_session_manager, ), - hook_manager: crate::hooks::HookManager::load( - std::env::current_dir().ok().as_deref(), - use_login_shell_path, - ), + hook_manager: if is_subagent { + crate::hooks::HookManager::default() + } else { + crate::hooks::HookManager::load( + std::env::current_dir().ok().as_deref(), + use_login_shell_path, + ) + }, + session_start_emitted: AtomicBool::new(false), #[cfg(test)] stop_hook_block_cap_override: None, container: Mutex::new(None), @@ -470,6 +480,22 @@ impl Agent { .await; } + pub async fn emit_hook_with_banners( + &self, + event: crate::hooks::HookEvent, + session_id: &str, + ) -> Vec { + if event == crate::hooks::HookEvent::SessionStart { + self.session_start_emitted.store(true, Ordering::Release); + } + if !self.hook_manager.has_hooks(event) { + return Vec::new(); + } + self.hook_manager + .emit_collecting_banners(event, crate::hooks::HookContext::new(event, session_id)) + .await + } + fn stop_hook_context( session_id: &str, last_assistant_message: &str, @@ -1907,7 +1933,7 @@ impl Agent { return Ok(Box::pin(futures::stream::empty())); } - if is_first_agent_turn { + if is_first_agent_turn && !self.session_start_emitted.swap(true, Ordering::AcqRel) { self.emit_hook(crate::hooks::HookEvent::SessionStart, &session_config.id) .await; } diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 3df819ab0..c0836493c 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -1290,7 +1290,7 @@ impl SummonClient { // Subagents must use Auto until get_agent_messages forwards // ActionRequired messages to the parent. Until then, any mode // that requires approval will hang on the subagent's confirmation_rx. - let agent_config = AgentConfig::new( + let mut agent_config = AgentConfig::new( self.context.session_manager.clone(), crate::config::permission::PermissionManager::instance(), None, @@ -1299,6 +1299,7 @@ impl SummonClient { crate::agents::GoosePlatform::GooseCli, ) .with_use_login_shell_path(self.context.use_login_shell_path); + agent_config.is_subagent = true; let subagent_session = self .create_subagent_session(&task_config, "Delegated task".to_string()) @@ -1846,7 +1847,7 @@ impl SummonClient { // Subagents must use Auto until get_agent_messages forwards // ActionRequired messages to the parent. Until then, any mode // that requires approval will hang on the subagent's confirmation_rx. - let agent_config = AgentConfig::new( + let mut agent_config = AgentConfig::new( self.context.session_manager.clone(), crate::config::permission::PermissionManager::instance(), None, @@ -1855,6 +1856,7 @@ impl SummonClient { crate::agents::GoosePlatform::GooseCli, ) .with_use_login_shell_path(self.context.use_login_shell_path); + agent_config.is_subagent = true; let subagent_session = self .create_subagent_session(&task_config, description.clone()) diff --git a/crates/goose/src/hooks/mod.rs b/crates/goose/src/hooks/mod.rs index f880fee9d..1d207bd72 100644 --- a/crates/goose/src/hooks/mod.rs +++ b/crates/goose/src/hooks/mod.rs @@ -391,6 +391,86 @@ impl HookManager { } } + /// Like [`Self::emit`], but collects banner lines from hook stdout. + /// + /// If a hook exits successfully and its stdout contains valid JSON with a + /// `"banner"` field, that string is collected. Multiple hooks can each + /// contribute banner lines. Non-JSON stdout or missing `"banner"` field + /// is silently ignored (backwards compatible). + pub async fn emit_collecting_banners(&self, event: HookEvent, ctx: HookContext) -> Vec { + let mut banners = Vec::new(); + let Some(rules) = self.rules.get(&event) else { + return banners; + }; + if rules.is_empty() { + return banners; + } + + let payload = match serde_json::to_string(&ctx) { + Ok(s) => s, + Err(err) => { + warn!(event = %event, error = %err, "Failed to serialize hook context"); + return banners; + } + }; + + for rule in rules { + if let Some(matcher) = &rule.matcher { + let target = ctx.matcher_context.as_deref().unwrap_or(""); + if !matcher.is_match(target) { + continue; + } + } + + for action in &rule.actions { + let LoadedAction::Command { command, timeout } = action; + debug!( + plugin = %rule.plugin_name, + event = %event, + command = %command, + "Running plugin hook (banner-collecting)", + ); + match run_command_hook( + command, + &rule.plugin_root, + &payload, + *timeout, + self.use_login_shell_path, + ) + .await + { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(banner) = extract_banner(stdout.trim()) { + banners.push(banner); + } + } + Ok(output) => { + warn!( + plugin = %rule.plugin_name, + event = %event, + command = %command, + "hook exited with {:?}: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr).trim(), + ); + } + Err(err) => { + warn!( + plugin = %rule.plugin_name, + event = %event, + command = %command, + error = %err, + "Plugin hook failed", + ); + } + } + } + } + + banners + } + /// Like [`Self::emit`], but stops at the first rule that denies the event /// and returns the denial. A hook denies by exiting with status code 2 /// (reason on stderr) or by printing `{"decision":"block","reason":"..."}` @@ -456,6 +536,20 @@ impl HookManager { } } +fn extract_banner(stdout: &str) -> Option { + if !stdout.starts_with('{') { + return None; + } + + #[derive(Deserialize)] + struct BannerResp { + banner: Option, + } + + let parsed: BannerResp = serde_json::from_str(stdout).ok()?; + parsed.banner.filter(|b| !b.is_empty()) +} + fn deny_reason(output: &std::process::Output) -> Option { const DEFAULT: &str = "denied by plugin hook"; let non_empty = |s: String| if s.is_empty() { DEFAULT.into() } else { s }; @@ -898,4 +992,70 @@ mod tests { .await; assert!(marker.exists()); } + + #[test] + fn extract_banner_from_json() { + assert_eq!( + extract_banner(r#"{"banner":" 🌱 hello"}"#), + Some(" 🌱 hello".to_string()) + ); + } + + #[test] + fn extract_banner_ignores_non_json() { + assert_eq!(extract_banner("just some text"), None); + } + + #[test] + fn extract_banner_ignores_json_without_banner_field() { + assert_eq!(extract_banner(r#"{"decision":"allow"}"#), None); + } + + #[test] + fn extract_banner_ignores_empty_banner() { + assert_eq!(extract_banner(r#"{"banner":""}"#), None); + } + + #[tokio::test] + async fn emit_collecting_banners_returns_banner_lines() { + let tmp = tempfile::tempdir().unwrap(); + let hooks = r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"printf '{\"banner\":\" 🌱 test banner\"}'" }]}]}}"#; + let root = write_plugin(tmp.path(), "p", hooks); + let mgr = make_manager(vec![DiscoveredPlugin { + name: "p".into(), + root, + scope: PluginScope::User, + }]); + + let banners = mgr + .emit_collecting_banners( + HookEvent::SessionStart, + HookContext::new(HookEvent::SessionStart, "s"), + ) + .await; + + assert_eq!(banners, vec![" 🌱 test banner"]); + } + + #[tokio::test] + async fn emit_collecting_banners_skips_non_json_output() { + let tmp = tempfile::tempdir().unwrap(); + let hooks = + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo hello"}]}]}}"#; + let root = write_plugin(tmp.path(), "p", hooks); + let mgr = make_manager(vec![DiscoveredPlugin { + name: "p".into(), + root, + scope: PluginScope::User, + }]); + + let banners = mgr + .emit_collecting_banners( + HookEvent::SessionStart, + HookContext::new(HookEvent::SessionStart, "s"), + ) + .await; + + assert!(banners.is_empty()); + } }