1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::config::Config;
6use crate::subprocess::run_command;
7
8
9struct HookContext {
11 maw_root: Option<std::path::PathBuf>,
13 edict_config: Option<Config>,
15 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
45pub fn run_session_start() -> Result<()> {
47 let ctx = HookContext::detect();
48
49 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 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 if let Some(ref agent) = ctx.agent {
69 stake_claim(agent);
70 }
71
72 Ok(())
73}
74
75pub 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 check_bus_inbox(&ctx, agent, hook_input)?;
85
86 refresh_claim_if_needed(agent);
88
89 Ok(())
90}
91
92pub 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
118fn 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(()), };
171
172 let agent_flag = format!("--agent={agent}");
173
174 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 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
236fn 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 if dir.join("ws/default").join(marker).exists() {
245 return Some(dir);
246 }
247 if !dir.pop() {
248 return None;
249 }
250 }
251}
252
253fn find_edict_config(start: &Path) -> Option<std::path::PathBuf> {
256 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 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
282fn 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}