use std::path::Path;
use anyhow::{Context as _, bail};
use serde_json::{Value, json};
use crate::context::{self, Binding};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Event {
SessionStart,
Heartbeat,
Stop,
SessionEnd,
Status,
Call,
}
pub const HOOK_CALL_TOOLS: &[&str] = &["whoami", "read_messages", "team_digest", "heartbeat"];
impl std::str::FromStr for Event {
type Err = anyhow::Error;
fn from_str(raw: &str) -> anyhow::Result<Self> {
match raw.trim().to_lowercase().replace(['-', ' '], "_").as_str() {
"session_start" | "sessionstart" => Ok(Self::SessionStart),
"heartbeat" => Ok(Self::Heartbeat),
"stop" => Ok(Self::Stop),
"session_end" | "sessionend" => Ok(Self::SessionEnd),
"status" => Ok(Self::Status),
"call" => Ok(Self::Call),
other => bail!(
"unknown hook event '{other}'; use session_start, heartbeat, stop, \
session_end, status or call"
),
}
}
}
#[derive(Debug)]
pub enum Resolution {
Authenticated(Box<Binding>),
Unauthenticated(Box<Binding>),
Missing,
}
pub fn resolve_binding(config_dir: &Path, binding: &str) -> Resolution {
match context::read_binding(config_dir, binding) {
None => Resolution::Missing,
Some(b) if b.closed_at.is_some() => Resolution::Unauthenticated(Box::new(b)),
Some(b) => match b.session_token.as_deref().filter(|t| !t.is_empty()) {
Some(_) => Resolution::Authenticated(Box::new(b)),
None => Resolution::Unauthenticated(Box::new(b)),
},
}
}
fn git_place(dir: &Path) -> (Option<String>, Option<String>) {
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
.filter(|s| !s.is_empty())
};
let repo = run(&["config", "--get", "remote.origin.url"]).map(|url| {
let trimmed = url.trim_end_matches(".git");
let tail: Vec<&str> = trimmed.rsplit(['/', ':']).take(2).collect();
if tail.len() == 2 {
format!("{}/{}", tail[1], tail[0])
} else {
trimmed.to_owned()
}
});
(repo, run(&["branch", "--show-current"]))
}
async fn call_as_window(binding: &Binding, tool: &str, args: Value) -> anyhow::Result<Value> {
let url = binding
.mcp_url
.as_deref()
.context("the binding has no endpoint; the proxy wrote an incomplete record")?;
let token = binding
.session_token
.as_deref()
.context("the binding has no credential")?;
let session = binding.session.as_deref().unwrap_or_default();
let mut config =
rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
url.to_owned(),
);
config.auth_header = Some(token.to_owned());
config.allow_stateless = true;
if !session.is_empty() {
config
.custom_headers
.insert(crate::auth::SESSION_HEADER.parse()?, session.parse()?);
}
if let Some(epoch) = binding.epoch {
config.custom_headers.insert(
crate::auth::EPOCH_HEADER.parse()?,
epoch.to_string().parse()?,
);
}
let transport = rmcp::transport::StreamableHttpClientTransport::from_config(config);
let client = {
use rmcp::ServiceExt;
rmcp::model::ClientConfig::default()
.serve(transport)
.await
.context("could not reach the bus with this window's credential")?
};
let arguments: rmcp::model::JsonObject =
serde_json::from_value(args).context("arguments must be an object")?;
let outcome = client
.call_tool(
rmcp::model::CallToolRequestParams::new(tool.to_owned()).with_arguments(arguments),
)
.await
.map_err(|e| anyhow::anyhow!("{tool}: {e}"));
let _ = client.cancel().await;
let result = outcome?;
if result.is_error == Some(true) {
bail!("{tool} returned an error");
}
Ok(result.structured_content.unwrap_or(Value::Null))
}
async fn publish_presence(
binding: &Binding,
cwd: &Path,
status: &str,
reset_activity: bool,
ttl: i64,
) -> anyhow::Result<()> {
let (repo, branch) = git_place(cwd);
let mut args = json!({ "status": status, "ttl_seconds": ttl });
if let Some(repo) = repo {
args["repo"] = Value::String(repo);
}
if let Some(branch) = branch {
args["branch"] = Value::String(branch);
}
if let Some(project) = binding.project.clone() {
args["project"] = Value::String(project);
}
if let Some(role) = binding.role.clone() {
args["role"] = Value::String(role);
}
if reset_activity {
args["activity"] = Value::String(String::new());
}
call_as_window(binding, "heartbeat", args).await.map(|_| ())
}
async fn session_start_context(binding: &Binding, digest_hours: i64) -> anyhow::Result<String> {
let who = call_as_window(binding, "whoami", json!({})).await?;
let agent = who["agent"].as_str().unwrap_or_default();
let team = who["team"].as_str().unwrap_or_default();
if agent.is_empty() || team.is_empty() {
bail!("the bus did not identify this window");
}
let session = who["session"].as_str().unwrap_or_default();
let mut lines = vec![format!(
"[ai-crew-sync] You are agent '{agent}' on team '{team}'. The team coordination bus \
is connected."
)];
if !session.is_empty() {
let mut line = format!(
"- This window is session '{session}'; teammates reach it exactly as \
'{agent}/{session}'."
);
if who["session_identity"].is_object() {
line.push_str(
" Its identity is proven by a session credential, not asserted in a header.",
);
}
lines.push(line);
}
match (who["project"].as_str(), who["role"].as_str()) {
(Some(p), Some(r)) => lines.push(format!(
"- Labelled project '{p}', role '{r}'. Teammates find it with list_sessions."
)),
_ => lines.push(
"- This window has no project/role label yet; set one with configure_session so \
teammates can find it with list_sessions."
.to_owned(),
),
}
if let Some(channel) = who["default_channel"].as_str() {
lines.push(format!(
"- Messages with no channel go to #{channel} by default."
));
}
let unread = who["unread_direct_messages"].as_i64().unwrap_or(0);
if unread > 0 {
lines.push(format!(
"- {unread} unread direct message(s) for this window. Read them with \
read_messages before starting work."
));
}
let claimed = who["open_claimed_tasks"].as_i64().unwrap_or(0);
if claimed > 0 {
lines.push(format!(
"- {claimed} task(s) claimed by this window are still open (list_tasks \
mine_only=true)."
));
}
if let Ok(digest) =
call_as_window(binding, "team_digest", json!({ "hours": digest_hours })).await
{
let mut compact = serde_json::to_string(&digest).unwrap_or_default();
if compact.len() > 2500 {
compact.truncate(2500);
compact.push_str("…(truncated — call team_digest for the whole picture)");
}
lines.push(format!(
"- Team activity, last {digest_hours}h (team_digest): {compact}"
));
}
lines.push(
"- Nothing is pushed into an idle turn: call read_messages or wait_for_updates to \
receive what teammates sent. Claim shared work with claim_task before starting it."
.to_owned(),
);
Ok(lines.join("\n"))
}
pub async fn run(
config_dir: &Path,
binding_id: &str,
event: Event,
cwd: &Path,
digest_hours: i64,
call: Option<(String, Value)>,
) -> anyhow::Result<Option<String>> {
if event == Event::Call
&& let Some((tool, _)) = &call
&& !HOOK_CALL_TOOLS.contains(&tool.as_str())
{
bail!(
"'{tool}' is not a hook operation: --event call serves {} only, so a hook can \
neither issue, rotate nor revoke a credential, nor act on the bus beyond what \
its scripts need",
HOOK_CALL_TOOLS.join(", ")
);
}
let resolved = resolve_binding(config_dir, binding_id);
if event == Event::Status {
let value = match &resolved {
Resolution::Authenticated(b) => json!({
"binding": binding_id,
"state": "authenticated",
"agent": b.agent,
"team": b.team,
"session": b.session,
"project": b.project,
"role": b.role,
"epoch": b.epoch,
"expires_at": b.expires_at,
"mcp_url": b.mcp_url,
}),
Resolution::Unauthenticated(b) => json!({
"binding": binding_id,
"state": "no-credential",
"agent": b.agent,
"team": b.team,
"session": b.session,
"closed_at": b.closed_at,
}),
Resolution::Missing => json!({
"binding": binding_id,
"state": "missing",
"hint": "no proxy of this conversation has registered a session; hooks stay \
silent rather than acting as another window",
}),
};
return Ok(Some(serde_json::to_string_pretty(&value)?));
}
let binding = match resolved {
Resolution::Authenticated(b) => b,
Resolution::Unauthenticated(_) | Resolution::Missing => return Ok(None),
};
match event {
Event::Status => unreachable!("handled above"),
Event::SessionStart => {
let _ = publish_presence(&binding, cwd, "active", true, 900).await;
let context = session_start_context(&binding, digest_hours).await?;
Ok(Some(
json!({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": context,
}
})
.to_string(),
))
}
Event::Heartbeat | Event::Stop => {
publish_presence(&binding, cwd, "active", false, 900).await?;
Ok(None)
}
Event::SessionEnd => {
publish_presence(&binding, cwd, "idle", false, 120).await?;
Ok(None)
}
Event::Call => {
let Some((tool, args)) = call else {
bail!("--event call needs --tool <name> and, optionally, --args <json object>");
};
let value = call_as_window(&binding, &tool, args).await?;
Ok(Some(value.to_string()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_names_are_forgiving_but_bounded() {
for (raw, expected) in [
("session_start", Event::SessionStart),
("SessionStart", Event::SessionStart),
("session-start", Event::SessionStart),
("heartbeat", Event::Heartbeat),
("Stop", Event::Stop),
("session_end", Event::SessionEnd),
("status", Event::Status),
("call", Event::Call),
] {
assert_eq!(raw.parse::<Event>().unwrap(), expected, "{raw}");
}
let err = "drain".parse::<Event>().unwrap_err().to_string();
assert!(err.contains("session_start"), "{err}");
}
#[tokio::test]
async fn a_missing_or_closed_binding_is_silent_never_another_window() {
let dir = std::env::temp_dir().join(format!("acs-hook-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let cwd = dir.clone();
for event in [Event::SessionStart, Event::Heartbeat, Event::SessionEnd] {
assert!(
run(&dir, "conv-unknown", event, &cwd, 8, None)
.await
.unwrap()
.is_none(),
"{event:?} spoke without a binding"
);
}
let path = context::binding_path(&dir, "conv-closed");
context::write_binding_file(
&path,
&json!({"session": "s-1", "agent": "joaquin", "team": "acme",
"mcp_url": "http://127.0.0.1:1/mcp", "closed_at": "2026-09-20T00:00:00Z"})
.to_string(),
)
.unwrap();
assert!(
run(&dir, "conv-closed", Event::Heartbeat, &cwd, 8, None)
.await
.unwrap()
.is_none()
);
let retained = context::binding_path(&dir, "conv-closed-token");
context::write_binding_file(
&retained,
&json!({"session": "s-2", "agent": "joaquin", "team": "acme",
"mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_retained",
"session_id": "11111111-1111-1111-1111-111111111111", "epoch": 3,
"closed_at": "2026-09-20T00:00:00Z"})
.to_string(),
)
.unwrap();
assert!(
run(&dir, "conv-closed-token", Event::Heartbeat, &cwd, 8, None)
.await
.unwrap()
.is_none(),
"a closed binding must not act, even with a credential on disk"
);
let out = run(&dir, "conv-closed-token", Event::Status, &cwd, 8, None)
.await
.unwrap()
.unwrap();
assert!(out.contains("no-credential"), "{out}");
assert!(
!out.contains("acss_retained"),
"status must not print a secret"
);
let live = context::binding_path(&dir, "conv-live");
context::write_binding_file(
&live,
&json!({"session": "s-3", "agent": "joaquin", "team": "acme",
"mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_live",
"session_id": "22222222-2222-2222-2222-222222222222", "epoch": 1})
.to_string(),
)
.unwrap();
for tool in [
"resume_session",
"register_session",
"renew_session",
"revoke_session",
"recover_conversation_history",
"post_message",
] {
let call = Some((tool.to_owned(), json!({})));
let err = run(&dir, "conv-live", Event::Call, &cwd, 8, call)
.await
.expect_err(tool)
.to_string();
assert!(err.contains("not a hook operation"), "{tool}: {err}");
assert!(!err.contains("acss_"), "{tool}: {err}");
}
for id in ["conv-unknown", "conv-closed", "conv-closed-token"] {
let call = Some(("whoami".to_owned(), json!({})));
assert!(
run(&dir, id, Event::Call, &cwd, 8, call)
.await
.unwrap()
.is_none(),
"call spoke for {id}"
);
}
let out = run(&dir, "conv-unknown", Event::Status, &cwd, 8, None)
.await
.unwrap()
.unwrap();
assert!(out.contains("\"state\": \"missing\""), "{out}");
let out = run(&dir, "conv-closed", Event::Status, &cwd, 8, None)
.await
.unwrap()
.unwrap();
assert!(out.contains("no-credential"), "{out}");
assert!(
!out.contains("session_token"),
"status must not print a secret"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "binding files are private");
let dir_mode = std::fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700, "the bindings directory is private");
}
let _ = std::fs::remove_dir_all(&dir);
}
}