1use std::path::Path;
27
28use anyhow::{Context as _, bail};
29use serde_json::{Value, json};
30
31use crate::context::{self, Binding};
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Event {
37 SessionStart,
40 Heartbeat,
42 Stop,
44 SessionEnd,
46 Status,
48 Call,
55}
56
57pub const HOOK_CALL_TOOLS: &[&str] = &["whoami", "read_messages", "team_digest", "heartbeat"];
62
63impl std::str::FromStr for Event {
64 type Err = anyhow::Error;
65 fn from_str(raw: &str) -> anyhow::Result<Self> {
66 match raw.trim().to_lowercase().replace(['-', ' '], "_").as_str() {
67 "session_start" | "sessionstart" => Ok(Self::SessionStart),
68 "heartbeat" => Ok(Self::Heartbeat),
69 "stop" => Ok(Self::Stop),
70 "session_end" | "sessionend" => Ok(Self::SessionEnd),
71 "status" => Ok(Self::Status),
72 "call" => Ok(Self::Call),
73 other => bail!(
74 "unknown hook event '{other}'; use session_start, heartbeat, stop, \
75 session_end, status or call"
76 ),
77 }
78 }
79}
80
81#[derive(Debug)]
83pub enum Resolution {
84 Authenticated(Box<Binding>),
86 Unauthenticated(Box<Binding>),
90 Missing,
92}
93
94pub fn resolve_binding(config_dir: &Path, binding: &str) -> Resolution {
95 match context::read_binding(config_dir, binding) {
96 None => Resolution::Missing,
97 Some(b) if b.closed_at.is_some() => Resolution::Unauthenticated(Box::new(b)),
101 Some(b) => match b.session_token.as_deref().filter(|t| !t.is_empty()) {
102 Some(_) => Resolution::Authenticated(Box::new(b)),
103 None => Resolution::Unauthenticated(Box::new(b)),
104 },
105 }
106}
107
108fn git_place(dir: &Path) -> (Option<String>, Option<String>) {
111 let run = |args: &[&str]| {
112 std::process::Command::new("git")
113 .args(args)
114 .current_dir(dir)
115 .output()
116 .ok()
117 .filter(|o| o.status.success())
118 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
119 .filter(|s| !s.is_empty())
120 };
121 let repo = run(&["config", "--get", "remote.origin.url"]).map(|url| {
122 let trimmed = url.trim_end_matches(".git");
123 let tail: Vec<&str> = trimmed.rsplit(['/', ':']).take(2).collect();
124 if tail.len() == 2 {
125 format!("{}/{}", tail[1], tail[0])
126 } else {
127 trimmed.to_owned()
128 }
129 });
130 (repo, run(&["branch", "--show-current"]))
131}
132
133async fn call_as_window(binding: &Binding, tool: &str, args: Value) -> anyhow::Result<Value> {
135 let url = binding
136 .mcp_url
137 .as_deref()
138 .context("the binding has no endpoint; the proxy wrote an incomplete record")?;
139 let token = binding
140 .session_token
141 .as_deref()
142 .context("the binding has no credential")?;
143 let session = binding.session.as_deref().unwrap_or_default();
144
145 let mut config =
146 rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
147 url.to_owned(),
148 );
149 config.auth_header = Some(token.to_owned());
150 config.allow_stateless = true;
151 if !session.is_empty() {
152 config
153 .custom_headers
154 .insert(crate::auth::SESSION_HEADER.parse()?, session.parse()?);
155 }
156 if let Some(epoch) = binding.epoch {
159 config.custom_headers.insert(
160 crate::auth::EPOCH_HEADER.parse()?,
161 epoch.to_string().parse()?,
162 );
163 }
164 let transport = rmcp::transport::StreamableHttpClientTransport::from_config(config);
165 let client = {
166 use rmcp::ServiceExt;
167 rmcp::model::ClientConfig::default()
168 .serve(transport)
169 .await
170 .context("could not reach the bus with this window's credential")?
171 };
172 let arguments: rmcp::model::JsonObject =
173 serde_json::from_value(args).context("arguments must be an object")?;
174 let outcome = client
175 .call_tool(
176 rmcp::model::CallToolRequestParams::new(tool.to_owned()).with_arguments(arguments),
177 )
178 .await
179 .map_err(|e| anyhow::anyhow!("{tool}: {e}"));
180 let _ = client.cancel().await;
181 let result = outcome?;
182 if result.is_error == Some(true) {
183 bail!("{tool} returned an error");
184 }
185 Ok(result.structured_content.unwrap_or(Value::Null))
186}
187
188async fn publish_presence(
190 binding: &Binding,
191 cwd: &Path,
192 status: &str,
193 reset_activity: bool,
194 ttl: i64,
195) -> anyhow::Result<()> {
196 let (repo, branch) = git_place(cwd);
197 let mut args = json!({ "status": status, "ttl_seconds": ttl });
198 if let Some(repo) = repo {
199 args["repo"] = Value::String(repo);
200 }
201 if let Some(branch) = branch {
202 args["branch"] = Value::String(branch);
203 }
204 if let Some(project) = binding.project.clone() {
205 args["project"] = Value::String(project);
206 }
207 if let Some(role) = binding.role.clone() {
208 args["role"] = Value::String(role);
209 }
210 if reset_activity {
211 args["activity"] = Value::String(String::new());
212 }
213 call_as_window(binding, "heartbeat", args).await.map(|_| ())
214}
215
216async fn session_start_context(binding: &Binding, digest_hours: i64) -> anyhow::Result<String> {
219 let who = call_as_window(binding, "whoami", json!({})).await?;
220 let agent = who["agent"].as_str().unwrap_or_default();
221 let team = who["team"].as_str().unwrap_or_default();
222 if agent.is_empty() || team.is_empty() {
223 bail!("the bus did not identify this window");
224 }
225 let session = who["session"].as_str().unwrap_or_default();
226
227 let mut lines = vec![format!(
228 "[ai-crew-sync] You are agent '{agent}' on team '{team}'. The team coordination bus \
229 is connected."
230 )];
231 if !session.is_empty() {
232 let mut line = format!(
233 "- This window is session '{session}'; teammates reach it exactly as \
234 '{agent}/{session}'."
235 );
236 if who["session_identity"].is_object() {
237 line.push_str(
238 " Its identity is proven by a session credential, not asserted in a header.",
239 );
240 }
241 lines.push(line);
242 }
243 match (who["project"].as_str(), who["role"].as_str()) {
244 (Some(p), Some(r)) => lines.push(format!(
245 "- Labelled project '{p}', role '{r}'. Teammates find it with list_sessions."
246 )),
247 _ => lines.push(
248 "- This window has no project/role label yet; set one with configure_session so \
249 teammates can find it with list_sessions."
250 .to_owned(),
251 ),
252 }
253 if let Some(channel) = who["default_channel"].as_str() {
254 lines.push(format!(
255 "- Messages with no channel go to #{channel} by default."
256 ));
257 }
258 let unread = who["unread_direct_messages"].as_i64().unwrap_or(0);
259 if unread > 0 {
260 lines.push(format!(
261 "- {unread} unread direct message(s) for this window. Read them with \
262 read_messages before starting work."
263 ));
264 }
265 let claimed = who["open_claimed_tasks"].as_i64().unwrap_or(0);
266 if claimed > 0 {
267 lines.push(format!(
268 "- {claimed} task(s) claimed by this window are still open (list_tasks \
269 mine_only=true)."
270 ));
271 }
272 if let Ok(digest) =
273 call_as_window(binding, "team_digest", json!({ "hours": digest_hours })).await
274 {
275 let mut compact = serde_json::to_string(&digest).unwrap_or_default();
276 if compact.len() > 2500 {
277 compact.truncate(2500);
278 compact.push_str("…(truncated — call team_digest for the whole picture)");
279 }
280 lines.push(format!(
281 "- Team activity, last {digest_hours}h (team_digest): {compact}"
282 ));
283 }
284 lines.push(
285 "- Nothing is pushed into an idle turn: call read_messages or wait_for_updates to \
286 receive what teammates sent. Claim shared work with claim_task before starting it."
287 .to_owned(),
288 );
289 Ok(lines.join("\n"))
290}
291
292pub async fn run(
297 config_dir: &Path,
298 binding_id: &str,
299 event: Event,
300 cwd: &Path,
301 digest_hours: i64,
302 call: Option<(String, Value)>,
303) -> anyhow::Result<Option<String>> {
304 if event == Event::Call
307 && let Some((tool, _)) = &call
308 && !HOOK_CALL_TOOLS.contains(&tool.as_str())
309 {
310 bail!(
311 "'{tool}' is not a hook operation: --event call serves {} only, so a hook can \
312 neither issue, rotate nor revoke a credential, nor act on the bus beyond what \
313 its scripts need",
314 HOOK_CALL_TOOLS.join(", ")
315 );
316 }
317 let resolved = resolve_binding(config_dir, binding_id);
318 if event == Event::Status {
319 let value = match &resolved {
320 Resolution::Authenticated(b) => json!({
321 "binding": binding_id,
322 "state": "authenticated",
323 "agent": b.agent,
324 "team": b.team,
325 "session": b.session,
326 "project": b.project,
327 "role": b.role,
328 "epoch": b.epoch,
329 "expires_at": b.expires_at,
330 "mcp_url": b.mcp_url,
331 }),
332 Resolution::Unauthenticated(b) => json!({
333 "binding": binding_id,
334 "state": "no-credential",
335 "agent": b.agent,
336 "team": b.team,
337 "session": b.session,
338 "closed_at": b.closed_at,
339 }),
340 Resolution::Missing => json!({
341 "binding": binding_id,
342 "state": "missing",
343 "hint": "no proxy of this conversation has registered a session; hooks stay \
344 silent rather than acting as another window",
345 }),
346 };
347 return Ok(Some(serde_json::to_string_pretty(&value)?));
348 }
349
350 let binding = match resolved {
354 Resolution::Authenticated(b) => b,
355 Resolution::Unauthenticated(_) | Resolution::Missing => return Ok(None),
356 };
357
358 match event {
359 Event::Status => unreachable!("handled above"),
360 Event::SessionStart => {
361 let _ = publish_presence(&binding, cwd, "active", true, 900).await;
364 let context = session_start_context(&binding, digest_hours).await?;
365 Ok(Some(
366 json!({
367 "hookSpecificOutput": {
368 "hookEventName": "SessionStart",
369 "additionalContext": context,
370 }
371 })
372 .to_string(),
373 ))
374 }
375 Event::Heartbeat | Event::Stop => {
376 publish_presence(&binding, cwd, "active", false, 900).await?;
377 Ok(None)
378 }
379 Event::SessionEnd => {
380 publish_presence(&binding, cwd, "idle", false, 120).await?;
381 Ok(None)
382 }
383 Event::Call => {
384 let Some((tool, args)) = call else {
385 bail!("--event call needs --tool <name> and, optionally, --args <json object>");
386 };
387 let value = call_as_window(&binding, &tool, args).await?;
388 Ok(Some(value.to_string()))
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn event_names_are_forgiving_but_bounded() {
399 for (raw, expected) in [
400 ("session_start", Event::SessionStart),
401 ("SessionStart", Event::SessionStart),
402 ("session-start", Event::SessionStart),
403 ("heartbeat", Event::Heartbeat),
404 ("Stop", Event::Stop),
405 ("session_end", Event::SessionEnd),
406 ("status", Event::Status),
407 ("call", Event::Call),
408 ] {
409 assert_eq!(raw.parse::<Event>().unwrap(), expected, "{raw}");
410 }
411 let err = "drain".parse::<Event>().unwrap_err().to_string();
412 assert!(err.contains("session_start"), "{err}");
413 }
414
415 #[tokio::test]
416 async fn a_missing_or_closed_binding_is_silent_never_another_window() {
417 let dir = std::env::temp_dir().join(format!("acs-hook-{}", uuid::Uuid::new_v4()));
418 std::fs::create_dir_all(&dir).unwrap();
419 let cwd = dir.clone();
420
421 for event in [Event::SessionStart, Event::Heartbeat, Event::SessionEnd] {
423 assert!(
424 run(&dir, "conv-unknown", event, &cwd, 8, None)
425 .await
426 .unwrap()
427 .is_none(),
428 "{event:?} spoke without a binding"
429 );
430 }
431
432 let path = context::binding_path(&dir, "conv-closed");
434 context::write_binding_file(
435 &path,
436 &json!({"session": "s-1", "agent": "joaquin", "team": "acme",
437 "mcp_url": "http://127.0.0.1:1/mcp", "closed_at": "2026-09-20T00:00:00Z"})
438 .to_string(),
439 )
440 .unwrap();
441 assert!(
442 run(&dir, "conv-closed", Event::Heartbeat, &cwd, 8, None)
443 .await
444 .unwrap()
445 .is_none()
446 );
447
448 let retained = context::binding_path(&dir, "conv-closed-token");
451 context::write_binding_file(
452 &retained,
453 &json!({"session": "s-2", "agent": "joaquin", "team": "acme",
454 "mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_retained",
455 "session_id": "11111111-1111-1111-1111-111111111111", "epoch": 3,
456 "closed_at": "2026-09-20T00:00:00Z"})
457 .to_string(),
458 )
459 .unwrap();
460 assert!(
461 run(&dir, "conv-closed-token", Event::Heartbeat, &cwd, 8, None)
462 .await
463 .unwrap()
464 .is_none(),
465 "a closed binding must not act, even with a credential on disk"
466 );
467 let out = run(&dir, "conv-closed-token", Event::Status, &cwd, 8, None)
468 .await
469 .unwrap()
470 .unwrap();
471 assert!(out.contains("no-credential"), "{out}");
472 assert!(
473 !out.contains("acss_retained"),
474 "status must not print a secret"
475 );
476
477 let live = context::binding_path(&dir, "conv-live");
481 context::write_binding_file(
482 &live,
483 &json!({"session": "s-3", "agent": "joaquin", "team": "acme",
484 "mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_live",
485 "session_id": "22222222-2222-2222-2222-222222222222", "epoch": 1})
486 .to_string(),
487 )
488 .unwrap();
489 for tool in [
490 "resume_session",
491 "register_session",
492 "renew_session",
493 "revoke_session",
494 "recover_conversation_history",
495 "post_message",
496 ] {
497 let call = Some((tool.to_owned(), json!({})));
498 let err = run(&dir, "conv-live", Event::Call, &cwd, 8, call)
499 .await
500 .expect_err(tool)
501 .to_string();
502 assert!(err.contains("not a hook operation"), "{tool}: {err}");
503 assert!(!err.contains("acss_"), "{tool}: {err}");
504 }
505
506 for id in ["conv-unknown", "conv-closed", "conv-closed-token"] {
509 let call = Some(("whoami".to_owned(), json!({})));
510 assert!(
511 run(&dir, id, Event::Call, &cwd, 8, call)
512 .await
513 .unwrap()
514 .is_none(),
515 "call spoke for {id}"
516 );
517 }
518
519 let out = run(&dir, "conv-unknown", Event::Status, &cwd, 8, None)
521 .await
522 .unwrap()
523 .unwrap();
524 assert!(out.contains("\"state\": \"missing\""), "{out}");
525 let out = run(&dir, "conv-closed", Event::Status, &cwd, 8, None)
526 .await
527 .unwrap()
528 .unwrap();
529 assert!(out.contains("no-credential"), "{out}");
530 assert!(
531 !out.contains("session_token"),
532 "status must not print a secret"
533 );
534
535 #[cfg(unix)]
536 {
537 use std::os::unix::fs::PermissionsExt;
538 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
539 assert_eq!(mode, 0o600, "binding files are private");
540 let dir_mode = std::fs::metadata(path.parent().unwrap())
541 .unwrap()
542 .permissions()
543 .mode()
544 & 0o777;
545 assert_eq!(dir_mode, 0o700, "the bindings directory is private");
546 }
547 let _ = std::fs::remove_dir_all(&dir);
548 }
549}