1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::io;
4use std::path::PathBuf;
5
6#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
12pub struct AttentionEntry {
13 pub agent: String,
15 pub project: String,
17 pub cwd: String,
19 pub event: String,
21 pub tmux_pane: String,
23 pub ts: u64,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub message: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub pid: Option<u32>,
36}
37
38pub struct StateStore {
43 dir: PathBuf,
44}
45
46impl StateStore {
47 #[must_use]
51 pub fn new(dir: PathBuf) -> Self {
52 Self { dir }
53 }
54
55 pub fn from_env() -> Self {
57 let base = std::env::var_os("XDG_RUNTIME_DIR")
58 .map_or_else(|| PathBuf::from("/tmp"), PathBuf::from);
59 Self::new(base.join("agent-status"))
60 }
61
62 #[cfg(test)]
64 #[must_use]
65 pub fn dir(&self) -> &std::path::Path {
66 &self.dir
67 }
68
69 pub fn write(&self, session_id: &str, entry: &AttentionEntry) -> io::Result<()> {
76 validate_session_id(session_id)?;
77 fs::create_dir_all(&self.dir)?;
78 let json = serde_json::to_vec(entry)
79 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
80 fs::write(self.dir.join(session_id), json)
81 }
82
83 pub fn remove(&self, session_id: &str) -> io::Result<bool> {
95 validate_session_id(session_id)?;
96 match fs::remove_file(self.dir.join(session_id)) {
97 Ok(()) => Ok(true),
98 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
99 Err(e) => Err(e),
100 }
101 }
102
103 pub fn list(&self) -> io::Result<Vec<(String, AttentionEntry)>> {
112 let iter = match fs::read_dir(&self.dir) {
113 Ok(it) => it,
114 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
115 Err(e) => return Err(e),
116 };
117 let mut out = Vec::new();
118 for entry in iter {
119 let entry = entry?;
120 if !entry.file_type()?.is_file() {
121 continue;
122 }
123 let path = entry.path();
124 let name = entry.file_name().to_string_lossy().into_owned();
125 let Ok(bytes) = fs::read(&path) else {
126 continue;
127 };
128 let Ok(parsed) = serde_json::from_slice::<AttentionEntry>(&bytes) else {
129 continue;
130 };
131 if let Some(pid) = parsed.pid {
135 if !is_pid_alive(pid) {
136 let _ = fs::remove_file(&path);
137 continue;
138 }
139 }
140 out.push((name, parsed));
141 }
142 out.sort_by(|a, b| a.1.ts.cmp(&b.1.ts).then_with(|| a.0.cmp(&b.0)));
143 Ok(out)
144 }
145}
146
147fn is_pid_alive(pid: u32) -> bool {
165 if pid == 0 {
166 return false;
167 }
168 let status = std::process::Command::new("/bin/kill")
173 .args(["-0", &pid.to_string()])
174 .stderr(std::process::Stdio::null())
175 .stdout(std::process::Stdio::null())
176 .status();
177 match status {
178 Ok(s) => s.success(),
179 Err(_) => true,
180 }
181}
182
183fn validate_session_id(session_id: &str) -> io::Result<()> {
184 if session_id.is_empty()
185 || session_id.contains('/')
186 || session_id.contains(std::path::MAIN_SEPARATOR)
187 || session_id == "."
188 || session_id == ".."
189 {
190 return Err(io::Error::new(
191 io::ErrorKind::InvalidInput,
192 "invalid session_id",
193 ));
194 }
195 Ok(())
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use tempfile::TempDir;
202
203 #[test]
204 fn entry_roundtrips_through_json() {
205 let entry = AttentionEntry {
206 agent: "claude-code".into(),
207 project: "claude-status".into(),
208 cwd: "/Users/x/work/claude-status".into(),
209 event: "notify".into(),
210 tmux_pane: "%42".into(),
211 ts: 1_700_000_000,
212 message: None,
213 pid: None,
214 };
215 let json = serde_json::to_string(&entry).unwrap();
216 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
217 assert_eq!(parsed, entry);
218 }
219
220 #[test]
221 fn entry_matches_bash_plan_field_names() {
222 let entry = AttentionEntry {
223 agent: "claude-code".into(),
224 project: "p".into(),
225 cwd: "/c".into(),
226 event: "done".into(),
227 tmux_pane: "%1".into(),
228 ts: 1,
229 message: None,
230 pid: None,
231 };
232 let v: serde_json::Value = serde_json::to_value(&entry).unwrap();
233 assert!(v.get("project").is_some());
235 assert!(v.get("cwd").is_some());
236 assert!(v.get("event").is_some());
237 assert!(v.get("tmux_pane").is_some());
238 assert!(v.get("ts").is_some());
239 assert!(v.get("agent").is_some());
241 }
242
243 fn sample_entry(project: &str) -> AttentionEntry {
244 AttentionEntry {
245 agent: "claude-code".into(),
246 project: project.into(),
247 cwd: format!("/x/{project}"),
248 event: "notify".into(),
249 tmux_pane: "%1".into(),
250 ts: 1,
251 message: None,
252 pid: None,
253 }
254 }
255
256 #[test]
257 fn write_then_list_returns_entry() {
258 let dir = TempDir::new().unwrap();
259 let store = StateStore::new(dir.path().into());
260 store.write("session-a", &sample_entry("alpha")).unwrap();
261 let listed = store.list().unwrap();
262 assert_eq!(listed.len(), 1);
263 assert_eq!(listed[0].0, "session-a");
264 assert_eq!(listed[0].1.project, "alpha");
265 }
266
267 #[test]
268 fn remove_is_idempotent() {
269 let dir = TempDir::new().unwrap();
270 let store = StateStore::new(dir.path().into());
271 assert!(!store.remove("never-existed").unwrap());
272 store.write("s1", &sample_entry("p")).unwrap();
273 assert!(store.remove("s1").unwrap());
274 assert!(!store.remove("s1").unwrap());
275 assert_eq!(store.list().unwrap().len(), 0);
276 }
277
278 #[test]
279 fn remove_returns_true_when_file_was_present() {
280 let dir = TempDir::new().unwrap();
281 let store = StateStore::new(dir.path().into());
282 store.write("s1", &sample_entry("p")).unwrap();
283 assert!(store.remove("s1").unwrap(), "first remove should report deletion");
284 }
285
286 #[test]
287 fn remove_returns_false_when_file_was_already_absent() {
288 let dir = TempDir::new().unwrap();
289 let store = StateStore::new(dir.path().into());
290 assert!(!store.remove("never-existed").unwrap());
291 store.write("s1", &sample_entry("p")).unwrap();
292 store.remove("s1").unwrap();
293 assert!(!store.remove("s1").unwrap(), "second remove should report no-op");
294 }
295
296 #[test]
297 fn list_on_missing_dir_returns_empty() {
298 let dir = TempDir::new().unwrap();
299 let path = dir.path().join("does-not-exist");
300 let store = StateStore::new(path);
301 assert_eq!(store.list().unwrap().len(), 0);
302 }
303
304 #[test]
305 fn list_skips_files_with_invalid_json() {
306 let dir = TempDir::new().unwrap();
307 let store = StateStore::new(dir.path().into());
308 store.write("good", &sample_entry("p")).unwrap();
309 std::fs::write(dir.path().join("bad"), "not json").unwrap();
310 let listed = store.list().unwrap();
311 assert_eq!(listed.len(), 1);
312 assert_eq!(listed[0].0, "good");
313 }
314
315 #[test]
316 fn from_env_path_ends_with_agent_status() {
317 let store = StateStore::from_env();
318 assert!(store.dir().ends_with("agent-status"));
319 }
320
321 #[test]
322 fn entry_message_field_roundtrips_when_set() {
323 let entry = AttentionEntry {
324 agent: "claude-code".into(),
325 project: "p".into(),
326 cwd: "/c".into(),
327 event: "notify".into(),
328 tmux_pane: "%1".into(),
329 ts: 1,
330 message: Some("Permission required".into()),
331 pid: None,
332 };
333 let json = serde_json::to_string(&entry).unwrap();
334 assert!(json.contains(r#""message":"Permission required""#));
335 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
336 assert_eq!(parsed.message.as_deref(), Some("Permission required"));
337 }
338
339 #[test]
340 fn entry_message_field_omitted_from_json_when_none() {
341 let entry = AttentionEntry {
342 agent: "claude-code".into(),
343 project: "p".into(),
344 cwd: "/c".into(),
345 event: "done".into(),
346 tmux_pane: "%1".into(),
347 ts: 1,
348 message: None,
349 pid: None,
350 };
351 let json = serde_json::to_string(&entry).unwrap();
352 assert!(!json.contains("message"), "got: {json}");
353 }
354
355 #[test]
356 fn entry_pid_field_roundtrips_when_set() {
357 let entry = AttentionEntry {
358 agent: "claude-code".into(),
359 project: "p".into(),
360 cwd: "/c".into(),
361 event: "notify".into(),
362 tmux_pane: "%1".into(),
363 ts: 1,
364 message: None,
365 pid: Some(42_000),
366 };
367 let json = serde_json::to_string(&entry).unwrap();
368 assert!(json.contains(r#""pid":42000"#));
369 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
370 assert_eq!(parsed.pid, Some(42_000));
371 }
372
373 #[test]
374 fn entry_pid_field_omitted_from_json_when_none() {
375 let entry = AttentionEntry {
376 agent: "claude-code".into(),
377 project: "p".into(),
378 cwd: "/c".into(),
379 event: "done".into(),
380 tmux_pane: "%1".into(),
381 ts: 1,
382 message: None,
383 pid: None,
384 };
385 let json = serde_json::to_string(&entry).unwrap();
386 assert!(!json.contains("pid"), "got: {json}");
387 }
388
389 #[test]
390 fn entry_deserializes_when_pid_field_absent() {
391 let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
393 let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
394 assert!(parsed.pid.is_none());
395 }
396
397 #[test]
398 fn entry_deserializes_when_message_field_absent() {
399 let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
401 let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
402 assert!(parsed.message.is_none());
403 }
404
405 #[test]
406 fn write_rejects_path_traversal_session_id() {
407 let dir = TempDir::new().unwrap();
408 let store = StateStore::new(dir.path().into());
409 let entry = sample_entry("p");
410 for bad in ["../escape", "a/b", "..", ".", ""] {
411 let err = store.write(bad, &entry).unwrap_err();
412 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "bad id: {bad:?}");
413 }
414 let err = store.remove("../escape").unwrap_err();
415 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
416 }
417
418 #[test]
419 fn is_pid_alive_returns_true_for_self() {
420 let me = std::process::id();
421 assert!(is_pid_alive(me), "kill -0 of own pid should succeed");
422 }
423
424 #[test]
425 fn is_pid_alive_returns_false_for_impossible_pid() {
426 assert!(!is_pid_alive(1_000_000_000));
428 }
429
430 #[test]
431 fn is_pid_alive_returns_false_for_pid_zero() {
432 assert!(!is_pid_alive(0));
436 }
437
438 #[test]
439 fn list_prunes_entries_with_dead_pid() {
440 let dir = TempDir::new().unwrap();
441 let store = StateStore::new(dir.path().into());
442
443 let mut alive = sample_entry("alive");
444 alive.pid = Some(std::process::id());
445 store.write("session-alive", &alive).unwrap();
446
447 let mut dead = sample_entry("dead");
448 dead.pid = Some(1_000_000_000);
449 store.write("session-dead", &dead).unwrap();
450
451 let listed = store.list().unwrap();
452 assert_eq!(listed.len(), 1, "should keep only the alive entry");
453 assert_eq!(listed[0].0, "session-alive");
454
455 assert!(!dir.path().join("session-dead").exists());
456 }
457
458 #[test]
459 fn list_keeps_entries_without_pid() {
460 let dir = TempDir::new().unwrap();
461 let store = StateStore::new(dir.path().into());
462 let no_pid_entry = sample_entry("legacy");
463 store.write("session-legacy", &no_pid_entry).unwrap();
464
465 let listed = store.list().unwrap();
466 assert_eq!(listed.len(), 1);
467 }
468}