1use std::fs::{self, File};
25use std::io::Read;
26use std::path::Path;
27
28use serde_json::Value;
29
30const MAX_WALK_ENTRIES: usize = 4_096;
34const MAX_SESSION_READS: usize = 256;
38const MAX_SESSION_BYTES: u64 = 8 * 1024;
40
41#[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 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
66pub trait ProcessProbe {
69 fn is_running(&self, pid: u32, start_time: Option<&str>) -> bool;
71}
72
73pub 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#[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 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 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#[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
150pub 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 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 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
198fn current_platform() -> &'static str {
201 match std::env::consts::OS {
202 "windows" => "win32",
203 "macos" => "darwin",
204 other => other,
205 }
206}
207
208fn 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 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 #[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 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 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 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 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 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 #[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 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}