Skip to main content

ai_usagebar/context/
activity.rs

1//! Live activity of Claude Code sessions: which ones are working, and which are
2//! waiting on the user.
3//!
4//! Claude Code keeps one `<CLAUDE_CONFIG_DIR>/sessions/<pid>.json` per running
5//! interactive session and rewrites it whenever that session's status changes:
6//! `busy` while it works, `waiting` while a permission prompt or a question is
7//! open, `idle` otherwise. Like the transcripts next to it this is an
8//! undocumented surface, so every field is optional data: an unreadable,
9//! oversized or malformed file is skipped, and an unknown status counts as
10//! neither working nor waiting.
11//!
12//! The file outlives a crashed or killed Claude Code, so a session only counts
13//! while its process is still running. On Linux that is checked against the
14//! start time Claude Code records (`procStart`, field 22 of `/proc/<pid>/stat`),
15//! which also rejects a pid the kernel has since handed to another process;
16//! elsewhere a running process with that pid is the best available signal. A
17//! file written on another operating system (`pidDomain`), as a config
18//! directory shared across a dual boot or a migration carries, never counts:
19//! its pid means nothing here.
20//!
21//! Read-only: these files belong to Claude Code, so a stale one is skipped,
22//! never removed.
23
24use std::fs::{self, File};
25use std::io::Read;
26use std::path::Path;
27
28use serde_json::Value;
29
30/// Directory entries visited, not sessions: Claude Code keeps a `.key` file
31/// next to each session's `.json`. This bounds the walk if something else ever
32/// fills the directory.
33const MAX_WALK_ENTRIES: usize = 4_096;
34/// Session files actually opened per scan. The Waybar tooltip scans on every
35/// tick, so a directory full of crash leftovers must stay cheap: at most this
36/// many reads of at most [`MAX_SESSION_BYTES`] each.
37const MAX_SESSION_READS: usize = 256;
38/// A session file is a few hundred bytes. Anything far larger is not one.
39const MAX_SESSION_BYTES: u64 = 8 * 1024;
40
41/// How many of an account's live interactive sessions are working, and how
42/// many are waiting on the user.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub struct SessionActivity {
45    pub working: usize,
46    pub waiting: usize,
47}
48
49impl SessionActivity {
50    pub fn is_idle(&self) -> bool {
51        self.working == 0 && self.waiting == 0
52    }
53
54    /// `"2 working · 1 waiting"`, naming only the counts that are non-zero;
55    /// `None` when nothing is happening.
56    pub fn summary(&self) -> Option<String> {
57        let parts: Vec<String> = [(self.working, "working"), (self.waiting, "waiting")]
58            .into_iter()
59            .filter(|(count, _)| *count > 0)
60            .map(|(count, state)| format!("{count} {state}"))
61            .collect();
62        (!parts.is_empty()).then(|| parts.join(" · "))
63    }
64}
65
66/// Whether a session's process is still the one that wrote its file. The seam
67/// tests replace: they must never probe the real process table.
68pub trait ProcessProbe {
69    /// `start_time` is the session file's `procStart`, when it carries one.
70    fn is_running(&self, pid: u32, start_time: Option<&str>) -> bool;
71}
72
73/// The production probe for the platform this was built for.
74pub struct SystemProbe;
75
76impl ProcessProbe for SystemProbe {
77    fn is_running(&self, pid: u32, start_time: Option<&str>) -> bool {
78        process_running(pid, start_time)
79    }
80}
81
82#[cfg(target_os = "linux")]
83fn process_running(pid: u32, start_time: Option<&str>) -> bool {
84    let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
85        return false;
86    };
87    stat_says_running(&stat, start_time)
88}
89
90/// A `/proc/<pid>/stat` line describes the session's process: not a zombie
91/// (`Z`) or dead (`X`) entry awaiting reaping, and, when the session recorded
92/// one, started at the same moment.
93#[cfg(any(target_os = "linux", test))]
94fn stat_says_running(stat: &str, start_time: Option<&str>) -> bool {
95    let Some((state, started)) = stat_fields(stat) else {
96        return false;
97    };
98    !matches!(state, "Z" | "X") && start_time.is_none_or(|expected| expected == started)
99}
100
101#[cfg(all(unix, not(target_os = "linux")))]
102fn process_running(pid: u32, _start_time: Option<&str>) -> bool {
103    use rustix::io::Errno;
104    use rustix::process::{Pid, test_kill_process};
105
106    let Some(pid) = i32::try_from(pid).ok().and_then(Pid::from_raw) else {
107        return false;
108    };
109    // `kill(pid, 0)`: EPERM still means the process exists, owned by someone else.
110    matches!(test_kill_process(pid), Ok(()) | Err(Errno::PERM))
111}
112
113#[cfg(windows)]
114fn process_running(pid: u32, _start_time: Option<&str>) -> bool {
115    use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
116    use windows_sys::Win32::System::Threading::{
117        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
118    };
119
120    // SAFETY: plain Win32 calls on a handle this function opens and closes
121    // itself; `code` outlives the call that writes it.
122    unsafe {
123        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
124        if handle.is_null() {
125            return false;
126        }
127        let mut code = 0u32;
128        let queried = GetExitCodeProcess(handle, &mut code) != 0;
129        CloseHandle(handle);
130        queried && code == STILL_ACTIVE as u32
131    }
132}
133
134#[cfg(not(any(unix, windows)))]
135fn process_running(_pid: u32, _start_time: Option<&str>) -> bool {
136    false
137}
138
139/// Fields 3 (`state`) and 22 (`starttime`) of `/proc/<pid>/stat`. The command
140/// name in field 2 is parenthesised and may itself contain spaces or `)`, so
141/// fields are counted from the last `)`, where field 3 starts.
142#[cfg(any(target_os = "linux", test))]
143fn stat_fields(stat: &str) -> Option<(&str, &str)> {
144    let (_, rest) = stat.rsplit_once(')')?;
145    let mut fields = rest.split_whitespace();
146    let state = fields.next()?;
147    Some((state, fields.nth(18)?))
148}
149
150/// Count the live interactive sessions under `config_dir/sessions` by status.
151/// A missing or unreadable directory is simply no activity.
152pub fn scan_dir(config_dir: &Path, probe: &impl ProcessProbe) -> SessionActivity {
153    let mut activity = SessionActivity::default();
154    let Ok(entries) = fs::read_dir(config_dir.join("sessions")) else {
155        return activity;
156    };
157    let mut reads = 0;
158    for entry in entries.take(MAX_WALK_ENTRIES).flatten() {
159        let Some(pid) = file_pid(&entry.file_name().to_string_lossy()) else {
160            continue;
161        };
162        // `DirEntry::file_type` does not follow symlinks: a link is not a file here.
163        if !entry.file_type().is_ok_and(|kind| kind.is_file()) {
164            continue;
165        }
166        if reads == MAX_SESSION_READS {
167            break;
168        }
169        reads += 1;
170        let Some(session) = read_session(&entry.path()) else {
171            continue;
172        };
173        // A file renamed or copied under another pid's name is not that
174        // session, and a pid from another operating system is not a process here.
175        if session.pid != pid || !session.interactive || !session.local {
176            continue;
177        }
178        let counter = match session.status.as_deref() {
179            Some("busy") => &mut activity.working,
180            Some("waiting") => &mut activity.waiting,
181            _ => continue,
182        };
183        if probe.is_running(pid, session.start_time.as_deref()) {
184            *counter += 1;
185        }
186    }
187    activity
188}
189
190struct SessionFile {
191    pid: u32,
192    interactive: bool,
193    local: bool,
194    status: Option<String>,
195    start_time: Option<String>,
196}
197
198/// The platform prefix Claude Code writes at the start of `pidDomain`
199/// (Node's `process.platform`): `linux:<machine-id>:pid:[<ns>]`, `win32:<user>`.
200fn current_platform() -> &'static str {
201    match std::env::consts::OS {
202        "windows" => "win32",
203        "macos" => "darwin",
204        other => other,
205    }
206}
207
208/// `<pid>.json`, with nothing but digits before the extension.
209fn file_pid(name: &str) -> Option<u32> {
210    let stem = name.strip_suffix(".json")?;
211    if stem.is_empty() || !stem.bytes().all(|byte| byte.is_ascii_digit()) {
212        return None;
213    }
214    stem.parse().ok()
215}
216
217fn read_session(path: &Path) -> Option<SessionFile> {
218    let mut bytes = Vec::new();
219    File::open(path)
220        .ok()?
221        .take(MAX_SESSION_BYTES + 1)
222        .read_to_end(&mut bytes)
223        .ok()?;
224    if bytes.len() as u64 > MAX_SESSION_BYTES {
225        return None;
226    }
227    let value: Value = serde_json::from_slice(&bytes).ok()?;
228    let text = |key: &str| value.get(key).and_then(Value::as_str);
229    Some(SessionFile {
230        pid: u32::try_from(value.get("pid")?.as_u64()?).ok()?,
231        // Agent SDK runs (`entrypoint: "sdk-cli"`, which `claude -p` and SDK
232        // hosts such as background agents use) are not someone's session at a
233        // terminal or in an editor, even though they also write
234        // `kind: "interactive"`. A file without either field predates them.
235        interactive: text("kind").is_none_or(|kind| kind == "interactive")
236            && text("entrypoint").is_none_or(|entrypoint| !entrypoint.starts_with("sdk")),
237        local: text("pidDomain")
238            .is_none_or(|domain| domain.split(':').next() == Some(current_platform())),
239        status: text("status").map(str::to_owned),
240        start_time: text("procStart").map(str::to_owned),
241    })
242}
243
244#[cfg(test)]
245mod tests {
246    use std::collections::HashMap;
247
248    use serde_json::json;
249    use tempfile::TempDir;
250
251    use super::*;
252
253    /// Running pids and the start time each one was started at.
254    #[derive(Default)]
255    struct FakeProbe {
256        running: HashMap<u32, Option<String>>,
257    }
258
259    impl FakeProbe {
260        fn with(mut self, pid: u32, start_time: Option<&str>) -> Self {
261            self.running.insert(pid, start_time.map(str::to_owned));
262            self
263        }
264    }
265
266    impl ProcessProbe for FakeProbe {
267        fn is_running(&self, pid: u32, start_time: Option<&str>) -> bool {
268            match (self.running.get(&pid), start_time) {
269                (None, _) => false,
270                (Some(Some(actual)), Some(expected)) => actual == expected,
271                (Some(_), _) => true,
272            }
273        }
274    }
275
276    fn config_dir() -> TempDir {
277        let dir = TempDir::new().unwrap();
278        fs::create_dir(dir.path().join("sessions")).unwrap();
279        dir
280    }
281
282    fn write(dir: &TempDir, name: &str, body: &str) {
283        fs::write(dir.path().join("sessions").join(name), body).unwrap();
284    }
285
286    /// The shape Claude Code 2.1 writes for a terminal session on this
287    /// platform, trimmed to the fields that matter here.
288    fn session_body(pid: u32, status: &str) -> Value {
289        json!({
290            "pid": pid,
291            "sessionId": format!("session-{pid}"),
292            "cwd": "/work/project",
293            "kind": "interactive",
294            "entrypoint": "cli",
295            "pidDomain": format!("{}:machine:pid:[4026531836]", current_platform()),
296            "name": "project",
297            "procStart": format!("{pid}00"),
298            "status": status,
299            "statusUpdatedAt": 1_791_169_032_529_u64,
300        })
301    }
302
303    fn session(dir: &TempDir, pid: u32, status: &str) {
304        write(
305            dir,
306            &format!("{pid}.json"),
307            &session_body(pid, status).to_string(),
308        );
309    }
310
311    fn session_with(dir: &TempDir, pid: u32, status: &str, key: &str, value: Value) {
312        let mut body = session_body(pid, status);
313        body[key] = value;
314        write(dir, &format!("{pid}.json"), &body.to_string());
315    }
316
317    /// Each pid running since the start time `session` records for it.
318    fn running(pids: &[u32]) -> FakeProbe {
319        pids.iter().fold(FakeProbe::default(), |probe, &pid| {
320            probe.with(pid, Some(&format!("{pid}00")))
321        })
322    }
323
324    #[test]
325    fn live_sessions_are_counted_by_status() {
326        let dir = config_dir();
327        session(&dir, 101, "busy");
328        session(&dir, 102, "busy");
329        session(&dir, 103, "waiting");
330        session(&dir, 104, "idle");
331
332        let activity = scan_dir(dir.path(), &running(&[101, 102, 103, 104]));
333
334        assert_eq!(
335            activity,
336            SessionActivity {
337                working: 2,
338                waiting: 1
339            }
340        );
341    }
342
343    #[test]
344    fn idle_shell_and_unknown_statuses_count_as_nothing() {
345        let dir = config_dir();
346        session(&dir, 101, "idle");
347        session(&dir, 102, "shell");
348        session(&dir, 103, "something-new");
349        write(
350            &dir,
351            "104.json",
352            &json!({"pid": 104, "kind": "interactive"}).to_string(),
353        );
354
355        let activity = scan_dir(dir.path(), &running(&[101, 102, 103, 104]));
356
357        assert!(activity.is_idle());
358    }
359
360    #[test]
361    fn a_session_whose_process_is_gone_does_not_count() {
362        let dir = config_dir();
363        session(&dir, 101, "busy");
364        session(&dir, 102, "waiting");
365
366        let activity = scan_dir(dir.path(), &running(&[101]));
367
368        assert_eq!(
369            activity,
370            SessionActivity {
371                working: 1,
372                waiting: 0
373            }
374        );
375    }
376
377    #[test]
378    fn a_recycled_pid_with_another_start_time_does_not_count() {
379        let dir = config_dir();
380        session(&dir, 101, "busy");
381
382        let probe = FakeProbe::default().with(101, Some("999999"));
383
384        assert!(scan_dir(dir.path(), &probe).is_idle());
385    }
386
387    #[test]
388    fn agent_sdk_runs_do_not_count_but_editor_sessions_do() {
389        let dir = config_dir();
390        // Agent SDK hosts write `kind: "interactive"` too; only the entrypoint
391        // tells them apart from a person's session.
392        session_with(&dir, 101, "busy", "entrypoint", json!("sdk-cli"));
393        session_with(&dir, 102, "busy", "kind", json!("print"));
394        session_with(&dir, 103, "waiting", "entrypoint", json!("claude-vscode"));
395        // A file from before `kind` and `entrypoint` existed was a session.
396        let legacy = json!({"pid": 104, "status": "busy"});
397        write(&dir, "104.json", &legacy.to_string());
398
399        let probe = FakeProbe::default()
400            .with(101, None)
401            .with(102, None)
402            .with(103, None)
403            .with(104, None);
404
405        assert_eq!(
406            scan_dir(dir.path(), &probe),
407            SessionActivity {
408                working: 1,
409                waiting: 1
410            }
411        );
412    }
413
414    #[test]
415    fn a_session_written_on_another_operating_system_does_not_count() {
416        let dir = config_dir();
417        // A config directory shared across a dual boot or carried over by a
418        // migration: that pid belongs to a process table that is not this one.
419        session_with(&dir, 101, "busy", "pidDomain", json!("plan9:someone"));
420
421        assert!(scan_dir(dir.path(), &running(&[101])).is_idle());
422    }
423
424    #[test]
425    fn a_file_named_for_another_pid_is_ignored() {
426        let dir = config_dir();
427        let body = json!({"pid": 101, "kind": "interactive", "status": "busy"});
428        write(&dir, "202.json", &body.to_string());
429
430        let probe = FakeProbe::default().with(101, None).with(202, None);
431
432        assert!(scan_dir(dir.path(), &probe).is_idle());
433    }
434
435    #[test]
436    fn malformed_oversized_and_foreign_files_are_skipped_without_hiding_others() {
437        let dir = config_dir();
438        session(&dir, 101, "busy");
439        write(&dir, "102.json", "{ not json");
440        write(
441            &dir,
442            "103.json",
443            &" ".repeat(MAX_SESSION_BYTES as usize + 1),
444        );
445        write(&dir, "104.json", r#"{"pid": "104", "status": "busy"}"#);
446        write(
447            &dir,
448            "notes.json",
449            &json!({"pid": 105, "status": "busy"}).to_string(),
450        );
451        write(
452            &dir,
453            "106.json.tmp",
454            &json!({"pid": 106, "status": "busy"}).to_string(),
455        );
456        fs::create_dir(dir.path().join("sessions").join("107.json")).unwrap();
457
458        let probe = running(&[101, 102, 103, 104, 105, 106, 107]);
459
460        assert_eq!(
461            scan_dir(dir.path(), &probe),
462            SessionActivity {
463                working: 1,
464                waiting: 0
465            }
466        );
467    }
468
469    #[cfg(unix)]
470    #[test]
471    fn a_symlinked_session_file_is_not_followed() {
472        let dir = config_dir();
473        let outside = dir.path().join("elsewhere.json");
474        let body = json!({"pid": 101, "kind": "interactive", "status": "busy"});
475        fs::write(&outside, body.to_string()).unwrap();
476        std::os::unix::fs::symlink(&outside, dir.path().join("sessions").join("101.json")).unwrap();
477
478        assert!(scan_dir(dir.path(), &FakeProbe::default().with(101, None)).is_idle());
479    }
480
481    /// The tooltip scans on every tick: a directory holding far more session
482    /// files than any machine runs costs a bounded number of reads.
483    #[test]
484    fn a_crowded_directory_is_read_only_up_to_the_cap() {
485        let dir = config_dir();
486        let pids: Vec<u32> = (1000..1000 + MAX_SESSION_READS as u32 + 20).collect();
487        for &pid in &pids {
488            session(&dir, pid, "busy");
489        }
490
491        let activity = scan_dir(dir.path(), &running(&pids));
492
493        assert_eq!(activity.working, MAX_SESSION_READS);
494    }
495
496    #[test]
497    fn a_missing_sessions_directory_is_no_activity() {
498        let dir = TempDir::new().unwrap();
499
500        assert!(scan_dir(dir.path(), &FakeProbe::default()).is_idle());
501    }
502
503    #[test]
504    fn summary_names_only_the_counts_that_are_not_zero() {
505        let summary = |working, waiting| SessionActivity { working, waiting }.summary();
506
507        assert_eq!(summary(2, 1).as_deref(), Some("2 working · 1 waiting"));
508        assert_eq!(summary(1, 0).as_deref(), Some("1 working"));
509        assert_eq!(summary(0, 3).as_deref(), Some("3 waiting"));
510        assert_eq!(summary(0, 0), None);
511    }
512
513    #[test]
514    fn stat_fields_are_counted_from_the_last_parenthesis() {
515        // A command name may contain spaces and `)`; fields 3 and 22 are still found.
516        let stat = "4242 (my (odd) cmd) S 1 4242 4242 0 -1 4194560 100 0 0 0 \
517                    1 2 0 0 20 0 1 0 7322237 1000000 200";
518
519        assert_eq!(stat_fields(stat), Some(("S", "7322237")));
520        assert_eq!(stat_fields("4242 (truncated"), None);
521        assert_eq!(stat_fields("4242 (short) S 1"), None);
522    }
523
524    #[test]
525    fn a_zombie_or_a_different_start_time_is_not_the_session() {
526        let stat = |state: &str| {
527            format!(
528                "4242 (claude) {state} 1 4242 4242 0 -1 4194560 100 0 0 0 1 2 0 0 20 0 1 0 7322237 1000000 200"
529            )
530        };
531
532        assert!(stat_says_running(&stat("S"), Some("7322237")));
533        assert!(stat_says_running(&stat("R"), None));
534        assert!(!stat_says_running(&stat("S"), Some("7322238")));
535        assert!(!stat_says_running(&stat("Z"), Some("7322237")));
536        assert!(!stat_says_running(&stat("X"), None));
537        assert!(!stat_says_running("garbage", None));
538    }
539}