Skip to main content

gate4agent_adapters/
managed_hook.rs

1use crate::{builtin_adapter_registry, MANAGED_HOOK_REVISION};
2use gate4agent_types::{AdapterBinding, AdapterFamily};
3use thiserror::Error;
4
5pub const MANAGED_HOOK_TIMEOUT_SECONDS: u64 = 10;
6pub const MANAGED_HOOK_TIMEOUT_MILLISECONDS: u64 = MANAGED_HOOK_TIMEOUT_SECONDS * 1_000;
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum ManagedHookConfigLocation {
10    HomeRelative(&'static str),
11    EnvironmentHome {
12        variable: &'static str,
13        fallback: &'static str,
14        suffix: &'static str,
15    },
16    AppDataOrHome {
17        app_data_suffix: &'static str,
18        home_fallback: &'static str,
19    },
20    RuntimeDataRelative(&'static str),
21}
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum ManagedHookConfigKind {
25    JsonHooks {
26        container: &'static str,
27        require_version_one: bool,
28    },
29    AmpPlugin,
30    KimiToml,
31}
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum ManagedHookEventShape {
35    NestedCommand {
36        matcher: Option<&'static str>,
37        timeout: u64,
38    },
39    DirectCommand {
40        timeout: u64,
41    },
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub struct ManagedHookEventSpec {
46    pub name: &'static str,
47    pub shape: ManagedHookEventShape,
48    pub passes_event_name: bool,
49}
50
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub struct ManagedHookAdapterSpec {
53    pub target: &'static str,
54    pub source_adapter: &'static str,
55    pub config_location: ManagedHookConfigLocation,
56    pub config_kind: ManagedHookConfigKind,
57    pub script_stem: &'static str,
58    pub events: &'static [ManagedHookEventSpec],
59}
60
61const fn nested(name: &'static str, matcher: Option<&'static str>) -> ManagedHookEventSpec {
62    ManagedHookEventSpec {
63        name,
64        shape: ManagedHookEventShape::NestedCommand {
65            matcher,
66            timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
67        },
68        passes_event_name: false,
69    }
70}
71
72const fn direct(name: &'static str, passes_event_name: bool) -> ManagedHookEventSpec {
73    ManagedHookEventSpec {
74        name,
75        shape: ManagedHookEventShape::DirectCommand {
76            timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
77        },
78        passes_event_name,
79    }
80}
81
82const CLAUDE_EVENTS: &[ManagedHookEventSpec] = &[
83    // The only event here that fires without the agent doing any work, and
84    // the reason it matters: every other event on this list needs a real
85    // turn, a tool call or a subagent to exist first, so a freshly spawned
86    // session that is merely SITTING there produces nothing at all. That is
87    // exactly the condition the node most needs a provider-authored answer
88    // for -- "did the agent actually come up?" -- and without this event we
89    // could observe the channel for Claude only by spending a model turn.
90    // Grok's contract carries it and Grok is the one provider whose channel
91    // has been proven end to end; Codex carries it too. Claude supports it
92    // (the operator's own hand-written hook sits on this event in the same
93    // settings file) and we simply never asked for it.
94    nested("SessionStart", None),
95    nested("UserPromptSubmit", None),
96    nested("Stop", None),
97    nested("StopFailure", None),
98    nested("SubagentStart", None),
99    nested("SubagentStop", None),
100    nested("TeammateIdle", None),
101    nested("PreToolUse", Some("*")),
102    nested("PostToolUse", Some("*")),
103    nested("PostToolUseFailure", Some("*")),
104    nested("PermissionRequest", Some("*")),
105];
106
107const CODEX_EVENTS: &[ManagedHookEventSpec] = &[
108    nested("SessionStart", None),
109    nested("UserPromptSubmit", None),
110    nested("PreToolUse", None),
111    nested("PermissionRequest", None),
112    nested("PostToolUse", None),
113    nested("Stop", None),
114];
115
116const GROK_EVENTS: &[ManagedHookEventSpec] = &[
117    nested("SessionStart", None),
118    nested("UserPromptSubmit", None),
119    nested("Stop", None),
120    nested("StopFailure", None),
121    nested("SessionEnd", None),
122    nested("PreToolUse", Some(".*")),
123    nested("PostToolUse", Some(".*")),
124    nested("PostToolUseFailure", Some(".*")),
125    nested("Notification", None),
126];
127
128const KIMI_EVENTS: &[ManagedHookEventSpec] = &[
129    // Read off Kimi's own shipped bundle rather than assumed: its dist
130    // carries SessionStart, SessionEnd and Notification alongside the
131    // events we already request, at the same frequency, so the contract we
132    // were writing was simply short. SessionStart is the one that matters
133    // most -- it is the only event here that fires without the agent doing
134    // any work, which is what lets the node learn "the agent came up"
135    // without spending a model turn to find out.
136    direct("SessionStart", false),
137    direct("SessionEnd", false),
138    direct("Notification", false),
139    direct("UserPromptSubmit", false),
140    direct("PreToolUse", false),
141    direct("PostToolUse", false),
142    direct("PostToolUseFailure", false),
143    direct("PermissionRequest", false),
144    direct("Stop", false),
145    direct("StopFailure", false),
146];
147
148const SPECS: &[ManagedHookAdapterSpec] = &[
149    json(
150        "claude",
151        "claude-code",
152        ".claude/settings.json",
153        "claude-hook",
154        CLAUDE_EVENTS,
155        false,
156    ),
157    ManagedHookAdapterSpec {
158        target: "codex",
159        source_adapter: "codex",
160        // Gate4Agent does not own a shadow Codex home. The explicit manager
161        // edits the provider's normal hooks.json while preserving its login.
162        config_location: ManagedHookConfigLocation::HomeRelative(".codex/hooks.json"),
163        config_kind: ManagedHookConfigKind::JsonHooks {
164            container: "hooks",
165            require_version_one: false,
166        },
167        script_stem: "codex-hook",
168        events: CODEX_EVENTS,
169    },
170    ManagedHookAdapterSpec {
171        target: "grok",
172        source_adapter: "grok",
173        config_location: ManagedHookConfigLocation::EnvironmentHome {
174            variable: "GROK_HOME",
175            fallback: ".grok",
176            suffix: "hooks/gate4agent-status.json",
177        },
178        config_kind: ManagedHookConfigKind::JsonHooks {
179            container: "hooks",
180            require_version_one: false,
181        },
182        script_stem: "grok-hook",
183        events: GROK_EVENTS,
184    },
185    ManagedHookAdapterSpec {
186        target: "kimi",
187        source_adapter: "kimi",
188        config_location: ManagedHookConfigLocation::EnvironmentHome {
189            variable: "KIMI_CODE_HOME",
190            fallback: ".kimi-code",
191            suffix: "config.toml",
192        },
193        config_kind: ManagedHookConfigKind::KimiToml,
194        script_stem: "kimi-hook",
195        events: KIMI_EVENTS,
196    },
197];
198
199const fn json(
200    target: &'static str,
201    source_adapter: &'static str,
202    path: &'static str,
203    script_stem: &'static str,
204    events: &'static [ManagedHookEventSpec],
205    require_version_one: bool,
206) -> ManagedHookAdapterSpec {
207    ManagedHookAdapterSpec {
208        target,
209        source_adapter,
210        config_location: ManagedHookConfigLocation::HomeRelative(path),
211        config_kind: ManagedHookConfigKind::JsonHooks {
212            container: "hooks",
213            require_version_one,
214        },
215        script_stem,
216        events,
217    }
218}
219
220pub fn managed_hook_specs() -> &'static [ManagedHookAdapterSpec] {
221    SPECS
222}
223
224pub fn managed_hook_spec(
225    binding: &AdapterBinding,
226) -> Result<&'static ManagedHookAdapterSpec, ManagedHookAdapterError> {
227    if binding.revision != MANAGED_HOOK_REVISION {
228        return Err(ManagedHookAdapterError::RevisionMismatch {
229            requested: binding.revision.clone(),
230        });
231    }
232    let registered = builtin_adapter_registry()
233        .binding(AdapterFamily::ManagedHook, binding.id.as_str())
234        .is_some_and(|registered| registered == binding);
235    if !registered {
236        return Err(ManagedHookAdapterError::UnsupportedTarget(
237            binding.id.as_str().to_owned(),
238        ));
239    }
240    SPECS
241        .iter()
242        .find(|spec| spec.target == binding.id.as_str())
243        .ok_or_else(|| ManagedHookAdapterError::UnsupportedTarget(binding.id.as_str().to_owned()))
244}
245
246#[derive(Clone, Debug, Error, Eq, PartialEq)]
247pub enum ManagedHookAdapterError {
248    #[error("managed Hook adapter target is unsupported: {0}")]
249    UnsupportedTarget(String),
250    #[error("managed Hook adapter revision mismatch: requested {requested}")]
251    RevisionMismatch { requested: String },
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use std::collections::BTreeSet;
258
259    #[test]
260    fn inventory_matches_the_fleet_managed_controls() {
261        let actual = managed_hook_specs()
262            .iter()
263            .map(|spec| spec.target)
264            .collect::<BTreeSet<_>>();
265        let expected = ["claude", "codex", "grok", "kimi"]
266            .into_iter()
267            .collect::<BTreeSet<_>>();
268        assert_eq!(actual, expected);
269    }
270
271    #[test]
272    fn registry_and_specs_are_revision_exact() {
273        for spec in managed_hook_specs() {
274            let binding = builtin_adapter_registry()
275                .binding(AdapterFamily::ManagedHook, spec.target)
276                .unwrap();
277            assert_eq!(managed_hook_spec(binding).unwrap(), spec);
278        }
279    }
280}