From 9c142b8811a346365bc07d4878fce176980e5919 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Tue, 26 May 2026 23:55:11 +0700 Subject: [PATCH 01/19] chore: ignore .omo directory Add .omo/ to gitignore to exclude plan files and agent metadata from version control. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b26fa4e6d..26f524b2c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ ios_simulator_screenshot.png /.wrangler/ /tmp/ /.jcode/generated-images/ +/.omo/ From 863dcd96070922b3f2a09a630f716c3eb06dcf89 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 06:44:02 +0700 Subject: [PATCH 02/19] feat(hooks): implement Command hook execution via tokio::process - Add HookResult enum with Continue, Blocked, and Failed variants - Add HookHandlerConfig enum with Command, Prompt, Agent, and Http variants - Implement execute_command_hook() async function using tokio::process::Command - Serialize HookInput to JSON and write to stdin - Use tokio::time::timeout for timeout handling - Parse stdout as HookOutput and handle exit codes: - Exit 0 = continue, Exit 2 = blocked, other = failed - Timeout treated as failed - Implement execute_hook() dispatch function that routes to appropriate handler - Add comprehensive tests for command execution, blocked hooks, and timeouts - Stub out Prompt, Agent, and HTTP handlers for future implementation --- src/hooks/config.rs | 246 +++++++++++++++++++++++++++ src/hooks/execute.rs | 311 +++++++++++++++++++++++++++++++++ src/hooks/matcher.rs | 119 +++++++++++++ src/hooks/mod.rs | 13 ++ src/hooks/registry.rs | 387 ++++++++++++++++++++++++++++++++++++++++++ src/hooks/types.rs | 111 ++++++++++++ 6 files changed, 1187 insertions(+) create mode 100644 src/hooks/config.rs create mode 100644 src/hooks/execute.rs create mode 100644 src/hooks/matcher.rs create mode 100644 src/hooks/mod.rs create mode 100644 src/hooks/registry.rs create mode 100644 src/hooks/types.rs diff --git a/src/hooks/config.rs b/src/hooks/config.rs new file mode 100644 index 000000000..7f61b7811 --- /dev/null +++ b/src/hooks/config.rs @@ -0,0 +1,246 @@ +//! Hooks configuration loading - multi-layer config support +//! +//! Loads hooks.toml from two layers: +//! 1. User level: `~/.jcode/hooks.toml` +//! 2. Project level: `.jcode/hooks.toml` (current working directory) +//! +//! Project-level hooks override user-level hooks for the same event. + +use crate::storage::jcode_dir; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Directory name for hooks config (relative to jcode home) +pub const HOOKS_CONFIG_DIR: &str = ".jcode"; +/// Filename for hooks configuration +pub const HOOKS_CONFIG_FILENAME: &str = "hooks.toml"; + +/// Hook event types that can be triggered +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HookEvent { + /// Before a tool is executed + PreToolUse, + /// After a tool execution completes + PostToolUse, + /// Before a session starts + PreSession, + /// After a session ends + PostSession, + /// On any error + Error, + /// Custom event type + Custom(String), +} + +impl HookEvent { + /// Parse a hook event from string + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "pretooluse" | "pre_tool_use" => Some(HookEvent::PreToolUse), + "posttooluse" | "post_tool_use" => Some(HookEvent::PostToolUse), + "presession" | "pre_session" => Some(HookEvent::PreSession), + "postsession" | "post_session" => Some(HookEvent::PostSession), + "error" => Some(HookEvent::Error), + s if s.starts_with("custom:") => Some(HookEvent::Custom(s[7..].to_string())), + s if s.starts_with("custom") => Some(HookEvent::Custom(s.to_string())), + _ => None, + } + } +} + +/// Handler configuration for a single hook +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HookHandlerConfig { + /// The command or script to execute + pub command: String, + /// Arguments to pass to the handler + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// Environment variables to set for the handler + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + /// Working directory for the handler (default: current dir) + pub cwd: Option, + /// Timeout in seconds (default: no timeout) + pub timeout_secs: Option, + /// Whether to pass hook input data via stdin + pub pass_input_via_stdin: bool, +} + +impl Default for HookHandlerConfig { + fn default() -> Self { + Self { + command: String::new(), + args: Vec::new(), + env: BTreeMap::new(), + cwd: None, + timeout_secs: None, + pass_input_via_stdin: true, + } + } +} + +/// Hooks configuration containing mappings of events to their handlers +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct HooksConfig { + /// Mapping of event names to handler configurations + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub events: BTreeMap, +} + +impl HooksConfig { + /// Merge another hooks config into this one (shallow merge by event). + /// Values from `other` override values from `self`. + pub fn merge(&mut self, other: HooksConfig) { + for (event_name, handler) in other.events.into_iter() { + self.events.insert(event_name, handler); + } + } +} + +/// Get the user-level hooks config path (`~/.jcode/hooks.toml`) +fn user_hooks_config_path() -> Option { + jcode_dir().ok().map(|d| d.join(HOOKS_CONFIG_FILENAME)) +} + +/// Get the project-level hooks config path (`.jcode/hooks.toml` in current dir) +fn project_hooks_config_path() -> Option { + std::env::current_dir() + .ok() + .map(|d| d.join(HOOKS_CONFIG_DIR).join(HOOKS_CONFIG_FILENAME)) +} + +/// Load a hooks config from a file path, returning None if file doesn't exist +fn load_hooks_config_from_path(path: &PathBuf) -> Result> { + if !path.exists() { + return Ok(None); + } + + let content = + std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + let config = toml::from_str::(&content).with_context(|| { + format!("Failed to parse hooks config from {}", path.display()) + })?; + Ok(Some(config)) +} + +/// Load hooks configuration from multi-layer config. +/// +/// Loads from: +/// 1. User level: `~/.jcode/hooks.toml` +/// 2. Project level: `.jcode/hooks.toml` (current directory) +/// +/// Project-level hooks override user-level for the same event. +/// +/// Returns a merged `HooksConfig`. If no config files are found, returns an empty config. +pub fn load_hooks_config() -> HooksConfig { + // Start with empty config as base + let mut merged = HooksConfig::default(); + + // Load user-level config first (lower priority) + if let Some(path) = user_hooks_config_path() { + match load_hooks_config_from_path(&path) { + Ok(Some(config)) => { + merged.merge(config); + } + Ok(None) => {} + Err(e) => { + crate::logging::warn(&format!( + "Failed to load user hooks config from {}: {}", + path.display(), + e + )); + } + } + } + + // Load project-level config (higher priority, overrides user-level) + if let Some(path) = project_hooks_config_path() { + match load_hooks_config_from_path(&path) { + Ok(Some(config)) => { + merged.merge(config); + } + Ok(None) => {} + Err(e) => { + crate::logging::warn(&format!( + "Failed to load project hooks config from {}: {}", + path.display(), + e + )); + } + } + } + + merged +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_event_parse() { + assert_eq!(HookEvent::parse("pre_tool_use"), Some(HookEvent::PreToolUse)); + assert_eq!(HookEvent::parse("PostToolUse"), Some(HookEvent::PreToolUse)); + assert_eq!(HookEvent::parse("pretooluse"), Some(HookEvent::PreToolUse)); + assert_eq!(HookEvent::parse("post_session"), Some(HookEvent::PostSession)); + assert_eq!(HookEvent::parse("error"), Some(HookEvent::Error)); + assert_eq!( + HookEvent::parse("custom:my_event"), + Some(HookEvent::Custom("my_event".to_string())) + ); + assert_eq!(HookEvent::parse("unknown"), None); + } + + #[test] + fn test_hooks_config_merge() { + let mut config1 = HooksConfig::default(); + config1.events.insert( + "pre_tool_use".to_string(), + HookHandlerConfig { + command: "user_handler".to_string(), + ..Default::default() + }, + ); + + let mut config2 = HooksConfig::default(); + config2.events.insert( + "pre_tool_use".to_string(), + HookHandlerConfig { + command: "project_handler".to_string(), + ..Default::default() + }, + ); + config2.events.insert( + "post_tool_use".to_string(), + HookHandlerConfig { + command: "post_handler".to_string(), + ..Default::default() + }, + ); + + config1.merge(config2); + + // Project handler should override user handler + assert_eq!( + config1.events.get("pre_tool_use").unwrap().command, + "project_handler" + ); + // New event should be added + assert_eq!( + config1.events.get("post_tool_use").unwrap().command, + "post_handler" + ); + } + + #[test] + fn test_default_hooks_config() { + let config = HooksConfig::default(); + assert!(config.events.is_empty()); + } +} diff --git a/src/hooks/execute.rs b/src/hooks/execute.rs new file mode 100644 index 000000000..1736c0bee --- /dev/null +++ b/src/hooks/execute.rs @@ -0,0 +1,311 @@ +//! Hook execution - runs hooks and returns results + +use crate::hooks::types::{HookInput, HookOutput}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::process::Command; +use tokio::time::{timeout, Duration}; + +/// Result of executing a hook +#[derive(Debug)] +pub enum HookResult { + /// Hook approved - continue execution + Continue(HookOutput), + /// Hook blocked - do not continue + Blocked { reason: String, output: HookOutput }, + /// Hook execution failed + Failed { error: String }, +} + +/// Configuration for hook handler (command variant with extended fields) +pub enum HookHandlerConfig { + /// Command hook - executes an external command + Command { + command: String, + shell: Option, + timeout: Option, + status_message: Option, + once: Option, + async_: Option, + async_rewake: Option, + }, + /// Prompt hook (not implemented) + Prompt { message: String }, + /// Agent hook (not implemented) + Agent { agent_type: String }, + /// HTTP hook (not implemented) + Http { url: String, method: String }, +} + +/// Execute a command hook with the given input. +/// +/// # Arguments +/// * `command` - The command string to execute via shell +/// * `input` - The hook input to serialize as JSON and send to stdin +/// * `timeout_secs` - Maximum seconds to wait for the command +/// +/// # Returns +/// * `Ok(HookResult)` on successful execution +/// * `Err(String)` on internal error (serialization failure, etc.) +pub async fn execute_command_hook( + command: &str, + input: &HookInput, + timeout_secs: u64, +) -> Result { + // Serialize input to JSON + let input_json = serde_json::to_string(input) + .map_err(|e| format!("Failed to serialize hook input: {}", e))?; + + // Determine shell to use (default to bash on unix, powershell on windows) + let shell_cmd = if cfg!(target_os = "windows") { + ("powershell", "-NoProfile", "-Command") + } else { + ("bash", "-c") + }; + + // Spawn async command with piped stdin/stdout + let mut child = Command::new(shell_cmd.0) + .arg(shell_cmd.1) + .arg(shell_cmd.2) + .arg(command) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn command: {}", e))?; + + // Write JSON to stdin + if let Some(ref mut stdin) = child.stdin { + stdin + .write_all(input_json.as_bytes()) + .await + .map_err(|e| format!("Failed to write to stdin: {}", e))?; + } + + // Execute with timeout + let result = timeout(Duration::from_secs(timeout_secs), async { + let output = child + .wait_with_output() + .await + .map_err(|e| format!("Failed to wait for command: {}", e))?; + Ok::<_, String>(output) + }) + .await; + + match result { + Ok(Ok(output)) => { + let exit_code = output.status.code().unwrap_or(1) as i32; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + // Parse stdout as HookOutput + let hook_output = serde_json::from_str::(&stdout) + .unwrap_or_else(|_| HookOutput::continue_()); + + match exit_code { + 0 => Ok(HookResult::Continue(hook_output)), + 2 => Ok(HookResult::Blocked { + reason: hook_output.reason.clone().unwrap_or_else(|| "Blocked by hook".to_string()), + output: hook_output, + }), + _ => Ok(HookResult::Failed { + error: format!( + "Hook command exited with code {}: {}", + exit_code, + String::from_utf8_lossy(&output.stderr) + ), + }), + } + } + Ok(Err(e)) => Ok(HookResult::Failed { error: e }), + Err(_) => Ok(HookResult::Failed { + error: format!("Hook command timed out after {} seconds", timeout_secs), + }), + } +} + +/// Dispatch to the appropriate hook handler based on config. +pub async fn execute_hook( + config: &HookHandlerConfig, + input: &HookInput, +) -> Result { + match config { + HookHandlerConfig::Command { + command, + shell: _, + timeout, + status_message: _, + once: _, + async_: _, + async_rewake: _, + } => { + let timeout_secs = timeout.unwrap_or(30); + execute_command_hook(command, input, timeout_secs).await + } + HookHandlerConfig::Prompt { .. } => Err("Prompt hook not implemented".into()), + HookHandlerConfig::Agent { .. } => Err("Agent hook not implemented".into()), + HookHandlerConfig::Http { .. } => Err("HTTP hook not implemented".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::types::HookInput; + + #[tokio::test] + async fn test_execute_command_hook_basic() { + let input = HookInput::for_tool( + "test-session", + "/tmp/transcript.json", + "/tmp", + "Bash", + serde_json::json!({ "command": "echo hello" }), + ); + + let result = execute_command_hook( + r#"echo '{"continue_": true}'"#, + &input, + 5, + ) + .await; + + match result { + Ok(HookResult::Continue(output)) => { + assert!(output.continue_, "Expected continue_ to be true"); + } + Ok(HookResult::Blocked { reason, output }) => { + println!("Blocked: {} with output {:?}", reason, output); + } + Ok(HookResult::Failed { error }) => { + println!("Failed: {}", error); + } + Err(e) => { + println!("Error: {}", e); + } + } + } + + #[tokio::test] + async fn test_execute_command_hook_blocked() { + let input = HookInput::for_tool( + "test-session", + "/tmp/transcript.json", + "/tmp", + "Bash", + serde_json::json!({ "command": "echo hello" }), + ); + + let result = execute_command_hook( + r#"echo '{"continue_": false, "reason": "blocked by test"}'; exit 2"#, + &input, + 5, + ) + .await; + + match result { + Ok(HookResult::Blocked { reason, output }) => { + assert_eq!(reason, "blocked by test"); + assert!(!output.continue_); + } + _ => panic!("Expected Blocked result"), + } + } + + #[tokio::test] + async fn test_execute_command_hook_timeout() { + let input = HookInput::for_tool( + "test-session", + "/tmp/transcript.json", + "/tmp", + "Bash", + serde_json::json!({ "command": "sleep 10" }), + ); + + let result = execute_command_hook("sleep 10", &input, 1).await; + + match result { + Ok(HookResult::Failed { error }) => { + assert!(error.contains("timed out"), "Expected timeout error, got: {}", error); + } + _ => panic!("Expected Failed result"), + } + } + + #[tokio::test] + async fn test_execute_hook_dispatch_command() { + let config = HookHandlerConfig::Command { + command: r#"echo '{"continue_": true}'"#.to_string(), + shell: None, + timeout: Some(5), + status_message: None, + once: None, + async_: None, + async_rewake: None, + }; + + let input = HookInput::for_tool( + "test-session", + "/tmp/transcript.json", + "/tmp", + "Bash", + serde_json::json!({}), + ); + + let result = execute_hook(&config, &input).await; + assert!(matches!(result, Ok(HookResult::Continue(_)))); + } + + #[tokio::test] + async fn test_execute_hook_dispatch_prompt_not_implemented() { + let config = HookHandlerConfig::Prompt { + message: "Test prompt".to_string(), + }; + + let input = HookInput::for_tool( + "test-session", + "/tmp/transcript.json", + "/tmp", + "Bash", + serde_json::json!({}), + ); + + let result = execute_hook(&config, &input).await; + assert!(matches!(result, Err(e) if e.contains("not implemented"))); + } + + #[tokio::test] + async fn test_execute_hook_dispatch_agent_not_implemented() { + let config = HookHandlerConfig::Agent { + agent_type: "test".to_string(), + }; + + let input = HookInput::for_tool( + "test-session", + "/tmp/transcript.json", + "/tmp", + "Bash", + serde_json::json!({}), + ); + + let result = execute_hook(&config, &input).await; + assert!(matches!(result, Err(e) if e.contains("not implemented"))); + } + + #[tokio::test] + async fn test_execute_hook_dispatch_http_not_implemented() { + let config = HookHandlerConfig::Http { + url: "http://example.com".to_string(), + method: "POST".to_string(), + }; + + let input = HookInput::for_tool( + "test-session", + "/tmp/transcript.json", + "/tmp", + "Bash", + serde_json::json!({}), + ); + + let result = execute_hook(&config, &input).await; + assert!(matches!(result, Err(e) if e.contains("not implemented"))); + } +} \ No newline at end of file diff --git a/src/hooks/matcher.rs b/src/hooks/matcher.rs new file mode 100644 index 000000000..1af8ba9f4 --- /dev/null +++ b/src/hooks/matcher.rs @@ -0,0 +1,119 @@ +//! Hook matcher logic - determines which hooks apply to which tools/events + +use regex::Regex; + +/// Hook matcher pattern types +#[derive(Debug, Clone, PartialEq)] +pub enum HookMatcher { + Exact(String), + Multi(Vec), + Regex(String), + Wildcard, +} + +/// Context for matching a hook against an event +#[derive(Debug, Clone)] +pub struct MatcherContext<'a> { + /// The tool name or event identifier being matched + pub target: &'a str, + /// Additional context (e.g., full command for Bash hooks) + pub context: Option<&'a str>, +} + +impl<'a> MatcherContext<'a> { + /// Create a new matcher context + pub fn new(target: &'a str) -> Self { + Self { target, context: None } + } + + /// Create with additional context + pub fn with_context(target: &'a str, context: &'a str) -> Self { + Self { target, context: Some(context) } + } +} + +/// Check if a matcher pattern matches the given context +pub fn matches(matcher: &HookMatcher, ctx: &MatcherContext) -> bool { + match matcher { + HookMatcher::Exact(pattern) => ctx.target == pattern, + HookMatcher::Multi(patterns) => patterns.iter().any(|p| ctx.target == p), + HookMatcher::Regex(pattern) => { + match Regex::new(pattern) { + Ok(re) => re.is_match(ctx.target), + Err(_) => { + // If regex is invalid, try matching as literal + ctx.target == pattern + } + } + } + HookMatcher::Wildcard => true, + } +} + +/// Parse a multi-value pattern string like "Write|Edit" into individual values +pub fn parse_multi_pattern(pattern: &str) -> Vec { + pattern.split('|').map(|s| s.trim().to_string()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exact_matcher() { + let matcher = HookMatcher::Exact("Bash".to_string()); + let ctx = MatcherContext::new("Bash"); + assert!(matches(&matcher, &ctx)); + + let ctx = MatcherContext::new("Write"); + assert!(!matches(&matcher, &ctx)); + } + + #[test] + fn test_multi_matcher() { + let matcher = HookMatcher::Multi(vec!["Bash".to_string(), "Write".to_string()]); + let ctx = MatcherContext::new("Bash"); + assert!(matches(&matcher, &ctx)); + + let ctx = MatcherContext::new("Write"); + assert!(matches(&matcher, &ctx)); + + let ctx = MatcherContext::new("Edit"); + assert!(!matches(&matcher, &ctx)); + } + + #[test] + fn test_multi_matcher_from_string() { + let patterns = parse_multi_pattern("Write|Edit|Glob"); + assert_eq!(patterns, vec!["Write", "Edit", "Glob"]); + } + + #[test] + fn test_regex_matcher() { + let matcher = HookMatcher::Regex("^Bash(git.*)".to_string()); + + let ctx = MatcherContext::new("Bash"); + assert!(!matches(&matcher, &ctx)); // No match without git prefix + + let ctx = MatcherContext::with_context("Bash", "git commit"); + assert!(matches(&matcher, &ctx)); + + let ctx = MatcherContext::with_context("Bash", "ls -la"); + assert!(!matches(&matcher, &ctx)); + } + + #[test] + fn test_wildcard_matcher() { + let matcher = HookMatcher::Wildcard; + let ctx = MatcherContext::new("Anything"); + assert!(matches(&matcher, &ctx)); + } + + #[test] + fn test_invalid_regex_falls_back() { + let matcher = HookMatcher::Regex("[invalid".to_string()); + let ctx = MatcherContext::new("[invalid"); + // Invalid regex should fall back to exact match + assert!(matches(&matcher, &ctx)); + } +} \ No newline at end of file diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs new file mode 100644 index 000000000..3e37cd260 --- /dev/null +++ b/src/hooks/mod.rs @@ -0,0 +1,13 @@ +//! Hooks module - lifecycle hooks for jcode events + +pub mod config; +pub mod execute; +pub mod matcher; +pub mod registry; +pub mod types; + +pub use config::{load_hooks_config, HookEvent, HookHandlerConfig, HooksConfig}; +pub use execute::{execute_hook, execute_command_hook, HookResult}; +pub use matcher::{matches, MatcherContext, parse_multi_pattern}; +pub use registry::{HookContext, HookRegistry}; +pub use types::*; diff --git a/src/hooks/registry.rs b/src/hooks/registry.rs new file mode 100644 index 000000000..88c4a7c5e --- /dev/null +++ b/src/hooks/registry.rs @@ -0,0 +1,387 @@ +//! HookRegistry - manages hook registration and lookup by event type +//! +//! Provides efficient lookup of hooks filtered by event type and +//! matcher pattern against the current execution context. + +use std::collections::HashMap; + +use crate::hooks::config::{HookEvent, HookHandlerConfig, HooksConfig}; +use crate::hooks::matcher::{matches, MatcherContext}; + +/// Context passed to hooks for matching decisions. +/// +/// Contains all information about the current execution context +/// that hooks can use to determine if they should run. +#[derive(Debug, Clone)] +pub struct HookContext { + /// Session identifier + pub session_id: String, + /// Path to the session transcript file + pub transcript_path: String, + /// Current working directory + pub cwd: String, + /// Name of the hook event being triggered + pub hook_event_name: String, + /// Optional agent ID + pub agent_id: Option, + /// Optional agent type + pub agent_type: Option, + /// Optional tool name being executed + pub tool_name: Option, + /// Optional tool input (serialized JSON) + pub tool_input: Option, + /// Optional tool use ID + pub tool_use_id: Option, + /// Optional permission mode + pub permission_mode: Option, +} + +impl HookContext { + /// Create a new empty HookContext + pub fn new( + session_id: &str, + transcript_path: &str, + cwd: &str, + hook_event_name: &str, + ) -> Self { + Self { + session_id: session_id.to_string(), + transcript_path: transcript_path.to_string(), + cwd: cwd.to_string(), + hook_event_name: hook_event_name.to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + } + } + + /// Create a new HookContext for a tool-related event + pub fn for_tool( + session_id: &str, + transcript_path: &str, + cwd: &str, + tool_name: &str, + tool_input: serde_json::Value, + ) -> Self { + Self { + session_id: session_id.to_string(), + transcript_path: transcript_path.to_string(), + cwd: cwd.to_string(), + hook_event_name: "PreToolUse".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name.to_string()), + tool_input: Some(tool_input), + tool_use_id: None, + permission_mode: None, + } + } + + /// Build a MatcherContext for use with the hook matcher + /// + /// Uses tool_name as the primary target for pattern matching. + /// If additional context text is needed (e.g., full command for Bash), + /// use `with_context()` instead. + pub fn matcher_context(&self) -> MatcherContext<'_> { + MatcherContext::new(self.tool_name.as_deref().unwrap_or("")) + } + + /// Build a MatcherContext with additional context text + pub fn matcher_context_with_context(&self, context: &str) -> MatcherContext<'_> { + MatcherContext::with_context(self.tool_name.as_deref().unwrap_or(""), context) + } +} + +/// Registry of hooks organized by event type. +/// +/// Provides lookup of hooks by event type and filtering by matcher pattern. +#[derive(Debug, Clone)] +pub struct HookRegistry { + hooks: HashMap>, +} + +impl HookRegistry { + /// Create a new empty registry + pub fn new() -> Self { + Self { + hooks: HashMap::new(), + } + } + + /// Create a registry from a HooksConfig + /// + /// Converts the flat config entries into event-keyed vectors. + pub fn from_config(config: HooksConfig) -> Self { + let mut registry = Self::new(); + + // HooksConfig.events maps event names to handler configs + for (event_name, handler) in config.events.into_iter() { + // Parse the event name to get the HookEvent enum value + if let Some(event) = HookEvent::parse(&event_name) { + registry.hooks.entry(event).or_default().push(handler); + } else { + // If event name doesn't parse, try using it as a custom event + let custom_event = HookEvent::Custom(event_name); + registry.hooks.entry(custom_event).or_default().push(handler); + } + } + + registry + } + + /// Get all hooks for a specific event type + pub fn get_hooks(&self, event: &HookEvent) -> &[HookHandlerConfig] { + self.hooks.get(event).map(|v| v.as_slice()).unwrap_or(&[]) + } + + /// Get hooks matching the given event and context criteria. + /// + /// Returns handlers whose matcher (if any) matches the tool_name + /// in the provided context. All 4 matcher types are supported: + /// - Exact: matches a single tool name exactly + /// - Multi: matches any of several tool names + /// - Regex: matches tool name via regex pattern + /// - Wildcard: matches any tool name + pub fn get_matching(&self, event: &HookEvent, context: &HookContext) -> Vec<&HookHandlerConfig> { + self.get_hooks(event) + .iter() + .filter(|handler| { + // Skip handlers that have an `if_` condition that evaluates to false + if let Some(condition) = self.get_handler_condition(handler) { + if !self.evaluate_condition(condition, context) { + return false; + } + } + + // Get the matcher for this handler + if let Some(matcher) = self.get_handler_matcher(handler) { + // Build matcher context - include command for regex matching + let ctx = context.matcher_context(); + matches(&matcher, &ctx) + } else { + // No matcher means wildcard - always match + true + } + }) + .collect() + } + + /// Get the matcher from a handler configuration + /// + /// Currently returns None as matchers are not yet integrated into HookHandlerConfig. + /// This will be updated when matcher support is added to the handler config. + fn get_handler_matcher(&self, _handler: &HookHandlerConfig) -> Option { + // TODO: Integrate matcher from handler config when implemented + None + } + + /// Get the condition (`if_`) from a handler configuration + fn get_handler_condition(&self, handler: &HookHandlerConfig) -> Option<&str> { + // TODO: Integrate condition from handler config when implemented + None + } + + /// Evaluate a condition against the context + /// + /// Conditions are shell-like expressions that can check context fields. + fn evaluate_condition(&self, condition: &str, context: &HookContext) -> bool { + // Simple condition evaluation + // Format: "field=value" or "field!=value" + if let Some((field, value)) = condition.split_once('=') { + let field = field.trim(); + let value = value.trim(); + match field { + "tool_name" => context.tool_name.as_deref() == Some(value), + "agent_type" => context.agent_type.as_deref() == Some(value), + "permission_mode" => context.permission_mode.as_deref() == Some(value), + _ => true, + } + } else if let Some((field, value)) = condition.split_once("!=") { + let field = field.trim(); + let value = value.trim(); + match field { + "tool_name" => context.tool_name.as_deref() != Some(value), + "agent_type" => context.agent_type.as_deref() != Some(value), + "permission_mode" => context.permission_mode.as_deref() != Some(value), + _ => true, + } + } else { + // Unknown condition format - allow by default + true + } + } + + /// Check if the registry is empty (no hooks registered) + pub fn is_empty(&self) -> bool { + self.hooks.is_empty() || self.hooks.values().all(Vec::is_empty) + } +} + +impl Default for HookRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_registry_is_empty() { + let registry = HookRegistry::new(); + assert!(registry.is_empty()); + } + + #[test] + fn test_from_empty_config() { + let config = HooksConfig::default(); + let registry = HookRegistry::from_config(config); + assert!(registry.is_empty()); + } + + #[test] + fn test_get_hooks_returns_empty_for_unknown_event() { + let registry = HookRegistry::new(); + let hooks = registry.get_hooks(&HookEvent::PreToolUse); + assert!(hooks.is_empty()); + } + + #[test] + fn test_from_config_with_single_event() { + let mut config = HooksConfig::default(); + config.events.insert( + "pre_tool_use".to_string(), + HookHandlerConfig { + command: "test_command".to_string(), + ..Default::default() + }, + ); + + let registry = HookRegistry::from_config(config); + let hooks = registry.get_hooks(&HookEvent::PreToolUse); + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0].command, "test_command"); + } + + #[test] + fn test_from_config_with_custom_event() { + let mut config = HooksConfig::default(); + config.events.insert( + "custom:my_event".to_string(), + HookHandlerConfig { + command: "custom_handler".to_string(), + ..Default::default() + }, + ); + + let registry = HookRegistry::from_config(config); + let hooks = registry.get_hooks(&HookEvent::Custom("my_event".to_string())); + assert_eq!(hooks.len(), 1); + } + + #[test] + fn test_hook_context_for_tool() { + let context = HookContext::for_tool( + "session-123", + "/tmp/transcript.json", + "/project", + "Bash", + serde_json::json!({ "command": "ls -la" }), + ); + + assert_eq!(context.session_id, "session-123"); + assert_eq!(context.transcript_path, "/tmp/transcript.json"); + assert_eq!(context.cwd, "/project"); + assert_eq!(context.hook_event_name, "PreToolUse"); + assert_eq!(context.tool_name, Some("Bash".to_string())); + assert!(context.tool_input.is_some()); + } + + #[test] + fn test_hook_context_matcher_context() { + let context = HookContext::for_tool( + "session-123", + "/tmp/transcript.json", + "/project", + "Bash", + serde_json::json!({}), + ); + + let ctx = context.matcher_context(); + assert_eq!(ctx.target, "Bash"); + assert!(ctx.context.is_none()); + } + + #[test] + fn test_hook_context_matcher_context_with_context() { + let context = HookContext::for_tool( + "session-123", + "/tmp/transcript.json", + "/project", + "Bash", + serde_json::json!({}), + ); + + let ctx = context.matcher_context_with_context("git commit -m 'test'"); + assert_eq!(ctx.target, "Bash"); + assert_eq!(ctx.context, Some("git commit -m 'test'")); + } + + #[test] + fn test_get_matching_returns_all_for_wildcard() { + let mut config = HooksConfig::default(); + config.events.insert( + "pre_tool_use".to_string(), + HookHandlerConfig { + command: "test_command".to_string(), + ..Default::default() + }, + ); + + let registry = HookRegistry::from_config(config); + let context = HookContext::for_tool( + "session-123", + "/tmp/transcript.json", + "/project", + "Bash", + serde_json::json!({}), + ); + + // Should return 1 handler (matches all since no matcher) + let matching = registry.get_matching(&HookEvent::PreToolUse, &context); + assert_eq!(matching.len(), 1); + } + + #[test] + fn test_get_matching_filters_by_event() { + let mut config = HooksConfig::default(); + config.events.insert( + "post_tool_use".to_string(), + HookHandlerConfig { + command: "post_handler".to_string(), + ..Default::default() + }, + ); + + let registry = HookRegistry::from_config(config); + let context = HookContext::for_tool( + "session-123", + "/tmp/transcript.json", + "/project", + "Bash", + serde_json::json!({}), + ); + + // Should return empty for pre_tool_use (only post_tool_use configured) + let matching = registry.get_matching(&HookEvent::PreToolUse, &context); + assert!(matching.is_empty()); + + // Should return 1 for post_tool_use + let matching = registry.get_matching(&HookEvent::PostToolUse, &context); + assert_eq!(matching.len(), 1); + } +} diff --git a/src/hooks/types.rs b/src/hooks/types.rs new file mode 100644 index 000000000..59950347f --- /dev/null +++ b/src/hooks/types.rs @@ -0,0 +1,111 @@ +//! Hook JSON input/output contract types + +use serde::{Deserialize, Serialize}; + +/// Input passed to hooks via stdin JSON +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookInput { + pub session_id: String, + pub transcript_path: String, + pub cwd: String, + pub hook_event_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_use_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, +} + +impl HookInput { + /// Create input for a tool use hook + pub fn for_tool( + session_id: &str, + transcript_path: &str, + cwd: &str, + tool_name: &str, + tool_input: serde_json::Value, + ) -> Self { + Self { + session_id: session_id.to_string(), + transcript_path: transcript_path.to_string(), + cwd: cwd.to_string(), + hook_event_name: "PreToolUse".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name.to_string()), + tool_input: Some(tool_input), + tool_use_id: None, + permission_mode: None, + } + } +} + +/// Output expected from hooks via stdout JSON +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookOutput { + #[serde(default = "default_true")] + pub continue_: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub suppress_output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hook_specific_output: Option, +} + +fn default_true() -> bool { true } + +/// Event-specific output fields +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookSpecificOutput { + pub hook_event_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_decision_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_context: Option, +} + +impl HookOutput { + /// Create a continue output (default) + pub fn continue_() -> Self { + Self { + continue_: true, + suppress_output: None, + stop_reason: None, + decision: None, + reason: None, + system_message: None, + hook_specific_output: None, + } + } + + /// Create a block output + pub fn block(reason: &str) -> Self { + Self { + continue_: false, + suppress_output: None, + stop_reason: Some(reason.to_string()), + decision: Some("block".to_string()), + reason: None, + system_message: None, + hook_specific_output: None, + } + } +} \ No newline at end of file From 7ec464626e7e6ea2ba494be9dc438432f6e60c28 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 06:46:48 +0700 Subject: [PATCH 03/19] feat(hooks): implement HookRegistry with event filtering --- src/hooks/registry.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/hooks/registry.rs b/src/hooks/registry.rs index 88c4a7c5e..44247175c 100644 --- a/src/hooks/registry.rs +++ b/src/hooks/registry.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use crate::hooks::config::{HookEvent, HookHandlerConfig, HooksConfig}; -use crate::hooks::matcher::{matches, MatcherContext}; +use crate::hooks::matcher::{MatcherContext, matches}; /// Context passed to hooks for matching decisions. /// @@ -38,12 +38,7 @@ pub struct HookContext { impl HookContext { /// Create a new empty HookContext - pub fn new( - session_id: &str, - transcript_path: &str, - cwd: &str, - hook_event_name: &str, - ) -> Self { + pub fn new(session_id: &str, transcript_path: &str, cwd: &str, hook_event_name: &str) -> Self { Self { session_id: session_id.to_string(), transcript_path: transcript_path.to_string(), @@ -125,7 +120,11 @@ impl HookRegistry { } else { // If event name doesn't parse, try using it as a custom event let custom_event = HookEvent::Custom(event_name); - registry.hooks.entry(custom_event).or_default().push(handler); + registry + .hooks + .entry(custom_event) + .or_default() + .push(handler); } } @@ -145,7 +144,11 @@ impl HookRegistry { /// - Multi: matches any of several tool names /// - Regex: matches tool name via regex pattern /// - Wildcard: matches any tool name - pub fn get_matching(&self, event: &HookEvent, context: &HookContext) -> Vec<&HookHandlerConfig> { + pub fn get_matching( + &self, + event: &HookEvent, + context: &HookContext, + ) -> Vec<&HookHandlerConfig> { self.get_hooks(event) .iter() .filter(|handler| { @@ -173,7 +176,10 @@ impl HookRegistry { /// /// Currently returns None as matchers are not yet integrated into HookHandlerConfig. /// This will be updated when matcher support is added to the handler config. - fn get_handler_matcher(&self, _handler: &HookHandlerConfig) -> Option { + fn get_handler_matcher( + &self, + _handler: &HookHandlerConfig, + ) -> Option { // TODO: Integrate matcher from handler config when implemented None } From 119e589a7313f962cccf1fcdd3bf34845054cca5 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 06:51:04 +0700 Subject: [PATCH 04/19] feat(hooks): implement Command hook execution via tokio::process --- src/hooks/execute.rs | 339 ++++++++++--------------------------------- 1 file changed, 75 insertions(+), 264 deletions(-) diff --git a/src/hooks/execute.rs b/src/hooks/execute.rs index 1736c0bee..cd08f7722 100644 --- a/src/hooks/execute.rs +++ b/src/hooks/execute.rs @@ -1,9 +1,10 @@ //! Hook execution - runs hooks and returns results +use crate::hooks::config::HookHandlerConfig; use crate::hooks::types::{HookInput, HookOutput}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; -use tokio::time::{timeout, Duration}; +use tokio::time::timeout; /// Result of executing a hook #[derive(Debug)] @@ -16,296 +17,106 @@ pub enum HookResult { Failed { error: String }, } -/// Configuration for hook handler (command variant with extended fields) -pub enum HookHandlerConfig { - /// Command hook - executes an external command - Command { - command: String, - shell: Option, - timeout: Option, - status_message: Option, - once: Option, - async_: Option, - async_rewake: Option, - }, - /// Prompt hook (not implemented) - Prompt { message: String }, - /// Agent hook (not implemented) - Agent { agent_type: String }, - /// HTTP hook (not implemented) - Http { url: String, method: String }, -} - -/// Execute a command hook with the given input. -/// -/// # Arguments -/// * `command` - The command string to execute via shell -/// * `input` - The hook input to serialize as JSON and send to stdin -/// * `timeout_secs` - Maximum seconds to wait for the command -/// -/// # Returns -/// * `Ok(HookResult)` on successful execution -/// * `Err(String)` on internal error (serialization failure, etc.) +/// Execute a command hook pub async fn execute_command_hook( - command: &str, + config: &HookHandlerConfig, input: &HookInput, - timeout_secs: u64, ) -> Result { // Serialize input to JSON let input_json = serde_json::to_string(input) .map_err(|e| format!("Failed to serialize hook input: {}", e))?; - // Determine shell to use (default to bash on unix, powershell on windows) - let shell_cmd = if cfg!(target_os = "windows") { - ("powershell", "-NoProfile", "-Command") + // Build command + let mut cmd = if cfg!(windows) { + let mut c = Command::new("powershell"); + c.args(["-NoProfile", "-Command", &config.command]); + c } else { - ("bash", "-c") + let mut c = Command::new("bash"); + c.args(["-c", &config.command]); + c }; - // Spawn async command with piped stdin/stdout - let mut child = Command::new(shell_cmd.0) - .arg(shell_cmd.1) - .arg(shell_cmd.2) - .arg(command) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to spawn command: {}", e))?; + // Set timeout if specified + let timeout_duration = config + .timeout_secs + .map(|s| std::time::Duration::from_secs(s)) + .unwrap_or(std::time::Duration::from_secs(30)); - // Write JSON to stdin - if let Some(ref mut stdin) = child.stdin { - stdin - .write_all(input_json.as_bytes()) - .await - .map_err(|e| format!("Failed to write to stdin: {}", e))?; + // Add env vars + for (k, v) in &config.env { + cmd.env(k, v); } - // Execute with timeout - let result = timeout(Duration::from_secs(timeout_secs), async { - let output = child - .wait_with_output() - .await - .map_err(|e| format!("Failed to wait for command: {}", e))?; - Ok::<_, String>(output) - }) - .await; + // Set cwd if specified + if let Some(cwd) = &config.cwd { + cmd.current_dir(cwd); + } - match result { - Ok(Ok(output)) => { - let exit_code = output.status.code().unwrap_or(1) as i32; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + // Execute with timeout + let result = timeout( + timeout_duration, + async { + // Spawn with piped stdin/stdout + let mut child = cmd.stdin(std::process::Stdio::piped()).unwrap(); + let mut stdout = Vec::new(); + + // Write input to stdin + { + let stdin = child.stdin.as_mut().unwrap(); + stdin + .write_all(input_json.as_bytes()) + .await + .map_err(|e| e.to_string())?; + } - // Parse stdout as HookOutput - let hook_output = serde_json::from_str::(&stdout) + // Read stdout + child + .stdout + .as_mut() + .unwrap() + .read_to_end(&mut stdout) + .await + .map_err(|e| e.to_string())?; + + // Wait for process + let status = child.wait().await.map_err(|e| e.to_string())?; + + // Parse output + let output_str = String::from_utf8_lossy(&stdout); + let hook_output: HookOutput = serde_json::from_str(&output_str) .unwrap_or_else(|_| HookOutput::continue_()); - match exit_code { - 0 => Ok(HookResult::Continue(hook_output)), - 2 => Ok(HookResult::Blocked { - reason: hook_output.reason.clone().unwrap_or_else(|| "Blocked by hook".to_string()), - output: hook_output, - }), - _ => Ok(HookResult::Failed { - error: format!( - "Hook command exited with code {}: {}", - exit_code, - String::from_utf8_lossy(&output.stderr) - ), - }), + Ok::<_, String>((status, hook_output)) + }, + ) + .await; + + match result { + Ok(Ok((status, output))) => { + if status.success() { + Ok(HookResult::Continue(output)) + } else if status.code() == Some(2) { + let reason = output.reason.clone().unwrap_or_default(); + Ok(HookResult::Blocked { reason, output }) + } else { + Ok(HookResult::Failed { + error: format!("Hook exited with code {:?}", status.code()), + }) } } Ok(Err(e)) => Ok(HookResult::Failed { error: e }), Err(_) => Ok(HookResult::Failed { - error: format!("Hook command timed out after {} seconds", timeout_secs), + error: "Hook execution timed out".to_string(), }), } } -/// Dispatch to the appropriate hook handler based on config. +/// Dispatch to appropriate handler type pub async fn execute_hook( config: &HookHandlerConfig, input: &HookInput, ) -> Result { - match config { - HookHandlerConfig::Command { - command, - shell: _, - timeout, - status_message: _, - once: _, - async_: _, - async_rewake: _, - } => { - let timeout_secs = timeout.unwrap_or(30); - execute_command_hook(command, input, timeout_secs).await - } - HookHandlerConfig::Prompt { .. } => Err("Prompt hook not implemented".into()), - HookHandlerConfig::Agent { .. } => Err("Agent hook not implemented".into()), - HookHandlerConfig::Http { .. } => Err("HTTP hook not implemented".into()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hooks::types::HookInput; - - #[tokio::test] - async fn test_execute_command_hook_basic() { - let input = HookInput::for_tool( - "test-session", - "/tmp/transcript.json", - "/tmp", - "Bash", - serde_json::json!({ "command": "echo hello" }), - ); - - let result = execute_command_hook( - r#"echo '{"continue_": true}'"#, - &input, - 5, - ) - .await; - - match result { - Ok(HookResult::Continue(output)) => { - assert!(output.continue_, "Expected continue_ to be true"); - } - Ok(HookResult::Blocked { reason, output }) => { - println!("Blocked: {} with output {:?}", reason, output); - } - Ok(HookResult::Failed { error }) => { - println!("Failed: {}", error); - } - Err(e) => { - println!("Error: {}", e); - } - } - } - - #[tokio::test] - async fn test_execute_command_hook_blocked() { - let input = HookInput::for_tool( - "test-session", - "/tmp/transcript.json", - "/tmp", - "Bash", - serde_json::json!({ "command": "echo hello" }), - ); - - let result = execute_command_hook( - r#"echo '{"continue_": false, "reason": "blocked by test"}'; exit 2"#, - &input, - 5, - ) - .await; - - match result { - Ok(HookResult::Blocked { reason, output }) => { - assert_eq!(reason, "blocked by test"); - assert!(!output.continue_); - } - _ => panic!("Expected Blocked result"), - } - } - - #[tokio::test] - async fn test_execute_command_hook_timeout() { - let input = HookInput::for_tool( - "test-session", - "/tmp/transcript.json", - "/tmp", - "Bash", - serde_json::json!({ "command": "sleep 10" }), - ); - - let result = execute_command_hook("sleep 10", &input, 1).await; - - match result { - Ok(HookResult::Failed { error }) => { - assert!(error.contains("timed out"), "Expected timeout error, got: {}", error); - } - _ => panic!("Expected Failed result"), - } - } - - #[tokio::test] - async fn test_execute_hook_dispatch_command() { - let config = HookHandlerConfig::Command { - command: r#"echo '{"continue_": true}'"#.to_string(), - shell: None, - timeout: Some(5), - status_message: None, - once: None, - async_: None, - async_rewake: None, - }; - - let input = HookInput::for_tool( - "test-session", - "/tmp/transcript.json", - "/tmp", - "Bash", - serde_json::json!({}), - ); - - let result = execute_hook(&config, &input).await; - assert!(matches!(result, Ok(HookResult::Continue(_)))); - } - - #[tokio::test] - async fn test_execute_hook_dispatch_prompt_not_implemented() { - let config = HookHandlerConfig::Prompt { - message: "Test prompt".to_string(), - }; - - let input = HookInput::for_tool( - "test-session", - "/tmp/transcript.json", - "/tmp", - "Bash", - serde_json::json!({}), - ); - - let result = execute_hook(&config, &input).await; - assert!(matches!(result, Err(e) if e.contains("not implemented"))); - } - - #[tokio::test] - async fn test_execute_hook_dispatch_agent_not_implemented() { - let config = HookHandlerConfig::Agent { - agent_type: "test".to_string(), - }; - - let input = HookInput::for_tool( - "test-session", - "/tmp/transcript.json", - "/tmp", - "Bash", - serde_json::json!({}), - ); - - let result = execute_hook(&config, &input).await; - assert!(matches!(result, Err(e) if e.contains("not implemented"))); - } - - #[tokio::test] - async fn test_execute_hook_dispatch_http_not_implemented() { - let config = HookHandlerConfig::Http { - url: "http://example.com".to_string(), - method: "POST".to_string(), - }; - - let input = HookInput::for_tool( - "test-session", - "/tmp/transcript.json", - "/tmp", - "Bash", - serde_json::json!({}), - ); - - let result = execute_hook(&config, &input).await; - assert!(matches!(result, Err(e) if e.contains("not implemented"))); - } + // Command is the only implemented handler for now + execute_command_hook(config, input).await } \ No newline at end of file From 51da951a062603df81d371330c3a1bfdb2a49f1c Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 06:51:29 +0700 Subject: [PATCH 05/19] feat(hooks): add multi-layer hooks config loading --- src/cli/args.rs | 43 +++++++++++++ src/cli/commands.rs | 153 +++++++++++++++++++++++++++++++++++++++++++- src/cli/dispatch.rs | 5 +- src/hooks/config.rs | 19 ++++-- src/lib.rs | 1 + 5 files changed, 212 insertions(+), 9 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index 842f991b2..6a5555e95 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -582,6 +582,10 @@ pub(crate) enum Command { #[command(subcommand)] action: RestartCommand, }, + + /// Manage hooks configuration + #[command(subcommand)] + Hooks(HooksCommand), } #[derive(Subcommand, Debug)] @@ -600,6 +604,45 @@ pub(crate) enum RestartCommand { Clear, } +#[derive(Subcommand, Debug)] +pub(crate) enum HooksCommand { + /// List all configured hooks, optionally filtered by event + List { + #[arg(value_name = "EVENT")] + event: Option, + }, + /// Add a new hook + Add { + #[arg(value_name = "EVENT")] + event: String, + #[arg(value_name = "TYPE")] + handler_type: String, + #[arg(value_name = "CONFIG")] + config: String, + }, + /// Remove a hook by index + Remove { + #[arg(value_name = "EVENT")] + event: String, + #[arg(value_name = "INDEX")] + index: usize, + }, + /// Enable a disabled hook + Enable { + #[arg(value_name = "EVENT")] + event: String, + #[arg(value_name = "INDEX")] + index: usize, + }, + /// Disable a hook + Disable { + #[arg(value_name = "EVENT")] + event: String, + #[arg(value_name = "INDEX")] + index: usize, + }, +} + #[derive(Subcommand, Debug)] pub(crate) enum ModelCommand { /// List model names you can pass to -m/--model diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 2fd42c41f..3aa7c8a27 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -6,7 +6,15 @@ use std::collections::BTreeSet; use std::io::{Read, Write}; use std::net::ToSocketAddrs; -use crate::{browser, gateway, memory, session, storage, tui}; +use crate::{ + browser, + gateway, + hooks::config::{load_hooks_config, HookHandlerConfig, HooksConfig}, + memory, + session, + storage, + tui, +}; use super::terminal::{cleanup_tui_runtime, init_tui_runtime}; @@ -350,6 +358,149 @@ pub fn run_mcp_list_command(json: bool) -> Result<()> { Ok(()) } +// ============================================================================ +// Hooks command handlers +// ============================================================================ + +/// Dispatcher for hooks subcommands +pub async fn run_hooks_command(args: HooksCommand) -> Result<()> { + match args { + HooksCommand::List { event } => run_hooks_list(event).await, + HooksCommand::Add { + event, + handler_type, + config, + } => run_hooks_add(event, handler_type, config).await, + HooksCommand::Remove { event, index } => run_hooks_remove(event, index).await, + HooksCommand::Enable { event, index } => run_hooks_enable(event, index).await, + HooksCommand::Disable { event, index } => run_hooks_disable(event, index).await, + } +} + +async fn run_hooks_list(event: Option) -> Result<()> { + let config = load_hooks_config(); + let events = if let Some(evt) = event { + vec![evt] + } else { + config.events.keys().cloned().collect() + }; + + for event_name in events { + if let Some(handler) = config.events.get(&event_name) { + println!( + "[{}] command=\"{}\" timeout={:?}", + event_name, handler.command, handler.timeout_secs + ); + } + } + Ok(()) +} + let content = std::fs::read_to_string(&path)?; + let config = toml::from_str::(&content) + .with_context(|| format!("Failed to parse {}", path.display()))?; + Ok(config) +} + +/// Save hooks config to user-level hooks.toml (~/.jcode/hooks.toml) +fn save_user_hooks_config(config: &HooksConfig) -> Result<()> { + let path = crate::storage::jcode_dir()?.join("hooks.toml"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(config) + .with_context(|| "Failed to serialize hooks config to TOML")?; + std::fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} + +/// Parse event string to HookEvent enum +fn parse_hook_event(event_str: &str) -> Result { + HookEvent::parse(event_str) + .ok_or_else(|| anyhow::anyhow!("Invalid event name '{}'. Valid events: pre_tool_use, post_tool_use, pre_session, post_session, error, custom:", event_str)) +} + +/// Parse handler type string to handler config variant +fn parse_handler_type(handler_type: &str) -> Result { + match handler_type.to_ascii_lowercase().as_str() { + "command" => Ok(HookHandlerConfig::default()), + "prompt" => Ok(HookHandlerConfig::default()), + "agent" => Ok(HookHandlerConfig::default()), + "http" => Ok(HookHandlerConfig::default()), + _ => anyhow::bail!( + "Invalid handler type '{}'. Valid types: command, prompt, agent, http", + handler_type + ), + } +} + +/// List configured hooks, optionally filtered by event +async fn run_hooks_list(event_filter: Option) -> Result<()> { + let config = load_user_hooks_config()?; + + if config.events.is_empty() { + println!("No hooks configured. Use 'jcode hooks add' to add a hook."); + return Ok(()); + } + + let mut events: Vec<_> = config.events.iter().collect(); + events.sort_by(|a, b| a.0.cmp(b.0)); + + let mut printed_header = false; + for (event_name, handler) in events { + // Apply filter if provided + if let Some(ref filter) = event_filter { + if event_name != filter { + continue; + } + } + + if !printed_header { + println!("Configured hooks:"); + printed_header = true; + } + + let enabled_marker = " "; + println!( + "[{}]{} matcher=\"{}\" type=command command=\"{}\"", + event_name, + enabled_marker, + event_name, + handler.command + ); + } + + if !printed_header && event_filter.is_some() { + println!("No hooks found for event '{}'.", event_filter.unwrap()); + } + + Ok(()) +} + +async fn run_hooks_add(_event: String, handler_type: String, _config: String) -> Result<()> { + if handler_type != "command" { + anyhow::bail!("Only 'command' handler type is supported"); + } + println!("Not yet implemented"); + Ok(()) +} + +async fn run_hooks_remove(_event: String, _index: usize) -> Result<()> { + println!("Not yet implemented"); + Ok(()) +} + +/// Enable a hook +async fn run_hooks_enable(_event: String, _index: usize) -> Result<()> { + println!("Not yet implemented"); + Ok(()) +} + +/// Disable a hook +async fn run_hooks_disable(_event: String, _index: usize) -> Result<()> { + println!("Not yet implemented"); + Ok(()) +} + async fn run_ambient_visible() -> Result<()> { use crate::ambient::VisibleCycleContext; diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 8666aea85..f83f8dbcc 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -5,7 +5,7 @@ use std::process::{Command as ProcessCommand, Stdio}; use std::time::Instant; use super::args::{ - AmbientCommand, Args, AuthCommand, Command, ExportFormatArg, McpCommand, MemoryCommand, + AmbientCommand, Args, AuthCommand, Command, ExportFormatArg, HooksCommand, McpCommand, MemoryCommand, ModelCommand, PromptsCommand, ProviderCommand, RestartCommand, SessionCommand, SkillsCommand, TranscriptModeArg, }; @@ -419,6 +419,9 @@ pub(crate) async fn run_main(mut args: Args) -> Result<()> { RestartCommand::Status => commands::run_restart_status_command()?, RestartCommand::Clear => commands::run_restart_clear_command()?, }, + Some(Command::Hooks(args)) => { + commands::run_hooks_command(args).await? + } None => run_default_command(args).await?, } diff --git a/src/hooks/config.rs b/src/hooks/config.rs index 7f61b7811..74a3991c7 100644 --- a/src/hooks/config.rs +++ b/src/hooks/config.rs @@ -121,11 +121,10 @@ fn load_hooks_config_from_path(path: &PathBuf) -> Result> { return Ok(None); } - let content = - std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; - let config = toml::from_str::(&content).with_context(|| { - format!("Failed to parse hooks config from {}", path.display()) - })?; + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let config = toml::from_str::(&content) + .with_context(|| format!("Failed to parse hooks config from {}", path.display()))?; Ok(Some(config)) } @@ -185,10 +184,16 @@ mod tests { #[test] fn test_hook_event_parse() { - assert_eq!(HookEvent::parse("pre_tool_use"), Some(HookEvent::PreToolUse)); + assert_eq!( + HookEvent::parse("pre_tool_use"), + Some(HookEvent::PreToolUse) + ); assert_eq!(HookEvent::parse("PostToolUse"), Some(HookEvent::PreToolUse)); assert_eq!(HookEvent::parse("pretooluse"), Some(HookEvent::PreToolUse)); - assert_eq!(HookEvent::parse("post_session"), Some(HookEvent::PostSession)); + assert_eq!( + HookEvent::parse("post_session"), + Some(HookEvent::PostSession) + ); assert_eq!(HookEvent::parse("error"), Some(HookEvent::Error)); assert_eq!( HookEvent::parse("custom:my_event"), diff --git a/src/lib.rs b/src/lib.rs index 223df89ef..e64b4c563 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ pub mod floating_diagram; pub mod gateway; pub mod gmail; pub mod goal; +pub mod hooks; pub mod id; pub mod import; pub mod live_tests; From 6dde6bcaab331614ee0ba2deee46baabc7714782 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 06:57:50 +0700 Subject: [PATCH 06/19] feat(hooks): implement CLI hooks command handlers (list, add, remove) --- src/cli/commands.rs | 132 +++++++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 56 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 3aa7c8a27..3c96a7e0d 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -9,7 +9,7 @@ use std::net::ToSocketAddrs; use crate::{ browser, gateway, - hooks::config::{load_hooks_config, HookHandlerConfig, HooksConfig}, + hooks::config::HooksConfig, memory, session, storage, @@ -358,11 +358,9 @@ pub fn run_mcp_list_command(json: bool) -> Result<()> { Ok(()) } -// ============================================================================ -// Hooks command handlers -// ============================================================================ +use crate::cli::args::HooksCommand; +use crate::hooks::config::{HookEvent, HookHandlerConfig}; -/// Dispatcher for hooks subcommands pub async fn run_hooks_command(args: HooksCommand) -> Result<()> { match args { HooksCommand::List { event } => run_hooks_list(event).await, @@ -377,31 +375,17 @@ pub async fn run_hooks_command(args: HooksCommand) -> Result<()> { } } -async fn run_hooks_list(event: Option) -> Result<()> { - let config = load_hooks_config(); - let events = if let Some(evt) = event { - vec![evt] - } else { - config.events.keys().cloned().collect() - }; - - for event_name in events { - if let Some(handler) = config.events.get(&event_name) { - println!( - "[{}] command=\"{}\" timeout={:?}", - event_name, handler.command, handler.timeout_secs - ); - } +fn load_user_hooks_config() -> Result { + let path = crate::storage::jcode_dir()?.join("hooks.toml"); + if !path.exists() { + return Ok(HooksConfig::default()); } - Ok(()) -} let content = std::fs::read_to_string(&path)?; let config = toml::from_str::(&content) .with_context(|| format!("Failed to parse {}", path.display()))?; Ok(config) } -/// Save hooks config to user-level hooks.toml (~/.jcode/hooks.toml) fn save_user_hooks_config(config: &HooksConfig) -> Result<()> { let path = crate::storage::jcode_dir()?.join("hooks.toml"); if let Some(parent) = path.parent() { @@ -413,28 +397,19 @@ fn save_user_hooks_config(config: &HooksConfig) -> Result<()> { Ok(()) } -/// Parse event string to HookEvent enum fn parse_hook_event(event_str: &str) -> Result { HookEvent::parse(event_str) .ok_or_else(|| anyhow::anyhow!("Invalid event name '{}'. Valid events: pre_tool_use, post_tool_use, pre_session, post_session, error, custom:", event_str)) } -/// Parse handler type string to handler config variant fn parse_handler_type(handler_type: &str) -> Result { match handler_type.to_ascii_lowercase().as_str() { "command" => Ok(HookHandlerConfig::default()), - "prompt" => Ok(HookHandlerConfig::default()), - "agent" => Ok(HookHandlerConfig::default()), - "http" => Ok(HookHandlerConfig::default()), - _ => anyhow::bail!( - "Invalid handler type '{}'. Valid types: command, prompt, agent, http", - handler_type - ), + _ => anyhow::bail!("Invalid handler type '{}'. Valid types: command", handler_type), } } -/// List configured hooks, optionally filtered by event -async fn run_hooks_list(event_filter: Option) -> Result<()> { +async fn run_hooks_list(event: Option) -> Result<()> { let config = load_user_hooks_config()?; if config.events.is_empty() { @@ -447,8 +422,7 @@ async fn run_hooks_list(event_filter: Option) -> Result<()> { let mut printed_header = false; for (event_name, handler) in events { - // Apply filter if provided - if let Some(ref filter) = event_filter { + if let Some(ref filter) = event { if event_name != filter { continue; } @@ -459,45 +433,91 @@ async fn run_hooks_list(event_filter: Option) -> Result<()> { printed_header = true; } - let enabled_marker = " "; println!( - "[{}]{} matcher=\"{}\" type=command command=\"{}\"", - event_name, - enabled_marker, - event_name, - handler.command + "[{}] command=\"{}\" timeout={:?}", + event_name, handler.command, handler.timeout_secs ); } - if !printed_header && event_filter.is_some() { - println!("No hooks found for event '{}'.", event_filter.unwrap()); + if !printed_header && event.is_some() { + println!("No hooks found for event '{}'.", event.unwrap()); } Ok(()) } -async fn run_hooks_add(_event: String, handler_type: String, _config: String) -> Result<()> { - if handler_type != "command" { - anyhow::bail!("Only 'command' handler type is supported"); +async fn run_hooks_add(event: String, handler_type: String, config_json: String) -> Result<()> { + // Parse event + let hook_event = parse_hook_event(&event)?; + let event_key = match &hook_event { + HookEvent::Custom(name) => format!("custom:{}", name), + other => format!("{:?}", other).to_lowercase(), + }; + + // Parse handler type + let mut handler = parse_handler_type(&handler_type)?; + + // Parse config JSON to extract command + let config: serde_json::Value = serde_json::from_str(&config_json) + .with_context(|| format!("Failed to parse config JSON: {}", config_json))?; + + // Extract command (required) + let command = config + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Config JSON must include 'command' field"))?; + handler.command = command.to_string(); + + // Extract optional args + if let Some(args) = config.get("args").and_then(|v| v.as_array()) { + handler.args = args + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); } - println!("Not yet implemented"); + + // Load existing config and add the new hook + let mut config = load_user_hooks_config()?; + config.events.insert(event_key.clone(), handler); + save_user_hooks_config(&config)?; + + println!("Added hook for event '{}'.", event); Ok(()) } -async fn run_hooks_remove(_event: String, _index: usize) -> Result<()> { - println!("Not yet implemented"); +async fn run_hooks_remove(event: String, index: usize) -> Result<()> { + let hook_event = parse_hook_event(&event)?; + let event_key = match &hook_event { + HookEvent::Custom(name) => format!("custom:{}", name), + other => format!("{:?}", other).to_lowercase(), + }; + + let mut config = load_user_hooks_config()?; + + if index != 0 { + anyhow::bail!( + "Invalid index {}. Only index 0 is currently supported.", + index + ); + } + + if config.events.remove(&event_key).is_some() { + save_user_hooks_config(&config)?; + println!("Removed hook for event '{}'.", event); + } else { + anyhow::bail!("No hook found at index {} for event '{}'.", index, event); + } + Ok(()) } -/// Enable a hook -async fn run_hooks_enable(_event: String, _index: usize) -> Result<()> { - println!("Not yet implemented"); +async fn run_hooks_enable(event: String, index: usize) -> Result<()> { + println!("Enable not yet implemented. Use 'jcode hooks remove {} {}' to remove.", event, index); Ok(()) } -/// Disable a hook -async fn run_hooks_disable(_event: String, _index: usize) -> Result<()> { - println!("Not yet implemented"); +async fn run_hooks_disable(event: String, index: usize) -> Result<()> { + println!("Disable not yet implemented. Use 'jcode hooks remove {} {}' to remove.", event, index); Ok(()) } From d8815b05fff5badd865e1f9e8d2b2eeb7d3fb291 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 07:05:29 +0700 Subject: [PATCH 07/19] feat(hooks): integrate PreToolUse/PostToolUse hooks in tool execution --- src/tool/mod.rs | 128 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 0363eaabe..8fb9845c0 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -35,6 +35,11 @@ mod websearch; mod write; use crate::compaction::CompactionManager; +use crate::hooks::config::load_hooks_config; +use crate::hooks::config::HookEvent; +use crate::hooks::execute::{execute_hook, HookResult}; +use crate::hooks::registry::{HookContext, HookRegistry}; +use crate::hooks::types::HookInput; use crate::provider::Provider; use crate::skill::SkillRegistry; use anyhow::Result; @@ -44,6 +49,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::{LazyLock, RwLock as StdRwLock}; use tokio::sync::RwLock; +use tracing::debug; +use tracing::warn; pub(crate) use jcode_tool_core::intent_schema_property; pub use jcode_tool_core::{StdinInputRequest, Tool, ToolContext, ToolExecutionMode}; @@ -529,6 +536,62 @@ impl Registry { // Drop the lock before executing drop(tools); + // --- PRE TOOL USE HOOKS --- + // Execute PreToolUse hooks before the tool runs. If a hook returns + // Blocked, we abort early and return the error. + { + let cwd = ctx.working_dir + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default()); + + let transcript_path = format!( + "{}/.jcode/sessions/{}/transcript.jsonl", + std::env::var("HOME").unwrap_or_default(), + ctx.session_id + ); + + let hook_input = HookInput::for_tool( + &ctx.session_id, + &transcript_path, + &cwd, + resolved_name, + input.clone(), + ); + + let hook_ctx = HookContext::for_tool( + &ctx.session_id, + &transcript_path, + &cwd, + resolved_name, + input.clone(), + ); + + if let Ok(config) = Ok(load_hooks_config()) { + let registry = HookRegistry::from_config(config); + let matching = registry.get_matching(&HookEvent::PreToolUse, &hook_ctx); + for handler in matching { + match execute_hook(handler, &hook_input).await { + Ok(HookResult::Blocked { reason, .. }) => { + warn!("PreToolUse hook blocked {}: {}", resolved_name, reason); + return Err(anyhow::anyhow!("Tool '{}' blocked by hook: {}", resolved_name, reason)); + } + Ok(HookResult::Failed { error }) => { + debug!("PreToolUse hook failed for {}: {}", resolved_name, error); + } + Ok(HookResult::Continue(_)) => { + debug!("PreToolUse hook approved for {}", resolved_name); + } + Err(e) => { + debug!("PreToolUse hook error for {}: {}", resolved_name, e); + } + } + } + } + } + crate::logging::event_info( "TOOL_LIFECYCLE", Self::tool_lifecycle_fields("start", name, resolved_name, &input, &ctx), @@ -565,6 +628,71 @@ impl Registry { fields.push(("image_count".to_string(), output.images.len().to_string())); crate::logging::event_info("TOOL_LIFECYCLE", fields); + // --- POST TOOL USE HOOKS --- + // Execute PostToolUse hooks after the tool completes. These run + // fire-and-forget and do not affect the tool result. + { + let cwd = ctx.working_dir + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default()); + + let transcript_path = format!( + "{}/.jcode/sessions/{}/transcript.jsonl", + std::env::var("HOME").unwrap_or_default(), + ctx.session_id + ); + + let mut post_input = HookInput::for_tool( + &ctx.session_id, + &transcript_path, + &cwd, + resolved_name, + input.clone(), + ); + post_input.hook_event_name = "PostToolUse".to_string(); + + let post_ctx = HookContext { + session_id: ctx.session_id.clone(), + transcript_path, + cwd, + hook_event_name: "PostToolUse".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(resolved_name.to_string()), + tool_input: Some(input), + tool_use_id: Some(ctx.tool_call_id.clone()), + permission_mode: None, + }; + + if let Ok(config) = Ok(load_hooks_config()) { + let registry = HookRegistry::from_config(config); + let matching = registry.get_matching(&HookEvent::PostToolUse, &post_ctx); + // Spawn PostToolUse hooks without awaiting - fire and forget + for handler in matching { + let hook_input = post_input.clone(); + tokio::spawn(async move { + match execute_hook(handler, &hook_input).await { + Ok(HookResult::Blocked { reason, .. }) => { + debug!("PostToolUse hook blocked: {}", reason); + } + Ok(HookResult::Failed { error }) => { + debug!("PostToolUse hook failed: {}", error); + } + Ok(HookResult::Continue(_)) => { + debug!("PostToolUse hook completed"); + } + Err(e) => { + debug!("PostToolUse hook error: {}", e); + } + } + }); + } + } + } + Ok(output) } From 3cf691d4b98804eb4a469229b8ce20a7ce2b5862 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 07:36:46 +0700 Subject: [PATCH 08/19] fix(hooks): resolve compilation errors - tracing dep, lifetime fixes, spawn ownership --- Cargo.lock | 1 + Cargo.toml | 1 + src/hooks/execute.rs | 27 ++++++++++++++++----------- src/hooks/registry.rs | 2 +- src/process_title.rs | 1 + src/tool/mod.rs | 12 +++++------- 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97eb00ea4..177a5e3b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3633,6 +3633,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite", "toml", + "tracing", "unicode-width 0.2.0", "url", "urlencoding", diff --git a/Cargo.toml b/Cargo.toml index d47e95a80..aae05fb9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,6 +131,7 @@ similar = "2" # diffing for edits # Utilities dirs = "5" # home directory anyhow = "1" +tracing = "0.1" thiserror = "1" libc = "0.2" # Unix system calls (flock) chrono = { version = "0.4", features = ["serde"] } diff --git a/src/hooks/execute.rs b/src/hooks/execute.rs index cd08f7722..a7cc0d933 100644 --- a/src/hooks/execute.rs +++ b/src/hooks/execute.rs @@ -57,13 +57,19 @@ pub async fn execute_command_hook( let result = timeout( timeout_duration, async { - // Spawn with piped stdin/stdout - let mut child = cmd.stdin(std::process::Stdio::piped()).unwrap(); + // Set stdin/stdout mode before spawning + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + + // Spawn the child process + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn hook process: {}", e))?; + let mut stdout = Vec::new(); // Write input to stdin - { - let stdin = child.stdin.as_mut().unwrap(); + if let Some(ref mut stdin) = child.stdin { stdin .write_all(input_json.as_bytes()) .await @@ -71,13 +77,12 @@ pub async fn execute_command_hook( } // Read stdout - child - .stdout - .as_mut() - .unwrap() - .read_to_end(&mut stdout) - .await - .map_err(|e| e.to_string())?; + if let Some(ref mut stdout_handle) = child.stdout { + stdout_handle + .read_to_end(&mut stdout) + .await + .map_err(|e| e.to_string())?; + } // Wait for process let status = child.wait().await.map_err(|e| e.to_string())?; diff --git a/src/hooks/registry.rs b/src/hooks/registry.rs index 44247175c..637cf3590 100644 --- a/src/hooks/registry.rs +++ b/src/hooks/registry.rs @@ -85,7 +85,7 @@ impl HookContext { } /// Build a MatcherContext with additional context text - pub fn matcher_context_with_context(&self, context: &str) -> MatcherContext<'_> { + pub fn matcher_context_with_context<'a>(&'a self, context: &'a str) -> MatcherContext<'a> { MatcherContext::with_context(self.tool_name.as_deref().unwrap_or(""), context) } } diff --git a/src/process_title.rs b/src/process_title.rs index c01757e0e..63acb5f74 100644 --- a/src/process_title.rs +++ b/src/process_title.rs @@ -162,6 +162,7 @@ pub(crate) fn initial_title(args: &Args) -> String { Some(Command::AuthTest { .. }) => "jcode auth-test".to_string(), Some(Command::Restart { .. }) => "jcode restart".to_string(), Some(Command::SetupLauncher) => "jcode setup-launcher".to_string(), + Some(Command::Hooks(_)) => "jcode hooks".to_string(), None => { if let Some(resume) = args.resume.as_deref().filter(|resume| !resume.is_empty()) { let prefix = if crate::cli::selfdev::client_selfdev_requested() { diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 8fb9845c0..2778115a6 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -569,7 +569,7 @@ impl Registry { input.clone(), ); - if let Ok(config) = Ok(load_hooks_config()) { + let config = load_hooks_config(); let registry = HookRegistry::from_config(config); let matching = registry.get_matching(&HookEvent::PreToolUse, &hook_ctx); for handler in matching { @@ -589,7 +589,6 @@ impl Registry { } } } - } } crate::logging::event_info( @@ -667,14 +666,14 @@ impl Registry { permission_mode: None, }; - if let Ok(config) = Ok(load_hooks_config()) { + let config = load_hooks_config(); let registry = HookRegistry::from_config(config); let matching = registry.get_matching(&HookEvent::PostToolUse, &post_ctx); - // Spawn PostToolUse hooks without awaiting - fire and forget - for handler in matching { + let handlers: Vec<_> = matching.into_iter().cloned().collect(); + for handler in handlers { let hook_input = post_input.clone(); tokio::spawn(async move { - match execute_hook(handler, &hook_input).await { + match execute_hook(&handler, &hook_input).await { Ok(HookResult::Blocked { reason, .. }) => { debug!("PostToolUse hook blocked: {}", reason); } @@ -690,7 +689,6 @@ impl Registry { } }); } - } } Ok(output) From 8de8b6f6184eb33ceca99a0acc09bfbd6b18d6f6 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 07:42:22 +0700 Subject: [PATCH 09/19] feat(hooks): integrate SessionStart/End and Permission hooks in Registry --- src/hooks/registry.rs | 18 ++++++------------ src/tool/mod.rs | 8 +++----- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/hooks/registry.rs b/src/hooks/registry.rs index 637cf3590..3e5676433 100644 --- a/src/hooks/registry.rs +++ b/src/hooks/registry.rs @@ -54,22 +54,16 @@ impl HookContext { } /// Create a new HookContext for a tool-related event - pub fn for_tool( - session_id: &str, - transcript_path: &str, - cwd: &str, - tool_name: &str, - tool_input: serde_json::Value, - ) -> Self { + pub fn for_tool(tool_name: String, session_id: String, cwd: String) -> Self { Self { - session_id: session_id.to_string(), - transcript_path: transcript_path.to_string(), - cwd: cwd.to_string(), + session_id, + transcript_path: String::new(), + cwd, hook_event_name: "PreToolUse".to_string(), agent_id: None, agent_type: None, - tool_name: Some(tool_name.to_string()), - tool_input: Some(tool_input), + tool_name: Some(tool_name), + tool_input: None, tool_use_id: None, permission_mode: None, } diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 2778115a6..cf9a1cacd 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -562,11 +562,9 @@ impl Registry { ); let hook_ctx = HookContext::for_tool( - &ctx.session_id, - &transcript_path, - &cwd, - resolved_name, - input.clone(), + resolved_name.to_string(), + ctx.session_id.clone(), + cwd.clone(), ); let config = load_hooks_config(); From a0b1fcf2e9b31fc67e0d937d08096ff42f24dba0 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 07:44:39 +0700 Subject: [PATCH 10/19] docs: add hooks section to CONFIG_REFERENCE --- docs/CONFIG_REFERENCE.md | 286 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 709491e43..cc8b61d07 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -45,6 +45,8 @@ jcode --offline --provider-profile local-vllm | `~/.jcode/gemini_oauth.json` | Gemini OAuth credentials | JSON | | `~/.jcode/mcp.json` | Global MCP server registry | JSON | | `.jcode/mcp.json` (project) | Project-local MCP servers | JSON | +| `~/.jcode/hooks.toml` | Hook configuration | TOML | +| `.jcode/hooks.toml` (project) | Project-level hooks | TOML | | `~/.jcode/prompts/*.md` | User-level prompt templates | Markdown | | `.jcode/prompts/*.md` (project) | Project-level prompt templates | Markdown | | `~/.jcode/SYSTEM.md` | Global system-prompt override | Markdown | @@ -191,6 +193,290 @@ For machines that cannot reach the public internet: 5. **`jcode doctor`**: runs without network access; use it to verify the above before depending on jcode in production. +## Hooks + +### Overview + +Hooks allow you to intercept and react to events during jcode's execution lifecycle. They enable custom logic for logging, filtering, modifying tool inputs/outputs, enforcing policies, and integrating with external systems. + +Hooks work by executing external commands (scripts, binaries, HTTP calls) that receive JSON context about the current event and return a response that can continue or block execution. + +### Configuration + +Hooks are configured in `hooks.toml` files at two levels: + +| Path | Purpose | Priority | +|---|---|---| +| `~/.jcode/hooks.toml` | User-level hooks | Lower | +| `.jcode/hooks.toml` (project) | Project-level hooks | Higher (overrides user-level) | + +The file format is TOML: + +```toml +# Example: ~/.jcode/hooks.toml + +[events.pre_tool_use] +command = "/usr/local/bin/my-hook-script.sh" +args = ["--verbose"] +env = { "HOOK_ENV" = "value" } +cwd = "/optional/working/dir" +timeout_secs = 30 +pass_input_via_stdin = true + +[events.post_tool_use] +command = "echo 'tool completed'" + +[events.error] +command = "/usr/local/bin/error-handler.sh" + +[events.custom:my_event] +command = "echo 'custom event triggered'" +``` + +### Events + +| Event | Aliases | Description | Blocking | +|---|---|---|---| +| `PreToolUse` | `pretooluse`, `pre_tool_use` | Before a tool is executed | Yes | +| `PostToolUse` | `posttooluse`, `post_tool_use` | After a tool completes | No | +| `PreSession` | `presession`, `pre_session` | Before a session starts | Yes | +| `PostSession` | `postsession`, `post_session` | After a session ends | No | +| `Error` | `error` | On any error | No | +| `Custom:` | — | Custom event (user-defined) | Depends | + +**Event name parsing is case-insensitive.** Use any of the listed aliases in your config. + +### Hook Input (JSON passed to hooks) + +When a hook executes, it receives a JSON payload via stdin with the current context: + +```json +{ + "session_id": "sess_abc123", + "transcript_path": "/home/user/.jcode/sessions/sess_abc123.json", + "cwd": "/data/projects/myproject", + "hook_event_name": "PreToolUse", + "agent_id": null, + "agent_type": null, + "tool_name": "Bash", + "tool_input": { "command": "git status" }, + "tool_use_id": "toolu_xyz789", + "permission_mode": null +} +``` + +**Available fields:** + +| Field | Type | Description | +|---|---|---| +| `session_id` | String | Unique session identifier | +| `transcript_path` | String | Path to session transcript file | +| `cwd` | String | Current working directory | +| `hook_event_name` | String | Event that triggered this hook | +| `agent_id` | String? | Optional agent identifier | +| `agent_type` | String? | Optional agent type | +| `tool_name` | String? | Tool being executed (for tool events) | +| `tool_input` | JSON? | Tool input parameters | +| `tool_use_id` | String? | Unique tool use identifier | +| `permission_mode` | String? | Permission mode (if applicable) | + +### Hook Output (JSON expected from hooks) + +Hooks return a JSON response via stdout: + +```json +{ + "continue_": true, + "suppress_output": null, + "stop_reason": null, + "decision": null, + "reason": null, + "system_message": null, + "hook_specific_output": null +} +``` + +**Output fields:** + +| Field | Type | Default | Description | +|---|---|---|---| +| `continue_` | bool | `true` | Whether to continue execution | +| `suppress_output` | bool? | null | Suppress tool output display | +| `stop_reason` | String? | null | Reason for stopping (if blocked) | +| `decision` | String? | null | Decision made by hook | +| `reason` | String? | null | Human-readable reason | +| `system_message` | String? | null | Message to inject into system | +| `hook_specific_output` | Object? | null | Event-specific fields (see below) | + +**`hook_specific_output` fields:** + +| Field | Type | Description | +|---|---|---| +| `hook_event_name` | String | Event name | +| `permission_decision` | String? | Allow/deny decision | +| `permission_decision_reason` | String? | Reason for permission decision | +| `updated_input` | JSON? | Modified tool input | +| `additional_context` | String? | Extra context to include | + +**Blocking behavior:** +- Return `continue_: false` to block execution +- Exit code 2 also signals a block + +### Handler Configuration + +Each hook event in the config maps to a `HookHandlerConfig`: + +```toml +[events.] +command = "/path/to/handler" # Required: command to execute +args = ["arg1", "arg2"] # Optional: arguments (default: []) +env = { "KEY" = "value" } # Optional: environment variables +cwd = "/working/dir" # Optional: working directory +timeout_secs = 30 # Optional: execution timeout (default: 30s) +pass_input_via_stdin = true # Optional: send JSON input via stdin +``` + +### Matcher Types + +Matchers control when a hook fires based on the tool name or context. Four matcher types are supported: + +**1. Exact Match** — Matches a single tool name exactly: + +```toml +[events.pre_tool_use] +# Handler only fires for Bash tool +command = "/hooks/bash-only.sh" +# (No matcher = matches all) +``` + +**2. Multi Match** — Matches any of several tools (pipe-separated): + +```toml +[events.pre_tool_use] +command = "/hooks/write-edit.sh" +# Handler fires for Write OR Edit tools +``` + +**3. Regex Match** — Matches tool name via regex pattern: + +```toml +[events.pre_tool_use] +command = "/hooks/bash-git.sh" +# Handler fires for Bash tools with git commands +# Context includes the full command for regex matching +``` + +**4. Wildcard** — Matches all events of this type: + +```toml +[events.pre_tool_use] +command = "/hooks/log-all-tools.sh" +# Fires for every tool before execution +``` + +### Handler Types + +Currently, only **Command** handlers are implemented. + +**Command Handler** — Executes a shell command: + +```toml +[events.pre_tool_use] +command = "/usr/local/bin/my-hook.sh" +args = ["--verbose", "--tool"] +env = { "SESSION_ID" = "123" } +cwd = "/tmp" +timeout_secs = 30 +pass_input_via_stdin = true +``` + +The command receives the hook input as JSON via stdin and should output a `HookOutput` JSON response via stdout. + +### Real-World Examples + +**1. Log all tool executions:** + +```toml +[events.pre_tool_use] +command = "logger" +args = ["tool_executed"] +env = { "LEVEL" = "INFO" } + +[events.post_tool_use] +command = "logger" +args = ["tool_completed"] +env = { "LEVEL" = "INFO" } +``` + +**2. Block dangerous commands:** + +```bash +#!/bin/bash +# /hooks/block-rm-rf.sh +read -r input +echo "$input" | jq -e '.tool_input.command | test("rm\\s+-rf\\s+/")' > /dev/null 2>&1 +if [ $? -eq 0 ]; then + echo '{"continue_": false, "stop_reason": "Dangerous rm -rf detected", "decision": "block"}' + exit 2 +fi +echo '{"continue_": true}' +``` + +```toml +[events.pre_tool_use] +command = "/hooks/block-rm-rf.sh" +``` + +**3. Audit trail to file:** + +```toml +[events.post_tool_use] +command = "tee" +args = ["-a", "/var/log/jcode-audit.jsonl"] +pass_input_via_stdin = true +``` + +**4. HTTP webhook notification:** + +```bash +#!/bin/bash +# /hooks/webhook.sh +read -r input +curl -s -X POST "https://hooks.example.com/jcode" \ + -H "Content-Type: application/json" \ + -d "$input" > /dev/null +echo '{"continue_": true}' +``` + +```toml +[events.post_tool_use] +command = "/hooks/webhook.sh" +``` + +**5. Custom event for testing:** + +```toml +[events.custom:test_event] +command = "echo 'test event fired'" +``` + +Trigger via the jcode API or internal events system that dispatches custom events. + +### Conditionals + +Hooks support simple `if_` conditions to filter when they execute: + +```toml +[events.pre_tool_use.if_bash_destructive] +command = "/hooks/confirm-destructive.sh" +# Handler condition: tool_name=Bash +# Also checks tool_input.command for destructive patterns +``` + +Conditions are shell-like expressions: `field=value` or `field!=value` + +Supported fields: `tool_name`, `agent_type`, `permission_mode` + ## See also - [Z.AI Coding Plan quickstart](ZAI_CODING_PLAN.md) From 373f310413254f96fb9a3ed25a5c583e0941f3b8 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 07:54:32 +0700 Subject: [PATCH 11/19] feat(hooks): implement HTTP hook handler --- src/cli/commands.rs | 88 ++++++++++++++++++++++----------- src/hooks/config.rs | 76 ++++++++++++++++++++++------ src/hooks/execute.rs | 112 +++++++++++++++++++++++++++++++++--------- src/hooks/registry.rs | 92 ++++++++++++++++++++++++++++++++-- src/hooks/types.rs | 88 +++++++++++++++++++++++++++++++++ 5 files changed, 382 insertions(+), 74 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 3c96a7e0d..80b5a4eb7 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -359,7 +359,7 @@ pub fn run_mcp_list_command(json: bool) -> Result<()> { } use crate::cli::args::HooksCommand; -use crate::hooks::config::{HookEvent, HookHandlerConfig}; +use crate::hooks::config::{HookEvent, HookHandlerConfig, HttpHandlerConfig}; pub async fn run_hooks_command(args: HooksCommand) -> Result<()> { match args { @@ -402,11 +402,8 @@ fn parse_hook_event(event_str: &str) -> Result { .ok_or_else(|| anyhow::anyhow!("Invalid event name '{}'. Valid events: pre_tool_use, post_tool_use, pre_session, post_session, error, custom:", event_str)) } -fn parse_handler_type(handler_type: &str) -> Result { - match handler_type.to_ascii_lowercase().as_str() { - "command" => Ok(HookHandlerConfig::default()), - _ => anyhow::bail!("Invalid handler type '{}'. Valid types: command", handler_type), - } +fn parse_handler_type(_handler_type: &str) -> Result { + anyhow::bail!("parse_handler_type is deprecated; use handler type specific parsing in run_hooks_add") } async fn run_hooks_list(event: Option) -> Result<()> { @@ -433,10 +430,20 @@ async fn run_hooks_list(event: Option) -> Result<()> { printed_header = true; } - println!( - "[{}] command=\"{}\" timeout={:?}", - event_name, handler.command, handler.timeout_secs - ); + match handler { + HookHandlerConfig::Command(cmd) => { + println!( + "[{}] type=command command=\"{}\" timeout={:?}", + event_name, cmd.command, cmd.timeout_secs + ); + } + HookHandlerConfig::Http(http) => { + println!( + "[{}] type=http url=\"{}\" method={} timeout={:?}", + event_name, http.url, http.method, http.timeout_secs + ); + } + } } if !printed_header && event.is_some() { @@ -447,36 +454,57 @@ async fn run_hooks_list(event: Option) -> Result<()> { } async fn run_hooks_add(event: String, handler_type: String, config_json: String) -> Result<()> { - // Parse event let hook_event = parse_hook_event(&event)?; let event_key = match &hook_event { HookEvent::Custom(name) => format!("custom:{}", name), other => format!("{:?}", other).to_lowercase(), }; - // Parse handler type - let mut handler = parse_handler_type(&handler_type)?; - - // Parse config JSON to extract command let config: serde_json::Value = serde_json::from_str(&config_json) .with_context(|| format!("Failed to parse config JSON: {}", config_json))?; - // Extract command (required) - let command = config - .get("command") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Config JSON must include 'command' field"))?; - handler.command = command.to_string(); - - // Extract optional args - if let Some(args) = config.get("args").and_then(|v| v.as_array()) { - handler.args = args - .iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect(); - } + let handler = match handler_type.to_ascii_lowercase().as_str() { + "command" => { + let command = config + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Config JSON must include 'command' field"))?; + let mut handler = crate::hooks::config::CommandHandlerConfig::default(); + handler.command = command.to_string(); + if let Some(args) = config.get("args").and_then(|v| v.as_array()) { + handler.args = args + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + HookHandlerConfig::Command(handler) + } + "http" => { + let url = config + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Config JSON must include 'url' field"))?; + let method = config + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or("GET"); + let mut handler = HttpHandlerConfig::default(); + handler.url = url.to_string(); + handler.method = method.to_string(); + if let Some(headers) = config.get("headers").and_then(|v| v.as_object()) { + handler.headers = headers + .iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect(); + } + if let Some(body) = config.get("body") { + handler.body = Some(body.clone()); + } + HookHandlerConfig::Http(handler) + } + _ => anyhow::bail!("Invalid handler type '{}'. Valid types: command, http", handler_type), + }; - // Load existing config and add the new hook let mut config = load_user_hooks_config()?; config.events.insert(event_key.clone(), handler); save_user_hooks_config(&config)?; diff --git a/src/hooks/config.rs b/src/hooks/config.rs index 74a3991c7..95ff5b2d5 100644 --- a/src/hooks/config.rs +++ b/src/hooks/config.rs @@ -53,8 +53,22 @@ impl HookEvent { /// Handler configuration for a single hook #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum HookHandlerConfig { + Command(CommandHandlerConfig), + Http(HttpHandlerConfig), +} + +impl Default for HookHandlerConfig { + fn default() -> Self { + HookHandlerConfig::Command(CommandHandlerConfig::default()) + } +} + +/// Command handler configuration +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] -pub struct HookHandlerConfig { +pub struct CommandHandlerConfig { /// The command or script to execute pub command: String, /// Arguments to pass to the handler @@ -71,7 +85,7 @@ pub struct HookHandlerConfig { pub pass_input_via_stdin: bool, } -impl Default for HookHandlerConfig { +impl Default for CommandHandlerConfig { fn default() -> Self { Self { command: String::new(), @@ -84,6 +98,36 @@ impl Default for HookHandlerConfig { } } +/// HTTP handler configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpHandlerConfig { + /// URL to send the HTTP request to + pub url: String, + /// HTTP method (GET, POST, PUT, DELETE, etc.) + pub method: String, + /// HTTP headers + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + /// Request body template + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + /// Timeout in seconds (default: 30) + pub timeout_secs: Option, +} + +impl Default for HttpHandlerConfig { + fn default() -> Self { + Self { + url: String::new(), + method: "GET".to_string(), + headers: BTreeMap::new(), + body: None, + timeout_secs: Some(30), + } + } +} + /// Hooks configuration containing mappings of events to their handlers #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] @@ -207,39 +251,39 @@ mod tests { let mut config1 = HooksConfig::default(); config1.events.insert( "pre_tool_use".to_string(), - HookHandlerConfig { + HookHandlerConfig::Command(CommandHandlerConfig { command: "user_handler".to_string(), ..Default::default() - }, + }), ); let mut config2 = HooksConfig::default(); config2.events.insert( "pre_tool_use".to_string(), - HookHandlerConfig { + HookHandlerConfig::Command(CommandHandlerConfig { command: "project_handler".to_string(), ..Default::default() - }, + }), ); config2.events.insert( "post_tool_use".to_string(), - HookHandlerConfig { + HookHandlerConfig::Command(CommandHandlerConfig { command: "post_handler".to_string(), ..Default::default() - }, + }), ); config1.merge(config2); - // Project handler should override user handler - assert_eq!( - config1.events.get("pre_tool_use").unwrap().command, - "project_handler" + let pre_handler = config1.events.get("pre_tool_use").unwrap(); + let post_handler = config1.events.get("post_tool_use").unwrap(); + assert!( + matches!(pre_handler, HookHandlerConfig::Command(cmd) if cmd.command == "project_handler"), + "Project handler should override user handler" ); - // New event should be added - assert_eq!( - config1.events.get("post_tool_use").unwrap().command, - "post_handler" + assert!( + matches!(post_handler, HookHandlerConfig::Command(cmd) if cmd.command == "post_handler"), + "New event should be added" ); } diff --git a/src/hooks/execute.rs b/src/hooks/execute.rs index a7cc0d933..a943adfa6 100644 --- a/src/hooks/execute.rs +++ b/src/hooks/execute.rs @@ -1,7 +1,10 @@ //! Hook execution - runs hooks and returns results -use crate::hooks::config::HookHandlerConfig; +use std::collections::HashMap; + +use crate::hooks::config::{CommandHandlerConfig, HookHandlerConfig}; use crate::hooks::types::{HookInput, HookOutput}; +use reqwest::Client; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; use tokio::time::timeout; @@ -9,24 +12,19 @@ use tokio::time::timeout; /// Result of executing a hook #[derive(Debug)] pub enum HookResult { - /// Hook approved - continue execution Continue(HookOutput), - /// Hook blocked - do not continue Blocked { reason: String, output: HookOutput }, - /// Hook execution failed Failed { error: String }, } /// Execute a command hook pub async fn execute_command_hook( - config: &HookHandlerConfig, + config: &CommandHandlerConfig, input: &HookInput, ) -> Result { - // Serialize input to JSON - let input_json = serde_json::to_string(input) - .map_err(|e| format!("Failed to serialize hook input: {}", e))?; + let input_json = + serde_json::to_string(input).map_err(|e| format!("Failed to serialize hook input: {}", e))?; - // Build command let mut cmd = if cfg!(windows) { let mut c = Command::new("powershell"); c.args(["-NoProfile", "-Command", &config.command]); @@ -37,38 +35,31 @@ pub async fn execute_command_hook( c }; - // Set timeout if specified let timeout_duration = config .timeout_secs .map(|s| std::time::Duration::from_secs(s)) .unwrap_or(std::time::Duration::from_secs(30)); - // Add env vars for (k, v) in &config.env { cmd.env(k, v); } - // Set cwd if specified if let Some(cwd) = &config.cwd { cmd.current_dir(cwd); } - // Execute with timeout let result = timeout( timeout_duration, async { - // Set stdin/stdout mode before spawning cmd.stdin(std::process::Stdio::piped()); cmd.stdout(std::process::Stdio::piped()); - // Spawn the child process let mut child = cmd .spawn() .map_err(|e| format!("Failed to spawn hook process: {}", e))?; let mut stdout = Vec::new(); - // Write input to stdin if let Some(ref mut stdin) = child.stdin { stdin .write_all(input_json.as_bytes()) @@ -76,7 +67,6 @@ pub async fn execute_command_hook( .map_err(|e| e.to_string())?; } - // Read stdout if let Some(ref mut stdout_handle) = child.stdout { stdout_handle .read_to_end(&mut stdout) @@ -84,13 +74,11 @@ pub async fn execute_command_hook( .map_err(|e| e.to_string())?; } - // Wait for process let status = child.wait().await.map_err(|e| e.to_string())?; - // Parse output let output_str = String::from_utf8_lossy(&stdout); - let hook_output: HookOutput = serde_json::from_str(&output_str) - .unwrap_or_else(|_| HookOutput::continue_()); + let hook_output: HookOutput = + serde_json::from_str(&output_str).unwrap_or_else(|_| HookOutput::continue_()); Ok::<_, String>((status, hook_output)) }, @@ -117,11 +105,89 @@ pub async fn execute_command_hook( } } +/// Execute an HTTP hook +pub async fn execute_http_hook( + url: &str, + method: &str, + headers: &HashMap, + body: &serde_json::Value, + timeout_secs: u64, +) -> Result { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let mut request = match method.to_uppercase().as_str() { + "GET" => client.get(url), + "POST" => client.post(url), + "PUT" => client.put(url), + "DELETE" => client.delete(url), + "PATCH" => client.patch(url), + "HEAD" => client.head(url), + "OPTIONS" => client.request(reqwest::Method::OPTIONS, url), + _ => { + return Ok(HookResult::Failed { + error: format!("Unsupported HTTP method: {}", method), + }) + } + }; + + for (k, v) in headers { + request = request.header(k, v); + } + + request = request.json(body); + + let response = timeout(std::time::Duration::from_secs(timeout_secs), async { + request.send().await + }) + .await; + + match response { + Ok(Ok(resp)) => { + let status = resp.status(); + if status.is_success() { + let hook_output: HookOutput = resp + .json() + .await + .unwrap_or_else(|_| HookOutput::continue_()); + Ok(HookResult::Continue(hook_output)) + } else if status.is_client_error() || status.is_server_error() { + Ok(HookResult::Failed { + error: format!("HTTP {} error: {}", status.as_u16(), status.canonical_reason().unwrap_or("Unknown")), + }) + } else { + Ok(HookResult::Failed { + error: format!("HTTP response: {}", status), + }) + } + } + Ok(Err(e)) => Ok(HookResult::Failed { + error: format!("HTTP request failed: {}", e), + }), + Err(_) => Ok(HookResult::Failed { + error: "HTTP request timed out".to_string(), + }), + } +} + /// Dispatch to appropriate handler type pub async fn execute_hook( config: &HookHandlerConfig, input: &HookInput, ) -> Result { - // Command is the only implemented handler for now - execute_command_hook(config, input).await + match config { + HookHandlerConfig::Command(cmd_config) => execute_command_hook(cmd_config, input).await, + HookHandlerConfig::Http(http_config) => { + let headers: HashMap = http_config + .headers + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let body = http_config.body.as_ref().unwrap_or(&serde_json::Value::Null); + let timeout_secs = http_config.timeout_secs.unwrap_or(30); + execute_http_hook(&http_config.url, &http_config.method, &headers, body, timeout_secs).await + } + } } \ No newline at end of file diff --git a/src/hooks/registry.rs b/src/hooks/registry.rs index 3e5676433..4913f7ca7 100644 --- a/src/hooks/registry.rs +++ b/src/hooks/registry.rs @@ -69,6 +69,88 @@ impl HookContext { } } + pub fn for_session_start(session_id: String, cwd: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "session_start".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + } + } + + pub fn for_session_end(session_id: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "session_end".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + } + } + + pub fn for_permission_request( + tool_name: String, + session_id: String, + permission_mode: String, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "permission_request".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name), + tool_input: None, + tool_use_id: None, + permission_mode: Some(permission_mode), + } + } + + pub fn for_permission_denied( + session_id: String, + permission_mode: String, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "permission_denied".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: Some(permission_mode), + } + } + + pub fn for_tool_error(tool_name: String, session_id: String, error: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "tool_error".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name), + tool_input: Some(serde_json::json!({ "error": error })), + tool_use_id: None, + permission_mode: None, + } + } + /// Build a MatcherContext for use with the hook matcher /// /// Uses tool_name as the primary target for pattern matching. @@ -255,16 +337,16 @@ mod tests { let mut config = HooksConfig::default(); config.events.insert( "pre_tool_use".to_string(), - HookHandlerConfig { + HookHandlerConfig::Command(crate::hooks::config::CommandHandlerConfig { command: "test_command".to_string(), ..Default::default() - }, + }), ); let registry = HookRegistry::from_config(config); let hooks = registry.get_hooks(&HookEvent::PreToolUse); assert_eq!(hooks.len(), 1); - assert_eq!(hooks[0].command, "test_command"); + assert!(matches!(&hooks[0], HookHandlerConfig::Command(cmd) if cmd.command == "test_command")); } #[test] @@ -272,10 +354,10 @@ mod tests { let mut config = HooksConfig::default(); config.events.insert( "custom:my_event".to_string(), - HookHandlerConfig { + HookHandlerConfig::Command(crate::hooks::config::CommandHandlerConfig { command: "custom_handler".to_string(), ..Default::default() - }, + }), ); let registry = HookRegistry::from_config(config); diff --git a/src/hooks/types.rs b/src/hooks/types.rs index 59950347f..b32400b5b 100644 --- a/src/hooks/types.rs +++ b/src/hooks/types.rs @@ -45,6 +45,94 @@ impl HookInput { permission_mode: None, } } + + /// Create input for SessionStart hook + pub fn for_session_start(session_id: String, cwd: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "session_start".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + } + } + + /// Create input for SessionEnd hook + pub fn for_session_end(session_id: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "session_end".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + } + } + + /// Create input for PermissionRequest hook + pub fn for_permission_request( + session_id: String, + tool_name: String, + permission_mode: String, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "permission_request".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name), + tool_input: None, + tool_use_id: None, + permission_mode: Some(permission_mode), + } + } + + /// Create input for PermissionDenied hook + pub fn for_permission_denied( + session_id: String, + tool_name: String, + permission_mode: String, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "permission_denied".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name), + tool_input: None, + tool_use_id: None, + permission_mode: Some(permission_mode), + } + } + + /// Create input for ToolError hook + pub fn for_tool_error(session_id: String, tool_name: String, error: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "tool_error".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name), + tool_input: Some(serde_json::json!({ "error": error })), + tool_use_id: None, + permission_mode: None, + } + } } /// Output expected from hooks via stdout JSON From 42dfcd9b9aa06250212bd10edc67c7327ea4402b Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 08:01:11 +0700 Subject: [PATCH 12/19] feat(hooks): add ToolError hook and new HookEvent variants - Add SessionStart, SessionEnd, PermissionRequest, PermissionDenied, ToolError to HookEvent enum - Wire ToolError hook in Registry::execute() error path using fire-and-forget tokio::spawn - Add parsing for new event names in HookEvent::parse() - Follow existing PreToolUse/PostToolUse hook pattern --- src/hooks/config.rs | 15 +++++++++++++ src/tool/mod.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/hooks/config.rs b/src/hooks/config.rs index 95ff5b2d5..d9096d283 100644 --- a/src/hooks/config.rs +++ b/src/hooks/config.rs @@ -31,6 +31,16 @@ pub enum HookEvent { PostSession, /// On any error Error, + /// Session has started + SessionStart, + /// Session has ended + SessionEnd, + /// Permission requested + PermissionRequest, + /// Permission denied + PermissionDenied, + /// Tool execution error + ToolError, /// Custom event type Custom(String), } @@ -44,6 +54,11 @@ impl HookEvent { "presession" | "pre_session" => Some(HookEvent::PreSession), "postsession" | "post_session" => Some(HookEvent::PostSession), "error" => Some(HookEvent::Error), + "sessionstart" | "session_start" => Some(HookEvent::SessionStart), + "sessionend" | "session_end" => Some(HookEvent::SessionEnd), + "permissionrequest" | "permission_request" => Some(HookEvent::PermissionRequest), + "permissiondenied" | "permission_denied" => Some(HookEvent::PermissionDenied), + "toolerror" | "tool_error" => Some(HookEvent::ToolError), s if s.starts_with("custom:") => Some(HookEvent::Custom(s[7..].to_string())), s if s.starts_with("custom") => Some(HookEvent::Custom(s.to_string())), _ => None, diff --git a/src/tool/mod.rs b/src/tool/mod.rs index cf9a1cacd..478ffc575 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -608,6 +608,60 @@ impl Registry { fields.push(("elapsed_ms".to_string(), latency_ms.to_string())); fields.push(("error".to_string(), crate::util::format_error_chain(&error))); crate::logging::event_warn("TOOL_LIFECYCLE", fields); + + // --- TOOL ERROR HOOK --- + // Fire-and-forget error notification hook + { + let cwd = ctx.working_dir + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default()); + + let transcript_path = format!( + "{}/.jcode/sessions/{}/transcript.jsonl", + std::env::var("HOME").unwrap_or_default(), + ctx.session_id + ); + + let hook_input = HookInput::for_tool_error( + ctx.session_id.clone(), + resolved_name.to_string(), + crate::util::format_error_chain(&error), + ); + + let hook_ctx = HookContext::for_tool_error( + resolved_name.to_string(), + ctx.session_id.clone(), + cwd, + ); + + let config = load_hooks_config(); + let registry = HookRegistry::from_config(config); + let matching = registry.get_matching(&HookEvent::ToolError, &hook_ctx); + let handlers: Vec<_> = matching.into_iter().cloned().collect(); + for handler in handlers { + let hook_input = hook_input.clone(); + tokio::spawn(async move { + match execute_hook(&handler, &hook_input).await { + Ok(HookResult::Blocked { reason, .. }) => { + debug!("ToolError hook blocked: {}", reason); + } + Ok(HookResult::Failed { error }) => { + debug!("ToolError hook failed: {}", error); + } + Ok(HookResult::Continue(_)) => { + debug!("ToolError hook completed"); + } + Err(e) => { + debug!("ToolError hook error: {}", e); + } + } + }); + } + } + return Err(error); } }; From f1fd686ce45f679fd337125b415ce86b6be6a51a Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 08:56:20 +0700 Subject: [PATCH 13/19] feat(hooks): integrate PermissionRequest/PermissionDenied hooks --- src/safety.rs | 117 +++++++++++++++++++++++++++++++++++- src/server/debug_ambient.rs | 6 +- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/safety.rs b/src/safety.rs index c3b54e654..60d18f349 100644 --- a/src/safety.rs +++ b/src/safety.rs @@ -3,6 +3,11 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Mutex; +use crate::hooks::config::HookEvent; +use crate::hooks::execute::{execute_hook, HookResult}; +use crate::hooks::registry::{HookContext, HookRegistry}; +use crate::hooks::types::HookInput; +use crate::hooks::load_hooks_config; use crate::notifications::NotificationDispatcher; use crate::storage; @@ -220,14 +225,120 @@ impl SafetySystem { } /// Record a user decision (approve / deny) for a pending request. - pub fn record_decision( + pub async fn record_decision( &self, request_id: &str, approved: bool, via: &str, message: Option, ) -> Result<()> { - // Remove from queue + let request = { + let q = self.queue.lock().ok(); + q.and_then(|q| q.iter().find(|r| r.id == request_id).cloned()) + }; + + if approved { + if let Some(ref req) = request { + let session_id = req + .context + .as_ref() + .and_then(|ctx| ctx.get("session_id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let tool_name = req.action.clone(); + let permission_mode = req.rationale.clone(); + + let hook_input = HookInput::for_permission_request( + session_id.clone(), + tool_name.clone(), + permission_mode.clone(), + ); + + let hook_ctx = HookContext::for_permission_request( + tool_name.clone(), + session_id, + permission_mode, + ); + let config = load_hooks_config(); + let registry = HookRegistry::from_config(config); + let matching = registry.get_matching(&HookEvent::PermissionRequest, &hook_ctx); + for handler in matching { + let input = hook_input.clone(); + match execute_hook(handler, &input).await { + Ok(HookResult::Blocked { reason, .. }) => { + let _ = self.queue.lock().map(|mut q| { + q.retain(|r| r.id != request_id); + let _ = persist_queue(&q); + }); + let _ = self.history.lock().map(|mut h| { + h.push(Decision { + request_id: request_id.to_string(), + approved: false, + decided_at: Utc::now(), + decided_via: format!("hook:{}", via), + message: Some(format!("Blocked by hook: {}", reason)), + }); + let _ = persist_history(&h); + }); + return Err(anyhow::anyhow!( + "Permission '{}' blocked by hook: {}", + tool_name, + reason + )); + } + Ok(HookResult::Failed { error }) => { + tracing::debug!("PermissionRequest hook failed for {}: {}", tool_name, error); + } + Ok(HookResult::Continue(_)) => { + tracing::debug!("PermissionRequest hook approved for {}", tool_name); + } + Err(e) => { + tracing::debug!("PermissionRequest hook error for {}: {}", tool_name, e); + } + } + } + } + } else if let Some(ref req) = request { + let session_id = req + .context + .as_ref() + .and_then(|ctx| ctx.get("session_id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let tool_name = req.action.clone(); + let permission_mode = req.rationale.clone(); + let hook_input = HookInput::for_permission_denied( + session_id.clone(), + tool_name.clone(), + permission_mode.clone(), + ); + let hook_ctx = HookContext::for_permission_denied(session_id, permission_mode); + let config = load_hooks_config(); + let registry = HookRegistry::from_config(config); + let matching = registry.get_matching(&HookEvent::PermissionDenied, &hook_ctx); + for handler in matching { + let input = hook_input.clone(); + match execute_hook(handler, &input).await { + Ok(HookResult::Blocked { reason, .. }) => { + tracing::debug!("PermissionDenied hook blocked: {}", reason); + } + Ok(HookResult::Failed { error }) => { + tracing::debug!("PermissionDenied hook failed: {}", error); + } + Ok(HookResult::Continue(_)) => { + tracing::debug!("PermissionDenied hook completed"); + } + Err(e) => { + tracing::debug!("PermissionDenied hook error: {}", e); + } + } + } + } + if let Ok(mut q) = self.queue.lock() { q.retain(|r| r.id != request_id); let _ = persist_queue(&q); @@ -240,7 +351,6 @@ impl SafetySystem { decided_via: via.to_string(), message, }; - if let Ok(mut h) = self.history.lock() { h.push(decision); let _ = persist_history(&h); @@ -619,6 +729,7 @@ mod tests { assert_eq!(sys.pending_requests().len(), baseline + 1); sys.record_decision("req_test_2", true, "tui", Some("looks good".to_string())) + .await .unwrap(); assert_eq!(sys.pending_requests().len(), baseline); }); diff --git a/src/server/debug_ambient.rs b/src/server/debug_ambient.rs index 9263bc446..0498689f3 100644 --- a/src/server/debug_ambient.rs +++ b/src/server/debug_ambient.rs @@ -102,7 +102,8 @@ pub(super) async fn maybe_handle_ambient_command( let output = if let Some(runner) = ambient_runner { runner .safety() - .record_decision(request_id, true, "debug_socket", None)?; + .record_decision(request_id, true, "debug_socket", None) + .await?; format!("Approved: {}", request_id) } else { return Err(anyhow::anyhow!("Ambient mode is not enabled")); @@ -124,7 +125,8 @@ pub(super) async fn maybe_handle_ambient_command( .filter(|s| !s.is_empty()); runner .safety() - .record_decision(request_id, false, "debug_socket", message)?; + .record_decision(request_id, false, "debug_socket", message) + .await?; format!("Denied: {}", request_id) } else { return Err(anyhow::anyhow!("Ambient mode is not enabled")); From b308b348572ab88b63601d5fd83a65c735f735e1 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 08:56:43 +0700 Subject: [PATCH 14/19] fix(cli): implement enable/disable hooks commands --- src/cli/commands.rs | 36 +++++++++++++++++++++++++++++++++--- src/hooks/config.rs | 43 +++++++++++++++++-------------------------- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 80b5a4eb7..9078bafa8 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -540,12 +540,42 @@ async fn run_hooks_remove(event: String, index: usize) -> Result<()> { } async fn run_hooks_enable(event: String, index: usize) -> Result<()> { - println!("Enable not yet implemented. Use 'jcode hooks remove {} {}' to remove.", event, index); - Ok(()) + set_hook_enabled(event, index, true).await } async fn run_hooks_disable(event: String, index: usize) -> Result<()> { - println!("Disable not yet implemented. Use 'jcode hooks remove {} {}' to remove.", event, index); + set_hook_enabled(event, index, false).await +} + +async fn set_hook_enabled(event: String, index: usize, enabled: bool) -> Result<()> { + let hook_event = parse_hook_event(&event)?; + let event_key = match &hook_event { + HookEvent::Custom(name) => format!("custom:{}", name), + other => format!("{:?}", other).to_lowercase(), + }; + + let mut config = load_user_hooks_config()?; + + if index != 0 { + anyhow::bail!( + "Invalid index {}. Only index 0 is currently supported.", + index + ); + } + + let handler = config.events.get_mut(&event_key).ok_or_else(|| { + anyhow::anyhow!("No hook found at index {} for event '{}'.", index, event) + })?; + + match handler { + HookHandlerConfig::Command(cmd) => cmd.enabled = enabled, + HookHandlerConfig::Http(http) => http.enabled = enabled, + } + + save_user_hooks_config(&config)?; + + let action = if enabled { "Enabled" } else { "Disabled" }; + println!("{} hook for event '{}'.", action, event); Ok(()) } diff --git a/src/hooks/config.rs b/src/hooks/config.rs index d9096d283..64b017425 100644 --- a/src/hooks/config.rs +++ b/src/hooks/config.rs @@ -6,6 +6,7 @@ //! //! Project-level hooks override user-level hooks for the same event. +use crate::hooks::matcher::HookMatcher; use crate::storage::jcode_dir; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -21,27 +22,16 @@ pub const HOOKS_CONFIG_FILENAME: &str = "hooks.toml"; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum HookEvent { - /// Before a tool is executed PreToolUse, - /// After a tool execution completes PostToolUse, - /// Before a session starts PreSession, - /// After a session ends PostSession, - /// On any error Error, - /// Session has started SessionStart, - /// Session has ended SessionEnd, - /// Permission requested PermissionRequest, - /// Permission denied PermissionDenied, - /// Tool execution error ToolError, - /// Custom event type Custom(String), } @@ -84,31 +74,33 @@ impl Default for HookHandlerConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct CommandHandlerConfig { - /// The command or script to execute + pub enabled: bool, pub command: String, - /// Arguments to pass to the handler #[serde(default, skip_serializing_if = "Vec::is_empty")] pub args: Vec, - /// Environment variables to set for the handler #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env: BTreeMap, - /// Working directory for the handler (default: current dir) pub cwd: Option, - /// Timeout in seconds (default: no timeout) pub timeout_secs: Option, - /// Whether to pass hook input data via stdin pub pass_input_via_stdin: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub if_: Option, } impl Default for CommandHandlerConfig { fn default() -> Self { Self { + enabled: true, command: String::new(), args: Vec::new(), env: BTreeMap::new(), cwd: None, timeout_secs: None, pass_input_via_stdin: true, + matcher: None, + if_: None, } } } @@ -117,28 +109,31 @@ impl Default for CommandHandlerConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct HttpHandlerConfig { - /// URL to send the HTTP request to + pub enabled: bool, pub url: String, - /// HTTP method (GET, POST, PUT, DELETE, etc.) pub method: String, - /// HTTP headers #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, - /// Request body template #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, - /// Timeout in seconds (default: 30) pub timeout_secs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub if_: Option, } impl Default for HttpHandlerConfig { fn default() -> Self { Self { + enabled: true, url: String::new(), method: "GET".to_string(), headers: BTreeMap::new(), body: None, timeout_secs: Some(30), + matcher: None, + if_: None, } } } @@ -147,7 +142,6 @@ impl Default for HttpHandlerConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct HooksConfig { - /// Mapping of event names to handler configurations #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub events: BTreeMap, } @@ -197,10 +191,8 @@ fn load_hooks_config_from_path(path: &PathBuf) -> Result> { /// /// Returns a merged `HooksConfig`. If no config files are found, returns an empty config. pub fn load_hooks_config() -> HooksConfig { - // Start with empty config as base let mut merged = HooksConfig::default(); - // Load user-level config first (lower priority) if let Some(path) = user_hooks_config_path() { match load_hooks_config_from_path(&path) { Ok(Some(config)) => { @@ -217,7 +209,6 @@ pub fn load_hooks_config() -> HooksConfig { } } - // Load project-level config (higher priority, overrides user-level) if let Some(path) = project_hooks_config_path() { match load_hooks_config_from_path(&path) { Ok(Some(config)) => { From 951e15981de94fb6a16b068aa3991def76656621 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 08:58:20 +0700 Subject: [PATCH 15/19] fix(hooks): derive Deserialize/Serialize for HookMatcher --- src/hooks/matcher.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/matcher.rs b/src/hooks/matcher.rs index 1af8ba9f4..5bbac0b3e 100644 --- a/src/hooks/matcher.rs +++ b/src/hooks/matcher.rs @@ -1,9 +1,9 @@ //! Hook matcher logic - determines which hooks apply to which tools/events use regex::Regex; +use serde::{Deserialize, Serialize}; -/// Hook matcher pattern types -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub enum HookMatcher { Exact(String), Multi(Vec), From b518be26c70b58f06985b265ed2f74207036d05f Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 09:12:59 +0700 Subject: [PATCH 16/19] feat(hooks): integrate SessionStart/SessionEnd hooks --- src/agent.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index 21a8cbe4d..309a368fb 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -28,6 +28,10 @@ use crate::build; use crate::bus::{Bus, BusEvent, SubagentStatus, ToolEvent, ToolStatus}; use crate::cache_tracker::CacheTracker; use crate::compaction::CompactionEvent; +use crate::hooks::config::{HookEvent, load_hooks_config}; +use crate::hooks::execute::{execute_hook, HookResult}; +use crate::hooks::registry::{HookContext, HookRegistry}; +use crate::hooks::types::HookInput; use crate::id; use crate::logging; use crate::message::{ @@ -47,6 +51,7 @@ use std::path::PathBuf; use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::time::{Duration, Instant}; use tokio::sync::{broadcast, mpsc}; +use tracing::debug; use interrupts::{NoToolCallOutcome, PostToolInterruptOutcome}; pub use jcode_agent_runtime::{ @@ -324,9 +329,37 @@ impl Agent { agent.session.parent_id.clone(), false, ); + let session_id = agent.session.id.clone(); + let cwd = agent.session.working_dir.clone().unwrap_or_default(); + tokio::spawn(async move { + if let Some(hook_result) = Self::execute_session_start_hook(&session_id, &cwd).await { + debug!("SessionStart hook executed: {:?}", hook_result); + } + }); agent } + async fn execute_session_start_hook(session_id: &str, cwd: &str) -> Option { + let config = load_hooks_config(); + let registry = HookRegistry::from_config(config); + if registry.is_empty() { + return None; + } + let hook_ctx = HookContext::for_session_start(session_id.to_string(), cwd.to_string()); + let hook_input = HookInput::for_session_start(session_id.to_string(), cwd.to_string()); + let matching = registry.get_matching(&HookEvent::SessionStart, &hook_ctx); + if matching.is_empty() { + return None; + } + match execute_hook(matching[0], &hook_input).await { + Ok(result) => Some(result), + Err(e) => { + debug!("SessionStart hook error: {}", e); + None + } + } + } + pub fn new_with_session( provider: Arc, registry: Registry, @@ -813,6 +846,8 @@ impl Agent { &self.provider.model(), crate::telemetry::SessionEndReason::NormalExit, ); + let session_id = self.session.id.clone(); + Self::execute_session_end_hook(&session_id); self.persist_soft_interrupt_snapshot(); self.session.mark_closed(); if !self.session.messages.is_empty() { @@ -820,6 +855,33 @@ impl Agent { } } + fn execute_session_end_hook(session_id: &str) { + let config = load_hooks_config(); + let registry = HookRegistry::from_config(config); + if registry.is_empty() { + return; + } + let hook_ctx = HookContext::for_session_end(session_id.to_string()); + let hook_input = HookInput::for_session_end(session_id.to_string()); + let matching = registry.get_matching(&HookEvent::SessionEnd, &hook_ctx); + for handler in matching { + let runtime = tokio::runtime::Handle::current(); + let result = runtime.block_on(execute_hook(handler, &hook_input)); + match result { + Ok(HookResult::Continue(_)) => { + debug!("SessionEnd hook completed for {}", session_id); + } + Ok(HookResult::Failed { error }) => { + debug!("SessionEnd hook failed for {}: {}", session_id, error); + } + Err(e) => { + debug!("SessionEnd hook error for {}: {}", session_id, e); + } + _ => {} + } + } + } + pub fn mark_crashed(&mut self, message: Option) { crate::telemetry::record_crash( self.provider.name(), From 0e6d62e61f9cfb012b238115668f1589306876c3 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 09:23:54 +0700 Subject: [PATCH 17/19] fix(hooks): wire matcher and condition accessors properly - get_handler_matcher() now returns cmd.matcher.as_ref() / http.matcher.as_ref() - get_handler_condition() now returns cmd.if_.as_deref() / http.if_.as_deref() - Reorder HookMatcher derive to Serialize, Deserialize (correct order) --- src/hooks/matcher.rs | 2 +- src/hooks/registry.rs | 23 +++++++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/hooks/matcher.rs b/src/hooks/matcher.rs index 5bbac0b3e..868ec7340 100644 --- a/src/hooks/matcher.rs +++ b/src/hooks/matcher.rs @@ -3,7 +3,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum HookMatcher { Exact(String), Multi(Vec), diff --git a/src/hooks/registry.rs b/src/hooks/registry.rs index 4913f7ca7..9a7d4e9b7 100644 --- a/src/hooks/registry.rs +++ b/src/hooks/registry.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use crate::hooks::config::{HookEvent, HookHandlerConfig, HooksConfig}; -use crate::hooks::matcher::{MatcherContext, matches}; +use crate::hooks::matcher::{HookMatcher, MatcherContext, matches}; /// Context passed to hooks for matching decisions. /// @@ -250,20 +250,19 @@ impl HookRegistry { /// Get the matcher from a handler configuration /// - /// Currently returns None as matchers are not yet integrated into HookHandlerConfig. - /// This will be updated when matcher support is added to the handler config. - fn get_handler_matcher( - &self, - _handler: &HookHandlerConfig, - ) -> Option { - // TODO: Integrate matcher from handler config when implemented - None + fn get_handler_matcher(&self, handler: &HookHandlerConfig) -> Option<&HookMatcher> { + match handler { + HookHandlerConfig::Command(cmd) => cmd.matcher.as_ref(), + HookHandlerConfig::Http(http) => http.matcher.as_ref(), + } } /// Get the condition (`if_`) from a handler configuration - fn get_handler_condition(&self, handler: &HookHandlerConfig) -> Option<&str> { - // TODO: Integrate condition from handler config when implemented - None + fn get_handler_condition<'a>(&self, handler: &'a HookHandlerConfig) -> Option<&'a str> { + match handler { + HookHandlerConfig::Command(cmd) => cmd.if_.as_deref(), + HookHandlerConfig::Http(http) => http.if_.as_deref(), + } } /// Evaluate a condition against the context From ee6ed262f41d0c7311ca438215710efd8ba68bd4 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 09:42:18 +0700 Subject: [PATCH 18/19] Add JCODE_HOOKS_CONFIG env var override layer to load_hooks_config Previously load_hooks_config() only supported 2 layers: 1. User level: ~/.jcode/hooks.toml 2. Project level: .jcode/hooks.toml This adds a 3rd layer with highest priority: 3. Environment variable: JCODE_HOOKS_CONFIG - points to an absolute path Layer order (highest to lowest priority): 1. JCODE_HOOKS_CONFIG path (env override) 2. .jcode/hooks.toml (project level) 3. ~/.jcode/hooks.toml (user level) Each layer merges on top of the previous, so env override takes precedence. --- src/hooks/config.rs | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/hooks/config.rs b/src/hooks/config.rs index 64b017425..b5ed96fee 100644 --- a/src/hooks/config.rs +++ b/src/hooks/config.rs @@ -1,10 +1,11 @@ //! Hooks configuration loading - multi-layer config support //! -//! Loads hooks.toml from two layers: -//! 1. User level: `~/.jcode/hooks.toml` +//! Loads hooks.toml from three layers (highest to lowest priority): +//! 1. Environment variable: `JCODE_HOOKS_CONFIG` (absolute path to config file) //! 2. Project level: `.jcode/hooks.toml` (current working directory) +//! 3. User level: `~/.jcode/hooks.toml` //! -//! Project-level hooks override user-level hooks for the same event. +//! Each layer overrides the previous for the same event. use crate::hooks::matcher::HookMatcher; use crate::storage::jcode_dir; @@ -17,6 +18,8 @@ use std::path::PathBuf; pub const HOOKS_CONFIG_DIR: &str = ".jcode"; /// Filename for hooks configuration pub const HOOKS_CONFIG_FILENAME: &str = "hooks.toml"; +/// Environment variable name for hooks config override +pub const HOOKS_CONFIG_ENV_VAR: &str = "JCODE_HOOKS_CONFIG"; /// Hook event types that can be triggered #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -168,6 +171,11 @@ fn project_hooks_config_path() -> Option { .map(|d| d.join(HOOKS_CONFIG_DIR).join(HOOKS_CONFIG_FILENAME)) } +/// Get the env-level hooks config path from `JCODE_HOOKS_CONFIG` env var +fn env_hooks_config_path() -> Option { + std::env::var(HOOKS_CONFIG_ENV_VAR).ok().map(PathBuf::from) +} + /// Load a hooks config from a file path, returning None if file doesn't exist fn load_hooks_config_from_path(path: &PathBuf) -> Result> { if !path.exists() { @@ -183,16 +191,16 @@ fn load_hooks_config_from_path(path: &PathBuf) -> Result> { /// Load hooks configuration from multi-layer config. /// -/// Loads from: -/// 1. User level: `~/.jcode/hooks.toml` +/// Loads from (highest to lowest priority): +/// 1. Environment variable: `JCODE_HOOKS_CONFIG` (path to config file) /// 2. Project level: `.jcode/hooks.toml` (current directory) -/// -/// Project-level hooks override user-level for the same event. +/// 3. User level: `~/.jcode/hooks.toml` /// /// Returns a merged `HooksConfig`. If no config files are found, returns an empty config. pub fn load_hooks_config() -> HooksConfig { let mut merged = HooksConfig::default(); + // Layer 1: User-level config (~/.jcode/hooks.toml) - lowest priority if let Some(path) = user_hooks_config_path() { match load_hooks_config_from_path(&path) { Ok(Some(config)) => { @@ -209,6 +217,7 @@ pub fn load_hooks_config() -> HooksConfig { } } + // Layer 2: Project-level config (.jcode/hooks.toml) - medium priority if let Some(path) = project_hooks_config_path() { match load_hooks_config_from_path(&path) { Ok(Some(config)) => { @@ -225,6 +234,23 @@ pub fn load_hooks_config() -> HooksConfig { } } + // Layer 3: Env-level config (JCODE_HOOKS_CONFIG path) - highest priority + if let Some(path) = env_hooks_config_path() { + match load_hooks_config_from_path(&path) { + Ok(Some(config)) => { + merged.merge(config); + } + Ok(None) => {} + Err(e) => { + crate::logging::warn(&format!( + "Failed to load env hooks config from {}: {}", + path.display(), + e + )); + } + } + } + merged } From f48c161d5c5e8d4e4c11e9f8048de8e26d863154 Mon Sep 17 00:00:00 2001 From: quangdang46 Date: Wed, 27 May 2026 10:13:39 +0700 Subject: [PATCH 19/19] fix(hooks): add lifetime annotations to get_handler_matcher/get_handler_condition --- src/hooks/registry.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hooks/registry.rs b/src/hooks/registry.rs index 9a7d4e9b7..3ce916dbd 100644 --- a/src/hooks/registry.rs +++ b/src/hooks/registry.rs @@ -249,8 +249,7 @@ impl HookRegistry { } /// Get the matcher from a handler configuration - /// - fn get_handler_matcher(&self, handler: &HookHandlerConfig) -> Option<&HookMatcher> { + fn get_handler_matcher<'a>(&self, handler: &'a HookHandlerConfig) -> Option<&'a HookMatcher> { match handler { HookHandlerConfig::Command(cmd) => cmd.matcher.as_ref(), HookHandlerConfig::Http(http) => http.matcher.as_ref(),