Skip to content

Commit b9ec8bd

Browse files
feat: add agent lifecycle hooks (Claude Code-compatible config)
Hooks fire at key agent lifecycle events and execute user-configured actions via MCP tools. Supports blocking (prompt submit, tool use) and context injection (PostCompact). - 8 of 16 events wired: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, PreCompact, PostCompact, Stop - Two action types: command (via developer__shell) and mcp_tool - Config reads .goose/settings.json, .claude/settings.json, or ~/.config/goose/hooks.json with forward-compatible parsing - Fail-open: hook errors never crash the agent
1 parent c543586 commit b9ec8bd

File tree

7 files changed

+1061
-4
lines changed

7 files changed

+1061
-4
lines changed

crates/goose/src/agents/agent.rs

Lines changed: 192 additions & 4 deletions
Large diffs are not rendered by default.

crates/goose/src/agents/execute_commands.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,29 @@ impl Agent {
8686
.conversation
8787
.ok_or_else(|| anyhow!("Session has no conversation"))?;
8888

89+
// Load hooks and fire PreCompact
90+
let hooks = crate::hooks::Hooks::load(&session.working_dir).ok();
91+
if let Some(ref hooks) = hooks {
92+
let invocation = crate::hooks::HookInvocation::pre_compact(
93+
session_id.to_string(),
94+
conversation.messages().len(),
95+
true, // manual = true for /compact command
96+
session.working_dir.to_string_lossy().to_string(),
97+
);
98+
let _ = hooks
99+
.run(invocation, &self.extension_manager, &session.working_dir)
100+
.await;
101+
}
102+
103+
let pre_compact_len = conversation.messages().len();
89104
let (compacted_conversation, usage) = compact_messages(
90105
self.provider().await?.as_ref(),
91106
session_id,
92107
&conversation,
93108
true, // is_manual_compact
94109
)
95110
.await?;
111+
let post_compact_len = compacted_conversation.messages().len();
96112

97113
manager
98114
.replace_conversation(session_id, &compacted_conversation)
@@ -101,6 +117,28 @@ impl Agent {
101117
self.update_session_metrics(session_id, session.schedule_id, &usage, true)
102118
.await?;
103119

120+
// Fire PostCompact hook and inject context if any
121+
if let Some(ref hooks) = hooks {
122+
let invocation = crate::hooks::HookInvocation::post_compact(
123+
session_id.to_string(),
124+
pre_compact_len,
125+
post_compact_len,
126+
true, // manual = true for /compact command
127+
session.working_dir.to_string_lossy().to_string(),
128+
);
129+
if let Ok(outcome) = hooks
130+
.run(invocation, &self.extension_manager, &session.working_dir)
131+
.await
132+
{
133+
if let Some(context) = outcome.context {
134+
let context_msg = Message::assistant()
135+
.with_text(context)
136+
.with_visibility(false, true); // agent-only
137+
manager.add_message(session_id, &context_msg).await?;
138+
}
139+
}
140+
}
141+
104142
Ok(Some(Message::assistant().with_system_notification(
105143
SystemNotificationType::InlineMessage,
106144
"Compaction complete",

crates/goose/src/agents/tool_execution.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pub const CHAT_MODE_TOOL_SKIPPED_RESPONSE: &str = "Let the user know the tool ca
4949
If needed, adjust the explanation based on user preferences or questions.";
5050

5151
impl Agent {
52+
#[allow(clippy::too_many_arguments)]
5253
pub(crate) fn handle_approval_tool_requests<'a>(
5354
&'a self,
5455
tool_requests: &'a [ToolRequest],
@@ -57,6 +58,7 @@ impl Agent {
5758
cancellation_token: Option<CancellationToken>,
5859
session: &'a Session,
5960
inspection_results: &'a [crate::tool_inspection::InspectionResult],
61+
hooks: &'a Option<crate::hooks::Hooks>,
6062
) -> BoxStream<'a, anyhow::Result<Message>> {
6163
try_stream! {
6264
for request in tool_requests.iter() {
@@ -97,6 +99,38 @@ impl Agent {
9799
}
98100

99101
if confirmation.permission == Permission::AllowOnce || confirmation.permission == Permission::AlwaysAllow {
102+
// Fire PreToolUse hook — if blocked, treat as declined
103+
if let Some(ref hooks) = hooks {
104+
let invocation = crate::hooks::HookInvocation::pre_tool_use(
105+
session.id.clone(),
106+
tool_call.name.to_string(),
107+
serde_json::to_value(&tool_call.arguments)
108+
.unwrap_or(serde_json::Value::Null),
109+
session.working_dir.to_string_lossy().to_string(),
110+
);
111+
let outcome = hooks
112+
.run(invocation, &self.extension_manager, &session.working_dir)
113+
.await
114+
.unwrap_or_default();
115+
if outcome.blocked {
116+
// Hook blocked — treat same as user declining
117+
if let Some(response_msg) = request_to_response_map.get(&request.id) {
118+
let mut response = response_msg.lock().await;
119+
*response = response.clone().with_tool_response_with_metadata(
120+
request.id.clone(),
121+
Ok(rmcp::model::CallToolResult {
122+
content: vec![Content::text("Tool execution blocked by hook")],
123+
structured_content: None,
124+
is_error: Some(true),
125+
meta: None,
126+
}),
127+
request.metadata.as_ref(),
128+
);
129+
}
130+
break;
131+
}
132+
}
133+
100134
let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), session).await;
101135
let mut futures = tool_futures.lock().await;
102136

crates/goose/src/hooks/config.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
use anyhow::{Context, Result};
2+
use serde::Deserialize;
3+
use std::collections::HashMap;
4+
use std::path::Path;
5+
use std::str::FromStr;
6+
7+
use super::types::HookEventKind;
8+
9+
#[derive(Debug, Clone, Default)]
10+
pub struct HookSettingsFile {
11+
pub hooks: HashMap<HookEventKind, Vec<HookEventConfig>>,
12+
pub allow_project_hooks: bool,
13+
}
14+
15+
impl<'de> serde::Deserialize<'de> for HookSettingsFile {
16+
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
17+
#[derive(Deserialize)]
18+
struct Raw {
19+
#[serde(default)]
20+
hooks: HashMap<String, Vec<HookEventConfig>>,
21+
#[serde(default)]
22+
allow_project_hooks: bool,
23+
}
24+
25+
let raw = Raw::deserialize(deserializer)?;
26+
let mut hooks = HashMap::new();
27+
28+
for (key, configs) in raw.hooks {
29+
match HookEventKind::from_str(&key) {
30+
Ok(event) => {
31+
hooks.insert(event, configs);
32+
}
33+
Err(_) => {
34+
tracing::warn!("Unknown hook event '{}', ignoring", key);
35+
}
36+
}
37+
}
38+
39+
Ok(Self {
40+
hooks,
41+
allow_project_hooks: raw.allow_project_hooks,
42+
})
43+
}
44+
}
45+
46+
#[derive(Debug, Clone, Deserialize)]
47+
#[serde(rename_all = "camelCase")]
48+
pub struct HookEventConfig {
49+
#[serde(default)]
50+
pub matcher: Option<String>,
51+
52+
pub hooks: Vec<HookAction>,
53+
}
54+
55+
#[derive(Debug, Clone, Deserialize)]
56+
#[serde(tag = "type", rename_all = "lowercase")]
57+
pub enum HookAction {
58+
Command {
59+
command: String,
60+
61+
#[serde(default = "default_timeout")]
62+
timeout: u64,
63+
},
64+
#[serde(rename = "mcp_tool")]
65+
McpTool {
66+
tool: String,
67+
#[serde(default)]
68+
arguments: serde_json::Map<String, serde_json::Value>,
69+
#[serde(default = "default_timeout")]
70+
timeout: u64,
71+
},
72+
}
73+
74+
fn default_timeout() -> u64 {
75+
600
76+
}
77+
78+
impl HookSettingsFile {
79+
pub fn load_merged(working_dir: &Path) -> Result<Self> {
80+
let global_path = crate::config::paths::Paths::in_config_dir("hooks.json");
81+
let goose_project_path = working_dir.join(".goose").join("settings.json");
82+
let claude_project_path = working_dir.join(".claude").join("settings.json");
83+
84+
let global = Self::load_from_file(&global_path).unwrap_or_else(|e| {
85+
tracing::debug!("No global hooks config at {:?}: {}", global_path, e);
86+
Self::default()
87+
});
88+
89+
// Use the allow_project_hooks setting from the global config
90+
let allow_project_hooks = global.allow_project_hooks;
91+
92+
// If project hooks are not allowed, check if they exist and log a warning
93+
if !allow_project_hooks {
94+
let project_path = if goose_project_path.exists() {
95+
Some(&goose_project_path)
96+
} else if claude_project_path.exists() {
97+
Some(&claude_project_path)
98+
} else {
99+
None
100+
};
101+
102+
if let Some(path) = project_path {
103+
tracing::info!(
104+
"Project hooks found at {:?} but project hooks are not enabled. Set allow_project_hooks: true in ~/.config/goose/hooks.json to enable.",
105+
path
106+
);
107+
}
108+
109+
return Ok(global);
110+
}
111+
112+
let project = if goose_project_path.exists() {
113+
if claude_project_path.exists() {
114+
tracing::warn!("Found hooks config in both .goose/ and .claude/; using .goose/");
115+
}
116+
Self::load_from_file(&goose_project_path).unwrap_or_else(|e| {
117+
tracing::warn!(
118+
"Failed to parse hooks config {:?}: {}",
119+
goose_project_path,
120+
e
121+
);
122+
Self::default()
123+
})
124+
} else {
125+
Self::load_from_file(&claude_project_path).unwrap_or_else(|e| {
126+
if claude_project_path.exists() {
127+
tracing::warn!(
128+
"Failed to parse hooks config {:?}: {}",
129+
claude_project_path,
130+
e
131+
);
132+
}
133+
Self::default()
134+
})
135+
};
136+
137+
Ok(Self::merge(global, project))
138+
}
139+
140+
fn load_from_file(path: &Path) -> Result<Self> {
141+
if !path.exists() {
142+
anyhow::bail!("Config file does not exist: {:?}", path);
143+
}
144+
145+
let content = std::fs::read_to_string(path)
146+
.with_context(|| format!("Failed to read hooks config from {:?}", path))?;
147+
148+
let config: Self = serde_json::from_str(&content)
149+
.with_context(|| format!("Failed to parse hooks config from {:?}", path))?;
150+
151+
Ok(config)
152+
}
153+
154+
fn merge(global: Self, project: Self) -> Self {
155+
let mut merged_hooks: HashMap<HookEventKind, Vec<HookEventConfig>> = global.hooks;
156+
157+
for (event, project_configs) in project.hooks {
158+
merged_hooks
159+
.entry(event)
160+
.or_default()
161+
.extend(project_configs);
162+
}
163+
164+
Self {
165+
hooks: merged_hooks,
166+
allow_project_hooks: global.allow_project_hooks,
167+
}
168+
}
169+
170+
pub fn get_hooks_for_event(&self, event: HookEventKind) -> &[HookEventConfig] {
171+
self.hooks.get(&event).map(|v| v.as_slice()).unwrap_or(&[])
172+
}
173+
}

0 commit comments

Comments
 (0)