agent-status 1.0.2

Tmux-integrated indicator showing which AI coding agent sessions are waiting on user input.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;

/// One entry stored per active agent session that is waiting on user attention.
///
/// Serialized as compact JSON to one file per session (keyed by `session_id`) under
/// `${XDG_RUNTIME_DIR:-/tmp}/agent-status/`. The field shape is wire-compatible with
/// the bash version of this tool.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct AttentionEntry {
    /// Stable identifier of the agent that wrote this entry (e.g. `"claude-code"`).
    pub agent: String,
    /// Basename of the project directory (typically the cwd's last component).
    pub project: String,
    /// Absolute path of the project directory at the time the hook fired.
    pub cwd: String,
    /// Hook event label, for example `notify` or `done`.
    pub event: String,
    /// Tmux pane id (such as `%17`), or empty if the hook fired outside tmux.
    pub tmux_pane: String,
    /// Unix timestamp (seconds) when the entry was written.
    pub ts: u64,
    /// Optional last-message text from the agent (e.g. Claude Code Notification's `message`
    /// field). Absent in the JSON when `None`; absent on entries written by older binaries.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    /// PID of the agent process at the time the hook fired (typically `getppid()`
    /// from inside the hook script — the claude/opencode/pi binary). Used to clean
    /// up state files whose owning process has exited without firing its
    /// session-end hook. Absent in entries written by older binaries; entries
    /// without a pid are never auto-pruned.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pid: Option<u32>,
}

/// Reads, writes and lists [`AttentionEntry`] files under a single state directory.
///
/// Each session writes one file keyed by its `session_id`, so concurrent writers from
/// different sessions never contend on the same path — no locking is required.
pub struct StateStore {
    dir: PathBuf,
}

impl StateStore {
    /// Construct a store backed by `dir`.
    ///
    /// The directory does not need to exist yet — [`write`](Self::write) creates it on demand.
    #[must_use]
    pub fn new(dir: PathBuf) -> Self {
        Self { dir }
    }

    /// Construct a store under `${XDG_RUNTIME_DIR:-/tmp}/agent-status/`.
    pub fn from_env() -> Self {
        let base = std::env::var_os("XDG_RUNTIME_DIR")
            .map_or_else(|| PathBuf::from("/tmp"), PathBuf::from);
        Self::new(base.join("agent-status"))
    }

    /// Path of the state directory.
    #[cfg(test)]
    #[must_use]
    pub fn dir(&self) -> &std::path::Path {
        &self.dir
    }

    /// Write an entry for `session_id`, creating the state directory if needed.
    ///
    /// # Errors
    /// Returns the underlying I/O error if the directory cannot be created or the file cannot
    /// be written. Returns [`io::ErrorKind::InvalidInput`] when `session_id` is empty or
    /// contains a path separator (defense against path-traversal).
    pub fn write(&self, session_id: &str, entry: &AttentionEntry) -> io::Result<()> {
        validate_session_id(session_id)?;
        fs::create_dir_all(&self.dir)?;
        let json = serde_json::to_vec(entry)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        fs::write(self.dir.join(session_id), json)
    }

    /// Remove the entry for `session_id`. Idempotent: returns `Ok(false)` when
    /// the file is absent and `Ok(true)` when a file was actually deleted.
    ///
    /// Callers can use the bool to skip side effects (e.g. tmux refresh) on
    /// no-op clears — relevant for hooks like Claude Code's `PreToolUse` that
    /// fire on every tool call and would otherwise generate excessive refreshes.
    ///
    /// # Errors
    /// Returns the underlying I/O error if removal fails for a reason other
    /// than `NotFound`. Returns [`io::ErrorKind::InvalidInput`] when
    /// `session_id` is empty or contains a path separator.
    pub fn remove(&self, session_id: &str) -> io::Result<bool> {
        validate_session_id(session_id)?;
        match fs::remove_file(self.dir.join(session_id)) {
            Ok(()) => Ok(true),
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
            Err(e) => Err(e),
        }
    }

    /// List all entries in the state directory, sorted by timestamp ascending then `session_id`.
    ///
    /// Files with invalid JSON or unreadable content are silently skipped — they are treated
    /// as if absent. Returns an empty `Vec` when the directory does not exist.
    ///
    /// # Errors
    /// Returns the underlying I/O error if `read_dir` or per-entry metadata access fails for
    /// a reason other than `NotFound`.
    pub fn list(&self) -> io::Result<Vec<(String, AttentionEntry)>> {
        let iter = match fs::read_dir(&self.dir) {
            Ok(it) => it,
            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(e) => return Err(e),
        };
        let mut out = Vec::new();
        for entry in iter {
            let entry = entry?;
            if !entry.file_type()?.is_file() {
                continue;
            }
            let path = entry.path();
            let name = entry.file_name().to_string_lossy().into_owned();
            let Ok(bytes) = fs::read(&path) else {
                continue;
            };
            let Ok(parsed) = serde_json::from_slice::<AttentionEntry>(&bytes) else {
                continue;
            };
            // Auto-prune entries whose owning process is dead. Entries with no
            // recorded pid (older binaries; bash precursor) are kept as-is — we
            // have no way to verify their liveness.
            if let Some(pid) = parsed.pid {
                if !is_pid_alive(pid) {
                    let _ = fs::remove_file(&path);
                    continue;
                }
            }
            out.push((name, parsed));
        }
        out.sort_by(|a, b| a.1.ts.cmp(&b.1.ts).then_with(|| a.0.cmp(&b.0)));
        Ok(out)
    }
}

/// Returns whether `pid` is a live process the current user can signal.
///
/// Uses `kill -0 <pid>` (POSIX). Returns `true` iff the command exits 0, which
/// means: the pid exists, and the caller has permission to send it a signal. A
/// dead pid, a pid in another user's namespace, or `pid == 0` (which `kill(2)`
/// treats as the whole process group — not what we want) all return `false`.
///
/// We deliberately do not use `libc::kill` directly so the crate keeps
/// `unsafe_code = "forbid"`. The cost is one fork+exec of `/bin/kill` per
/// entry checked; with the typical handful of waiting sessions this is well
/// under a millisecond and fires only on `agent-status status`/`list` and
/// `agent-switcher`'s tick (state-directory refresh).
///
/// Fails open: if the `kill` command can't be spawned at all (no `/bin/kill`,
/// stripped `$PATH` in a hardened user-service env, …), we return `true` so
/// the caller keeps the state file. Pruning every live entry on an unrelated
/// platform misconfiguration would be much worse than skipping the prune.
fn is_pid_alive(pid: u32) -> bool {
    if pid == 0 {
        return false;
    }
    // Absolute path so a stripped or hostile $PATH can't shadow us with a
    // fake `kill`. /bin/kill is present on every POSIX target this crate
    // supports (Darwin, Linux). Falls through to a $PATH lookup only if the
    // absolute path doesn't exist.
    let status = std::process::Command::new("/bin/kill")
        .args(["-0", &pid.to_string()])
        .stderr(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .status();
    match status {
        Ok(s) => s.success(),
        Err(_) => true,
    }
}

fn validate_session_id(session_id: &str) -> io::Result<()> {
    if session_id.is_empty()
        || session_id.contains('/')
        || session_id.contains(std::path::MAIN_SEPARATOR)
        || session_id == "."
        || session_id == ".."
    {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "invalid session_id",
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn entry_roundtrips_through_json() {
        let entry = AttentionEntry {
            agent: "claude-code".into(),
            project: "claude-status".into(),
            cwd: "/Users/x/work/claude-status".into(),
            event: "notify".into(),
            tmux_pane: "%42".into(),
            ts: 1_700_000_000,
            message: None,
            pid: None,
        };
        let json = serde_json::to_string(&entry).unwrap();
        let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, entry);
    }

    #[test]
    fn entry_matches_bash_plan_field_names() {
        let entry = AttentionEntry {
            agent: "claude-code".into(),
            project: "p".into(),
            cwd: "/c".into(),
            event: "done".into(),
            tmux_pane: "%1".into(),
            ts: 1,
            message: None,
            pid: None,
        };
        let v: serde_json::Value = serde_json::to_value(&entry).unwrap();
        // Original fields from the bash precursor — must not be renamed/removed.
        assert!(v.get("project").is_some());
        assert!(v.get("cwd").is_some());
        assert!(v.get("event").is_some());
        assert!(v.get("tmux_pane").is_some());
        assert!(v.get("ts").is_some());
        // New attribution field added when this CLI grew multi-agent support.
        assert!(v.get("agent").is_some());
    }

    fn sample_entry(project: &str) -> AttentionEntry {
        AttentionEntry {
            agent: "claude-code".into(),
            project: project.into(),
            cwd: format!("/x/{project}"),
            event: "notify".into(),
            tmux_pane: "%1".into(),
            ts: 1,
            message: None,
            pid: None,
        }
    }

    #[test]
    fn write_then_list_returns_entry() {
        let dir = TempDir::new().unwrap();
        let store = StateStore::new(dir.path().into());
        store.write("session-a", &sample_entry("alpha")).unwrap();
        let listed = store.list().unwrap();
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].0, "session-a");
        assert_eq!(listed[0].1.project, "alpha");
    }

    #[test]
    fn remove_is_idempotent() {
        let dir = TempDir::new().unwrap();
        let store = StateStore::new(dir.path().into());
        assert!(!store.remove("never-existed").unwrap());
        store.write("s1", &sample_entry("p")).unwrap();
        assert!(store.remove("s1").unwrap());
        assert!(!store.remove("s1").unwrap());
        assert_eq!(store.list().unwrap().len(), 0);
    }

    #[test]
    fn remove_returns_true_when_file_was_present() {
        let dir = TempDir::new().unwrap();
        let store = StateStore::new(dir.path().into());
        store.write("s1", &sample_entry("p")).unwrap();
        assert!(store.remove("s1").unwrap(), "first remove should report deletion");
    }

    #[test]
    fn remove_returns_false_when_file_was_already_absent() {
        let dir = TempDir::new().unwrap();
        let store = StateStore::new(dir.path().into());
        assert!(!store.remove("never-existed").unwrap());
        store.write("s1", &sample_entry("p")).unwrap();
        store.remove("s1").unwrap();
        assert!(!store.remove("s1").unwrap(), "second remove should report no-op");
    }

    #[test]
    fn list_on_missing_dir_returns_empty() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("does-not-exist");
        let store = StateStore::new(path);
        assert_eq!(store.list().unwrap().len(), 0);
    }

    #[test]
    fn list_skips_files_with_invalid_json() {
        let dir = TempDir::new().unwrap();
        let store = StateStore::new(dir.path().into());
        store.write("good", &sample_entry("p")).unwrap();
        std::fs::write(dir.path().join("bad"), "not json").unwrap();
        let listed = store.list().unwrap();
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].0, "good");
    }

    #[test]
    fn from_env_path_ends_with_agent_status() {
        let store = StateStore::from_env();
        assert!(store.dir().ends_with("agent-status"));
    }

    #[test]
    fn entry_message_field_roundtrips_when_set() {
        let entry = AttentionEntry {
            agent: "claude-code".into(),
            project: "p".into(),
            cwd: "/c".into(),
            event: "notify".into(),
            tmux_pane: "%1".into(),
            ts: 1,
            message: Some("Permission required".into()),
            pid: None,
        };
        let json = serde_json::to_string(&entry).unwrap();
        assert!(json.contains(r#""message":"Permission required""#));
        let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.message.as_deref(), Some("Permission required"));
    }

    #[test]
    fn entry_message_field_omitted_from_json_when_none() {
        let entry = AttentionEntry {
            agent: "claude-code".into(),
            project: "p".into(),
            cwd: "/c".into(),
            event: "done".into(),
            tmux_pane: "%1".into(),
            ts: 1,
            message: None,
            pid: None,
        };
        let json = serde_json::to_string(&entry).unwrap();
        assert!(!json.contains("message"), "got: {json}");
    }

    #[test]
    fn entry_pid_field_roundtrips_when_set() {
        let entry = AttentionEntry {
            agent: "claude-code".into(),
            project: "p".into(),
            cwd: "/c".into(),
            event: "notify".into(),
            tmux_pane: "%1".into(),
            ts: 1,
            message: None,
            pid: Some(42_000),
        };
        let json = serde_json::to_string(&entry).unwrap();
        assert!(json.contains(r#""pid":42000"#));
        let parsed: AttentionEntry = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.pid, Some(42_000));
    }

    #[test]
    fn entry_pid_field_omitted_from_json_when_none() {
        let entry = AttentionEntry {
            agent: "claude-code".into(),
            project: "p".into(),
            cwd: "/c".into(),
            event: "done".into(),
            tmux_pane: "%1".into(),
            ts: 1,
            message: None,
            pid: None,
        };
        let json = serde_json::to_string(&entry).unwrap();
        assert!(!json.contains("pid"), "got: {json}");
    }

    #[test]
    fn entry_deserializes_when_pid_field_absent() {
        // Older state files (no pid field) must still load.
        let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
        let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
        assert!(parsed.pid.is_none());
    }

    #[test]
    fn entry_deserializes_when_message_field_absent() {
        // Old state files written before this field was added must still load.
        let json = r#"{"agent":"claude-code","project":"p","cwd":"/c","event":"done","tmux_pane":"%1","ts":1}"#;
        let parsed: AttentionEntry = serde_json::from_str(json).unwrap();
        assert!(parsed.message.is_none());
    }

    #[test]
    fn write_rejects_path_traversal_session_id() {
        let dir = TempDir::new().unwrap();
        let store = StateStore::new(dir.path().into());
        let entry = sample_entry("p");
        for bad in ["../escape", "a/b", "..", ".", ""] {
            let err = store.write(bad, &entry).unwrap_err();
            assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "bad id: {bad:?}");
        }
        let err = store.remove("../escape").unwrap_err();
        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
    }

    #[test]
    fn is_pid_alive_returns_true_for_self() {
        let me = std::process::id();
        assert!(is_pid_alive(me), "kill -0 of own pid should succeed");
    }

    #[test]
    fn is_pid_alive_returns_false_for_impossible_pid() {
        // pid_max on Linux is typically 4194304 (2^22); macOS 99998. Both well below 1_000_000_000.
        assert!(!is_pid_alive(1_000_000_000));
    }

    #[test]
    fn is_pid_alive_returns_false_for_pid_zero() {
        // kill(0, 0) signals the whole process group — not what we want. The helper
        // must reject pid 0 explicitly so a corrupted state file with pid:0 doesn't
        // accidentally keep itself alive.
        assert!(!is_pid_alive(0));
    }

    #[test]
    fn list_prunes_entries_with_dead_pid() {
        let dir = TempDir::new().unwrap();
        let store = StateStore::new(dir.path().into());

        let mut alive = sample_entry("alive");
        alive.pid = Some(std::process::id());
        store.write("session-alive", &alive).unwrap();

        let mut dead = sample_entry("dead");
        dead.pid = Some(1_000_000_000);
        store.write("session-dead", &dead).unwrap();

        let listed = store.list().unwrap();
        assert_eq!(listed.len(), 1, "should keep only the alive entry");
        assert_eq!(listed[0].0, "session-alive");

        assert!(!dir.path().join("session-dead").exists());
    }

    #[test]
    fn list_keeps_entries_without_pid() {
        let dir = TempDir::new().unwrap();
        let store = StateStore::new(dir.path().into());
        let no_pid_entry = sample_entry("legacy");
        store.write("session-legacy", &no_pid_entry).unwrap();

        let listed = store.list().unwrap();
        assert_eq!(listed.len(), 1);
    }
}