Skip to main content

tmx/agents/
attention.rs

1//! Read the cross-tool attention queue at
2//! `${XDG_CACHE_HOME:-$HOME/.cache}/claude-attention/queue.jsonl`.
3//!
4//! This file is the shared "notification inbox" produced by the
5//! `notify-stop.sh` / `notify-notification.sh` / `notify-codex.sh`
6//! hooks. Each line is one event Claude or Codex thinks the user should
7//! eventually look at — turn finished, prompt waiting, codex response
8//! ready. `attention-picker.sh` (bound to `prefix + A`) consumes them
9//! via fzf with a 1-hour cutoff; we apply the same cutoff so the
10//! dashboard agrees with the picker on "what's pending".
11//!
12//! The file is append-only — entries leave only when the picker
13//! deletes them on consumption. We tolerate malformed lines (truncated
14//! writes, hand-edited junk) by skipping them.
15
16use std::fs;
17use std::path::PathBuf;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20/// One pending attention entry.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct AttentionEntry {
23    pub ts: i64,
24    pub kind: String,
25    pub source: String,
26    pub title: String,
27    pub body: String,
28    pub session_id: String,
29    pub cwd: PathBuf,
30    pub tmux_target: String,
31    pub tmux_session: String,
32}
33
34#[derive(serde::Deserialize)]
35struct Raw {
36    ts: i64,
37    #[serde(default)]
38    kind: String,
39    #[serde(default)]
40    source: String,
41    #[serde(default)]
42    title: String,
43    #[serde(default)]
44    body: String,
45    #[serde(default)]
46    session_id: String,
47    #[serde(default)]
48    cwd: String,
49    #[serde(default)]
50    tmux_target: String,
51    #[serde(default)]
52    tmux_session: String,
53}
54
55/// Path the bash hooks write to. Mirrors `${XDG_CACHE_HOME:-$HOME/.cache}`
56/// exactly — we deliberately do NOT use `dirs::cache_dir()` because on
57/// macOS that returns `~/Library/Caches`, while the shell hooks fall back
58/// to `$HOME/.cache` even on macOS.
59pub fn queue_path() -> Option<PathBuf> {
60    let cache = std::env::var("XDG_CACHE_HOME")
61        .ok()
62        .filter(|s| !s.is_empty())
63        .map(PathBuf::from)
64        .or_else(|| dirs::home_dir().map(|h| h.join(".cache")))?;
65    Some(cache.join("claude-attention/queue.jsonl"))
66}
67
68/// Read the queue, filter to entries with `ts >= now - cutoff_secs`,
69/// return newest first. Missing / unreadable file → empty Vec.
70pub fn read_recent(cutoff_secs: i64) -> Vec<AttentionEntry> {
71    let Some(path) = queue_path() else {
72        return Vec::new();
73    };
74    let Ok(bytes) = fs::read(&path) else {
75        return Vec::new();
76    };
77    let now = SystemTime::now()
78        .duration_since(UNIX_EPOCH)
79        .map(|d| d.as_secs() as i64)
80        .unwrap_or(0);
81    let cutoff = now.saturating_sub(cutoff_secs);
82    parse_recent(&bytes, cutoff)
83}
84
85pub fn parse_recent(bytes: &[u8], cutoff: i64) -> Vec<AttentionEntry> {
86    let mut entries: Vec<AttentionEntry> = bytes
87        .split(|b| *b == b'\n')
88        .filter(|line| !line.is_empty())
89        .filter_map(|line| serde_json::from_slice::<Raw>(line).ok())
90        .filter(|r| r.ts >= cutoff)
91        .map(|r| AttentionEntry {
92            ts: r.ts,
93            kind: r.kind,
94            source: r.source,
95            title: r.title,
96            body: r.body,
97            session_id: r.session_id,
98            cwd: PathBuf::from(r.cwd),
99            tmux_target: r.tmux_target,
100            tmux_session: r.tmux_session,
101        })
102        .collect();
103    // Newest first — picker shows newest-first via `tac`; we sort
104    // explicitly so the file order doesn't matter.
105    entries.sort_by_key(|e| std::cmp::Reverse(e.ts));
106    entries
107}
108
109/// Render an epoch-second timestamp as a short relative age string
110/// (`12s`, `4m`, `2h`, `3d`). Matches the `human_age` helper in
111/// `attention-picker.sh` for visual consistency.
112pub fn human_age(ts: i64, now: i64) -> String {
113    let secs = (now - ts).max(0);
114    if secs < 60 {
115        format!("{secs}s")
116    } else if secs < 3600 {
117        format!("{}m", secs / 60)
118    } else if secs < 86_400 {
119        format!("{}h", secs / 3600)
120    } else {
121        format!("{}d", secs / 86_400)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    fn line(ts: i64, kind: &str, body: &str) -> String {
130        format!(
131            r#"{{"ts":{ts},"kind":"{kind}","source":"claude","title":"t","body":"{body}","session_id":"sid","cwd":"/x","tmux_session":"s","tmux_window_idx":"0","tmux_window_name":"n","tmux_target":"s:0","tmux_socket":"","tmux_bin":"","tmux_client_pid":"","terminal_pid":""}}"#
132        )
133    }
134
135    #[test]
136    fn parse_recent_drops_entries_below_cutoff() {
137        let now: i64 = 10_000;
138        let cutoff = now - 3600;
139        let raw = format!(
140            "{}\n{}\n{}\n",
141            line(now - 60, "notification", "fresh"),
142            line(now - 7200, "stop", "stale"),
143            line(now - 300, "codex-turn", "also-fresh"),
144        );
145        let entries = parse_recent(raw.as_bytes(), cutoff);
146        assert_eq!(entries.len(), 2);
147        // Newest first
148        assert_eq!(entries[0].body, "fresh");
149        assert_eq!(entries[1].body, "also-fresh");
150    }
151
152    #[test]
153    fn parse_recent_tolerates_malformed_lines() {
154        let raw = b"\
155{\"ts\":100,\"kind\":\"notification\",\"body\":\"ok\"}
156not-json
157{\"ts\":200,\"kind\":\"stop\",\"body\":\"ok2\"}
158"
159        .to_vec();
160        let entries = parse_recent(&raw, 0);
161        assert_eq!(entries.len(), 2);
162        assert_eq!(entries[0].body, "ok2");
163        assert_eq!(entries[1].body, "ok");
164    }
165
166    #[test]
167    fn parse_recent_empty_returns_empty() {
168        assert!(parse_recent(b"", 0).is_empty());
169        assert!(parse_recent(b"\n\n", 0).is_empty());
170    }
171
172    #[test]
173    fn parse_recent_missing_ts_skipped() {
174        // ts is required (no #[serde(default)]); entries without it
175        // should not appear.
176        let raw = br#"{"kind":"stop","body":"no-ts"}
177{"ts":42,"kind":"stop","body":"ok"}
178"#;
179        let entries = parse_recent(raw, 0);
180        assert_eq!(entries.len(), 1);
181        assert_eq!(entries[0].body, "ok");
182    }
183
184    #[test]
185    fn human_age_buckets() {
186        assert_eq!(human_age(0, 30), "30s");
187        assert_eq!(human_age(0, 120), "2m");
188        assert_eq!(human_age(0, 7200), "2h");
189        assert_eq!(human_age(0, 200_000), "2d");
190        // Future timestamp (clock skew) → 0 floor.
191        assert_eq!(human_age(100, 0), "0s");
192    }
193
194    #[test]
195    fn queue_path_respects_xdg_cache_home() {
196        let saved = std::env::var("XDG_CACHE_HOME").ok();
197        // SAFETY: tests are single-threaded for this module since the
198        // env var is scoped to this body and restored on exit. (Cargo
199        // runs tests in parallel across modules; this var is unlikely
200        // to be observed by another test.)
201        unsafe {
202            std::env::set_var("XDG_CACHE_HOME", "/tmp/_tmx_test_cache");
203        }
204        let p = queue_path().unwrap();
205        assert!(p.ends_with("claude-attention/queue.jsonl"));
206        assert!(p.starts_with("/tmp/_tmx_test_cache"));
207        // Restore.
208        unsafe {
209            match saved {
210                Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
211                None => std::env::remove_var("XDG_CACHE_HOME"),
212            }
213        }
214    }
215}