diff --git a/config.example.toml b/config.example.toml index 51017ef9..ac8d22c0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -687,7 +687,8 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # Configure as `[[hooks.hooks]]` under a `[hooks]` table. # # Available events: session_start, session_end, message_submit, -# tool_call_before, tool_call_after, mode_change, on_error, shell_env. +# tool_call_before, tool_call_after, mode_change, on_error, +# subagent_spawn, subagent_complete, shell_env. # # `shell_env` (#456) is special: the hook runs immediately before each # `exec_shell` invocation and its stdout is parsed as `KEY=VALUE\n` lines. @@ -714,6 +715,15 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # command = "aws-vault export my-profile --format=env" # # Optionally limit to specific tool names / categories: # # condition = { type = "tool_category", category = "shell" } +# +# # Observe sub-agent lifecycle events. These hooks receive bounded JSON +# # metadata on stdin and are warn-only: failures do not affect sub-agent +# # scheduling, prompts, or results. continue_on_error has no effect for +# # these observer events; later matching hooks always continue. +# [[hooks.hooks]] +# name = "subagent-audit" +# event = "subagent_complete" +# command = "~/.codewhale/hooks/subagent-audit.sh" # ───────────────────────────────────────────────────────────────────────────────── # Runtime API (`deepseek serve --http`) (#561) diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index 52650029..e837e477 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -63,6 +63,14 @@ fn events() -> CommandResult { HookEvent::OnError, "fires on transport / capacity / tool errors", ), + ( + HookEvent::SubagentSpawn, + "fires when a sub-agent starts (observer-only)", + ), + ( + HookEvent::SubagentComplete, + "fires when a sub-agent completes, fails, or is cancelled (observer-only)", + ), ]; for (event, desc) in ordered { out.push_str(&format!(" - `{}` — {desc}\n", event_label(event))); @@ -138,6 +146,8 @@ fn event_label(event: HookEvent) -> &'static str { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::SubagentSpawn => "subagent_spawn", + HookEvent::SubagentComplete => "subagent_complete", HookEvent::ShellEnv => "shell_env", } } @@ -261,6 +271,8 @@ mod tests { "tool_call_after", "mode_change", "on_error", + "subagent_spawn", + "subagent_complete", ] .iter() .map(|name| { @@ -298,6 +310,11 @@ mod tests { assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit"); assert_eq!(event_label(HookEvent::ModeChange), "mode_change"); assert_eq!(event_label(HookEvent::OnError), "on_error"); + assert_eq!(event_label(HookEvent::SubagentSpawn), "subagent_spawn"); + assert_eq!( + event_label(HookEvent::SubagentComplete), + "subagent_complete" + ); } #[test] diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index 6450d9e6..a528bc1a 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -41,6 +41,10 @@ pub enum HookEvent { ModeChange, /// Triggered when an error occurs OnError, + /// Triggered when a sub-agent is spawned + SubagentSpawn, + /// Triggered when a sub-agent reaches a terminal state + SubagentComplete, /// Triggered immediately before each `exec_shell` invocation. The hook's /// stdout is parsed as `KEY=VALUE\n` lines and merged on top of the /// shell command's environment — useful for ephemeral credentials, @@ -62,6 +66,8 @@ impl HookEvent { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::SubagentSpawn => "subagent_spawn", + HookEvent::SubagentComplete => "subagent_complete", HookEvent::ShellEnv => "shell_env", } } @@ -777,6 +783,59 @@ impl HookExecutor { results } + /// Execute observer hooks with a structured JSON stdin payload. + /// + /// Unlike `message_submit`, stdout is deliberately ignored by callers: + /// these hooks are lifecycle observers and cannot mutate or block the + /// underlying action. + pub fn execute_json_observer( + &self, + event: HookEvent, + context: &HookContext, + payload: &serde_json::Value, + ) -> Vec { + if !self.config.enabled { + return Vec::new(); + } + + let hooks = self.config.hooks_for_event(event); + if hooks.is_empty() { + return Vec::new(); + } + + let env_vars = context.to_env_vars(); + let mut results = Vec::new(); + for hook in hooks { + if !self.matches_condition(hook, context) { + continue; + } + + let result = if hook.background { + self.execute_background_with_stdin(hook, &env_vars, payload) + } else { + self.execute_sync_with_stdin(hook, &env_vars, payload) + }; + + if !result.success { + let label = result.name.as_deref().unwrap_or("(unnamed)"); + tracing::warn!( + target: "hooks", + hook = label, + event = event.as_str(), + exit_code = ?result.exit_code, + duration_ms = result.duration.as_millis() as u64, + error = result.error.as_deref().unwrap_or(""), + stderr_head = %result.stderr.lines().next().unwrap_or(""), + "observer hook failed" + ); + } + + results.push(result); + } + + results + } + /// Check if a hook's condition matches the context #[allow(clippy::only_used_in_recursion)] fn matches_condition(&self, hook: &Hook, context: &HookContext) -> bool { @@ -949,6 +1008,24 @@ impl HookExecutor { /// Execute a hook in the background (non-blocking) fn execute_background(&self, hook: &Hook, env_vars: &HashMap) -> HookResult { + self.execute_background_inner(hook, env_vars, None) + } + + fn execute_background_with_stdin( + &self, + hook: &Hook, + env_vars: &HashMap, + stdin_json: &serde_json::Value, + ) -> HookResult { + self.execute_background_inner(hook, env_vars, Some(stdin_json)) + } + + fn execute_background_inner( + &self, + hook: &Hook, + env_vars: &HashMap, + stdin_json: Option<&serde_json::Value>, + ) -> HookResult { let started = Instant::now(); let working_dir = self .config @@ -956,16 +1033,45 @@ impl HookExecutor { .clone() .unwrap_or_else(|| self.default_working_dir.clone()); + let stdin_bytes = match stdin_json.map(serde_json::to_vec).transpose() { + Ok(bytes) => bytes, + Err(e) => { + return HookResult { + name: hook.name.clone(), + success: false, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + duration: started.elapsed(), + error: Some(format!("Failed to encode hook stdin: {e}")), + }; + } + }; let cmd = hook.command.clone(); let env = env_vars.clone(); let wd = working_dir.clone(); // Spawn in a detached thread std::thread::spawn(move || { - let _ = HookExecutor::build_shell_command(&cmd) + let mut command = HookExecutor::build_shell_command(&cmd); + command .current_dir(&wd) .envs(&env) - .output(); + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if stdin_bytes.is_some() { + command.stdin(Stdio::piped()); + } + + let Ok(mut child) = command.spawn() else { + return; + }; + if let (Some(mut bytes), Some(mut stdin)) = (stdin_bytes, child.stdin.take()) { + bytes.push(b'\n'); + let _ = stdin.write_all(&bytes); + let _ = stdin.flush(); + } + let _ = child.wait(); }); // Return immediately with success (background execution is fire-and-forget) @@ -1237,6 +1343,8 @@ NOEQUAL line dropped assert_eq!(HookEvent::SessionStart.as_str(), "session_start"); assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after"); assert_eq!(HookEvent::ModeChange.as_str(), "mode_change"); + assert_eq!(HookEvent::SubagentSpawn.as_str(), "subagent_spawn"); + assert_eq!(HookEvent::SubagentComplete.as_str(), "subagent_complete"); } #[test] @@ -1423,6 +1531,107 @@ printf '\ndone:%s\n' "${#payload}" .with_tokens(42) } + #[cfg(not(windows))] + #[test] + fn json_observer_hook_receives_structured_stdin() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("payload.json"); + let command = write_hook_script( + &dir, + "capture_observer.sh", + &format!( + r#"#!/bin/sh +cat > "{}" +"#, + out.display() + ), + ); + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::SubagentSpawn, &command)], + ..Default::default() + }, + dir.path().to_path_buf(), + ); + let payload = json!({ + "event": "subagent_spawn", + "agent_id": "agent_123", + "prompt_preview": "inspect this", + "prompt_truncated": false, + }); + + let results = executor.execute_json_observer( + HookEvent::SubagentSpawn, + &submit_context(&dir), + &payload, + ); + + assert_eq!(results.len(), 1); + assert!(results[0].success); + let captured: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(out).expect("payload written")) + .expect("valid JSON payload"); + assert_eq!(captured["event"], "subagent_spawn"); + assert_eq!(captured["agent_id"], "agent_123"); + assert_eq!(captured["prompt_preview"], "inspect this"); + assert_eq!(captured["prompt_truncated"], false); + } + + #[cfg(not(windows))] + #[test] + fn json_observer_hook_failure_does_not_stop_later_hooks() { + let dir = tempfile::tempdir().expect("tempdir"); + let marker = dir.path().join("later-ran"); + let failing = write_hook_script( + &dir, + "failing_observer.sh", + r#"#!/bin/sh +echo boom >&2 +exit 1 +"#, + ); + let later = write_hook_script( + &dir, + "later_observer.sh", + &format!( + r#"#!/bin/sh +cat > "{}" +"#, + marker.display() + ), + ); + let mut first = Hook::new(HookEvent::SubagentComplete, &failing); + first.continue_on_error = false; + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![first, Hook::new(HookEvent::SubagentComplete, &later)], + ..Default::default() + }, + dir.path().to_path_buf(), + ); + let payload = json!({ + "event": "subagent_complete", + "agent_id": "agent_456", + "status": "completed", + }); + + let results = executor.execute_json_observer( + HookEvent::SubagentComplete, + &submit_context(&dir), + &payload, + ); + + assert_eq!(results.len(), 2); + assert!(!results[0].success); + assert!(results[1].success); + assert!( + marker.exists(), + "observer failures must be warn-only and non-blocking" + ); + } + #[cfg(not(windows))] #[test] fn message_submit_transform_applies_hooks_in_order() { @@ -1703,6 +1912,8 @@ exit 7 HookEvent::ToolCallAfter, HookEvent::ModeChange, HookEvent::OnError, + HookEvent::SubagentSpawn, + HookEvent::SubagentComplete, ] { assert!( !executor.has_hooks_for_event(event), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 58644cb2..d5d83025 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -150,6 +150,7 @@ const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; const CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT: f64 = 60.0; const UI_IDLE_POLL_MS: u64 = 48; const UI_ACTIVE_POLL_MS: u64 = 24; +const SUBAGENT_HOOK_PREVIEW_LIMIT: usize = 2_048; const WEB_CONFIG_POLL_MS: u64 = 16; const DISPATCH_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(30); /// Maximum wall-clock time a turn may stay in `"in_progress"` before the UI @@ -649,6 +650,97 @@ fn terminal_probe_timeout(config: &Config) -> Duration { Duration::from_millis(timeout_ms) } +fn execute_subagent_observer_hook( + app: &App, + event: HookEvent, + agent_id: &str, + text_field: &str, + text: &str, +) { + if !app.hooks.has_hooks_for_event(event) { + return; + } + + let (preview, truncated) = bounded_subagent_hook_preview(text); + let context = app.base_hook_context().with_message(&preview); + let mut payload = serde_json::json!({ + "event": event.as_str(), + "agent_id": agent_id, + "session_id": context.session_id.as_deref(), + "workspace": context.workspace.as_ref().map(|path| path.display().to_string()), + "mode": context.mode.as_deref(), + "model": context.model.as_deref(), + "total_tokens": context.total_tokens, + }); + if let Some(object) = payload.as_object_mut() { + object.insert( + format!("{text_field}_preview"), + serde_json::Value::String(preview), + ); + object.insert( + format!("{text_field}_truncated"), + serde_json::Value::Bool(truncated), + ); + } + + if event == HookEvent::SubagentComplete { + payload["status"] = serde_json::Value::String( + subagent_completion_status(text).unwrap_or_else(|| "unknown".to_string()), + ); + } + + let hooks = app.hooks.clone(); + let _ = std::thread::Builder::new() + .name(format!("{}-observer-hook", event.as_str())) + .spawn(move || { + let _ = hooks.execute_json_observer(event, &context, &payload); + }); +} + +fn bounded_subagent_hook_preview(text: &str) -> (String, bool) { + if text.len() <= SUBAGENT_HOOK_PREVIEW_LIMIT { + return (text.to_string(), false); + } + let safe_end = text + .char_indices() + .take_while(|(idx, ch)| idx + ch.len_utf8() <= SUBAGENT_HOOK_PREVIEW_LIMIT) + .last() + .map(|(idx, ch)| idx + ch.len_utf8()) + .unwrap_or(0); + (format!("{}...[truncated]", &text[..safe_end]), true) +} + +fn subagent_completion_status(result: &str) -> Option { + const START: &str = ""; + const END: &str = ""; + + if let Some(start) = result.find(START).map(|idx| idx + START.len()) + && let Some(end) = result[start..].find(END).map(|idx| idx + start) + && let Ok(value) = serde_json::from_str::(&result[start..end]) + && let Some(status) = value.get("status").and_then(serde_json::Value::as_str) + { + return Some(status.to_string()); + } + + let summary = result.lines().find_map(|line| { + let trimmed = line.trim(); + (!trimmed.is_empty()).then_some(trimmed) + })?; + let summary = summary.to_ascii_lowercase(); + if matches!(summary.as_str(), "cancelled" | "canceled") + || summary.starts_with("cancelled:") + || summary.starts_with("canceled:") + { + Some("cancelled".to_string()) + } else if summary == "failed" || summary.starts_with("failed:") { + Some("failed".to_string()) + } else if summary == "interrupted" || summary.starts_with("interrupted:") { + Some("interrupted".to_string()) + } else { + None + } +} + struct TerminalCleanupGuard { use_alt_screen: bool, use_mouse_capture: bool, @@ -1993,6 +2085,13 @@ async fn run_event_loop( } EngineEvent::AgentSpawned { id, prompt } => { let prompt_summary = summarize_tool_output(&prompt); + execute_subagent_observer_hook( + app, + HookEvent::SubagentSpawn, + &id, + "prompt", + &prompt, + ); app.agent_progress .insert(id.clone(), format!("starting: {prompt_summary}")); if app.agent_activity_started_at.is_none() { @@ -2017,6 +2116,13 @@ async fn run_event_loop( app.status_message = Some(format!("Sub-agent {id}: {display}")); } EngineEvent::AgentComplete { id, result } => { + execute_subagent_observer_hook( + app, + HookEvent::SubagentComplete, + &id, + "result", + &result, + ); let subagent_elapsed = app .agent_activity_started_at .or(app.turn_started_at) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 80ac2bb3..c16dde91 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3084,6 +3084,46 @@ fn sort_subagents_orders_running_before_terminal_statuses() { assert_eq!(agents[2].agent_id, "agent_c"); } +#[test] +fn subagent_hook_preview_is_bounded_on_char_boundaries() { + let text = format!("{}{}", "鲸".repeat(900), "tail"); + + let (preview, truncated) = bounded_subagent_hook_preview(&text); + + assert!(truncated); + assert!(preview.ends_with("...[truncated]")); + assert!(preview.len() <= SUBAGENT_HOOK_PREVIEW_LIMIT + "...[truncated]".len()); + assert!(preview.is_char_boundary(preview.len())); +} + +#[test] +fn subagent_completion_status_reads_done_sentinel() { + let result = r#"done +{"agent_id":"agent_x","status":"completed"}"#; + + assert_eq!( + subagent_completion_status(result).as_deref(), + Some("completed") + ); + assert_eq!(subagent_completion_status("no sentinel"), None); +} + +#[test] +fn subagent_completion_status_reads_summary_fallbacks() { + assert_eq!( + subagent_completion_status("Cancelled").as_deref(), + Some("cancelled") + ); + assert_eq!( + subagent_completion_status("Failed: tool timed out").as_deref(), + Some("failed") + ); + assert_eq!( + subagent_completion_status("Interrupted: process restarted").as_deref(), + Some("interrupted") + ); +} + #[test] fn running_agent_count_unions_cache_and_progress() { let mut app = create_test_app(); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0e05bd47..d18f2156 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -495,6 +495,60 @@ the message. Existing environment variables remain available. `shell_env` hooks keep their existing `KEY=VALUE` stdout contract; the JSON stdout contract applies only to `message_submit`. +### Sub-agent lifecycle hooks + +`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle +events. They receive bounded JSON metadata on stdin and are observer-only: +hook failures are logged as warnings and do not block sub-agent scheduling, +change prompts, or change results. For these observer events, +`continue_on_error` has no effect: later matching hooks still run even when an +earlier hook exits non-zero. + +```toml +[[hooks.hooks]] +event = "subagent_complete" +command = "~/.codewhale/hooks/subagent-audit.sh" +timeout_secs = 2 +continue_on_error = true +``` + +`subagent_spawn` receives: + +```json +{ + "event": "subagent_spawn", + "agent_id": "agent_12345678", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "total_tokens": 1234, + "prompt_preview": "bounded prompt preview", + "prompt_truncated": false +} +``` + +`subagent_complete` receives the same common fields plus terminal metadata: + +```json +{ + "event": "subagent_complete", + "agent_id": "agent_12345678", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "total_tokens": 1234, + "status": "completed", + "result_preview": "bounded result preview", + "result_truncated": false +} +``` + +Previews are capped before delivery so lifecycle hooks do not receive full +sub-agent prompts, transcripts, or unbounded results. Use `agent_eval` from a +normal model/tool flow when full sub-agent details are needed. + ### Composer stash (`/stash`, Ctrl+S) Press **Ctrl+S** in the composer to park the current draft to