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)]
104pub struct AttentionEntry {
105 pub agent: String,
107 pub project: String,
109 pub cwd: String,
111 pub event: Event,
113 pub tmux_pane: String,
115 pub ts: u64,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub message: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub pid: Option<u32>,
128}
129
130pub struct StateStore {
135 dir: PathBuf,
136}
137
138impl StateStore {
139 #[must_use]
143 pub fn new(dir: PathBuf) -> Self {
144 Self { dir }
145 }
146
147 pub fn from_env() -> Self {
149 let base = std::env::var_os("XDG_RUNTIME_DIR")
150 .map_or_else(|| PathBuf::from("/tmp"), PathBuf::from);
151 Self::new(base.join("agent-status"))
152 }
153
154 #[cfg(test)]
156 #[must_use]
157 pub fn dir(&self) -> &std::path::Path {
158 &self.dir
159 }
160
161 pub fn write(&self, session_id: &str, entry: &AttentionEntry) -> io::Result<()> {
168 validate_session_id(session_id)?;
169 fs::create_dir_all(&self.dir)?;
170 let json = serde_json::to_vec(entry)
171 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
172 fs::write(self.dir.join(session_id), json)
173 }
174
175 pub fn remove(&self, session_id: &str) -> io::Result<bool> {
187 validate_session_id(session_id)?;
188 match fs::remove_file(self.dir.join(session_id)) {
189 Ok(()) => Ok(true),
190 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
191 Err(e) => Err(e),
192 }
193 }
194
195 pub fn list(&self) -> io::Result<Vec<(String, AttentionEntry)>> {
204 let iter = match fs::read_dir(&self.dir) {
205 Ok(it) => it,
206 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
207 Err(e) => return Err(e),
208 };
209 let mut out = Vec::new();
210 for entry in iter {
211 let entry = entry?;
212 if !entry.file_type()?.is_file() {
213 continue;
214 }
215 let path = entry.path();
216 let name = entry.file_name().to_string_lossy().into_owned();
217 let Ok(bytes) = fs::read(&path) else {
218 continue;
219 };
220 let Ok(parsed) = serde_json::from_slice::<AttentionEntry>(&bytes) else {
221 continue;
222 };
223 if let Some(pid) = parsed.pid {
227 if !is_pid_alive(pid) {
228 let _ = fs::remove_file(&path);
229 continue;
230 }
231 }
232 out.push((name, parsed));
233 }
234 out.sort_by(|a, b| a.1.ts.cmp(&b.1.ts).then_with(|| a.0.cmp(&b.0)));
235 Ok(out)
236 }
237}
238
239fn is_pid_alive(pid: u32) -> bool {
257 if pid == 0 {
258 return false;
259 }
260 let status = std::process::Command::new("/bin/kill")
265 .args(["-0", &pid.to_string()])
266 .stderr(std::process::Stdio::null())
267 .stdout(std::process::Stdio::null())
268 .status();
269 match status {
270 Ok(s) => s.success(),
271 Err(_) => true,
272 }
273}
274
275fn validate_session_id(session_id: &str) -> io::Result<()> {
276 if session_id.is_empty()
277 || session_id.contains('/')
278 || session_id.contains(std::path::MAIN_SEPARATOR)
279 || session_id == "."
280 || session_id == ".."
281 {
282 return Err(io::Error::new(
283 io::ErrorKind::InvalidInput,
284 "invalid session_id",
285 ));
286 }
287 Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use tempfile::TempDir;
294
295 #[test]
296 fn entry_roundtrips_through_json() {
297 let entry = AttentionEntry {
298 agent: "claude-code".into(),
299 project: "claude-status".into(),
300 cwd: "/Users/x/work/claude-status".into(),
301 event: Event::Notify,
302 tmux_pane: "%42".into(),
303 ts: 1_700_000_000,
304 message: None,
305 pid: None,
306 };
307 let json = serde_json::to_string(&entry).unwrap();
308 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
309 assert_eq!(parsed, entry);
310 }
311
312 #[test]
313 fn event_known_values_serialize_as_plain_strings() {
314 for (evt, wire) in [
315 (Event::Notify, "\"notify\""),
316 (Event::Done, "\"done\""),
317 (Event::Idle, "\"idle\""),
318 (Event::Working, "\"working\""),
319 ] {
320 assert_eq!(serde_json::to_string(&evt).unwrap(), wire);
321 let parsed: Event = serde_json::from_str(wire).unwrap();
322 assert_eq!(parsed, evt);
323 }
324 }
325
326 #[test]
327 fn event_unknown_value_roundtrips_verbatim() {
328 let json = r#""compacting""#;
332 let parsed: Event = serde_json::from_str(json).unwrap();
333 assert_eq!(parsed, Event::Unknown("compacting".to_string()));
334 assert_eq!(serde_json::to_string(&parsed).unwrap(), json);
335 }
336
337 #[test]
338 fn event_needs_attention_matches_legacy_filter() {
339 assert!(Event::Notify.needs_attention());
343 assert!(Event::Done.needs_attention());
344 assert!(Event::Unknown("anything-new".into()).needs_attention());
345 assert!(!Event::Working.needs_attention());
346 assert!(!Event::Idle.needs_attention());
347 }
348
349 #[test]
350 fn entry_matches_bash_plan_field_names() {
351 let entry = AttentionEntry {
352 agent: "claude-code".into(),
353 project: "p".into(),
354 cwd: "/c".into(),
355 event: Event::Done,
356 tmux_pane: "%1".into(),
357 ts: 1,
358 message: None,
359 pid: None,
360 };
361 let v: serde_json::Value = serde_json::to_value(&entry).unwrap();
362 assert!(v.get("project").is_some());
364 assert!(v.get("cwd").is_some());
365 assert!(v.get("event").is_some());
366 assert!(v.get("tmux_pane").is_some());
367 assert!(v.get("ts").is_some());
368 assert!(v.get("agent").is_some());
370 }
371
372 fn sample_entry(project: &str) -> AttentionEntry {
373 AttentionEntry {
374 agent: "claude-code".into(),
375 project: project.into(),
376 cwd: format!("/x/{project}"),
377 event: Event::Notify,
378 tmux_pane: "%1".into(),
379 ts: 1,
380 message: None,
381 pid: None,
382 }
383 }
384
385 #[test]
386 fn write_then_list_returns_entry() {
387 let dir = TempDir::new().unwrap();
388 let store = StateStore::new(dir.path().into());
389 store.write("session-a", &sample_entry("alpha")).unwrap();
390 let listed = store.list().unwrap();
391 assert_eq!(listed.len(), 1);
392 assert_eq!(listed[0].0, "session-a");
393 assert_eq!(listed[0].1.project, "alpha");
394 }
395
396 #[test]
397 fn remove_is_idempotent() {
398 let dir = TempDir::new().unwrap();
399 let store = StateStore::new(dir.path().into());
400 assert!(!store.remove("never-existed").unwrap());
401 store.write("s1", &sample_entry("p")).unwrap();
402 assert!(store.remove("s1").unwrap());
403 assert!(!store.remove("s1").unwrap());
404 assert_eq!(store.list().unwrap().len(), 0);
405 }
406
407 #[test]
408 fn remove_returns_true_when_file_was_present() {
409 let dir = TempDir::new().unwrap();
410 let store = StateStore::new(dir.path().into());
411 store.write("s1", &sample_entry("p")).unwrap();
412 assert!(store.remove("s1").unwrap(), "first remove should report deletion");
413 }
414
415 #[test]
416 fn remove_returns_false_when_file_was_already_absent() {
417 let dir = TempDir::new().unwrap();
418 let store = StateStore::new(dir.path().into());
419 assert!(!store.remove("never-existed").unwrap());
420 store.write("s1", &sample_entry("p")).unwrap();
421 store.remove("s1").unwrap();
422 assert!(!store.remove("s1").unwrap(), "second remove should report no-op");
423 }
424
425 #[test]
426 fn list_on_missing_dir_returns_empty() {
427 let dir = TempDir::new().unwrap();
428 let path = dir.path().join("does-not-exist");
429 let store = StateStore::new(path);
430 assert_eq!(store.list().unwrap().len(), 0);
431 }
432
433 #[test]
434 fn list_skips_files_with_invalid_json() {
435 let dir = TempDir::new().unwrap();
436 let store = StateStore::new(dir.path().into());
437 store.write("good", &sample_entry("p")).unwrap();
438 std::fs::write(dir.path().join("bad"), "not json").unwrap();
439 let listed = store.list().unwrap();
440 assert_eq!(listed.len(), 1);
441 assert_eq!(listed[0].0, "good");
442 }
443
444 #[test]
445 fn from_env_path_ends_with_agent_status() {
446 let store = StateStore::from_env();
447 assert!(store.dir().ends_with("agent-status"));
448 }
449
450 #[test]
451 fn entry_message_field_roundtrips_when_set() {
452 let entry = AttentionEntry {
453 agent: "claude-code".into(),
454 project: "p".into(),
455 cwd: "/c".into(),
456 event: Event::Notify,
457 tmux_pane: "%1".into(),
458 ts: 1,
459 message: Some("Permission required".into()),
460 pid: None,
461 };
462 let json = serde_json::to_string(&entry).unwrap();
463 assert!(json.contains(r#""message":"Permission required""#));
464 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
465 assert_eq!(parsed.message.as_deref(), Some("Permission required"));
466 }
467
468 #[test]
469 fn entry_message_field_omitted_from_json_when_none() {
470 let entry = AttentionEntry {
471 agent: "claude-code".into(),
472 project: "p".into(),
473 cwd: "/c".into(),
474 event: Event::Done,
475 tmux_pane: "%1".into(),
476 ts: 1,
477 message: None,
478 pid: None,
479 };
480 let json = serde_json::to_string(&entry).unwrap();
481 assert!(!json.contains("message"), "got: {json}");
482 }
483
484 #[test]
485 fn entry_pid_field_roundtrips_when_set() {
486 let entry = AttentionEntry {
487 agent: "claude-code".into(),
488 project: "p".into(),
489 cwd: "/c".into(),
490 event: Event::Notify,
491 tmux_pane: "%1".into(),
492 ts: 1,
493 message: None,
494 pid: Some(42_000),
495 };
496 let json = serde_json::to_string(&entry).unwrap();
497 assert!(json.contains(r#""pid":42000"#));
498 let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
499 assert_eq!(parsed.pid, Some(42_000));
500 }
501
502 #[test]
503 fn entry_pid_field_omitted_from_json_when_none() {
504 let entry = AttentionEntry {
505 agent: "claude-code".into(),
506 project: "p".into(),
507 cwd: "/c".into(),
508 event: Event::Done,
509 tmux_pane: "%1".into(),
510 ts: 1,
511 message: None,
512 pid: None,
513 };
514 let json = serde_json::to_string(&entry).unwrap();
515 assert!(!json.contains("pid"), "got: {json}");
516 }
517
518 #[test]
519 fn entry_deserializes_when_pid_field_absent() {
520 let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
522 let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
523 assert!(parsed.pid.is_none());
524 }
525
526 #[test]
527 fn entry_deserializes_when_message_field_absent() {
528 let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
530 let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
531 assert!(parsed.message.is_none());
532 }
533
534 #[test]
535 fn write_rejects_path_traversal_session_id() {
536 let dir = TempDir::new().unwrap();
537 let store = StateStore::new(dir.path().into());
538 let entry = sample_entry("p");
539 for bad in ["../escape", "a/b", "..", ".", ""] {
540 let err = store.write(bad, &entry).unwrap_err();
541 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "bad id: {bad:?}");
542 }
543 let err = store.remove("../escape").unwrap_err();
544 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
545 }
546
547 #[test]
548 fn is_pid_alive_returns_true_for_self() {
549 let me = std::process::id();
550 assert!(is_pid_alive(me), "kill -0 of own pid should succeed");
551 }
552
553 #[test]
554 fn is_pid_alive_returns_false_for_impossible_pid() {
555 assert!(!is_pid_alive(1_000_000_000));
557 }
558
559 #[test]
560 fn is_pid_alive_returns_false_for_pid_zero() {
561 assert!(!is_pid_alive(0));
565 }
566
567 #[test]
568 fn list_prunes_entries_with_dead_pid() {
569 let dir = TempDir::new().unwrap();
570 let store = StateStore::new(dir.path().into());
571
572 let mut alive = sample_entry("alive");
573 alive.pid = Some(std::process::id());
574 store.write("session-alive", &alive).unwrap();
575
576 let mut dead = sample_entry("dead");
577 dead.pid = Some(1_000_000_000);
578 store.write("session-dead", &dead).unwrap();
579
580 let listed = store.list().unwrap();
581 assert_eq!(listed.len(), 1, "should keep only the alive entry");
582 assert_eq!(listed[0].0, "session-alive");
583
584 assert!(!dir.path().join("session-dead").exists());
585 }
586
587 #[test]
588 fn list_keeps_entries_without_pid() {
589 let dir = TempDir::new().unwrap();
590 let store = StateStore::new(dir.path().into());
591 let no_pid_entry = sample_entry("legacy");
592 store.write("session-legacy", &no_pid_entry).unwrap();
593
594 let listed = store.list().unwrap();
595 assert_eq!(listed.len(), 1);
596 }
597}