Skip to content

Commit fb74643

Browse files
committed
Merge branch 'dev/hook0610' into dev/websearch0610
# Conflicts: # crates/cli/src/prompt_command.rs # crates/core/src/tools/router.rs # crates/server/src/runtime/turn_exec.rs
2 parents 624989a + 239bb75 commit fb74643

28 files changed

Lines changed: 1748 additions & 12 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cli/src/prompt_command.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ pub(crate) async fn run_prompt(
8282
collaboration_mode: devo_protocol::CollaborationMode::Build,
8383
agent_coordinator: None,
8484
local_web_search: None,
85+
hooks: (!app_config.hooks.is_empty()).then(|| devo_core::HookRuntimeContext {
86+
runner: devo_core::HookRunner::new(app_config.hooks.clone()),
87+
base: devo_core::HookBaseInput {
88+
session_id: session_state.id.clone(),
89+
transcript_path: String::new(),
90+
cwd: cwd.clone(),
91+
permission_mode: Some("auto-approve".to_string()),
92+
agent_id: None,
93+
agent_type: None,
94+
},
95+
}),
8596
network_proxy: None,
8697
},
8798
);

crates/config/README.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ consumer crates.
1414
- `server.rs` defines server transport and connection defaults.
1515
- `logging.rs` defines logging and rolling file-log settings.
1616
- `skills.rs` defines skill discovery settings.
17+
- `hooks.rs` defines external hook event and command configuration.
1718
- `experimental.rs` defines opt-in experimental feature gates.
1819
- `error.rs` defines app and provider config error types.
1920
- `provider.rs` re-exports provider config APIs and contains provider-focused
@@ -88,6 +89,7 @@ without clearing every omitted provider field from user config.
8889
- `updates.enabled = true`
8990
- `updates.check_on_startup = true`
9091
- `updates.check_interval_hours = 24`
92+
- `hooks = {}`
9193
- `project_root_markers = [".git"]`
9294
- `projects = {}`
9395

@@ -154,6 +156,14 @@ check_interval_hours = 24
154156

155157
[projects."/path/to/project"]
156158
permission_preset = "default" # read-only, default, auto-review, or full-access
159+
160+
[[hooks.PreToolUse]]
161+
matcher = "exec_command"
162+
163+
[[hooks.PreToolUse.hooks]]
164+
type = "command"
165+
command = "hooks/pre-tool-use.sh"
166+
timeout = 30
157167
```
158168

159169
`logging.file.directory` is optional. Relative logging directories resolve under
@@ -176,6 +186,105 @@ permission_preset = "default" # read-only, default, auto-review, or full-access
176186
Provider-specific validation happens while resolving or mutating provider
177187
config.
178188

189+
## Hooks
190+
191+
External hooks are configured under the top-level `[hooks]` table. Each hook
192+
event contains matcher entries, and each matcher entry contains one or more hook
193+
commands:
194+
195+
```toml
196+
[[hooks.PostToolUse]]
197+
matcher = "exec_command|read_file"
198+
199+
[[hooks.PostToolUse.hooks]]
200+
type = "command"
201+
command = "hooks/post-tool-use.sh"
202+
shell = "bash"
203+
timeout = 30
204+
205+
[[hooks.UserPromptSubmit]]
206+
207+
[[hooks.UserPromptSubmit.hooks]]
208+
type = "command"
209+
command = "hooks/check-prompt.sh"
210+
async = false
211+
```
212+
213+
Command hooks receive one JSON object on stdin. The common fields are
214+
`hook_event_name`, `session_id`, `transcript_path`, and `cwd`. Runtime contexts
215+
may also include `permission_mode`, `agent_id`, and `agent_type`, followed by
216+
event-specific fields such as `tool_name`, `tool_input`, `tool_use_id`,
217+
`tool_response`, `prompt`, `source`, `trigger`, `reason`, `file_path`, `event`,
218+
`old_cwd`, and `new_cwd`.
219+
220+
Hook command results follow Claude Code-style blocking semantics:
221+
222+
- Exit status `0` succeeds unless stdout contains a blocking JSON decision.
223+
- Exit status `2` blocks the triggering action. The block reason is read from
224+
stdout JSON or stderr.
225+
- Stdout JSON shaped as `{"decision":"block","reason":"..."}` blocks even when
226+
the process exits successfully.
227+
- Claude-style `hookSpecificOutput` denial JSON blocks for `PreToolUse` and
228+
`PermissionRequest`.
229+
- Stdout JSON shaped as `{"continue":false,"stopReason":"..."}` is treated as a
230+
blocking stop for lifecycle events that consume blocking decisions.
231+
- Other non-zero exits are logged as non-blocking hook failures.
232+
233+
The `command` hook type is executed by the runtime. `prompt`, `agent`, and
234+
`http` hook definitions are parsed so config files remain forward compatible,
235+
but they are currently logged as unsupported and not executed. `shell` accepts
236+
`bash` and `powershell`. `timeout` is in seconds and defaults to `600`.
237+
`async = true` and `asyncRewake = true` spawn the command in the background and
238+
do not wait for a blocking decision. `if`, `status_message`, and `once` are
239+
preserved in config but are not interpreted by the current runtime.
240+
241+
All 27 hook event names are accepted by config:
242+
243+
- `PreToolUse`, `PostToolUse`, `PostToolUseFailure`
244+
- `Notification`, `UserPromptSubmit`
245+
- `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`
246+
- `SubagentStart`, `SubagentStop`
247+
- `PreCompact`, `PostCompact`
248+
- `PermissionRequest`, `PermissionDenied`
249+
- `Setup`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`
250+
- `Elicitation`, `ElicitationResult`
251+
- `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`
252+
- `InstructionsLoaded`, `CwdChanged`, `FileChanged`
253+
254+
The current runtime triggers hooks where Devo has a matching lifecycle point:
255+
tool execution, prompt submission, server setup, session start and resume,
256+
session shutdown, turn stop and failure, subagent start and stop, manual
257+
compaction, permission request and denial, config writes through `provider/upsert`
258+
and `skills/set_enabled`, per-turn cwd changes, and file changes reported by
259+
`write`/`apply_patch` tool metadata.
260+
261+
Runtime-triggered events:
262+
263+
- `PreToolUse`, `PostToolUse`, `PostToolUseFailure`
264+
- `UserPromptSubmit`
265+
- `SessionStart`, `SessionEnd`
266+
- `Stop`, `StopFailure`
267+
- `SubagentStart`, `SubagentStop`
268+
- `PreCompact`, `PostCompact`
269+
- `PermissionRequest`, `PermissionDenied`
270+
- `Setup`
271+
- `ConfigChange`
272+
- `CwdChanged`, `FileChanged`
273+
274+
Config-ready but not currently triggered:
275+
276+
- `Notification`: Devo has protocol notifications, but no single user-facing
277+
notification lifecycle equivalent to Claude's external notification hook.
278+
- `TeammateIdle`, `TaskCreated`, `TaskCompleted`: the standalone `devo-tasks`
279+
crate is not wired into the server runtime task lifecycle.
280+
- `Elicitation`, `ElicitationResult`: MCP elicitation is currently handled
281+
inside the MCP manager with an automatic response and no server-session hook
282+
bridge.
283+
- `WorktreeCreate`, `WorktreeRemove`: Devo currently has no worktree lifecycle
284+
API.
285+
- `InstructionsLoaded`: Devo discovers AGENTS-style instructions during context
286+
assembly, but does not expose a hookable per-file instruction-load event.
287+
179288
## Provider Config
180289

181290
Provider config is part of `config.toml` and is modeled by `ProviderConfigSection`.

crates/config/src/app.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use devo_util_paths::FileSystemConfigPathResolver;
1616
use crate::AUTH_CONFIG_FILE_NAME;
1717
use crate::AppConfigError;
1818
use crate::ExperimentalConfig;
19+
use crate::HooksConfig;
1920
use crate::LogRotation;
2021
use crate::LoggingConfig;
2122
use crate::LoggingFileConfig;
@@ -65,6 +66,9 @@ pub struct AppConfig {
6566
/// Tool-specific runtime configuration.
6667
#[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
6768
pub tools: ToolsConfig,
69+
/// External lifecycle hooks keyed by event name.
70+
#[serde(default, skip_serializing_if = "HooksConfig::is_empty")]
71+
pub hooks: HooksConfig,
6872
/// Provider, model, and active model defaults.
6973
#[serde(flatten)]
7074
pub provider: ProviderConfigSection,
@@ -154,6 +158,7 @@ impl Default for AppConfig {
154158
mcp_oauth_credentials_store: Some(OAuthCredentialsStoreMode::default()),
155159
mcp: McpConfig::default(),
156160
tools: ToolsConfig::default(),
161+
hooks: HooksConfig::default(),
157162
provider: ProviderConfigSection::default(),
158163
provider_http: ProviderHttpConfig::default(),
159164
updates: UpdatesConfig {

crates/config/src/hooks.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
use std::collections::BTreeMap;
2+
3+
use serde::Deserialize;
4+
use serde::Serialize;
5+
6+
/// External hook events understood by Devo configuration.
7+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8+
pub enum HookEvent {
9+
PreToolUse,
10+
PostToolUse,
11+
PostToolUseFailure,
12+
Notification,
13+
UserPromptSubmit,
14+
SessionStart,
15+
SessionEnd,
16+
Stop,
17+
StopFailure,
18+
SubagentStart,
19+
SubagentStop,
20+
PreCompact,
21+
PostCompact,
22+
PermissionRequest,
23+
PermissionDenied,
24+
Setup,
25+
TeammateIdle,
26+
TaskCreated,
27+
TaskCompleted,
28+
Elicitation,
29+
ElicitationResult,
30+
ConfigChange,
31+
WorktreeCreate,
32+
WorktreeRemove,
33+
InstructionsLoaded,
34+
CwdChanged,
35+
FileChanged,
36+
}
37+
38+
/// Top-level hook configuration keyed by event name.
39+
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
40+
#[serde(transparent)]
41+
pub struct HooksConfig(pub BTreeMap<HookEvent, Vec<HookMatcherConfig>>);
42+
43+
impl HooksConfig {
44+
pub fn is_empty(&self) -> bool {
45+
self.0.values().all(Vec::is_empty)
46+
}
47+
48+
pub fn matchers_for(&self, event: HookEvent) -> &[HookMatcherConfig] {
49+
self.0.get(&event).map_or(&[], Vec::as_slice)
50+
}
51+
}
52+
53+
/// A matcher groups one or more hooks for an event-specific match query.
54+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55+
pub struct HookMatcherConfig {
56+
#[serde(default, skip_serializing_if = "Option::is_none")]
57+
pub matcher: Option<String>,
58+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
59+
pub hooks: Vec<HookCommandConfig>,
60+
}
61+
62+
/// Persistable hook command definitions.
63+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64+
#[serde(tag = "type", rename_all = "snake_case")]
65+
pub enum HookCommandConfig {
66+
Command(CommandHookConfig),
67+
Prompt(PromptHookConfig),
68+
Agent(AgentHookConfig),
69+
Http(HttpHookConfig),
70+
}
71+
72+
/// Shell command hook definition.
73+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74+
pub struct CommandHookConfig {
75+
pub command: String,
76+
#[serde(default, skip_serializing_if = "Option::is_none")]
77+
pub shell: Option<HookShell>,
78+
#[serde(rename = "if", default, skip_serializing_if = "Option::is_none")]
79+
pub condition: Option<String>,
80+
#[serde(default, skip_serializing_if = "Option::is_none")]
81+
pub timeout: Option<u64>,
82+
#[serde(
83+
rename = "statusMessage",
84+
alias = "status_message",
85+
default,
86+
skip_serializing_if = "Option::is_none"
87+
)]
88+
pub status_message: Option<String>,
89+
#[serde(default, skip_serializing_if = "Option::is_none")]
90+
pub once: Option<bool>,
91+
#[serde(rename = "async", default, skip_serializing_if = "Option::is_none")]
92+
pub async_hook: Option<bool>,
93+
#[serde(
94+
rename = "asyncRewake",
95+
default,
96+
skip_serializing_if = "Option::is_none"
97+
)]
98+
pub async_rewake: Option<bool>,
99+
}
100+
101+
/// Shell used to run a command hook.
102+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
103+
pub enum HookShell {
104+
#[serde(rename = "bash")]
105+
Bash,
106+
#[serde(rename = "powershell", alias = "power_shell")]
107+
PowerShell,
108+
}
109+
110+
/// Parsed but currently unsupported LLM prompt hook definition.
111+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112+
pub struct PromptHookConfig {
113+
pub prompt: String,
114+
#[serde(rename = "if", default, skip_serializing_if = "Option::is_none")]
115+
pub condition: Option<String>,
116+
#[serde(default, skip_serializing_if = "Option::is_none")]
117+
pub timeout: Option<u64>,
118+
#[serde(default, skip_serializing_if = "Option::is_none")]
119+
pub model: Option<String>,
120+
#[serde(
121+
rename = "statusMessage",
122+
alias = "status_message",
123+
default,
124+
skip_serializing_if = "Option::is_none"
125+
)]
126+
pub status_message: Option<String>,
127+
#[serde(default, skip_serializing_if = "Option::is_none")]
128+
pub once: Option<bool>,
129+
}
130+
131+
/// Parsed but currently unsupported agent hook definition.
132+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133+
pub struct AgentHookConfig {
134+
pub prompt: String,
135+
#[serde(rename = "if", default, skip_serializing_if = "Option::is_none")]
136+
pub condition: Option<String>,
137+
#[serde(default, skip_serializing_if = "Option::is_none")]
138+
pub timeout: Option<u64>,
139+
#[serde(default, skip_serializing_if = "Option::is_none")]
140+
pub model: Option<String>,
141+
#[serde(
142+
rename = "statusMessage",
143+
alias = "status_message",
144+
default,
145+
skip_serializing_if = "Option::is_none"
146+
)]
147+
pub status_message: Option<String>,
148+
#[serde(default, skip_serializing_if = "Option::is_none")]
149+
pub once: Option<bool>,
150+
}
151+
152+
/// Parsed but currently unsupported HTTP hook definition.
153+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154+
pub struct HttpHookConfig {
155+
pub url: String,
156+
#[serde(rename = "if", default, skip_serializing_if = "Option::is_none")]
157+
pub condition: Option<String>,
158+
#[serde(default, skip_serializing_if = "Option::is_none")]
159+
pub timeout: Option<u64>,
160+
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
161+
pub headers: BTreeMap<String, String>,
162+
#[serde(
163+
rename = "allowedEnvVars",
164+
default,
165+
skip_serializing_if = "Vec::is_empty"
166+
)]
167+
pub allowed_env_vars: Vec<String>,
168+
#[serde(
169+
rename = "statusMessage",
170+
alias = "status_message",
171+
default,
172+
skip_serializing_if = "Option::is_none"
173+
)]
174+
pub status_message: Option<String>,
175+
#[serde(default, skip_serializing_if = "Option::is_none")]
176+
pub once: Option<bool>,
177+
}

crates/config/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod app;
22
mod error;
33
mod experimental;
4+
mod hooks;
45
mod logging;
56
mod mcp;
67
mod oauth;
@@ -12,6 +13,7 @@ mod tools;
1213
pub use app::*;
1314
pub use error::*;
1415
pub use experimental::*;
16+
pub use hooks::*;
1517
pub use logging::*;
1618
pub use mcp::*;
1719
pub use oauth::*;

0 commit comments

Comments
 (0)