Skip to main content

agent_top_core/
collector.rs

1//! Joins the process table with the transcripts into a `Snapshot`.
2
3use 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    /// How long after its last write a process-less transcript still shows as `stopped`.
15    pub stopped_window: Duration,
16    /// How often to re-list transcript directories.
17    pub fs_scan_interval: Duration,
18    /// A transcript idle for longer than this counts as idle even if the
19    /// harness never wrote an end-of-turn marker.
20    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            // One process can host several conversations. Claude Code runs one
93            // per process; the Codex app-server runs many.
94            let (paths, attribution) = match harness {
95                Harness::Claude => {
96                    let (p, a) = attribute_claude(&root, raw, cwd.as_deref(), proc_start, &registry);
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            // No transcript: the process still deserves a row.
107            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                // Only the first row carries the process, so that a machine's
146                // totals are not multiplied by the number of conversations.
147                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                // A thread names itself after its own working directory, which
165                // is the only thing distinguishing two rows on one app-server.
166                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        // Stopped agents: recently written transcripts nobody owns.
210        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        // Drop trackers for transcripts that fell out of the window.
264        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
274/// A transcript that parsed while its usage records did not is a format change,
275/// not a quiet session. Naming the harness version makes the report actionable:
276/// it is the first thing anyone will ask for.
277fn 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(&reg.cwd, &reg.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
316/// Codex conversations belonging to one process, newest activity first.
317///
318/// A `codex` CLI runs one conversation from the directory it was started in, so
319/// a cwd match finds it. The VS Code app-server is a different shape: one
320/// long-lived process, running from `/`, hosting any number of conversations
321/// over its life. Returning a single rollout for it collapses every one of
322/// those into one row and attributes whichever happened to be newest, so this
323/// returns all of them that are currently live and lets the caller give each
324/// its own row.
325///
326/// A rollout already claimed by another process is skipped, so two Codex
327/// processes cannot both show the same conversation.
328fn 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    // The CLI case: the conversation runs where the process runs.
341    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    // The app-server case: no cwd to match on, so take the conversations that
350    // are actually being written to. A rollout nobody has touched in a while is
351    // a finished conversation, not a thread of this process.
352    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
361/// One process is not plausibly running more conversations than this at once,
362/// and an unbounded fan-out would let a stale directory fill the table.
363const 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
369/// Older Claude Code versions stored subagent transcripts as `agent-<id>.jsonl`
370/// next to the parent session; they are not sessions of their own. Current
371/// versions nest them under `<session>/subagents/`, where the directory walk
372/// does not look, and `ClaudeTranscript` folds them into the parent.
373fn 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    // Statuses observed in the registry so far (Claude Code 2.1.259): "busy",
379    // "idle", "shell". Unknown values fall through to the transcript heuristic.
380    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    /// Write a rollout with an explicit modification time.
417    ///
418    /// Ordering must not be left to how finely the filesystem happens to
419    /// timestamp three writes microseconds apart: Linux gave all three the
420    /// same mtime, the stable sort preserved insertion order, and the test
421    /// failed there while passing on macOS.
422    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    /// One app-server, several conversations. Every live one must get a row:
431    /// returning only the newest is what collapsed them into a single
432    /// mis-attributed row.
433    #[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        // Distinct write times, oldest first, so "newest first" has a single
443        // correct answer.
444        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        // The app-server case: the process cwd matches no conversation.
451        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        // A conversation already claimed by another process is not shown twice.
457        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        // A conversation nobody has written to for longer than the activity
463        // window has finished; it belongs in the stopped list, not on this
464        // process.
465        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        // The CLI case: one conversation, in the directory the process runs in.
471        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        // A rollout that predates the process is not this process's.
476        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}