Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 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
db8a2db
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 2, 2026
54c2a5a
refactor(hooks): remove hook_timeout_ms config field and use enum for…
ssddOnTop Apr 2, 2026
604041a
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 2, 2026
efd1bc6
chore(hooks): upgrade TODO/Note comments to FIXME with detailed expla…
ssddOnTop Apr 2, 2026
f803492
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 2, 2026
63f86d7
feat(hooks): allow pretolluse hooks to update tool arguments
ssddOnTop Apr 3, 2026
f735424
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 3, 2026
193f556
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 3, 2026
ecf22b6
refactor(hooks): render hook feedback with template elements
ssddOnTop Apr 7, 2026
0f1a70f
chore(gitignore): remove hooksref ignore entries
ssddOnTop Apr 7, 2026
e117e3e
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 7, 2026
4385f8a
fix tests
ssddOnTop Apr 7, 2026
e473f5f
test(hooks): load hook config fixtures via include_str
ssddOnTop Apr 7, 2026
33d9c13
feat(hooks): block execution when continue is false and use stop reason
ssddOnTop Apr 7, 2026
17355ec
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 7, 2026
ec50b00
test(hooks): add hook config json fixtures
ssddOnTop Apr 7, 2026
16176e6
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 7, 2026
76bd36a
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 7, 2026
c77b858
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 7, 2026
ccd4b5f
feat(hooks): suppress blocked prompts and continue on stop hook block
ssddOnTop Apr 8, 2026
d0f41a8
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 8, 2026
1ccb95a
feat(hooks): include last assistant message in stop hook input
ssddOnTop Apr 8, 2026
924b5c9
refactor(hooks): accept cwd as Path in user hook executor
ssddOnTop Apr 8, 2026
06e78e4
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 8, 2026
4b276aa
feat(hooks): add user hook config schema and toml fixtures
ssddOnTop Apr 8, 2026
d8c4764
feat(hooks): load user hook config from .forge.toml via forge_config
ssddOnTop Apr 8, 2026
1d26f4e
feat(config): remove hook_timeout_ms from default .forge.toml
ssddOnTop Apr 8, 2026
e0bc85e
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 8, 2026
847650e
fix(hooks): escape newline in stop hook continue message
ssddOnTop Apr 8, 2026
f1235f4
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 8, 2026
824b2d6
refactor(hooks): simplify lifecycle hook handling in orchestrator
ssddOnTop Apr 9, 2026
f7e17e8
feat(hooks): emit user hook warnings via lifecycle events
ssddOnTop Apr 9, 2026
46cc17d
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 9, 2026
0dca15c
refactor(hooks): accept mutable end event in pending todos handler
ssddOnTop Apr 9, 2026
faaeae8
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 9, 2026
f4b8ea0
feat(hooks): inject additional context and stop hook feedback
ssddOnTop Apr 9, 2026
b260c3e
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 9, 2026
9582f9c
test(hooks): use default end payload and add stop flag to fixtures
ssddOnTop Apr 9, 2026
b9c0cce
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 9, 2026
ab01d4f
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Apr 9, 2026
4bd1743
feat(hooks): store posttooluse feedback for ordered injection
ssddOnTop Apr 9, 2026
6222ba5
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 9, 2026
e4fbbf7
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Apr 9, 2026
deec13a
refactor(hooks): consolidate lifecycle hook execution (#2920)
ssddOnTop Apr 10, 2026
520f693
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 10, 2026
e46d2d6
test(hooks): use default end payload in title generation test
ssddOnTop Apr 10, 2026
066b930
Merge branch 'main' into feat/user-configurable-hooks
ssddOnTop Apr 10, 2026
192c969
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 10, 2026
543ae89
test(config): add hook config fixtures for pipeline and layered defaults
ssddOnTop Apr 10, 2026
0301839
test(config): add tests for hook parsing through config pipeline
ssddOnTop Apr 10, 2026
bdb9d6c
perf(hooks): cache pre-compiled regex patterns in UserHookHandler
ssddOnTop Apr 11, 2026
75fbb6e
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 11, 2026
c3bda18
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Apr 11, 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 crates/forge_app/src/agent_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ impl<S: Services + EnvironmentInfra<Config = forge_config::ForgeConfig>> AgentEx
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::HookWarning { .. } => ctx.send(message).await?,
ChatResponse::Interrupt { reason } => {
return Err(Error::AgentToolInterrupted(reason))
.context(format!(
Expand Down
31 changes: 28 additions & 3 deletions crates/forge_app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ use crate::changed_files::ChangedFiles;
use crate::dto::ToolsOverview;
use crate::hooks::{
CompactionHandler, DoomLoopDetector, PendingTodosHandler, TitleGenerationHandler,
TracingHandler,
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 @@ -157,7 +159,7 @@ impl<S: Services + EnvironmentInfra<Config = forge_config::ForgeConfig>> ForgeAp
tracing_handler.clone().and(title_handler.clone())
};

let hook = Hook::default()
let internal_hook = Hook::default()
.on_start(tracing_handler.clone().and(title_handler))
.on_request(tracing_handler.clone().and(DoomLoopDetector::default()))
.on_response(
Expand All @@ -169,6 +171,29 @@ impl<S: Services + EnvironmentInfra<Config = forge_config::ForgeConfig>> ForgeAp
.on_toolcall_end(tracing_handler)
.on_end(on_end_hook);

// 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 orch = Orchestrator::new(
services.clone(),
conversation,
Expand Down
2 changes: 1 addition & 1 deletion crates/forge_app/src/hooks/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ impl CompactionHandler {
impl EventHandle<EventData<ResponsePayload>> for CompactionHandler {
async fn handle(
&self,
_event: &EventData<ResponsePayload>,
_event: &mut EventData<ResponsePayload>,
conversation: &mut Conversation,
) -> anyhow::Result<()> {
if let Some(context) = &conversation.context {
Expand Down
2 changes: 1 addition & 1 deletion crates/forge_app/src/hooks/doom_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ impl DoomLoopDetector {
impl EventHandle<EventData<RequestPayload>> for DoomLoopDetector {
async fn handle(
&self,
event: &EventData<RequestPayload>,
event: &mut EventData<RequestPayload>,
conversation: &mut Conversation,
) -> anyhow::Result<()> {
if let Some(consecutive_calls) = self.detect_from_conversation(conversation) {
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 @@ -3,9 +3,12 @@ mod doom_loop;
mod pending_todos;
mod title_generation;
mod tracing;
mod user_hook_executor;
mod user_hook_handler;

pub use compaction::CompactionHandler;
pub use doom_loop::DoomLoopDetector;
pub use pending_todos::PendingTodosHandler;
pub use title_generation::TitleGenerationHandler;
pub use tracing::TracingHandler;
pub use user_hook_handler::UserHookHandler;
36 changes: 20 additions & 16 deletions crates/forge_app/src/hooks/pending_todos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ impl PendingTodosHandler {
impl EventHandle<EventData<EndPayload>> for PendingTodosHandler {
async fn handle(
&self,
_event: &EventData<EndPayload>,
_event: &mut EventData<EndPayload>,
conversation: &mut Conversation,
) -> anyhow::Result<()> {
let pending_todos = conversation.metrics.get_active_todos();
Expand Down Expand Up @@ -157,17 +157,21 @@ mod tests {
}

fn fixture_event() -> EventData<EndPayload> {
EventData::new(fixture_agent(), ModelId::new("test-model"), EndPayload)
EventData::new(
fixture_agent(),
ModelId::new("test-model"),
EndPayload::default(),
)
}

#[tokio::test]
async fn test_no_pending_todos_does_nothing() {
let handler = PendingTodosHandler::new();
let event = fixture_event();
let mut event = fixture_event();
let mut conversation = fixture_conversation(vec![]);

let initial_msg_count = conversation.context.as_ref().unwrap().messages.len();
handler.handle(&event, &mut conversation).await.unwrap();
handler.handle(&mut event, &mut conversation).await.unwrap();

let actual = conversation.context.as_ref().unwrap().messages.len();
let expected = initial_msg_count;
Expand All @@ -177,13 +181,13 @@ mod tests {
#[tokio::test]
async fn test_pending_todos_injects_reminder() {
let handler = PendingTodosHandler::new();
let event = fixture_event();
let mut event = fixture_event();
let mut conversation = fixture_conversation(vec![
Todo::new("Fix the build").status(TodoStatus::Pending),
Todo::new("Write tests").status(TodoStatus::InProgress),
]);

handler.handle(&event, &mut conversation).await.unwrap();
handler.handle(&mut event, &mut conversation).await.unwrap();

let actual = conversation.context.as_ref().unwrap().messages.len();
let expected = 1;
Expand All @@ -193,13 +197,13 @@ mod tests {
#[tokio::test]
async fn test_reminder_contains_formatted_list() {
let handler = PendingTodosHandler::new();
let event = fixture_event();
let mut event = fixture_event();
let mut conversation = fixture_conversation(vec![
Todo::new("Fix the build").status(TodoStatus::Pending),
Todo::new("Write tests").status(TodoStatus::InProgress),
]);

handler.handle(&event, &mut conversation).await.unwrap();
handler.handle(&mut event, &mut conversation).await.unwrap();

let entry = &conversation.context.as_ref().unwrap().messages[0];
let actual = entry.message.content().unwrap();
Expand All @@ -210,14 +214,14 @@ mod tests {
#[tokio::test]
async fn test_completed_todos_not_included() {
let handler = PendingTodosHandler::new();
let event = fixture_event();
let mut event = fixture_event();
let mut conversation = fixture_conversation(vec![
Todo::new("Completed task").status(TodoStatus::Completed),
Todo::new("Cancelled task").status(TodoStatus::Cancelled),
]);

let initial_msg_count = conversation.context.as_ref().unwrap().messages.len();
handler.handle(&event, &mut conversation).await.unwrap();
handler.handle(&mut event, &mut conversation).await.unwrap();

let actual = conversation.context.as_ref().unwrap().messages.len();
let expected = initial_msg_count;
Expand All @@ -227,32 +231,32 @@ mod tests {
#[tokio::test]
async fn test_reminder_not_duplicated_for_same_todos() {
let handler = PendingTodosHandler::new();
let event = fixture_event();
let mut event = fixture_event();
let mut conversation =
fixture_conversation(vec![Todo::new("Fix the build").status(TodoStatus::Pending)]);

// First call should inject a reminder
handler.handle(&event, &mut conversation).await.unwrap();
handler.handle(&mut event, &mut conversation).await.unwrap();
let after_first = conversation.context.as_ref().unwrap().messages.len();
assert_eq!(after_first, 1);

// Second call with the same pending todos should NOT add another reminder
handler.handle(&event, &mut conversation).await.unwrap();
handler.handle(&mut event, &mut conversation).await.unwrap();
let after_second = conversation.context.as_ref().unwrap().messages.len();
assert_eq!(after_second, 1); // Still 1, no duplicate
}

#[tokio::test]
async fn test_reminder_added_when_todos_change() {
let handler = PendingTodosHandler::new();
let event = fixture_event();
let mut event = fixture_event();
let mut conversation = fixture_conversation(vec![
Todo::new("Fix the build").status(TodoStatus::Pending),
Todo::new("Write tests").status(TodoStatus::InProgress),
]);

// First call should inject a reminder
handler.handle(&event, &mut conversation).await.unwrap();
handler.handle(&mut event, &mut conversation).await.unwrap();
let after_first = conversation.context.as_ref().unwrap().messages.len();
assert_eq!(after_first, 1);

Expand All @@ -265,7 +269,7 @@ mod tests {
]);

// Second call with different pending todos should add a new reminder
handler.handle(&event, &mut conversation).await.unwrap();
handler.handle(&mut event, &mut conversation).await.unwrap();
let after_second = conversation.context.as_ref().unwrap().messages.len();
assert_eq!(after_second, 2); // New reminder added because todos changed
}
Expand Down
16 changes: 8 additions & 8 deletions crates/forge_app/src/hooks/title_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ impl<S> TitleGenerationHandler<S> {
impl<S: AgentService> EventHandle<EventData<StartPayload>> for TitleGenerationHandler<S> {
async fn handle(
&self,
event: &EventData<StartPayload>,
event: &mut EventData<StartPayload>,
conversation: &mut Conversation,
) -> anyhow::Result<()> {
if conversation.title.is_some() {
Expand Down Expand Up @@ -85,7 +85,7 @@ impl<S: AgentService> EventHandle<EventData<StartPayload>> for TitleGenerationHa
impl<S: AgentService> EventHandle<EventData<EndPayload>> for TitleGenerationHandler<S> {
async fn handle(
&self,
_event: &EventData<EndPayload>,
_event: &mut EventData<EndPayload>,
conversation: &mut Conversation,
) -> anyhow::Result<()> {
if let Some((_, entry)) = self.title_tasks.remove(&conversation.id) {
Expand Down Expand Up @@ -176,7 +176,7 @@ mod tests {
conversation.title = Some("existing".into());

handler
.handle(&event(StartPayload), &mut conversation)
.handle(&mut event(StartPayload), &mut conversation)
.await
.unwrap();

Expand All @@ -195,7 +195,7 @@ mod tests {
.insert(conversation.id, TitleGenerationState { rx, handle });

handler
.handle(&event(StartPayload), &mut conversation)
.handle(&mut event(StartPayload), &mut conversation)
.await
.unwrap();

Expand All @@ -215,7 +215,7 @@ mod tests {
.insert(conversation.id, TitleGenerationState { rx, handle });

handler
.handle(&event(EndPayload), &mut conversation)
.handle(&mut event(EndPayload::default()), &mut conversation)
.await
.unwrap();

Expand All @@ -237,7 +237,7 @@ mod tests {
.insert(conversation.id, TitleGenerationState { rx, handle });

handler
.handle(&event(EndPayload), &mut conversation)
.handle(&mut event(EndPayload::default()), &mut conversation)
.await
.unwrap();

Expand All @@ -262,7 +262,7 @@ mod tests {
.insert(conversation.id, TitleGenerationState { rx, handle });

handler
.handle(&event(EndPayload), &mut conversation)
.handle(&mut event(EndPayload::default()), &mut conversation)
.await
.unwrap();

Expand Down Expand Up @@ -290,7 +290,7 @@ mod tests {
joins.push(tokio::spawn(async move {
barrier.wait().await;
handler
.handle(&event(StartPayload), &mut conv)
.handle(&mut event(StartPayload), &mut conv)
.await
.unwrap();
}));
Expand Down
Loading
Loading