Skip to content

Commit 23b2022

Browse files
committed
feat(core): reactive hook events for CwdChanged and FileChanged (#2487)
Add CwdChanged and FileChanged hook events to Zeph's lifecycle system, providing competitive parity with Claude Code and Goose environment-change hooks. - New `[hooks]` config section with `cwd_changed` and `file_changed` entries - `FileChangeWatcher` in zeph-core uses notify + 500ms debounce; stored in LifecycleState to live for the agent's full lifetime - `SetCwdExecutor` new tool (`set_working_directory`) that calls `std::env::set_current_dir`, making cwd changes intentional and observable - Post-tool-execution cwd poll detects changes and fires `cwd_changed` hooks with ZEPH_OLD_CWD / ZEPH_NEW_CWD env vars - FileChanged events fire hooks with ZEPH_CHANGED_PATH / ZEPH_CHANGE_KIND - Hook execution reuses SEC-H-002 env-cleared shell pattern - `with_hooks_config` wired in all three entry points: runner, acp, daemon
1 parent 464ae8b commit 23b2022

File tree

18 files changed

+616
-6
lines changed

18 files changed

+616
-6
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4444
- feat(memory): `AsyncMemoryRouter` trait now implemented for `HeuristicRouter` and `HybridRouter`; `SemanticMemory::recall_routed_async()` added to dispatch routing via the async path; `parse_route_str` is now public
4545
- feat(mcp): MCP Elicitation support — MCP servers can now request structured user input mid-task via the `elicitation/create` protocol method; requests are routed to the active channel (CLI prompts interactively, non-interactive channels auto-decline); CLI channel renders a phishing-prevention header showing the requesting server name before prompting; `ElicitationSchema` properties are mapped to typed `ElicitationField` entries (string, integer, number, boolean, enum); URL elicitation variant deferred to a future phase — auto-declined with a log message (closes #2486)
4646
- feat(config): `[mcp] elicitation_enabled` (default `false`) and `elicitation_timeout` (default 120 s) global config options; per-server `elicitation_enabled` override (`Option<bool>`) in `[[mcp.servers]]` entries — `null`/absent inherits the global flag; `Sandboxed` trust-level servers are never allowed to elicit regardless of config
47+
- hooks: reactive `CwdChanged` and `FileChanged` hook events (#2487)
48+
- new `[hooks]` config section with `[[hooks.cwd_changed]]` and `[hooks.file_changed]` subsections
49+
- new `set_working_directory` tool — explicit, persistent cwd change detected post-tool and fires `CwdChanged` hooks with `ZEPH_OLD_CWD`/`ZEPH_NEW_CWD` env vars
50+
- `FileChangeWatcher` monitors configured `watch_paths` via `notify-debouncer-mini` (debounced 500ms by default) and fires hooks with `ZEPH_CHANGED_PATH`
51+
- TUI spinner status emitted on both event types
4752

4853
### Removed
4954

crates/zeph-config/src/hooks.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2+
// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+
use std::path::PathBuf;
5+
6+
use serde::{Deserialize, Serialize};
7+
8+
use crate::subagent::HookDef;
9+
10+
fn default_debounce_ms() -> u64 {
11+
500
12+
}
13+
14+
/// Configuration for hooks triggered when watched files change.
15+
#[derive(Debug, Clone, Deserialize, Serialize)]
16+
#[serde(default)]
17+
pub struct FileChangedConfig {
18+
/// Paths to watch for changes. Resolved relative to the project root (cwd at startup).
19+
pub watch_paths: Vec<PathBuf>,
20+
/// Debounce interval in milliseconds. Default: 500.
21+
#[serde(default = "default_debounce_ms")]
22+
pub debounce_ms: u64,
23+
/// Hooks fired when a watched file changes.
24+
#[serde(default)]
25+
pub hooks: Vec<HookDef>,
26+
}
27+
28+
impl Default for FileChangedConfig {
29+
fn default() -> Self {
30+
Self {
31+
watch_paths: Vec::new(),
32+
debounce_ms: default_debounce_ms(),
33+
hooks: Vec::new(),
34+
}
35+
}
36+
}
37+
38+
/// Top-level hooks configuration section.
39+
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40+
#[serde(default)]
41+
pub struct HooksConfig {
42+
/// Hooks fired when the agent's working directory changes via `set_working_directory`.
43+
pub cwd_changed: Vec<HookDef>,
44+
/// File-change watcher configuration with associated hooks.
45+
pub file_changed: Option<FileChangedConfig>,
46+
}
47+
48+
impl HooksConfig {
49+
#[must_use]
50+
pub fn is_empty(&self) -> bool {
51+
self.cwd_changed.is_empty() && self.file_changed.is_none()
52+
}
53+
}
54+
55+
#[cfg(test)]
56+
mod tests {
57+
use super::*;
58+
use crate::subagent::HookType;
59+
60+
#[test]
61+
fn hooks_config_default_is_empty() {
62+
let cfg = HooksConfig::default();
63+
assert!(cfg.is_empty());
64+
}
65+
66+
#[test]
67+
fn file_changed_config_default_debounce() {
68+
let cfg = FileChangedConfig::default();
69+
assert_eq!(cfg.debounce_ms, 500);
70+
assert!(cfg.watch_paths.is_empty());
71+
assert!(cfg.hooks.is_empty());
72+
}
73+
74+
#[test]
75+
fn hooks_config_parses_from_toml() {
76+
let toml = r#"
77+
[[cwd_changed]]
78+
type = "command"
79+
command = "echo changed"
80+
timeout_secs = 10
81+
fail_closed = false
82+
83+
[file_changed]
84+
watch_paths = ["src/", "Cargo.toml"]
85+
debounce_ms = 300
86+
[[file_changed.hooks]]
87+
type = "command"
88+
command = "cargo check"
89+
timeout_secs = 30
90+
fail_closed = false
91+
"#;
92+
let cfg: HooksConfig = toml::from_str(toml).unwrap();
93+
assert_eq!(cfg.cwd_changed.len(), 1);
94+
assert_eq!(cfg.cwd_changed[0].command, "echo changed");
95+
assert_eq!(cfg.cwd_changed[0].hook_type, HookType::Command);
96+
let fc = cfg.file_changed.as_ref().unwrap();
97+
assert_eq!(fc.watch_paths.len(), 2);
98+
assert_eq!(fc.debounce_ms, 300);
99+
assert_eq!(fc.hooks.len(), 1);
100+
assert_eq!(fc.hooks[0].command, "cargo check");
101+
}
102+
103+
#[test]
104+
fn hooks_config_not_empty_with_cwd_hooks() {
105+
let cfg = HooksConfig {
106+
cwd_changed: vec![HookDef {
107+
hook_type: HookType::Command,
108+
command: "echo hi".into(),
109+
timeout_secs: 10,
110+
fail_closed: false,
111+
}],
112+
file_changed: None,
113+
};
114+
assert!(!cfg.is_empty());
115+
}
116+
}

crates/zeph-config/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod env;
1616
pub mod error;
1717
pub mod experiment;
1818
pub mod features;
19+
pub mod hooks;
1920
pub mod learning;
2021
mod loader;
2122
pub mod logging;
@@ -50,6 +51,7 @@ pub use features::{
5051
ScheduledTaskConfig, ScheduledTaskKind, SchedulerConfig, SkillPromptMode, SkillsConfig,
5152
TraceConfig, VaultConfig,
5253
};
54+
pub use hooks::{FileChangedConfig, HooksConfig};
5355
pub use learning::{DetectorMode, LearningConfig};
5456
pub use logging::{LogRotation, LoggingConfig};
5557
pub use memory::{

crates/zeph-config/src/root.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use crate::features::{
1616
CostConfig, DaemonConfig, DebugConfig, GatewayConfig, IndexConfig, ObservabilityConfig,
1717
SchedulerConfig, SkillPromptMode, SkillsConfig, VaultConfig,
1818
};
19+
use crate::hooks::HooksConfig;
1920
use crate::learning::LearningConfig;
2021
use crate::logging::LoggingConfig;
2122
use crate::memory::{
@@ -90,6 +91,8 @@ pub struct Config {
9091
pub debug: DebugConfig,
9192
#[serde(default)]
9293
pub logging: LoggingConfig,
94+
#[serde(default)]
95+
pub hooks: HooksConfig,
9396
#[cfg(feature = "lsp-context")]
9497
#[serde(default)]
9598
pub lsp: LspConfig,
@@ -233,6 +236,7 @@ impl Default for Config {
233236
logging: LoggingConfig::default(),
234237
#[cfg(feature = "lsp-context")]
235238
lsp: LspConfig::default(),
239+
hooks: HooksConfig::default(),
236240
secrets: ResolvedSecrets::default(),
237241
}
238242
}

crates/zeph-core/src/agent/builder.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,56 @@ impl<C: Channel> Agent<C> {
10241024
self
10251025
}
10261026

1027+
/// Configure reactive hook events from the `[hooks]` config section.
1028+
///
1029+
/// Stores hook definitions in `SessionState` and starts a `FileChangeWatcher`
1030+
/// when `file_changed.watch_paths` is non-empty. Initializes `last_known_cwd`
1031+
/// from the current process cwd at call time (the project root).
1032+
#[must_use]
1033+
pub fn with_hooks_config(mut self, config: &zeph_config::HooksConfig) -> Self {
1034+
self.session
1035+
.hooks_config
1036+
.cwd_changed
1037+
.clone_from(&config.cwd_changed);
1038+
1039+
if let Some(ref fc) = config.file_changed {
1040+
self.session
1041+
.hooks_config
1042+
.file_changed_hooks
1043+
.clone_from(&fc.hooks);
1044+
1045+
if !fc.watch_paths.is_empty() {
1046+
let (tx, rx) = tokio::sync::mpsc::channel(64);
1047+
match crate::file_watcher::FileChangeWatcher::start(
1048+
&fc.watch_paths,
1049+
fc.debounce_ms,
1050+
tx,
1051+
) {
1052+
Ok(watcher) => {
1053+
self.lifecycle.file_watcher = Some(watcher);
1054+
self.lifecycle.file_changed_rx = Some(rx);
1055+
tracing::info!(
1056+
paths = ?fc.watch_paths,
1057+
debounce_ms = fc.debounce_ms,
1058+
"file change watcher started"
1059+
);
1060+
}
1061+
Err(e) => {
1062+
tracing::warn!(error = %e, "failed to start file change watcher");
1063+
}
1064+
}
1065+
}
1066+
}
1067+
1068+
// Sync last_known_cwd with env_context.working_dir if already set.
1069+
let cwd_str = &self.session.env_context.working_dir;
1070+
if !cwd_str.is_empty() {
1071+
self.lifecycle.last_known_cwd = std::path::PathBuf::from(cwd_str);
1072+
}
1073+
1074+
self
1075+
}
1076+
10271077
#[must_use]
10281078
pub fn with_warmup_ready(mut self, rx: watch::Receiver<bool>) -> Self {
10291079
self.lifecycle.warmup_ready = Some(rx);

crates/zeph-core/src/agent/mod.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ impl<C: Channel> Agent<C> {
422422
lsp_hooks: None,
423423
#[cfg(feature = "policy-enforcer")]
424424
policy_config: None,
425+
hooks_config: state::HooksConfigSnapshot::default(),
425426
},
426427
instructions: InstructionState {
427428
blocks: Vec::new(),
@@ -491,6 +492,9 @@ impl<C: Channel> Agent<C> {
491492
warmup_ready: None,
492493
update_notify_rx: None,
493494
custom_task_rx: None,
495+
last_known_cwd: std::env::current_dir().unwrap_or_default(),
496+
file_changed_rx: None,
497+
file_watcher: None,
494498
},
495499
providers: ProviderState {
496500
summary_provider: None,
@@ -2629,6 +2633,10 @@ impl<C: Channel> Agent<C> {
26292633
let text = format!("{SCHEDULED_TASK_PREFIX}{prompt}");
26302634
Some(crate::channel::ChannelMessage { text, attachments: Vec::new() })
26312635
}
2636+
Some(event) = recv_optional(&mut self.lifecycle.file_changed_rx) => {
2637+
self.handle_file_changed(event).await;
2638+
continue;
2639+
}
26322640
};
26332641
let Some(msg) = incoming else { break };
26342642
self.drain_channel();
@@ -5094,6 +5102,76 @@ impl<C: Channel> Agent<C> {
50945102
let _ = tx.send("SideQuest: scoring tool outputs...".into());
50955103
}
50965104
}
5105+
5106+
/// Check if the process cwd has changed since last call and fire `CwdChanged` hooks.
5107+
///
5108+
/// Called after each tool batch completes. The check is a single syscall and has
5109+
/// negligible cost. Only fires when cwd actually changed (defense-in-depth: normally
5110+
/// only `set_working_directory` changes cwd; shell child processes cannot affect it).
5111+
pub(crate) async fn check_cwd_changed(&mut self) {
5112+
let current = match std::env::current_dir() {
5113+
Ok(p) => p,
5114+
Err(e) => {
5115+
tracing::warn!("check_cwd_changed: failed to get cwd: {e}");
5116+
return;
5117+
}
5118+
};
5119+
if current == self.lifecycle.last_known_cwd {
5120+
return;
5121+
}
5122+
let old_cwd = std::mem::replace(&mut self.lifecycle.last_known_cwd, current.clone());
5123+
self.session.env_context.working_dir = current.display().to_string();
5124+
5125+
tracing::info!(
5126+
old = %old_cwd.display(),
5127+
new = %current.display(),
5128+
"working directory changed"
5129+
);
5130+
5131+
let _ = self
5132+
.channel
5133+
.send_status("Working directory changed\u{2026}")
5134+
.await;
5135+
5136+
let hooks = self.session.hooks_config.cwd_changed.clone();
5137+
if !hooks.is_empty() {
5138+
let mut env = std::collections::HashMap::new();
5139+
env.insert("ZEPH_OLD_CWD".to_owned(), old_cwd.display().to_string());
5140+
env.insert("ZEPH_NEW_CWD".to_owned(), current.display().to_string());
5141+
if let Err(e) = zeph_subagent::hooks::fire_hooks(&hooks, &env).await {
5142+
tracing::warn!(error = %e, "CwdChanged hook failed");
5143+
}
5144+
}
5145+
5146+
let _ = self.channel.send_status("").await;
5147+
}
5148+
5149+
/// Handle a `FileChangedEvent` from the file watcher.
5150+
pub(crate) async fn handle_file_changed(
5151+
&mut self,
5152+
event: crate::file_watcher::FileChangedEvent,
5153+
) {
5154+
tracing::info!(path = %event.path.display(), "file changed");
5155+
5156+
let _ = self
5157+
.channel
5158+
.send_status("Running file-change hook\u{2026}")
5159+
.await;
5160+
5161+
let hooks = self.session.hooks_config.file_changed_hooks.clone();
5162+
if !hooks.is_empty() {
5163+
let mut env = std::collections::HashMap::new();
5164+
env.insert(
5165+
"ZEPH_CHANGED_PATH".to_owned(),
5166+
event.path.display().to_string(),
5167+
);
5168+
if let Err(e) = zeph_subagent::hooks::fire_hooks(&hooks, &env).await {
5169+
tracing::warn!(error = %e, "FileChanged hook failed");
5170+
}
5171+
}
5172+
5173+
let _ = self.channel.send_status("").await;
5174+
}
50975175
}
50985176
pub(crate) async fn shutdown_signal(rx: &mut watch::Receiver<bool>) {
50995177
while !*rx.borrow_and_update() {

crates/zeph-core/src/agent/state/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ use crate::config::{ProviderEntry, SecurityConfig, SkillPromptMode, TimeoutConfi
2121
use crate::config_watcher::ConfigEvent;
2222
use crate::context::EnvironmentContext;
2323
use crate::cost::CostTracker;
24+
use crate::file_watcher::FileChangedEvent;
2425
use crate::instructions::{InstructionBlock, InstructionEvent, InstructionReloadState};
2526
use crate::metrics::MetricsSnapshot;
2627
use crate::vault::Secret;
28+
use zeph_config;
2729
use zeph_memory::TokenCounter;
2830
use zeph_memory::semantic::SemanticMemory;
2931
use zeph_sanitizer::ContentSanitizer;
@@ -277,6 +279,12 @@ pub(crate) struct LifecycleState {
277279
pub(crate) warmup_ready: Option<watch::Receiver<bool>>,
278280
pub(crate) update_notify_rx: Option<mpsc::Receiver<String>>,
279281
pub(crate) custom_task_rx: Option<mpsc::Receiver<String>>,
282+
/// Last known process cwd. Compared after each tool call to detect changes.
283+
pub(crate) last_known_cwd: PathBuf,
284+
/// Receiver for file-change events from `FileChangeWatcher`. `None` when no paths configured.
285+
pub(crate) file_changed_rx: Option<mpsc::Receiver<FileChangedEvent>>,
286+
/// Keeps the `FileChangeWatcher` alive for the agent's lifetime. Dropping it aborts the watcher task.
287+
pub(crate) file_watcher: Option<crate::file_watcher::FileChangeWatcher>,
280288
}
281289

282290
/// Minimal config snapshot needed to reconstruct a provider at runtime via `/provider <name>`.
@@ -437,6 +445,17 @@ pub(crate) struct SessionState {
437445
/// Snapshot of the policy config for `/policy` command inspection.
438446
#[cfg(feature = "policy-enforcer")]
439447
pub(crate) policy_config: Option<zeph_tools::PolicyConfig>,
448+
/// `CwdChanged` hook definitions extracted from `[hooks]` config.
449+
pub(crate) hooks_config: HooksConfigSnapshot,
450+
}
451+
452+
/// Extracted hook lists from `[hooks]` config, stored in `SessionState`.
453+
#[derive(Default)]
454+
pub(crate) struct HooksConfigSnapshot {
455+
/// Hooks fired when working directory changes.
456+
pub(crate) cwd_changed: Vec<zeph_config::HookDef>,
457+
/// Hooks fired when a watched file changes.
458+
pub(crate) file_changed_hooks: Vec<zeph_config::HookDef>,
440459
}
441460

442461
// Groups message buffering and image staging state.

crates/zeph-core/src/agent/state/tests.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ use std::collections::VecDeque;
1313
use crate::agent::feedback_detector::FeedbackDetector;
1414
use crate::agent::rate_limiter::{RateLimitConfig, ToolRateLimiter};
1515
use crate::agent::state::{
16-
ExperimentState, FeedbackState, InstructionState, MessageState, RuntimeConfig, SessionState,
16+
ExperimentState, FeedbackState, HooksConfigSnapshot, InstructionState, MessageState,
17+
RuntimeConfig, SessionState,
1718
};
1819
use crate::config::{SecurityConfig, TimeoutConfig};
1920
use crate::context::EnvironmentContext;
@@ -71,6 +72,7 @@ fn make_session_state() -> SessionState {
7172
lsp_hooks: None,
7273
#[cfg(feature = "policy-enforcer")]
7374
policy_config: None,
75+
hooks_config: HooksConfigSnapshot::default(),
7476
}
7577
}
7678

0 commit comments

Comments
 (0)