Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5883a90
feat(hooks): add user-configurable hook system with config, executor,…
ssddOnTop Mar 31, 2026
cd1d97e
feat(hooks): integrate user-configurable hooks into app lifecycle and…
ssddOnTop Mar 31, 2026
8b8747c
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 31, 2026
e831ba4
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Mar 31, 2026
9d96e9b
feat(hooks): add user hook config service with multi-source merge logic
ssddOnTop Apr 1, 2026
cb3e3e2
feat(hooks): add configurable hook timeout via hook_timeout_ms config
ssddOnTop Apr 1, 2026
20cb9ad
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 1, 2026
397bb4d
feat(hooks): add HookError chat response and surface it in ui and orc…
ssddOnTop Apr 1, 2026
8e060d6
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 1, 2026
6f50be0
refactor(hooks): replace manual Display impl with strum_macros::Displ…
ssddOnTop Apr 1, 2026
6ae1fb0
feat(hooks): implement UserPromptSubmit hook event with blocking and …
ssddOnTop Apr 1, 2026
156cbc8
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 1, 2026
cb100b4
feat(hooks): add ForgeHookCommandService wrapping CommandInfra for ho…
ssddOnTop Apr 1, 2026
ab08745
refactor(hooks): inject HookCommandService into UserHookExecutor and …
ssddOnTop Apr 1, 2026
95b390a
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 1, 2026
2ff8b98
fix(info): read hook timeout from config instead of env
ssddOnTop Apr 1, 2026
01a6bf6
fix merge errors
ssddOnTop Apr 2, 2026
8120fc0
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 2, 2026
4b747f7
feat(hooks): pass env vars from services into UserHookHandler
ssddOnTop Apr 2, 2026
50ecb0a
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 2, 2026
0daca78
refactor(hooks): propagate load errors and use FileInfoInfra for exis…
ssddOnTop Apr 2, 2026
9358557
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 2, 2026
bfc1bb7
refactor(hooks): move timeout enforcement from infra to executor layer
ssddOnTop Apr 2, 2026
f9eb278
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 2, 2026
6918f37
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ Cargo.lock
**/.forge/request.body.json
node_modules/
bench/__pycache__
/hooksref*
#/cc
1 change: 1 addition & 0 deletions crates/forge_app/src/agent_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ impl<S: Services> AgentExecutor<S> {
ChatResponse::ToolCallStart { .. } => ctx.send(message).await?,
ChatResponse::ToolCallEnd(_) => ctx.send(message).await?,
ChatResponse::RetryAttempt { .. } => ctx.send(message).await?,
ChatResponse::HookError { .. } => ctx.send(message).await?,
ChatResponse::Interrupt { reason } => {
return Err(Error::AgentToolInterrupted(reason))
.context(format!(
Expand Down
33 changes: 30 additions & 3 deletions crates/forge_app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ use forge_stream::MpscStream;
use crate::apply_tunable_parameters::ApplyTunableParameters;
use crate::changed_files::ChangedFiles;
use crate::dto::ToolsOverview;
use crate::hooks::{CompactionHandler, DoomLoopDetector, TitleGenerationHandler, TracingHandler};
use crate::hooks::{
CompactionHandler, DoomLoopDetector, TitleGenerationHandler, TracingHandler, UserHookHandler,
};
use crate::init_conversation_metrics::InitConversationMetrics;
use crate::orch::Orchestrator;
use crate::services::{AgentRegistry, CustomInstructionsService, ProviderAuthService};
use crate::services::{
AgentRegistry, CustomInstructionsService, ProviderAuthService, UserHookConfigService,
};
use crate::set_conversation_id::SetConversationId;
use crate::system_prompt::SystemPrompt;
use crate::tool_registry::ToolRegistry;
Expand Down Expand Up @@ -143,7 +147,7 @@ impl<S: Services> ForgeApp<S> {
// Create the orchestrator with all necessary dependencies
let tracing_handler = TracingHandler::new();
let title_handler = TitleGenerationHandler::new(services.clone());
let hook = Hook::default()
let internal_hook = Hook::default()
.on_start(tracing_handler.clone().and(title_handler.clone()))
.on_request(tracing_handler.clone().and(DoomLoopDetector::default()))
.on_response(
Expand All @@ -155,6 +159,29 @@ impl<S: Services> ForgeApp<S> {
.on_toolcall_end(tracing_handler.clone())
.on_end(tracing_handler.and(title_handler));

// Load user-configurable hooks from settings files
let user_hook_config = services.get_user_hook_config().await?;

let hook = if !user_hook_config.is_empty() {
let user_handler = UserHookHandler::new(
services.hook_command_service().clone(),
services.get_env_vars(),
user_hook_config,
environment.cwd.clone(),
conversation.id.to_string(),
);
let user_hook = Hook::default()
.on_start(user_handler.clone())
.on_request(user_handler.clone())
.on_response(user_handler.clone())
.on_toolcall_start(user_handler.clone())
.on_toolcall_end(user_handler.clone())
.on_end(user_handler);
internal_hook.zip(user_hook)
} else {
internal_hook
};

let retry_config = forge_config.retry.clone().unwrap_or_default();

let orch = Orchestrator::new(services.clone(), retry_config, conversation, agent)
Expand Down
3 changes: 3 additions & 0 deletions crates/forge_app/src/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ mod compaction;
mod doom_loop;
mod title_generation;
mod tracing;
mod user_hook_executor;
mod user_hook_handler;

pub use compaction::CompactionHandler;
pub use doom_loop::DoomLoopDetector;
pub use title_generation::TitleGenerationHandler;
pub use tracing::TracingHandler;
pub use user_hook_handler::UserHookHandler;
245 changes: 245 additions & 0 deletions crates/forge_app/src/hooks/user_hook_executor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;

use forge_domain::{CommandOutput, HookExecutionResult};
use tracing::debug;

use crate::services::HookCommandService;

/// Executes user hook commands by delegating to a [`HookCommandService`].
///
/// Holds the service by value; the service itself is responsible for any
/// internal reference counting (`Arc`). Keeps hook-specific timeout resolution
/// in one place.
#[derive(Clone)]
pub struct UserHookExecutor<S>(S);

impl<S> UserHookExecutor<S> {
/// Creates a new `UserHookExecutor` backed by the given service.
pub fn new(service: S) -> Self {
Self(service)
}
}

impl<S: HookCommandService> UserHookExecutor<S> {
/// Executes a shell command, piping `input_json` to stdin and capturing
/// stdout/stderr.
///
/// Applies `timeout_duration` by racing the service call against the
/// deadline. On timeout, returns a `HookExecutionResult` with
/// `exit_code: None` and a descriptive message in `stderr`.
///
/// # Arguments
/// * `command` - The shell command string to execute.
/// * `input_json` - JSON string to pipe to the command's stdin.
/// * `timeout_duration` - Maximum time to wait for the command.
/// * `cwd` - Working directory for the command.
/// * `env_vars` - Additional environment variables to set.
///
/// # Errors
/// Returns an error if the process cannot be spawned.
pub async fn execute(
&self,
command: &str,
input_json: &str,
timeout_duration: Duration,
cwd: &PathBuf,

Check warning on line 47 in crates/forge_app/src/hooks/user_hook_executor.rs

View workflow job for this annotation

GitHub Actions / Lint Fix

writing `&PathBuf` instead of `&Path` involves a new object where a slice will do
env_vars: &HashMap<String, String>,
) -> anyhow::Result<HookExecutionResult> {
debug!(
command = command,
cwd = %cwd.display(),
timeout_ms = timeout_duration.as_millis() as u64,
"Executing user hook command"
);

let result = tokio::time::timeout(
timeout_duration,
self.0.execute_command_with_input(
command.to_string(),
cwd.clone(),
input_json.to_string(),
env_vars.clone(),
),
)
.await;

let output = match result {
Ok(Ok(output)) => output,
Ok(Err(e)) => return Err(e),
Err(_) => {
tracing::warn!(
command = command,
timeout_ms = timeout_duration.as_millis() as u64,
"Hook command timed out"
);
CommandOutput {
command: command.to_string(),
exit_code: None,
stdout: String::new(),
stderr: format!(
"Hook command timed out after {}ms",
timeout_duration.as_millis()
),
}
}
};

debug!(
command = command,
exit_code = ?output.exit_code,
stdout_len = output.stdout.len(),
stderr_len = output.stderr.len(),
"Hook command completed"
);

Ok(HookExecutionResult {
exit_code: output.exit_code,
stdout: output.stdout,
stderr: output.stderr,
})
}
}

#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;

use forge_domain::CommandOutput;
use pretty_assertions::assert_eq;

use super::*;

/// A minimal service stub that records calls and returns a fixed result.
#[derive(Clone)]
struct StubInfra {
result: CommandOutput,
}

impl StubInfra {
fn success(stdout: &str) -> Self {
Self {
result: CommandOutput {
command: String::new(),
exit_code: Some(0),
stdout: stdout.to_string(),
stderr: String::new(),
},
}
}

fn exit(code: i32, stderr: &str) -> Self {
Self {
result: CommandOutput {
command: String::new(),
exit_code: Some(code),
stdout: String::new(),
stderr: stderr.to_string(),
},
}
}

fn timeout() -> Self {
Self {
result: CommandOutput {
command: String::new(),
exit_code: None,
stdout: String::new(),
stderr: "Hook command timed out after 100ms".to_string(),
},
}
}
}

#[async_trait::async_trait]
impl HookCommandService for StubInfra {
async fn execute_command_with_input(
&self,
command: String,
_working_dir: PathBuf,
_stdin_input: String,
_env_vars: HashMap<String, String>,
) -> anyhow::Result<CommandOutput> {
let mut out = self.result.clone();
out.command = command;
Ok(out)
}
}

#[tokio::test]
async fn test_execute_success() {
let fixture = UserHookExecutor::new(StubInfra::success("hello"));
let actual = fixture
.execute(
"echo hello",
"{}",
Duration::from_secs(0),
&std::env::current_dir().unwrap(),
&HashMap::new(),
)
.await
.unwrap();

assert_eq!(actual.exit_code, Some(0));
assert_eq!(actual.stdout, "hello");
assert!(actual.is_success());
}

#[tokio::test]
async fn test_execute_exit_code_2() {
let fixture = UserHookExecutor::new(StubInfra::exit(2, "blocked"));
let actual = fixture
.execute(
"exit 2",
"{}",
Duration::from_secs(0),
&std::env::current_dir().unwrap(),
&HashMap::new(),
)
.await
.unwrap();

assert_eq!(actual.exit_code, Some(2));
assert!(actual.is_blocking_exit());
assert!(actual.stderr.contains("blocked"));
}

#[tokio::test]
async fn test_execute_non_blocking_error() {
let fixture = UserHookExecutor::new(StubInfra::exit(1, ""));
let actual = fixture
.execute(
"exit 1",
"{}",
Duration::from_secs(0),
&std::env::current_dir().unwrap(),
&HashMap::new(),
)
.await
.unwrap();

assert_eq!(actual.exit_code, Some(1));
assert!(actual.is_non_blocking_error());
}

#[tokio::test]
async fn test_execute_timeout() {
let fixture = UserHookExecutor::new(StubInfra::timeout());
let actual = fixture
.execute(
"sleep 10",
"{}",
Duration::from_millis(100),
&std::env::current_dir().unwrap(),
&HashMap::new(),
)
.await
.unwrap();

assert!(actual.exit_code.is_none());
assert!(actual.stderr.contains("timed out"));
}
}
Loading
Loading