1use serde::de::{self, Deserializer};
2use serde::ser::Serializer;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::io;
6use std::path::PathBuf;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub enum Event {
19 Notify,
22 Done,
24 Idle,
27 Working,
31 Unknown(String),
34}
35
36impl Event {
37 #[must_use]
39 pub fn as_str(&self) -> &str {
40 match self {
41 Self::Notify => "notify",
42 Self::Done => "done",
43 Self::Idle => "idle",
44 Self::Working => "working",
45 Self::Unknown(s) => s.as_str(),
46 }
47 }
48
49 #[must_use]
56 pub fn needs_attention(&self) -> bool {
57 !matches!(self, Self::Working | Self::Idle)
58 }
59}
60
61impl From<&str> for Event {
62 fn from(s: &str) -> Self {
63 match s {
64 "notify" => Self::Notify,
65 "done" => Self::Done,
66 "idle" => Self::Idle,
67 "working" => Self::Working,
68 _ => Self::Unknown(s.to_string()),
69 }
70 }
71}
72
73impl From<String> for Event {
74 fn from(s: String) -> Self {
75 match s.as_str() {
76 "notify" => Self::Notify,
77 "done" => Self::Done,
78 "idle" => Self::Idle,
79 "working" => Self::Working,
80 _ => Self::Unknown(s),
81 }
82 }
83}
84
85impl Serialize for Event {
86 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
87 s.serialize_str(self.as_str())
88 }
89}
90
91impl<'de> Deserialize<'de> for Event {
92 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
93 let s = String::deserialize(d).map_err(de::Error::custom)?;
94 Ok(Self::from(s))
95 }
96}
97
98#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
110pub struct AttentionEntry {
111 pub agent: String,
113 pub ts: u64,
115 pub event: Event,
117 pub project: String,
119 pub cwd: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub pid: Option<u32>,
128 #[serde(
135 default,
136 deserialize_with = "empty_string_as_none",
137 skip_serializing_if = "Option::is_none"
138 )]
139 pub tmux_session: Option<String>,
140 #[serde(
146 default,
147 deserialize_with = "empty_string_as_none",
148 skip_serializing_if = "Option::is_none"
149 )]
150 pub tmux_pane: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub message: Option<String>,
155}
156
157pub struct StateStore {
162 dir: PathBuf,
163}
164
165impl StateStore {
166 #[must_use]
170 pub fn new(dir: PathBuf) -> Self {
171 Self { dir }
172 }
173
174 pub fn from_env() -> Self {
176 let base = std::env::var_os("XDG_RUNTIME_DIR")
177 .map_or_else(|| PathBuf::from("/tmp"), PathBuf::from);
178 Self::new(base.join("agent-status"))
179 }
180
181 #[cfg(test)]
183 #[must_use]
184 pub fn dir(&self) -> &std::path::Path {
185 &self.dir
186 }
187
188 pub fn write(&self, session_id: &str, entry: &AttentionEntry) -> io::Result<()> {
195 validate_session_id(session_id)?;
196 fs::create_dir_all(&self.dir)?;
197 let json = serde_json::to_vec(entry)
198 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
199 fs::write(self.dir.join(session_id), json)
200 }
201
202 pub fn remove(&self, session_id: &str) -> io::Result<bool> {
214 validate_session_id(session_id)?;
215 match fs::remove_file(self.dir.join(session_id)) {
216 Ok(()) => Ok(true),
217 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
218 Err(e) => Err(e),
219 }
220 }
221
222 pub fn list(&self) -> io::Result<Vec<(String, AttentionEntry)>> {
231 let iter = match fs::read_dir(&self.dir) {
232 Ok(it) => it,
233 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
234 Err(e) => return Err(e),
235 };
236 let mut out = Vec::new();
237 for entry in iter {
238 let entry = entry?;
239 if !entry.file_type()?.is_file() {
240 continue;
241 }
242 let path = entry.path();
243 let name = entry.file_name().to_string_lossy().into_owned();
244 let Ok(bytes) = fs::read(&path) else {
245 continue;
246 };
247 let Ok(parsed) = serde_json::from_slice::<AttentionEntry>(&bytes) else {
248 continue;
249 };
250 if let Some(pid) = parsed.pid {
254 if !is_pid_alive(pid) {
255 let _ = fs::remove_file(&path);
256 continue;
257 }
258 }
259 out.push((name, parsed));
260 }
261 out.sort_by(|a, b| a.1.ts.cmp(&b.1.ts).then_with(|| a.0.cmp(&b.0)));
262 Ok(out)
263 }
264}
265
266fn is_pid_alive(pid: u32) -> bool {
284 if pid == 0 {
285 return false;
286 }
287 let status = std::process::Command::new("/bin/kill")
292 .args(["-0", &pid.to_string()])
293 .stderr(std::process::Stdio::null())
294 .stdout(std::process::Stdio::null())
295 .status();
296 match status {
297 Ok(s) => s.success(),
298 Err(_) => true,
299 }
300}
301
302fn validate_session_id(session_id: &str) -> io::Result<()> {
303 if session_id.is_empty()
304 || session_id.contains('/')
305 || session_id.contains(std::path::MAIN_SEPARATOR)
306 || session_id == "."
307 || session_id == ".."
308 {
309 return Err(io::Error::new(
310 io::ErrorKind::InvalidInput,
311 "invalid session_id",
312 ));
313 }
314 Ok(())
315}
316
317fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
322where
323 D: Deserializer<'de>,
324{
325 let opt = Option::<String>::deserialize(deserializer)?;
326 Ok(opt.filter(|s| !s.is_empty()))
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use tempfile::TempDir;
333
334 #[test]
335 fn entry_roundtrips_through_json() {
336 let entry = AttentionEntry {
337 agent: "claude-code".into(),
338 project: "claude-status".into(),
339 cwd: "/Users/x/work/claude-status".into(),
340 event: Event::Notify,
341 tmux_pane: Some("%42".into()),
342 ts: 1_700_000_000,
343 message: None,
344 pid: None,
345 tmux_session: None,
346 };
347 let json = serde_json::to_string(&entry).unwrap();
348 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
349 assert_eq!(parsed, entry);
350 }
351
352 #[test]
353 fn event_known_values_serialize_as_plain_strings() {
354 for (evt, wire) in [
355 (Event::Notify, "\"notify\""),
356 (Event::Done, "\"done\""),
357 (Event::Idle, "\"idle\""),
358 (Event::Working, "\"working\""),
359 ] {
360 assert_eq!(serde_json::to_string(&evt).unwrap(), wire);
361 let parsed: Event = serde_json::from_str(wire).unwrap();
362 assert_eq!(parsed, evt);
363 }
364 }
365
366 #[test]
367 fn event_unknown_value_roundtrips_verbatim() {
368 let json = r#""compacting""#;
372 let parsed: Event = serde_json::from_str(json).unwrap();
373 assert_eq!(parsed, Event::Unknown("compacting".to_string()));
374 assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
375 }
376
377 #[test]
378 fn event_needs_attention_matches_legacy_filter() {
379 assert!(Event::Notify.needs_attention());
383 assert!(Event::Done.needs_attention());
384 assert!(Event::Unknown("anything-new".into()).needs_attention());
385 assert!(!Event::Working.needs_attention());
386 assert!(!Event::Idle.needs_attention());
387 }
388
389 #[test]
390 fn entry_matches_bash_plan_field_names() {
391 let entry = AttentionEntry {
392 agent: "claude-code".into(),
393 project: "p".into(),
394 cwd: "/c".into(),
395 event: Event::Done,
396 tmux_pane: Some("%1".into()),
397 ts: 1,
398 message: None,
399 pid: None,
400 tmux_session: None,
401 };
402 let v: serde_json::Value = serde_json::to_value(&entry).unwrap();
403 assert!(v.get("project").is_some());
405 assert!(v.get("cwd").is_some());
406 assert!(v.get("event").is_some());
407 assert!(v.get("tmux_pane").is_some());
408 assert!(v.get("ts").is_some());
409 assert!(v.get("agent").is_some());
411 }
412
413 fn sample_entry(project: &str) -> AttentionEntry {
414 AttentionEntry {
415 agent: "claude-code".into(),
416 project: project.into(),
417 cwd: format!("/x/{project}"),
418 event: Event::Notify,
419 tmux_pane: Some("%1".into()),
420 ts: 1,
421 message: None,
422 pid: None,
423 tmux_session: None,
424 }
425 }
426
427 #[test]
428 fn write_then_list_returns_entry() {
429 let dir = TempDir::new().unwrap();
430 let store = StateStore::new(dir.path().into());
431 store.write("session-a", &sample_entry("alpha")).unwrap();
432 let listed = store.list().unwrap();
433 assert_eq!(listed.len(), 1);
434 assert_eq!(listed[0].0, "session-a");
435 assert_eq!(listed[0].1.project, "alpha");
436 }
437
438 #[test]
439 fn remove_is_idempotent() {
440 let dir = TempDir::new().unwrap();
441 let store = StateStore::new(dir.path().into());
442 assert!(!store.remove("never-existed").unwrap());
443 store.write("s1", &sample_entry("p")).unwrap();
444 assert!(store.remove("s1").unwrap());
445 assert!(!store.remove("s1").unwrap());
446 assert_eq!(store.list().unwrap().len(), 0);
447 }
448
449 #[test]
450 fn remove_returns_true_when_file_was_present() {
451 let dir = TempDir::new().unwrap();
452 let store = StateStore::new(dir.path().into());
453 store.write("s1", &sample_entry("p")).unwrap();
454 assert!(store.remove("s1").unwrap(), "first remove should report deletion");
455 }
456
457 #[test]
458 fn remove_returns_false_when_file_was_already_absent() {
459 let dir = TempDir::new().unwrap();
460 let store = StateStore::new(dir.path().into());
461 assert!(!store.remove("never-existed").unwrap());
462 store.write("s1", &sample_entry("p")).unwrap();
463 store.remove("s1").unwrap();
464 assert!(!store.remove("s1").unwrap(), "second remove should report no-op");
465 }
466
467 #[test]
468 fn list_on_missing_dir_returns_empty() {
469 let dir = TempDir::new().unwrap();
470 let path = dir.path().join("does-not-exist");
471 let store = StateStore::new(path);
472 assert_eq!(store.list().unwrap().len(), 0);
473 }
474
475 #[test]
476 fn list_skips_files_with_invalid_json() {
477 let dir = TempDir::new().unwrap();
478 let store = StateStore::new(dir.path().into());
479 store.write("good", &sample_entry("p")).unwrap();
480 std::fs::write(dir.path().join("bad"), "not json").unwrap();
481 let listed = store.list().unwrap();
482 assert_eq!(listed.len(), 1);
483 assert_eq!(listed[0].0, "good");
484 }
485
486 #[test]
487 fn from_env_path_ends_with_agent_status() {
488 let store = StateStore::from_env();
489 assert!(store.dir().ends_with("agent-status"));
490 }
491
492 #[test]
493 fn entry_message_field_roundtrips_when_set() {
494 let entry = AttentionEntry {
495 agent: "claude-code".into(),
496 project: "p".into(),
497 cwd: "/c".into(),
498 event: Event::Notify,
499 tmux_pane: Some("%1".into()),
500 ts: 1,
501 message: Some("Permission required".into()),
502 pid: None,
503 tmux_session: None,
504 };
505 let json = serde_json::to_string(&entry).unwrap();
506 assert!(json.contains(r#""message":"Permission required""#));
507 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
508 assert_eq!(parsed.message.as_deref(), Some("Permission required"));
509 }
510
511 #[test]
512 fn entry_message_field_omitted_from_json_when_none() {
513 let entry = AttentionEntry {
514 agent: "claude-code".into(),
515 project: "p".into(),
516 cwd: "/c".into(),
517 event: Event::Done,
518 tmux_pane: Some("%1".into()),
519 ts: 1,
520 message: None,
521 pid: None,
522 tmux_session: None,
523 };
524 let json = serde_json::to_string(&entry).unwrap();
525 assert!(!json.contains("message"), "got: {json}");
526 }
527
528 #[test]
529 fn entry_pid_field_roundtrips_when_set() {
530 let entry = AttentionEntry {
531 agent: "claude-code".into(),
532 project: "p".into(),
533 cwd: "/c".into(),
534 event: Event::Notify,
535 tmux_pane: Some("%1".into()),
536 ts: 1,
537 message: None,
538 pid: Some(42_000),
539 tmux_session: None,
540 };
541 let json = serde_json::to_string(&entry).unwrap();
542 assert!(json.contains(r#""pid":42000"#));
543 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
544 assert_eq!(parsed.pid, Some(42_000));
545 }
546
547 #[test]
548 fn entry_pid_field_omitted_from_json_when_none() {
549 let entry = AttentionEntry {
550 agent: "claude-code".into(),
551 project: "p".into(),
552 cwd: "/c".into(),
553 event: Event::Done,
554 tmux_pane: Some("%1".into()),
555 ts: 1,
556 message: None,
557 pid: None,
558 tmux_session: None,
559 };
560 let json = serde_json::to_string(&entry).unwrap();
561 assert!(!json.contains("pid"), "got: {json}");
562 }
563
564 #[test]
565 fn entry_deserializes_when_pid_field_absent() {
566 let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
568 let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
569 assert!(parsed.pid.is_none());
570 }
571
572 #[test]
573 fn entry_deserializes_when_message_field_absent() {
574 let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
576 let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
577 assert!(parsed.message.is_none());
578 }
579
580 #[test]
581 fn entry_tmux_session_field_roundtrips_when_set() {
582 let entry = AttentionEntry {
583 agent: "claude-code".into(),
584 project: "p".into(),
585 cwd: "/c".into(),
586 event: Event::Notify,
587 tmux_pane: Some("%1".into()),
588 ts: 1,
589 message: None,
590 pid: None,
591 tmux_session: Some("outer".into()),
592 };
593 let json = serde_json::to_string(&entry).unwrap();
594 assert!(json.contains(r#""tmux_session":"outer""#));
595 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
596 assert_eq!(parsed.tmux_session.as_deref(), Some("outer"));
597 }
598
599 #[test]
600 fn entry_tmux_session_field_omitted_from_json_when_none() {
601 let entry = AttentionEntry {
602 agent: "claude-code".into(),
603 project: "p".into(),
604 cwd: "/c".into(),
605 event: Event::Done,
606 tmux_pane: Some("%1".into()),
607 ts: 1,
608 message: None,
609 pid: None,
610 tmux_session: None,
611 };
612 let json = serde_json::to_string(&entry).unwrap();
613 assert!(!json.contains("tmux_session"), "got: {json}");
614 }
615
616 #[test]
617 fn entry_deserializes_when_tmux_session_field_absent() {
618 let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
620 let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
621 assert!(parsed.tmux_session.is_none());
622 }
623
624 #[test]
625 fn entry_normalizes_empty_tmux_pane_string_to_none() {
626 let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"","ts":1}"#;
630 let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
631 assert!(parsed.tmux_pane.is_none());
632 }
633
634 #[test]
635 fn entry_normalizes_empty_tmux_session_string_to_none() {
636 let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","tmux_session":"","ts":1}"#;
637 let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
638 assert!(parsed.tmux_session.is_none());
639 }
640
641 #[test]
642 fn entry_tmux_pane_field_omitted_from_json_when_none() {
643 let entry = AttentionEntry {
644 agent: "claude-code".into(),
645 project: "p".into(),
646 cwd: "/c".into(),
647 event: Event::Done,
648 tmux_pane: None,
649 ts: 1,
650 message: None,
651 pid: None,
652 tmux_session: None,
653 };
654 let json = serde_json::to_string(&entry).unwrap();
655 assert!(!json.contains("tmux_pane"), "got: {json}");
656 }
657
658 #[test]
659 fn write_rejects_path_traversal_session_id() {
660 let dir = TempDir::new().unwrap();
661 let store = StateStore::new(dir.path().into());
662 let entry = sample_entry("p");
663 for bad in ["../escape", "a/b", "..", ".", ""] {
664 let err = store.write(bad, &entry).unwrap_err();
665 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "bad id: {bad:?}");
666 }
667 let err = store.remove("../escape").unwrap_err();
668 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
669 }
670
671 #[test]
672 fn is_pid_alive_returns_true_for_self() {
673 let me = std::process::id();
674 assert!(is_pid_alive(me), "kill -0 of own pid should succeed");
675 }
676
677 #[test]
678 fn is_pid_alive_returns_false_for_impossible_pid() {
679 assert!(!is_pid_alive(1_000_000_000));
681 }
682
683 #[test]
684 fn is_pid_alive_returns_false_for_pid_zero() {
685 assert!(!is_pid_alive(0));
689 }
690
691 #[test]
692 fn list_prunes_entries_with_dead_pid() {
693 let dir = TempDir::new().unwrap();
694 let store = StateStore::new(dir.path().into());
695
696 let mut alive = sample_entry("alive");
697 alive.pid = Some(std::process::id());
698 store.write("session-alive", &alive).unwrap();
699
700 let mut dead = sample_entry("dead");
701 dead.pid = Some(1_000_000_000);
702 store.write("session-dead", &dead).unwrap();
703
704 let listed = store.list().unwrap();
705 assert_eq!(listed.len(), 1, "should keep only the alive entry");
706 assert_eq!(listed[0].0, "session-alive");
707
708 assert!(!dir.path().join("session-dead").exists());
709 }
710
711 #[test]
712 fn list_keeps_entries_without_pid() {
713 let dir = TempDir::new().unwrap();
714 let store = StateStore::new(dir.path().into());
715 let no_pid_entry = sample_entry("legacy");
716 store.write("session-legacy", &no_pid_entry).unwrap();
717
718 let listed = store.list().unwrap();
719 assert_eq!(listed.len(), 1);
720 }
721}