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 bash precursor's five original
102/// field *names* (`project`, `cwd`, `event`, `tmux_pane`, `ts`) are preserved so
103/// readers expecting that vocabulary still find what they need. `tmux_pane` is
104/// now an `Option<String>` rather than the bash version's required string with
105/// an `""` sentinel: empty strings deserialize to `None`, and `None` is omitted
106/// when serializing. The struct has grown additional optional fields over time
107/// (`message`, `pid`, `tmux_session`); each carries `#[serde(default)]` so
108/// older state files lacking the field still load.
109#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
110pub struct AttentionEntry {
111    /// Stable identifier of the agent that wrote this entry (e.g. `"claude-code"`).
112    pub agent: String,
113    /// Unix timestamp (seconds) when the entry was written.
114    pub ts: u64,
115    /// Hook event the producing agent reported.
116    pub event: Event,
117    /// Basename of the project directory (typically the cwd's last component).
118    pub project: String,
119    /// Absolute path of the project directory at the time the hook fired.
120    pub cwd: 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    /// Name of the tmux session the hook fired from (the `#S` of the owning
129    /// pane), or `None` when the hook fired outside tmux or the lookup failed.
130    /// Preferred over `project` as the human-facing handle in the switcher
131    /// because it carries the full multi-worktree context. `#[serde(default)]`
132    /// lets entries written by older binaries (which lack this field)
133    /// deserialize as `None`.
134    #[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    /// Tmux pane id (such as `%17`), or `None` if the hook fired outside tmux.
141    /// `#[serde(default)]` lets entries written by older binaries (which
142    /// always emitted the field, possibly as `""`) still deserialize. The
143    /// `empty_string_as_none` deserializer normalizes the bash precursor's
144    /// `""` sentinel to `None` so callers only need to check one shape.
145    #[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    /// Optional last-message text from the agent (e.g. Claude Code Notification's `message`
152    /// field). Absent in the JSON when `None`; absent on entries written by older binaries.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub message: Option<String>,
155}
156
157/// Reads, writes and lists [`AttentionEntry`] files under a single state directory.
158///
159/// Each session writes one file keyed by its `session_id`, so concurrent writers from
160/// different sessions never contend on the same path — no locking is required.
161pub struct StateStore {
162    dir: PathBuf,
163}
164
165impl StateStore {
166    /// Construct a store backed by `dir`.
167    ///
168    /// The directory does not need to exist yet — [`write`](Self::write) creates it on demand.
169    #[must_use]
170    pub fn new(dir: PathBuf) -> Self {
171        Self { dir }
172    }
173
174    /// Construct a store under `${XDG_RUNTIME_DIR:-/tmp}/agent-status/`.
175    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    /// Path of the state directory.
182    #[cfg(test)]
183    #[must_use]
184    pub fn dir(&self) -> &std::path::Path {
185        &self.dir
186    }
187
188    /// Write an entry for `session_id`, creating the state directory if needed.
189    ///
190    /// # Errors
191    /// Returns the underlying I/O error if the directory cannot be created or the file cannot
192    /// be written. Returns [`io::ErrorKind::InvalidInput`] when `session_id` is empty or
193    /// contains a path separator (defense against path-traversal).
194    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    /// Remove the entry for `session_id`. Idempotent: returns `Ok(false)` when
203    /// the file is absent and `Ok(true)` when a file was actually deleted.
204    ///
205    /// Callers can use the bool to skip side effects (e.g. tmux refresh) on
206    /// no-op clears — relevant for hooks like Claude Code's `PreToolUse` that
207    /// fire on every tool call and would otherwise generate excessive refreshes.
208    ///
209    /// # Errors
210    /// Returns the underlying I/O error if removal fails for a reason other
211    /// than `NotFound`. Returns [`io::ErrorKind::InvalidInput`] when
212    /// `session_id` is empty or contains a path separator.
213    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    /// List all entries in the state directory, sorted by timestamp ascending then `session_id`.
223    ///
224    /// Files with invalid JSON or unreadable content are silently skipped — they are treated
225    /// as if absent. Returns an empty `Vec` when the directory does not exist.
226    ///
227    /// # Errors
228    /// Returns the underlying I/O error if `read_dir` or per-entry metadata access fails for
229    /// a reason other than `NotFound`.
230    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            // Auto-prune entries whose owning process is dead. Entries with no
251            // recorded pid (older binaries; bash precursor) are kept as-is — we
252            // have no way to verify their liveness.
253            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
266/// Returns whether `pid` is a live process the current user can signal.
267///
268/// Uses `kill -0 <pid>` (POSIX). Returns `true` iff the command exits 0, which
269/// means: the pid exists, and the caller has permission to send it a signal. A
270/// dead pid, a pid in another user's namespace, or `pid == 0` (which `kill(2)`
271/// treats as the whole process group — not what we want) all return `false`.
272///
273/// We deliberately do not use `libc::kill` directly so the crate keeps
274/// `unsafe_code = "forbid"`. The cost is one fork+exec of `/bin/kill` per
275/// entry checked; with the typical handful of waiting sessions this is well
276/// under a millisecond and fires only on `agent-status status`/`list` and
277/// `agent-switcher`'s tick (state-directory refresh).
278///
279/// Fails open: if the `kill` command can't be spawned at all (no `/bin/kill`,
280/// stripped `$PATH` in a hardened user-service env, …), we return `true` so
281/// the caller keeps the state file. Pruning every live entry on an unrelated
282/// platform misconfiguration would be much worse than skipping the prune.
283fn is_pid_alive(pid: u32) -> bool {
284    if pid == 0 {
285        return false;
286    }
287    // Absolute path so a stripped or hostile $PATH can't shadow us with a
288    // fake `kill`. /bin/kill is present on every POSIX target this crate
289    // supports (Darwin, Linux). Falls through to a $PATH lookup only if the
290    // absolute path doesn't exist.
291    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
317/// Map both an absent JSON field and an empty string to `None`. Lets old state
318/// files (which always emitted `tmux_pane: ""` outside tmux) deserialize into
319/// the modern `Option<String>` shape without callers having to double-check
320/// `is_empty()`.
321fn 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        // Forward compat: a future agent emitting a new event string must
369        // deserialize cleanly and re-serialize without losing the original
370        // text, so a mixed-version setup doesn't silently rewrite state.
371        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        // notify + done + Unknown(future event) → surface in tmux/list;
380        // working + idle → hide. This is the contract the bash precursor
381        // and the v0.2.0+ binary share.
382        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        // Original fields from the bash precursor — must not be renamed/removed.
404        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        // New attribution field added when this CLI grew multi-agent support.
410        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        // Older state files (no pid field) must still load.
567        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        // Old state files written before this field was added must still load.
575        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        // Older binaries don't emit this field; default must yield None.
619        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        // The bash precursor emitted `tmux_pane: ""` outside tmux. The
627        // empty-string deserializer collapses that to None so callers only
628        // need to handle one shape.
629        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        // pid_max on Linux is typically 4194304 (2^22); macOS 99998. Both well below 1_000_000_000.
680        assert!(!is_pid_alive(1_000_000_000));
681    }
682
683    #[test]
684    fn is_pid_alive_returns_false_for_pid_zero() {
685        // kill(0, 0) signals the whole process group — not what we want. The helper
686        // must reject pid 0 explicitly so a corrupted state file with pid:0 doesn't
687        // accidentally keep itself alive.
688        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}