use std::path::Path;
use serde_json::Value;
fn state(payload: &serde_json::Map<String, Value>) -> Option<&'static str> {
match payload.get("hook_event_name").and_then(Value::as_str) {
Some("UserPromptSubmit" | "PreToolUse") => Some("working"),
Some("Notification") => Some("blocked"),
Some("Stop") if awaits_background_work(payload) => Some("monitoring"),
Some("Stop") => Some("idle"),
_ => None,
}
}
fn awaits_background_work(payload: &serde_json::Map<String, Value>) -> bool {
let pending = |key: &str| {
payload
.get(key)
.and_then(Value::as_array)
.is_some_and(|entries| !entries.is_empty())
};
pending("background_tasks") || pending("session_crons")
}
pub fn handle(raw: &str, cwd: &Path) {
let git_dir = cwd.join(".git");
if !git_dir.is_dir() {
return;
}
let Ok(Value::Object(payload)) = serde_json::from_str::<Value>(raw) else {
return;
};
let path = git_dir.join("agent-status");
if payload.get("hook_event_name").and_then(Value::as_str) == Some("SessionEnd") {
let _ = std::fs::remove_file(&path);
return;
}
let Some(state) = state(&payload) else {
return;
};
let current = std::fs::read_to_string(&path)
.ok()
.map(|text| text.trim().to_string());
if current.as_deref() != Some(state) {
let _ = std::fs::write(&path, format!("{state}\n"));
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn checkout() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let path = dir.path().to_path_buf();
(dir, path)
}
fn event(name: &str) -> String {
format!("{{\"hook_event_name\": \"{name}\"}}")
}
fn tool_event(name: &str, tool: &str) -> String {
format!("{{\"hook_event_name\": \"{name}\", \"tool_name\": \"{tool}\"}}")
}
fn status_of(checkout: &Path) -> String {
std::fs::read_to_string(checkout.join(".git").join("agent-status")).unwrap()
}
fn stop_event(background_tasks: &str, session_crons: &str) -> String {
format!(
"{{\"hook_event_name\": \"Stop\", \"background_tasks\": {background_tasks}, \
\"session_crons\": {session_crons}}}"
)
}
#[test]
fn events_map_to_states() {
for (payload, state) in [
(event("UserPromptSubmit"), "working"),
(tool_event("PreToolUse", "Bash"), "working"),
(tool_event("PreToolUse", "Monitor"), "working"),
(event("Notification"), "blocked"),
(event("Stop"), "idle"),
(stop_event("[]", "[]"), "idle"),
] {
let (_dir, checkout) = checkout();
handle(&payload, &checkout);
assert_eq!(status_of(&checkout), format!("{state}\n"));
}
}
#[test]
fn stopping_with_background_work_pending_is_monitoring() {
for (payload, state) in [
(
stop_event(
r#"[{"id": "b1", "type": "shell", "status": "running"}]"#,
"[]",
),
"monitoring",
),
(
stop_event(
r#"[{"id": "a1", "type": "subagent", "status": "running"}]"#,
"[]",
),
"monitoring",
),
(
stop_event(
r#"[{"id": "a2", "type": "subagent", "status": "pending"}]"#,
"[]",
),
"monitoring",
),
(
stop_event(
"[]",
r#"[{"id": "c1", "schedule": "57 18 * * *", "recurring": false}]"#,
),
"monitoring",
),
] {
let (_dir, checkout) = checkout();
handle(&payload, &checkout);
assert_eq!(
status_of(&checkout),
format!("{state}\n"),
"payload: {payload}"
);
}
}
#[test]
fn session_end_removes_the_file() {
let (_dir, checkout) = checkout();
handle(&event("Stop"), &checkout);
handle(&event("SessionEnd"), &checkout);
assert!(!checkout.join(".git").join("agent-status").exists());
handle(&event("SessionEnd"), &checkout); }
#[test]
fn unchanged_state_keeps_the_mtime() {
let (_dir, checkout) = checkout();
handle(&event("UserPromptSubmit"), &checkout);
let path = checkout.join(".git").join("agent-status");
let started = std::fs::metadata(&path).unwrap().modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
handle(&tool_event("PreToolUse", "Bash"), &checkout);
assert_eq!(
std::fs::metadata(&path).unwrap().modified().unwrap(),
started
);
handle(&event("Stop"), &checkout);
assert_ne!(
std::fs::metadata(&path).unwrap().modified().unwrap(),
started
);
}
#[test]
fn outside_a_checkout_does_nothing() {
let dir = tempfile::tempdir().unwrap();
handle(&event("Stop"), dir.path());
assert!(!dir.path().join(".git").exists());
}
#[test]
fn unusable_input_is_ignored() {
let (_dir, checkout) = checkout();
handle("not json", &checkout);
handle("[1, 2]", &checkout);
handle(&event("SomethingNew"), &checkout);
assert!(!checkout.join(".git").join("agent-status").exists());
}
}