Skip to main content

agent_status/
state.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::io;
4use std::path::PathBuf;
5
6/// One entry stored per active agent session that is waiting on user attention.
7///
8/// Serialized as compact JSON to one file per session (keyed by `session_id`) under
9/// `${XDG_RUNTIME_DIR:-/tmp}/agent-status/`. The field shape is wire-compatible with
10/// the bash version of this tool.
11#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
12pub struct AttentionEntry {
13    /// Stable identifier of the agent that wrote this entry (e.g. `"claude-code"`).
14    pub agent: String,
15    /// Basename of the project directory (typically the cwd's last component).
16    pub project: String,
17    /// Absolute path of the project directory at the time the hook fired.
18    pub cwd: String,
19    /// Hook event label, for example `notify` or `done`.
20    pub event: String,
21    /// Tmux pane id (such as `%17`), or empty if the hook fired outside tmux.
22    pub tmux_pane: String,
23    /// Unix timestamp (seconds) when the entry was written.
24    pub ts: u64,
25    /// Optional last-message text from the agent (e.g. Claude Code Notification's `message`
26    /// field). Absent in the JSON when `None`; absent on entries written by older binaries.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub message: Option<String>,
29    /// PID of the agent process at the time the hook fired (typically `getppid()`
30    /// from inside the hook script — the claude/opencode/pi binary). Used to clean
31    /// up state files whose owning process has exited without firing its
32    /// session-end hook. Absent in entries written by older binaries; entries
33    /// without a pid are never auto-pruned.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub pid: Option<u32>,
36}
37
38/// Reads, writes and lists [`AttentionEntry`] files under a single state directory.
39///
40/// Each session writes one file keyed by its `session_id`, so concurrent writers from
41/// different sessions never contend on the same path — no locking is required.
42pub struct StateStore {
43    dir: PathBuf,
44}
45
46impl StateStore {
47    /// Construct a store backed by `dir`.
48    ///
49    /// The directory does not need to exist yet — [`write`](Self::write) creates it on demand.
50    #[must_use]
51    pub fn new(dir: PathBuf) -> Self {
52        Self { dir }
53    }
54
55    /// Construct a store under `${XDG_RUNTIME_DIR:-/tmp}/agent-status/`.
56    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    /// Path of the state directory.
63    #[cfg(test)]
64    #[must_use]
65    pub fn dir(&self) -> &std::path::Path {
66        &self.dir
67    }
68
69    /// Write an entry for `session_id`, creating the state directory if needed.
70    ///
71    /// # Errors
72    /// Returns the underlying I/O error if the directory cannot be created or the file cannot
73    /// be written. Returns [`io::ErrorKind::InvalidInput`] when `session_id` is empty or
74    /// contains a path separator (defense against path-traversal).
75    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    /// Remove the entry for `session_id`. Idempotent: returns `Ok(false)` when
84    /// the file is absent and `Ok(true)` when a file was actually deleted.
85    ///
86    /// Callers can use the bool to skip side effects (e.g. tmux refresh) on
87    /// no-op clears — relevant for hooks like Claude Code's `PreToolUse` that
88    /// fire on every tool call and would otherwise generate excessive refreshes.
89    ///
90    /// # Errors
91    /// Returns the underlying I/O error if removal fails for a reason other
92    /// than `NotFound`. Returns [`io::ErrorKind::InvalidInput`] when
93    /// `session_id` is empty or contains a path separator.
94    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    /// List all entries in the state directory, sorted by timestamp ascending then `session_id`.
104    ///
105    /// Files with invalid JSON or unreadable content are silently skipped — they are treated
106    /// as if absent. Returns an empty `Vec` when the directory does not exist.
107    ///
108    /// # Errors
109    /// Returns the underlying I/O error if `read_dir` or per-entry metadata access fails for
110    /// a reason other than `NotFound`.
111    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            // Auto-prune entries whose owning process is dead. Entries with no
132            // recorded pid (older binaries; bash precursor) are kept as-is — we
133            // have no way to verify their liveness.
134            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
147/// Returns whether `pid` is a live process the current user can signal.
148///
149/// Uses `kill -0 <pid>` (POSIX). Returns `true` iff the command exits 0, which
150/// means: the pid exists, and the caller has permission to send it a signal. A
151/// dead pid, a pid in another user's namespace, or `pid == 0` (which `kill(2)`
152/// treats as the whole process group — not what we want) all return `false`.
153///
154/// We deliberately do not use `libc::kill` directly so the crate keeps
155/// `unsafe_code = "forbid"`. The cost is one fork+exec of `/bin/kill` per
156/// entry checked; with the typical handful of waiting sessions this is well
157/// under a millisecond and fires only on `agent-status status`/`list` and
158/// `agent-switcher`'s tick (state-directory refresh).
159///
160/// Fails open: if the `kill` command can't be spawned at all (no `/bin/kill`,
161/// stripped `$PATH` in a hardened user-service env, …), we return `true` so
162/// the caller keeps the state file. Pruning every live entry on an unrelated
163/// platform misconfiguration would be much worse than skipping the prune.
164fn is_pid_alive(pid: u32) -> bool {
165    if pid == 0 {
166        return false;
167    }
168    // Absolute path so a stripped or hostile $PATH can't shadow us with a
169    // fake `kill`. /bin/kill is present on every POSIX target this crate
170    // supports (Darwin, Linux). Falls through to a $PATH lookup only if the
171    // absolute path doesn't exist.
172    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        // Original fields from the bash precursor — must not be renamed/removed.
234        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        // New attribution field added when this CLI grew multi-agent support.
240        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        // Older state files (no pid field) must still load.
392        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        // Old state files written before this field was added must still load.
400        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        // pid_max on Linux is typically 4194304 (2^22); macOS 99998. Both well below 1_000_000_000.
427        assert!(!is_pid_alive(1_000_000_000));
428    }
429
430    #[test]
431    fn is_pid_alive_returns_false_for_pid_zero() {
432        // kill(0, 0) signals the whole process group — not what we want. The helper
433        // must reject pid 0 explicitly so a corrupted state file with pid:0 doesn't
434        // accidentally keep itself alive.
435        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}