Skip to main content

edict/hooks/
run.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::config::Config;
6use crate::subprocess::run_command;
7
8
9/// Detected runtime context for hooks.
10struct HookContext {
11    /// If in a maw repo, the path containing .manifold
12    maw_root: Option<std::path::PathBuf>,
13    /// If in an edict project, the loaded config
14    edict_config: Option<Config>,
15    /// Agent name from $AGENT or $BOTBUS_AGENT
16    agent: Option<String>,
17}
18
19impl HookContext {
20    fn detect() -> Self {
21        let cwd = std::env::current_dir().unwrap_or_default();
22
23        let agent = std::env::var("AGENT")
24            .or_else(|_| std::env::var("BOTBUS_AGENT"))
25            .ok()
26            .filter(|a| validate_agent_name(a));
27
28        let maw_root = find_ancestor_with(&cwd, ".manifold");
29
30        let edict_config = find_edict_config(&cwd)
31            .and_then(|p| Config::load(&p).ok());
32
33        Self {
34            maw_root,
35            edict_config,
36            agent,
37        }
38    }
39
40    fn channel(&self) -> Option<String> {
41        self.edict_config.as_ref().map(|c| c.channel())
42    }
43}
44
45/// Run session-start hook: maw guidance + agent identity + stake claim
46pub fn run_session_start() -> Result<()> {
47    let ctx = HookContext::detect();
48
49    // 1. Maw repo guidance
50    if ctx.maw_root.is_some() {
51        println!(
52            "This project uses Git + maw for version control. \
53            Source files live in workspaces under ws/, not at the project root. \
54            Use `maw exec <workspace> -- <command>` to run commands. \
55            Run `maw --help` for more info. Do NOT run jj commands."
56        );
57    }
58
59    // 2. Agent identity + project channel (if edict project and agent set)
60    if let Some(ref agent) = ctx.agent {
61        if let Some(ref config) = ctx.edict_config {
62            println!("Agent ID for use with botbus/crit/bn: {agent}");
63            println!("Project channel: {}", config.channel());
64        }
65    }
66
67    // 3. Stake claim (if agent set)
68    if let Some(ref agent) = ctx.agent {
69        stake_claim(agent);
70    }
71
72    Ok(())
73}
74
75/// Run post-tool-call hook: check bus inbox + refresh claim
76pub fn run_post_tool_call(hook_input: Option<&str>) -> Result<()> {
77    let ctx = HookContext::detect();
78
79    let Some(ref agent) = ctx.agent else {
80        return Ok(());
81    };
82
83    // 1. Check bus inbox
84    check_bus_inbox(&ctx, agent, hook_input)?;
85
86    // 2. Refresh claim if expiring
87    refresh_claim_if_needed(agent);
88
89    Ok(())
90}
91
92/// Run session-end hook: release claim + clear status
93pub fn run_session_end() -> Result<()> {
94    let agent = std::env::var("AGENT")
95        .or_else(|_| std::env::var("BOTBUS_AGENT"))
96        .ok()
97        .filter(|a| validate_agent_name(a));
98
99    let Some(agent) = agent else {
100        return Ok(());
101    };
102
103    let claim_uri = format!("agent://{agent}");
104    let _ = run_command(
105        "bus",
106        &["claims", "release", "--agent", &agent, &claim_uri, "-q"],
107        None,
108    );
109    let _ = run_command(
110        "bus",
111        &["statuses", "clear", "--agent", &agent, "-q"],
112        None,
113    );
114
115    Ok(())
116}
117
118// --- Internal helpers ---
119
120fn stake_claim(agent: &str) {
121    let claim_uri = format!("agent://{agent}");
122    let _ = run_command(
123        "bus",
124        &[
125            "claims", "stake", "--agent", agent, &claim_uri, "--ttl", "600", "-q",
126        ],
127        None,
128    );
129}
130
131fn refresh_claim_if_needed(agent: &str) {
132    let claim_uri = format!("agent://{agent}");
133    let refresh_threshold = 120;
134
135    let list_output = run_command(
136        "bus",
137        &[
138            "claims", "list", "--mine", "--agent", agent, "--format", "json",
139        ],
140        None,
141    )
142    .ok();
143
144    if let Some(output) = list_output
145        && let Ok(data) = serde_json::from_str::<serde_json::Value>(&output)
146        && let Some(claims) = data["claims"].as_array()
147    {
148        for claim in claims {
149            if let Some(patterns) = claim["patterns"].as_array()
150                && patterns.iter().any(|p| p.as_str() == Some(&claim_uri))
151                && let Some(expires_in) = claim["expires_in_secs"].as_i64()
152                && expires_in < refresh_threshold
153            {
154                let _ = run_command(
155                    "bus",
156                    &[
157                        "claims", "refresh", "--agent", agent, &claim_uri, "--ttl", "600", "-q",
158                    ],
159                    None,
160                );
161            }
162        }
163    }
164}
165
166fn check_bus_inbox(ctx: &HookContext, agent: &str, _hook_input: Option<&str>) -> Result<()> {
167    let channel = match ctx.channel() {
168        Some(ch) => ch,
169        None => return Ok(()), // No edict project, skip inbox check
170    };
171
172    let agent_flag = format!("--agent={agent}");
173
174    // Check unread count
175    let count_output = run_command(
176        "bus",
177        &[
178            "inbox",
179            &agent_flag,
180            "--count-only",
181            "--mentions",
182            "--channels",
183            &channel,
184        ],
185        None,
186    )
187    .ok();
188
189    let count: u32 = count_output
190        .as_ref()
191        .and_then(|s| s.trim().parse().ok())
192        .unwrap_or(0);
193
194    if count == 0 {
195        return Ok(());
196    }
197
198    // Fetch messages as JSON
199    let inbox_json = run_command(
200        "bus",
201        &[
202            "inbox",
203            &agent_flag,
204            "--mentions",
205            "--channels",
206            &channel,
207            "--limit-per-channel",
208            "5",
209            "--format",
210            "json",
211        ],
212        None,
213    )
214    .unwrap_or_default();
215
216    let messages = parse_inbox_previews(&inbox_json, Some(agent));
217
218    let mark_read_cmd = format!("bus inbox --agent {agent} --mentions --channels {channel} --mark-read");
219
220    let context = format!(
221        "STOP: You have {count} unread bus message(s) in #{channel}. Check if any need a response:\n{messages}\n\nTo read and respond: `{mark_read_cmd}`"
222    );
223
224    let hook_output = serde_json::json!({
225        "hookSpecificOutput": {
226            "hookEventName": "PostToolUse",
227            "additionalContext": context
228        }
229    });
230
231    println!("{}", serde_json::to_string(&hook_output)?);
232
233    Ok(())
234}
235
236/// Walk up from `start` looking for a directory containing `marker`.
237fn find_ancestor_with(start: &Path, marker: &str) -> Option<std::path::PathBuf> {
238    let mut dir = start.to_path_buf();
239    loop {
240        if dir.join(marker).exists() {
241            return Some(dir);
242        }
243        // Also check ws/default/ (bare repo layout)
244        if dir.join("ws/default").join(marker).exists() {
245            return Some(dir);
246        }
247        if !dir.pop() {
248            return None;
249        }
250    }
251}
252
253/// Walk up from `start` looking for an edict/botbox config file.
254/// Returns the config file path if found.
255fn find_edict_config(start: &Path) -> Option<std::path::PathBuf> {
256    // Current name first, then legacy names in order of recency
257    const CONFIG_NAMES: &[&str] = &[".edict.toml", ".botbox.toml", ".botbox.json"];
258    let mut dir = start.to_path_buf();
259    loop {
260        for name in CONFIG_NAMES {
261            let p = dir.join(name);
262            if p.exists() {
263                return Some(p);
264            }
265        }
266        // Also check ws/default/
267        let ws_default = dir.join("ws/default");
268        if ws_default.exists() {
269            for name in CONFIG_NAMES {
270                let p = ws_default.join(name);
271                if p.exists() {
272                    return Some(p);
273                }
274            }
275        }
276        if !dir.pop() {
277            return None;
278        }
279    }
280}
281
282/// Validates an agent name against `[a-z0-9][a-z0-9-/]*`.
283fn validate_agent_name(name: &str) -> bool {
284    !name.is_empty()
285        && name
286            .bytes()
287            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'/')
288        && !name.starts_with('-')
289        && !name.starts_with('/')
290}
291
292fn parse_inbox_previews(inbox_json: &str, agent: Option<&str>) -> String {
293    let data: serde_json::Value = match serde_json::from_str(inbox_json) {
294        Ok(v) => v,
295        Err(_) => return String::new(),
296    };
297
298    let mut previews = Vec::new();
299
300    let messages: Vec<&serde_json::Map<String, serde_json::Value>> =
301        if let Some(arr) = data["mentions"].as_array() {
302            arr.iter()
303                .filter_map(|m| m["message"].as_object())
304                .collect()
305        } else if let Some(arr) = data["messages"].as_array() {
306            arr.iter().filter_map(|m| m.as_object()).collect()
307        } else {
308            Vec::new()
309        };
310
311    for msg in messages {
312        let sender = msg
313            .get("agent")
314            .and_then(|v| v.as_str())
315            .unwrap_or("unknown");
316        let body = msg.get("body").and_then(|v| v.as_str()).unwrap_or("");
317
318        let tag = if let Some(a) = agent {
319            if body.contains(&format!("@{a}")) {
320                "[MENTIONS YOU] "
321            } else {
322                ""
323            }
324        } else {
325            ""
326        };
327
328        let mut preview = format!("{tag}{sender}: {body}");
329        if preview.len() > 100 {
330            preview.truncate(97);
331            preview.push_str("...");
332        }
333
334        previews.push(format!("  - {preview}"));
335    }
336
337    previews.join("\n")
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use std::fs;
344
345    #[test]
346    fn find_ancestor_with_direct() {
347        let tmp = tempfile::tempdir().unwrap();
348        fs::create_dir_all(tmp.path().join(".manifold")).unwrap();
349        let result = find_ancestor_with(tmp.path(), ".manifold");
350        assert_eq!(result, Some(tmp.path().to_path_buf()));
351    }
352
353    #[test]
354    fn find_ancestor_with_ws_default() {
355        let tmp = tempfile::tempdir().unwrap();
356        fs::create_dir_all(tmp.path().join("ws/default/.manifold")).unwrap();
357        let result = find_ancestor_with(tmp.path(), ".manifold");
358        assert_eq!(result, Some(tmp.path().to_path_buf()));
359    }
360
361    #[test]
362    fn find_ancestor_with_not_found() {
363        let tmp = tempfile::tempdir().unwrap();
364        let result = find_ancestor_with(tmp.path(), ".manifold");
365        assert!(result.is_none());
366    }
367
368    #[test]
369    fn find_edict_config_edict_toml_preferred() {
370        let tmp = tempfile::tempdir().unwrap();
371        fs::write(tmp.path().join(".edict.toml"), "").unwrap();
372        fs::write(tmp.path().join(".botbox.toml"), "").unwrap();
373        let result = find_edict_config(tmp.path());
374        assert_eq!(result, Some(tmp.path().join(".edict.toml")));
375    }
376
377    #[test]
378    fn find_edict_config_legacy_toml_accepted() {
379        let tmp = tempfile::tempdir().unwrap();
380        fs::write(tmp.path().join(".botbox.toml"), "").unwrap();
381        let result = find_edict_config(tmp.path());
382        assert_eq!(result, Some(tmp.path().join(".botbox.toml")));
383    }
384
385    #[test]
386    fn find_edict_config_ws_default() {
387        let tmp = tempfile::tempdir().unwrap();
388        fs::create_dir_all(tmp.path().join("ws/default")).unwrap();
389        fs::write(tmp.path().join("ws/default/.edict.toml"), "").unwrap();
390        let result = find_edict_config(tmp.path());
391        assert_eq!(
392            result,
393            Some(tmp.path().join("ws/default/.edict.toml"))
394        );
395    }
396
397    #[test]
398    fn find_edict_config_not_found() {
399        let tmp = tempfile::tempdir().unwrap();
400        let result = find_edict_config(tmp.path());
401        assert!(result.is_none());
402    }
403
404    #[test]
405    fn parse_inbox_previews_empty() {
406        let json = r#"{"mentions":[]}"#;
407        let result = parse_inbox_previews(json, None);
408        assert_eq!(result, "");
409    }
410
411    #[test]
412    fn parse_inbox_previews_with_messages() {
413        let json = r#"{
414            "mentions": [
415                {
416                    "message": {
417                        "agent": "alice",
418                        "body": "Hey @bob, check this out"
419                    }
420                }
421            ]
422        }"#;
423        let result = parse_inbox_previews(json, Some("bob"));
424        assert!(result.contains("[MENTIONS YOU]"));
425        assert!(result.contains("alice"));
426    }
427
428    #[test]
429    fn parse_inbox_previews_truncation() {
430        let long_body = "a".repeat(200);
431        let json = format!(
432            r#"{{"mentions": [{{"message": {{"agent": "sender", "body": "{}"}}}}]}}"#,
433            long_body
434        );
435        let result = parse_inbox_previews(&json, None);
436        assert!(result.len() < 150);
437        assert!(result.ends_with("..."));
438    }
439
440    #[test]
441    fn validate_agent_name_accepts_valid() {
442        assert!(validate_agent_name("botbox-dev"));
443        assert!(validate_agent_name("botbox-dev/worker-1"));
444        assert!(validate_agent_name("a"));
445        assert!(validate_agent_name("agent123"));
446    }
447
448    #[test]
449    fn validate_agent_name_rejects_invalid() {
450        assert!(!validate_agent_name(""));
451        assert!(!validate_agent_name("-starts-dash"));
452        assert!(!validate_agent_name("/starts-slash"));
453        assert!(!validate_agent_name("Has Uppercase"));
454        assert!(!validate_agent_name("has space"));
455        assert!(!validate_agent_name("$(inject)"));
456        assert!(!validate_agent_name("--help"));
457    }
458}