use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct AttentionEntry {
pub agent: String,
pub project: String,
pub cwd: String,
pub event: String,
pub tmux_pane: String,
pub ts: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
}
pub struct StateStore {
dir: PathBuf,
}
impl StateStore {
#[must_use]
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
pub fn from_env() -> Self {
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map_or_else(|| PathBuf::from("/tmp"), PathBuf::from);
Self::new(base.join("agent-status"))
}
#[cfg(test)]
#[must_use]
pub fn dir(&self) -> &std::path::Path {
&self.dir
}
pub fn write(&self, session_id: &str, entry: &AttentionEntry) -> io::Result<()> {
validate_session_id(session_id)?;
fs::create_dir_all(&self.dir)?;
let json = serde_json::to_vec(entry)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(self.dir.join(session_id), json)
}
pub fn remove(&self, session_id: &str) -> io::Result<bool> {
validate_session_id(session_id)?;
match fs::remove_file(self.dir.join(session_id)) {
Ok(()) => Ok(true),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e),
}
}
pub fn list(&self) -> io::Result<Vec<(String, AttentionEntry)>> {
let iter = match fs::read_dir(&self.dir) {
Ok(it) => it,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut out = Vec::new();
for entry in iter {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let Ok(bytes) = fs::read(&path) else {
continue;
};
let Ok(parsed) = serde_json::from_slice::<AttentionEntry>(&bytes) else {
continue;
};
if let Some(pid) = parsed.pid {
if !is_pid_alive(pid) {
let _ = fs::remove_file(&path);
continue;
}
}
out.push((name, parsed));
}
out.sort_by(|a, b| a.1.ts.cmp(&b.1.ts).then_with(|| a.0.cmp(&b.0)));
Ok(out)
}
}
fn is_pid_alive(pid: u32) -> bool {
if pid == 0 {
return false;
}
let status = std::process::Command::new("/bin/kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.status();
match status {
Ok(s) => s.success(),
Err(_) => true,
}
}
fn validate_session_id(session_id: &str) -> io::Result<()> {
if session_id.is_empty()
|| session_id.contains('/')
|| session_id.contains(std::path::MAIN_SEPARATOR)
|| session_id == "."
|| session_id == ".."
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid session_id",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn entry_roundtrips_through_json() {
let entry = AttentionEntry {
agent: "claude-code".into(),
project: "claude-status".into(),
cwd: "/Users/x/work/claude-status".into(),
event: "notify".into(),
tmux_pane: "%42".into(),
ts: 1_700_000_000,
message: None,
pid: None,
};
let json = serde_json::to_string(&entry).unwrap();
let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, entry);
}
#[test]
fn entry_matches_bash_plan_field_names() {
let entry = AttentionEntry {
agent: "claude-code".into(),
project: "p".into(),
cwd: "/c".into(),
event: "done".into(),
tmux_pane: "%1".into(),
ts: 1,
message: None,
pid: None,
};
let v: serde_json::Value = serde_json::to_value(&entry).unwrap();
assert!(v.get("project").is_some());
assert!(v.get("cwd").is_some());
assert!(v.get("event").is_some());
assert!(v.get("tmux_pane").is_some());
assert!(v.get("ts").is_some());
assert!(v.get("agent").is_some());
}
fn sample_entry(project: &str) -> AttentionEntry {
AttentionEntry {
agent: "claude-code".into(),
project: project.into(),
cwd: format!("/x/{project}"),
event: "notify".into(),
tmux_pane: "%1".into(),
ts: 1,
message: None,
pid: None,
}
}
#[test]
fn write_then_list_returns_entry() {
let dir = TempDir::new().unwrap();
let store = StateStore::new(dir.path().into());
store.write("session-a", &sample_entry("alpha")).unwrap();
let listed = store.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].0, "session-a");
assert_eq!(listed[0].1.project, "alpha");
}
#[test]
fn remove_is_idempotent() {
let dir = TempDir::new().unwrap();
let store = StateStore::new(dir.path().into());
assert!(!store.remove("never-existed").unwrap());
store.write("s1", &sample_entry("p")).unwrap();
assert!(store.remove("s1").unwrap());
assert!(!store.remove("s1").unwrap());
assert_eq!(store.list().unwrap().len(), 0);
}
#[test]
fn remove_returns_true_when_file_was_present() {
let dir = TempDir::new().unwrap();
let store = StateStore::new(dir.path().into());
store.write("s1", &sample_entry("p")).unwrap();
assert!(store.remove("s1").unwrap(), "first remove should report deletion");
}
#[test]
fn remove_returns_false_when_file_was_already_absent() {
let dir = TempDir::new().unwrap();
let store = StateStore::new(dir.path().into());
assert!(!store.remove("never-existed").unwrap());
store.write("s1", &sample_entry("p")).unwrap();
store.remove("s1").unwrap();
assert!(!store.remove("s1").unwrap(), "second remove should report no-op");
}
#[test]
fn list_on_missing_dir_returns_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("does-not-exist");
let store = StateStore::new(path);
assert_eq!(store.list().unwrap().len(), 0);
}
#[test]
fn list_skips_files_with_invalid_json() {
let dir = TempDir::new().unwrap();
let store = StateStore::new(dir.path().into());
store.write("good", &sample_entry("p")).unwrap();
std::fs::write(dir.path().join("bad"), "not json").unwrap();
let listed = store.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].0, "good");
}
#[test]
fn from_env_path_ends_with_agent_status() {
let store = StateStore::from_env();
assert!(store.dir().ends_with("agent-status"));
}
#[test]
fn entry_message_field_roundtrips_when_set() {
let entry = AttentionEntry {
agent: "claude-code".into(),
project: "p".into(),
cwd: "/c".into(),
event: "notify".into(),
tmux_pane: "%1".into(),
ts: 1,
message: Some("Permission required".into()),
pid: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains(r#""message":"Permission required""#));
let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.message.as_deref(), Some("Permission required"));
}
#[test]
fn entry_message_field_omitted_from_json_when_none() {
let entry = AttentionEntry {
agent: "claude-code".into(),
project: "p".into(),
cwd: "/c".into(),
event: "done".into(),
tmux_pane: "%1".into(),
ts: 1,
message: None,
pid: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("message"), "got: {json}");
}
#[test]
fn entry_pid_field_roundtrips_when_set() {
let entry = AttentionEntry {
agent: "claude-code".into(),
project: "p".into(),
cwd: "/c".into(),
event: "notify".into(),
tmux_pane: "%1".into(),
ts: 1,
message: None,
pid: Some(42_000),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains(r#""pid":42000"#));
let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.pid, Some(42_000));
}
#[test]
fn entry_pid_field_omitted_from_json_when_none() {
let entry = AttentionEntry {
agent: "claude-code".into(),
project: "p".into(),
cwd: "/c".into(),
event: "done".into(),
tmux_pane: "%1".into(),
ts: 1,
message: None,
pid: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("pid"), "got: {json}");
}
#[test]
fn entry_deserializes_when_pid_field_absent() {
let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
assert!(parsed.pid.is_none());
}
#[test]
fn entry_deserializes_when_message_field_absent() {
let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
assert!(parsed.message.is_none());
}
#[test]
fn write_rejects_path_traversal_session_id() {
let dir = TempDir::new().unwrap();
let store = StateStore::new(dir.path().into());
let entry = sample_entry("p");
for bad in ["../escape", "a/b", "..", ".", ""] {
let err = store.write(bad, &entry).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "bad id: {bad:?}");
}
let err = store.remove("../escape").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn is_pid_alive_returns_true_for_self() {
let me = std::process::id();
assert!(is_pid_alive(me), "kill -0 of own pid should succeed");
}
#[test]
fn is_pid_alive_returns_false_for_impossible_pid() {
assert!(!is_pid_alive(1_000_000_000));
}
#[test]
fn is_pid_alive_returns_false_for_pid_zero() {
assert!(!is_pid_alive(0));
}
#[test]
fn list_prunes_entries_with_dead_pid() {
let dir = TempDir::new().unwrap();
let store = StateStore::new(dir.path().into());
let mut alive = sample_entry("alive");
alive.pid = Some(std::process::id());
store.write("session-alive", &alive).unwrap();
let mut dead = sample_entry("dead");
dead.pid = Some(1_000_000_000);
store.write("session-dead", &dead).unwrap();
let listed = store.list().unwrap();
assert_eq!(listed.len(), 1, "should keep only the alive entry");
assert_eq!(listed[0].0, "session-alive");
assert!(!dir.path().join("session-dead").exists());
}
#[test]
fn list_keeps_entries_without_pid() {
let dir = TempDir::new().unwrap();
let store = StateStore::new(dir.path().into());
let no_pid_entry = sample_entry("legacy");
store.write("session-legacy", &no_pid_entry).unwrap();
let listed = store.list().unwrap();
assert_eq!(listed.len(), 1);
}
}