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/ 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/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) 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(), 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..9078bafa8 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::HooksConfig, + memory, + session, + storage, + tui, +}; use super::terminal::{cleanup_tui_runtime, init_tui_runtime}; @@ -350,6 +358,227 @@ pub fn run_mcp_list_command(json: bool) -> Result<()> { Ok(()) } +use crate::cli::args::HooksCommand; +use crate::hooks::config::{HookEvent, HookHandlerConfig, HttpHandlerConfig}; + +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, + } +} + +fn load_user_hooks_config() -> Result { + let path = crate::storage::jcode_dir()?.join("hooks.toml"); + if !path.exists() { + return Ok(HooksConfig::default()); + } + let content = std::fs::read_to_string(&path)?; + let config = toml::from_str::(&content) + .with_context(|| format!("Failed to parse {}", path.display()))?; + Ok(config) +} + +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(()) +} + +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)) +} + +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<()> { + 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 { + if let Some(ref filter) = event { + if event_name != filter { + continue; + } + } + + if !printed_header { + println!("Configured hooks:"); + printed_header = true; + } + + 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() { + println!("No hooks found for event '{}'.", event.unwrap()); + } + + Ok(()) +} + +async fn run_hooks_add(event: String, handler_type: String, config_json: String) -> 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 config: serde_json::Value = serde_json::from_str(&config_json) + .with_context(|| format!("Failed to parse config JSON: {}", config_json))?; + + 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), + }; + + 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<()> { + 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(()) +} + +async fn run_hooks_enable(event: String, index: usize) -> Result<()> { + set_hook_enabled(event, index, true).await +} + +async fn run_hooks_disable(event: String, index: usize) -> Result<()> { + 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(()) +} + 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 new file mode 100644 index 000000000..b5ed96fee --- /dev/null +++ b/src/hooks/config.rs @@ -0,0 +1,327 @@ +//! Hooks configuration loading - multi-layer config support +//! +//! 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` +//! +//! Each layer overrides the previous for the same event. + +use crate::hooks::matcher::HookMatcher; +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"; +/// 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)] +#[serde(rename_all = "lowercase")] +pub enum HookEvent { + PreToolUse, + PostToolUse, + PreSession, + PostSession, + Error, + SessionStart, + SessionEnd, + PermissionRequest, + PermissionDenied, + ToolError, + 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), + "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, + } + } +} + +/// 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 CommandHandlerConfig { + pub enabled: bool, + pub command: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + pub cwd: Option, + pub timeout_secs: Option, + 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, + } + } +} + +/// HTTP handler configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpHandlerConfig { + pub enabled: bool, + pub url: String, + pub method: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + 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, + } + } +} + +/// Hooks configuration containing mappings of events to their handlers +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct HooksConfig { + #[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)) +} + +/// 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() { + 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 (highest to lowest priority): +/// 1. Environment variable: `JCODE_HOOKS_CONFIG` (path to config file) +/// 2. Project level: `.jcode/hooks.toml` (current directory) +/// 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)) => { + merged.merge(config); + } + Ok(None) => {} + Err(e) => { + crate::logging::warn(&format!( + "Failed to load user hooks config from {}: {}", + path.display(), + e + )); + } + } + } + + // 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)) => { + merged.merge(config); + } + Ok(None) => {} + Err(e) => { + crate::logging::warn(&format!( + "Failed to load project hooks config from {}: {}", + path.display(), + e + )); + } + } + } + + // 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 +} + +#[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(CommandHandlerConfig { + command: "user_handler".to_string(), + ..Default::default() + }), + ); + + let mut config2 = HooksConfig::default(); + config2.events.insert( + "pre_tool_use".to_string(), + HookHandlerConfig::Command(CommandHandlerConfig { + command: "project_handler".to_string(), + ..Default::default() + }), + ); + config2.events.insert( + "post_tool_use".to_string(), + HookHandlerConfig::Command(CommandHandlerConfig { + command: "post_handler".to_string(), + ..Default::default() + }), + ); + + config1.merge(config2); + + 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" + ); + assert!( + matches!(post_handler, HookHandlerConfig::Command(cmd) if cmd.command == "post_handler"), + "New event should be added" + ); + } + + #[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..a943adfa6 --- /dev/null +++ b/src/hooks/execute.rs @@ -0,0 +1,193 @@ +//! Hook execution - runs hooks and returns results + +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; + +/// Result of executing a hook +#[derive(Debug)] +pub enum HookResult { + Continue(HookOutput), + Blocked { reason: String, output: HookOutput }, + Failed { error: String }, +} + +/// Execute a command hook +pub async fn execute_command_hook( + config: &CommandHandlerConfig, + input: &HookInput, +) -> Result { + let input_json = + serde_json::to_string(input).map_err(|e| format!("Failed to serialize hook input: {}", e))?; + + let mut cmd = if cfg!(windows) { + let mut c = Command::new("powershell"); + c.args(["-NoProfile", "-Command", &config.command]); + c + } else { + let mut c = Command::new("bash"); + c.args(["-c", &config.command]); + c + }; + + let timeout_duration = config + .timeout_secs + .map(|s| std::time::Duration::from_secs(s)) + .unwrap_or(std::time::Duration::from_secs(30)); + + for (k, v) in &config.env { + cmd.env(k, v); + } + + if let Some(cwd) = &config.cwd { + cmd.current_dir(cwd); + } + + let result = timeout( + timeout_duration, + async { + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn hook process: {}", e))?; + + let mut stdout = Vec::new(); + + if let Some(ref mut stdin) = child.stdin { + stdin + .write_all(input_json.as_bytes()) + .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())?; + } + + let status = child.wait().await.map_err(|e| e.to_string())?; + + let output_str = String::from_utf8_lossy(&stdout); + let hook_output: HookOutput = + serde_json::from_str(&output_str).unwrap_or_else(|_| HookOutput::continue_()); + + 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: "Hook execution timed out".to_string(), + }), + } +} + +/// 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 { + 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/matcher.rs b/src/hooks/matcher.rs new file mode 100644 index 000000000..868ec7340 --- /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; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +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..3ce916dbd --- /dev/null +++ b/src/hooks/registry.rs @@ -0,0 +1,467 @@ +//! 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::{HookMatcher, MatcherContext, matches}; + +/// 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(tool_name: String, session_id: String, cwd: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "PreToolUse".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name), + tool_input: None, + tool_use_id: None, + permission_mode: None, + } + } + + 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. + /// 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<'a>(&'a self, context: &'a str) -> MatcherContext<'a> { + 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 + 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(), + } + } + + /// Get the condition (`if_`) from a handler configuration + 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 + /// + /// 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(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!(matches!(&hooks[0], HookHandlerConfig::Command(cmd) if cmd.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(crate::hooks::config::CommandHandlerConfig { + 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..b32400b5b --- /dev/null +++ b/src/hooks/types.rs @@ -0,0 +1,199 @@ +//! 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, + } + } + + /// 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 +#[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 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; 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/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")); diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 0363eaabe..478ffc575 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,59 @@ 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( + resolved_name.to_string(), + ctx.session_id.clone(), + cwd.clone(), + ); + + let config = 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), @@ -548,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); } }; @@ -565,6 +679,70 @@ 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, + }; + + let config = load_hooks_config(); + let registry = HookRegistry::from_config(config); + let matching = registry.get_matching(&HookEvent::PostToolUse, &post_ctx); + 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 { + 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) }