1use crate::harness::claude::{self, ClaudeTranscript, PidSession};
4use crate::harness::codex::{self, CodexTranscript};
5use crate::harness::{SessionSummary, SessionTracker};
6use crate::model::*;
7use crate::process::{ProcessScanner, RawProc, build_forest, session_id_from_args};
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12#[derive(Debug, Clone)]
13pub struct CollectorOptions {
14 pub stopped_window: Duration,
16 pub fs_scan_interval: Duration,
18 pub activity_timeout: Duration,
21}
22
23impl Default for CollectorOptions {
24 fn default() -> Self {
25 CollectorOptions {
26 stopped_window: Duration::from_secs(30 * 60),
27 fs_scan_interval: Duration::from_secs(5),
28 activity_timeout: Duration::from_secs(15 * 60),
29 }
30 }
31}
32
33pub struct Collector {
34 opts: CollectorOptions,
35 scanner: ProcessScanner,
36 trackers: HashMap<PathBuf, Box<dyn SessionTracker>>,
37 last_fs_scan: Option<Instant>,
38 recent_claude: Vec<PathBuf>,
39 recent_codex: Vec<(PathBuf, PathBuf, SystemTime)>,
40}
41
42impl Collector {
43 pub fn new(opts: CollectorOptions) -> Self {
44 Collector {
45 opts,
46 scanner: ProcessScanner::new(),
47 trackers: HashMap::new(),
48 last_fs_scan: None,
49 recent_claude: Vec::new(),
50 recent_codex: Vec::new(),
51 }
52 }
53
54 fn rescan_fs_if_due(&mut self) {
55 let due = self.last_fs_scan.map(|t| t.elapsed() >= self.opts.fs_scan_interval).unwrap_or(true);
56 if !due {
57 return;
58 }
59 self.last_fs_scan = Some(Instant::now());
60 let since = SystemTime::now().checked_sub(self.opts.stopped_window).unwrap_or(UNIX_EPOCH);
61 self.recent_claude = claude::recent_transcripts(since);
62 self.recent_codex =
63 codex::recent_rollouts(since).into_iter().filter_map(|p| codex::read_meta(&p).map(|(cwd, ts)| (p, cwd, ts))).collect();
64 }
65
66 fn tracker_for(&mut self, path: &Path, harness: Harness) -> &mut Box<dyn SessionTracker> {
67 self.trackers.entry(path.to_path_buf()).or_insert_with(|| match harness {
68 Harness::Codex => Box::new(CodexTranscript::new(path)),
69 _ => Box::new(ClaudeTranscript::new(path)),
70 })
71 }
72
73 pub fn collect(&mut self) -> Snapshot {
74 self.scanner.refresh();
75 self.rescan_fs_if_due();
76 let host = self.scanner.host();
77 let procs = self.scanner.processes();
78 let by_pid: HashMap<u32, &RawProc> = procs.iter().map(|p| (p.pid, p)).collect();
79 let (roots, orphans) = build_forest(&procs);
80 let registry: HashMap<u32, PidSession> = claude::read_pid_sessions().into_iter().map(|s| (s.pid, s)).collect();
81
82 let now = SystemTime::now();
83 let mut agents = Vec::new();
84 let mut attached: HashSet<PathBuf> = HashSet::new();
85
86 for root in roots {
87 let raw = by_pid.get(&root.pid).copied();
88 let harness = root.harness.unwrap_or(Harness::Unknown);
89 let proc_start = raw.map(|p| UNIX_EPOCH + Duration::from_secs(p.start_time)).unwrap_or(now);
90 let cwd = root.cwd.clone().or_else(|| registry.get(&root.pid).map(|r| r.cwd.clone()));
91
92 let (paths, attribution) = match harness {
95 Harness::Claude => {
96 let (p, a) = attribute_claude(&root, raw, cwd.as_deref(), proc_start, ®istry);
97 (p.into_iter().collect::<Vec<_>>(), a)
98 }
99 Harness::Codex => attribute_codex(cwd.as_deref(), proc_start, &self.recent_codex, &attached, now, &self.opts),
100 _ => (Vec::new(), Attribution::None),
101 };
102
103 let (cpu, rss, count, mcp) = root.totals();
104 let reg = registry.get(&root.pid);
105
106 if paths.is_empty() {
108 let summary = SessionSummary::default();
109 let state = live_state(reg, summary.activity, None, cpu, &self.opts);
110 agents.push(Agent {
111 id: format!("pid:{}", root.pid),
112 name: reg.and_then(|r| r.name.clone()).unwrap_or_else(|| display_name(harness, cwd.as_deref())),
113 harness,
114 state,
115 activity: summary.activity,
116 pid: Some(root.pid),
117 session_id: reg.map(|r| r.session_id.clone()),
118 session_path: None,
119 cwd,
120 model: None,
121 harness_version: reg.and_then(|r| r.version.clone()),
122 usage: summary.usage,
123 cost_usd: 0.0,
124 unpriced_tokens: 0,
125 turns: 0,
126 subagent_turns: 0,
127 tool_calls: 0,
128 web_searches: 0,
129 spans: Vec::new(),
130 age_secs: root.age_secs,
131 idle_secs: None,
132 cpu_percent: cpu,
133 rss_bytes: rss,
134 process_count: count,
135 mcp_count: mcp,
136 tree: Some(root),
137 attribution,
138 shares_process: false,
139 parse_warning: None,
140 });
141 continue;
142 }
143
144 for (i, path) in paths.iter().enumerate() {
145 let owns_process = i == 0;
148 let tr = self.tracker_for(path, harness);
149 let _ = tr.refresh();
150 let mut summary = tr.summary().clone();
151 attached.insert(path.clone());
152
153 if let Some(reg) = reg {
154 if summary.session_id.is_none() {
155 summary.session_id = Some(reg.session_id.clone());
156 }
157 if summary.harness_version.is_none() {
158 summary.harness_version = reg.version.clone();
159 }
160 }
161
162 let idle_secs = summary.last_activity.and_then(|t| now.duration_since(t).ok()).map(|d| d.as_secs());
163 let state = live_state(reg, summary.activity, idle_secs, cpu, &self.opts);
164 let name = match (reg.and_then(|r| r.name.clone()), paths.len()) {
167 (Some(n), 1) => n,
168 _ => display_name(harness, summary.cwd.as_deref().or(cwd.as_deref())),
169 };
170 let id = match summary.session_id.as_deref() {
171 Some(sid) => format!("pid:{}:{}", root.pid, sid),
172 None => format!("pid:{}:{}", root.pid, path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()),
173 };
174
175 agents.push(Agent {
176 id,
177 name,
178 harness,
179 state,
180 activity: summary.activity,
181 pid: Some(root.pid),
182 session_id: summary.session_id.clone(),
183 session_path: Some(path.clone()),
184 cwd: summary.cwd.clone().or_else(|| cwd.clone()),
185 model: summary.model.clone(),
186 harness_version: summary.harness_version.clone(),
187 usage: summary.usage,
188 cost_usd: summary.cost_usd,
189 unpriced_tokens: summary.unpriced_tokens,
190 turns: summary.turns,
191 subagent_turns: summary.subagent_turns,
192 tool_calls: summary.tool_calls,
193 web_searches: summary.web_searches,
194 spans: summary.spans.to_vec(),
195 age_secs: root.age_secs,
196 idle_secs,
197 cpu_percent: if owns_process { cpu } else { 0.0 },
198 rss_bytes: if owns_process { rss } else { 0 },
199 process_count: if owns_process { count } else { 0 },
200 mcp_count: if owns_process { mcp } else { 0 },
201 tree: if owns_process { Some(root.clone()) } else { None },
202 attribution,
203 shares_process: !owns_process,
204 parse_warning: parse_warning(&summary, harness),
205 });
206 }
207 }
208
209 let mut stopped: Vec<(PathBuf, Harness)> = Vec::new();
211 for p in &self.recent_claude {
212 if !attached.contains(p) && !is_subagent_transcript(p) {
213 stopped.push((p.clone(), Harness::Claude));
214 }
215 }
216 for (p, _, _) in &self.recent_codex {
217 if !attached.contains(p) {
218 stopped.push((p.clone(), Harness::Codex));
219 }
220 }
221 for (p, harness) in stopped {
222 let tr = self.tracker_for(&p, harness);
223 let _ = tr.refresh();
224 let s = tr.summary().clone();
225 if s.turns == 0 && s.usage.total() == 0 {
226 continue;
227 }
228 let idle_secs = s.last_activity.and_then(|t| now.duration_since(t).ok()).map(|d| d.as_secs());
229 let id = s.session_id.clone().unwrap_or_else(|| p.file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default());
230 agents.push(Agent {
231 id: format!("session:{id}"),
232 name: display_name(harness, s.cwd.as_deref()),
233 harness,
234 state: AgentState::Stopped,
235 activity: s.activity,
236 pid: None,
237 session_id: Some(id),
238 session_path: Some(p),
239 cwd: s.cwd.clone(),
240 model: s.model.clone(),
241 harness_version: s.harness_version.clone(),
242 usage: s.usage,
243 cost_usd: s.cost_usd,
244 unpriced_tokens: s.unpriced_tokens,
245 turns: s.turns,
246 subagent_turns: s.subagent_turns,
247 tool_calls: s.tool_calls,
248 web_searches: s.web_searches,
249 spans: s.spans.to_vec(),
250 age_secs: idle_secs.unwrap_or(0),
251 idle_secs,
252 cpu_percent: 0.0,
253 rss_bytes: 0,
254 process_count: 0,
255 mcp_count: 0,
256 tree: None,
257 attribution: Attribution::TranscriptOnly,
258 shares_process: false,
259 parse_warning: parse_warning(&s, harness),
260 });
261 }
262
263 let keep: HashSet<&PathBuf> = agents.iter().filter_map(|a| a.session_path.as_ref()).collect();
265 self.trackers.retain(|p, _| keep.contains(p));
266
267 let mut snap =
268 Snapshot { schema_version: SNAPSHOT_SCHEMA_VERSION, taken_at: now, host, agents, orphans, totals: Totals::default() };
269 snap.compute_totals();
270 snap
271 }
272}
273
274fn parse_warning(s: &SessionSummary, harness: Harness) -> Option<String> {
278 if !s.health.fields_unrecognised() {
279 return None;
280 }
281 let version = s.harness_version.as_deref().unwrap_or("unknown version");
282 Some(format!(
283 "usage fields not recognised in {} {}: tokens and cost are unreliable, agent-top may need updating",
284 harness.label(),
285 version
286 ))
287}
288
289fn attribute_claude(
290 root: &ProcNode,
291 raw: Option<&RawProc>,
292 cwd: Option<&Path>,
293 proc_start: SystemTime,
294 registry: &HashMap<u32, PidSession>,
295) -> (Option<PathBuf>, Attribution) {
296 if let Some(reg) = registry.get(&root.pid)
297 && let Some(p) = claude::transcript_path(®.cwd, ®.session_id)
298 {
299 return (Some(p), Attribution::HarnessRegistry);
300 }
301 if let (Some(raw), Some(cwd)) = (raw, cwd)
302 && let Some(id) = session_id_from_args(&raw.cmd)
303 && let Some(p) = claude::transcript_path(cwd, &id)
304 && p.exists()
305 {
306 return (Some(p), Attribution::CommandLine);
307 }
308 if let Some(cwd) = cwd
309 && let Some(p) = claude::guess_transcript(cwd, proc_start)
310 {
311 return (Some(p), Attribution::CwdHeuristic);
312 }
313 (None, Attribution::None)
314}
315
316fn attribute_codex(
329 cwd: Option<&Path>,
330 proc_start: SystemTime,
331 recent: &[(PathBuf, PathBuf, SystemTime)],
332 taken: &HashSet<PathBuf>,
333 now: SystemTime,
334 opts: &CollectorOptions,
335) -> (Vec<PathBuf>, Attribution) {
336 let slack = Duration::from_secs(60);
337 let started_after = |ts: &SystemTime| *ts + slack >= proc_start;
338 let candidates = || recent.iter().filter(|(p, _, ts)| started_after(ts) && !taken.contains(p));
339
340 if let Some(cwd) = cwd {
342 let mut matched: Vec<&(PathBuf, PathBuf, SystemTime)> = candidates().filter(|(_, c, _)| c == cwd).collect();
343 if !matched.is_empty() {
344 matched.sort_by_key(|(p, _, _)| std::cmp::Reverse(written_at(p)));
345 return (matched.into_iter().map(|(p, _, _)| p.clone()).collect(), Attribution::CwdHeuristic);
346 }
347 }
348
349 let mut live: Vec<&(PathBuf, PathBuf, SystemTime)> = candidates()
353 .filter(|(p, _, _)| written_at(p).map(|w| now.duration_since(w).unwrap_or_default() <= opts.activity_timeout).unwrap_or(false))
354 .collect();
355 live.sort_by_key(|(p, _, _)| std::cmp::Reverse(written_at(p)));
356 live.truncate(MAX_CODEX_THREADS);
357 let attribution = if live.is_empty() { Attribution::None } else { Attribution::CwdHeuristic };
358 (live.into_iter().map(|(p, _, _)| p.clone()).collect(), attribution)
359}
360
361const MAX_CODEX_THREADS: usize = 12;
364
365fn written_at(p: &Path) -> Option<SystemTime> {
366 std::fs::metadata(p).and_then(|m| m.modified()).ok()
367}
368
369fn is_subagent_transcript(p: &Path) -> bool {
374 p.file_name().and_then(|f| f.to_str()).map(|f| f.starts_with("agent-")).unwrap_or(false)
375}
376
377fn live_state(reg: Option<&PidSession>, activity: Activity, idle_secs: Option<u64>, cpu: f32, opts: &CollectorOptions) -> AgentState {
378 match reg.and_then(|r| r.status.as_deref()) {
381 Some("busy" | "running" | "working" | "shell" | "tool" | "thinking") => return AgentState::Running,
382 Some("idle" | "waiting" | "paused" | "permission" | "blocked") => return AgentState::Idle,
383 _ => {}
384 }
385 match activity {
386 Activity::Working => {
387 if idle_secs.map(|s| s > opts.activity_timeout.as_secs()).unwrap_or(false) {
388 AgentState::Idle
389 } else {
390 AgentState::Running
391 }
392 }
393 Activity::Waiting => AgentState::Idle,
394 Activity::Unknown => {
395 if cpu > 5.0 || idle_secs.map(|s| s < 10).unwrap_or(false) {
396 AgentState::Running
397 } else {
398 AgentState::Idle
399 }
400 }
401 }
402}
403
404fn display_name(harness: Harness, cwd: Option<&Path>) -> String {
405 match cwd.and_then(|c| c.file_name()).map(|f| f.to_string_lossy().into_owned()) {
406 Some(dir) => format!("{}:{}", harness.label(), dir),
407 None => harness.label().to_string(),
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use std::fs;
415
416 fn rollout(dir: &Path, name: &str, written: SystemTime) -> PathBuf {
423 let p = dir.join(name);
424 fs::write(&p, b"x").unwrap();
425 let f = fs::File::options().write(true).open(&p).unwrap();
426 f.set_times(fs::FileTimes::new().set_accessed(written).set_modified(written)).unwrap();
427 p
428 }
429
430 #[test]
434 fn every_live_codex_thread_is_returned_newest_first() {
435 let dir = std::env::temp_dir().join(format!("agent-top-threads-{}", std::process::id()));
436 let _ = fs::remove_dir_all(&dir);
437 fs::create_dir_all(&dir).unwrap();
438 let now = SystemTime::now();
439 let started = now - Duration::from_secs(600);
440 let opts = CollectorOptions::default();
441
442 let a = rollout(&dir, "a.jsonl", now - Duration::from_secs(300));
445 let b = rollout(&dir, "b.jsonl", now - Duration::from_secs(200));
446 let c = rollout(&dir, "c.jsonl", now - Duration::from_secs(100));
447 let recent: Vec<(PathBuf, PathBuf, SystemTime)> =
448 [&a, &b, &c].iter().map(|p| ((*p).clone(), PathBuf::from("/Users/dev/code/one"), started)).collect();
449
450 let (paths, attribution) = attribute_codex(Some(Path::new("/")), started, &recent, &HashSet::new(), now, &opts);
452 assert_eq!(paths.len(), 3, "all three conversations get a row");
453 assert_eq!(paths[0], c, "newest activity first");
454 assert_eq!(attribution, Attribution::CwdHeuristic, "still a heuristic, and still labelled one");
455
456 let taken: HashSet<PathBuf> = [c.clone()].into_iter().collect();
458 let (paths, _) = attribute_codex(Some(Path::new("/")), started, &recent, &taken, now, &opts);
459 assert_eq!(paths.len(), 2);
460 assert!(!paths.contains(&c));
461
462 let stale = now + opts.activity_timeout + Duration::from_secs(60);
466 let (paths, attribution) = attribute_codex(Some(Path::new("/")), started, &recent, &HashSet::new(), stale, &opts);
467 assert!(paths.is_empty());
468 assert_eq!(attribution, Attribution::None);
469
470 let (paths, _) = attribute_codex(Some(Path::new("/Users/dev/code/one")), started, &recent, &HashSet::new(), now, &opts);
472 assert_eq!(paths.len(), 3, "a cwd match takes every conversation in that directory");
473 assert_eq!(paths[0], c);
474
475 let (paths, _) = attribute_codex(Some(Path::new("/")), now + Duration::from_secs(3600), &recent, &HashSet::new(), now, &opts);
477 assert!(paths.is_empty());
478
479 let _ = fs::remove_dir_all(&dir);
480 }
481}