Skip to main content

agent_status/
state.rs

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/// Lifecycle state the producing hook reported for a session.
9///
10/// On the wire this is a plain JSON string — the four known values
11/// (`notify`, `done`, `working`, `idle`) round-trip through their named
12/// variants, and anything else is preserved verbatim in `Unknown(String)`
13/// so new hook event types added by future agents don't break older
14/// binaries. The variant order matches the switcher's display priority
15/// (most-attention-needing first): `Notify`, `Done`, `Idle`, `Working`,
16/// `Unknown`.
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub enum Event {
19    /// Agent explicitly signals the user (Claude Code `Notification` /
20    /// `PermissionRequest`, pi `before_user_input` analogues).
21    Notify,
22    /// Agent just finished a turn (Claude Code `Stop`, pi `agent_end`).
23    Done,
24    /// Session is alive but not interacting (Claude Code `SessionStart`
25    /// placeholder, pi `session_start`).
26    Idle,
27    /// Agent is in the middle of working — typing, calling tools
28    /// (Claude Code `UserPromptSubmit` / `PreToolUse`, pi
29    /// `before_agent_start` / `tool_execution_start`).
30    Working,
31    /// Forward-compat: a hook reported an event string we don't recognize.
32    /// Kept verbatim so re-serialization is lossless.
33    Unknown(String),
34}
35
36impl Event {
37    /// Borrow this event as the wire string the hooks use.
38    #[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    /// Whether this event represents a session asking for the user's eyes
50    /// right now. `Notify` is an explicit "blocked on you" signal; `Done`
51    /// is the just-finished state the next prompt will move on from. Any
52    /// future / unknown event value is treated as attention-worthy so a
53    /// new hook type added by an agent does not silently disappear from
54    /// the tmux indicator. `Working` and `Idle` are alive-but-not-asking.
55    #[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/// One entry stored per active agent session that is waiting on user attention.
99///
100/// Serialized as compact JSON to one file per session (keyed by `session_id`) under
101/// `${XDG_RUNTIME_DIR:-/tmp}/agent-status/`. The field shape is wire-compatible with
102/// the bash version of this tool.
103#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
104pub struct AttentionEntry {
105    /// Stable identifier of the agent that wrote this entry (e.g. `"claude-code"`).
106    pub agent: String,
107    /// Basename of the project directory (typically the cwd's last component).
108    pub project: String,
109    /// Absolute path of the project directory at the time the hook fired.
110    pub cwd: String,
111    /// Hook event the producing agent reported.
112    pub event: Event,
113    /// Tmux pane id (such as `%17`), or empty if the hook fired outside tmux.
114    pub tmux_pane: String,
115    /// Unix timestamp (seconds) when the entry was written.
116    pub ts: u64,
117    /// Optional last-message text from the agent (e.g. Claude Code Notification's `message`
118    /// field). Absent in the JSON when `None`; absent on entries written by older binaries.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub message: Option<String>,
121    /// PID of the agent process at the time the hook fired (typically `getppid()`
122    /// from inside the hook script — the claude/opencode/pi binary). Used to clean
123    /// up state files whose owning process has exited without firing its
124    /// session-end hook. Absent in entries written by older binaries; entries
125    /// without a pid are never auto-pruned.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub pid: Option<u32>,
128}
129
130/// Reads, writes and lists [`AttentionEntry`] files under a single state directory.
131///
132/// Each session writes one file keyed by its `session_id`, so concurrent writers from
133/// different sessions never contend on the same path — no locking is required.
134pub struct StateStore {
135    dir: PathBuf,
136}
137
138impl StateStore {
139    /// Construct a store backed by `dir`.
140    ///
141    /// The directory does not need to exist yet — [`write`](Self::write) creates it on demand.
142    #[must_use]
143    pub fn new(dir: PathBuf) -> Self {
144        Self { dir }
145    }
146
147    /// Construct a store under `${XDG_RUNTIME_DIR:-/tmp}/agent-status/`.
148    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    /// Path of the state directory.
155    #[cfg(test)]
156    #[must_use]
157    pub fn dir(&self) -> &std::path::Path {
158        &self.dir
159    }
160
161    /// Write an entry for `session_id`, creating the state directory if needed.
162    ///
163    /// # Errors
164    /// Returns the underlying I/O error if the directory cannot be created or the file cannot
165    /// be written. Returns [`io::ErrorKind::InvalidInput`] when `session_id` is empty or
166    /// contains a path separator (defense against path-traversal).
167    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    /// Remove the entry for `session_id`. Idempotent: returns `Ok(false)` when
176    /// the file is absent and `Ok(true)` when a file was actually deleted.
177    ///
178    /// Callers can use the bool to skip side effects (e.g. tmux refresh) on
179    /// no-op clears — relevant for hooks like Claude Code's `PreToolUse` that
180    /// fire on every tool call and would otherwise generate excessive refreshes.
181    ///
182    /// # Errors
183    /// Returns the underlying I/O error if removal fails for a reason other
184    /// than `NotFound`. Returns [`io::ErrorKind::InvalidInput`] when
185    /// `session_id` is empty or contains a path separator.
186    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    /// List all entries in the state directory, sorted by timestamp ascending then `session_id`.
196    ///
197    /// Files with invalid JSON or unreadable content are silently skipped — they are treated
198    /// as if absent. Returns an empty `Vec` when the directory does not exist.
199    ///
200    /// # Errors
201    /// Returns the underlying I/O error if `read_dir` or per-entry metadata access fails for
202    /// a reason other than `NotFound`.
203    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            // Auto-prune entries whose owning process is dead. Entries with no
224            // recorded pid (older binaries; bash precursor) are kept as-is — we
225            // have no way to verify their liveness.
226            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
239/// Returns whether `pid` is a live process the current user can signal.
240///
241/// Uses `kill -0 <pid>` (POSIX). Returns `true` iff the command exits 0, which
242/// means: the pid exists, and the caller has permission to send it a signal. A
243/// dead pid, a pid in another user's namespace, or `pid == 0` (which `kill(2)`
244/// treats as the whole process group — not what we want) all return `false`.
245///
246/// We deliberately do not use `libc::kill` directly so the crate keeps
247/// `unsafe_code = "forbid"`. The cost is one fork+exec of `/bin/kill` per
248/// entry checked; with the typical handful of waiting sessions this is well
249/// under a millisecond and fires only on `agent-status status`/`list` and
250/// `agent-switcher`'s tick (state-directory refresh).
251///
252/// Fails open: if the `kill` command can't be spawned at all (no `/bin/kill`,
253/// stripped `$PATH` in a hardened user-service env, …), we return `true` so
254/// the caller keeps the state file. Pruning every live entry on an unrelated
255/// platform misconfiguration would be much worse than skipping the prune.
256fn is_pid_alive(pid: u32) -> bool {
257    if pid == 0 {
258        return false;
259    }
260    // Absolute path so a stripped or hostile $PATH can't shadow us with a
261    // fake `kill`. /bin/kill is present on every POSIX target this crate
262    // supports (Darwin, Linux). Falls through to a $PATH lookup only if the
263    // absolute path doesn't exist.
264    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        // Forward compat: a future agent emitting a new event string must
329        // deserialize cleanly and re-serialize without losing the original
330        // text, so a mixed-version setup doesn't silently rewrite state.
331        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        // notify + done + Unknown(future event) → surface in tmux/list;
340        // working + idle → hide. This is the contract the bash precursor
341        // and the v0.2.0+ binary share.
342        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        // Original fields from the bash precursor — must not be renamed/removed.
363        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        // New attribution field added when this CLI grew multi-agent support.
369        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        // Older state files (no pid field) must still load.
521        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        // Old state files written before this field was added must still load.
529        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        // pid_max on Linux is typically 4194304 (2^22); macOS 99998. Both well below 1_000_000_000.
556        assert!(!is_pid_alive(1_000_000_000));
557    }
558
559    #[test]
560    fn is_pid_alive_returns_false_for_pid_zero() {
561        // kill(0, 0) signals the whole process group — not what we want. The helper
562        // must reject pid 0 explicitly so a corrupted state file with pid:0 doesn't
563        // accidentally keep itself alive.
564        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}