Skip to main content

agent_top_core/
model.rs

1//! The data model shared by discovery, the TUI and the JSON output.
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7/// Which agent harness a process or transcript belongs to.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
9#[serde(rename_all = "kebab-case")]
10pub enum Harness {
11    Claude,
12    Codex,
13    Gemini,
14    OpenCode,
15    Aider,
16    Copilot,
17    Cursor,
18    Unknown,
19}
20
21impl Harness {
22    pub fn label(self) -> &'static str {
23        match self {
24            Harness::Claude => "claude",
25            Harness::Codex => "codex",
26            Harness::Gemini => "gemini",
27            Harness::OpenCode => "opencode",
28            Harness::Aider => "aider",
29            Harness::Copilot => "copilot",
30            Harness::Cursor => "cursor",
31            Harness::Unknown => "unknown",
32        }
33    }
34}
35
36/// Coarse lifecycle state, in the htop sense.
37///
38/// `Running` means the agent is mid-turn (inference or tool execution),
39/// `Idle` means the process is alive but waiting for a human, `Stopped` means
40/// the transcript exists and was recently active but no process owns it.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
42#[serde(rename_all = "kebab-case")]
43pub enum AgentState {
44    Running,
45    Idle,
46    Stopped,
47}
48
49impl AgentState {
50    pub fn label(self) -> &'static str {
51        match self {
52            AgentState::Running => "running",
53            AgentState::Idle => "idle",
54            AgentState::Stopped => "stopped",
55        }
56    }
57}
58
59/// What the transcript says the agent was last doing. Harness-neutral.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
61#[serde(rename_all = "kebab-case")]
62pub enum Activity {
63    /// Mid-turn: a prompt or tool result was just submitted, or a tool call is pending.
64    Working,
65    /// The last thing that happened was the assistant ending its turn.
66    Waiting,
67    #[default]
68    Unknown,
69}
70
71/// Token counts, split the way the Anthropic and OpenAI usage objects split them.
72#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
73pub struct TokenUsage {
74    pub input: u64,
75    pub cache_write_5m: u64,
76    pub cache_write_1h: u64,
77    pub cache_read: u64,
78    pub output: u64,
79}
80
81impl TokenUsage {
82    pub fn cache_write(&self) -> u64 {
83        self.cache_write_5m + self.cache_write_1h
84    }
85
86    /// Everything the model consumed or produced. This is the "TOKENS" column.
87    pub fn total(&self) -> u64 {
88        self.input + self.cache_write() + self.cache_read + self.output
89    }
90
91    pub fn add(&mut self, other: &TokenUsage) {
92        self.input += other.input;
93        self.cache_write_5m += other.cache_write_5m;
94        self.cache_write_1h += other.cache_write_1h;
95        self.cache_read += other.cache_read;
96        self.output += other.output;
97    }
98
99    pub fn sub(&mut self, other: &TokenUsage) {
100        self.input = self.input.saturating_sub(other.input);
101        self.cache_write_5m = self.cache_write_5m.saturating_sub(other.cache_write_5m);
102        self.cache_write_1h = self.cache_write_1h.saturating_sub(other.cache_write_1h);
103        self.cache_read = self.cache_read.saturating_sub(other.cache_read);
104        self.output = self.output.saturating_sub(other.output);
105    }
106}
107
108/// What a span measures. Tool calls came first and gave the type its name;
109/// the other two label the time between them.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, PartialOrd, Ord)]
111#[serde(rename_all = "kebab-case")]
112pub enum SpanKind {
113    /// One tool call, from the harness issuing it to the result coming back.
114    #[default]
115    Tool,
116    /// The model producing a response: from a prompt or tool results being
117    /// submitted to the last block of the reply being written. The gap in a
118    /// waterfall that is not a tool call is almost always one of these.
119    Inference,
120    /// One human turn: from the prompt to the model ending its reply.
121    /// Contains every tool call and inference span issued in between.
122    Turn,
123}
124
125impl SpanKind {
126    pub const ALL: [SpanKind; 3] = [SpanKind::Tool, SpanKind::Inference, SpanKind::Turn];
127
128    pub fn label(self) -> &'static str {
129        match self {
130            SpanKind::Tool => "tool",
131            SpanKind::Inference => "inference",
132            SpanKind::Turn => "turn",
133        }
134    }
135}
136
137/// One span of an agent trace: a tool call, an inference, or a turn.
138///
139/// Every harness writes the same shape in its own vocabulary — Claude pairs a
140/// `tool_use` block with a `tool_result` block by `tool_use_id`, Codex pairs a
141/// `function_call` with a `function_call_output` by `call_id` — and both stamp
142/// each line with a timestamp. That is a span: a name, a start and a duration.
143/// Only the call's metadata is kept; arguments and output are never read.
144///
145/// The name predates `kind`: the type carried only tool calls until 0.3.1 and
146/// is kept for the sake of the published API.
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148pub struct ToolSpan {
149    /// The harness's own call id, so a span survives across refreshes. For
150    /// inference and turn spans, a counter the parser assigns.
151    pub id: String,
152    /// Tool name as the harness reports it (`Bash`, `exec_command`, ...), or
153    /// `inference` / `turn`.
154    pub name: String,
155    pub started_at: SystemTime,
156    /// Wall-clock duration, or `None` while the call is still in flight.
157    pub duration_ms: Option<u64>,
158    /// The call was issued by a subagent (Claude's `isSidechain`).
159    pub sidechain: bool,
160    /// The harness reported the result as an error.
161    pub error: bool,
162    /// Absent in snapshots written before 0.3.1, which held tool calls only.
163    #[serde(default)]
164    pub kind: SpanKind,
165}
166
167impl ToolSpan {
168    pub fn is_open(&self) -> bool {
169        self.duration_ms.is_none()
170    }
171
172    /// Duration if closed, otherwise how long it has been running as of `now`.
173    pub fn elapsed_ms(&self, now: SystemTime) -> u64 {
174        match self.duration_ms {
175            Some(ms) => ms,
176            None => now.duration_since(self.started_at).map(|d| d.as_millis() as u64).unwrap_or(0),
177        }
178    }
179
180    pub fn ended_at(&self, now: SystemTime) -> SystemTime {
181        self.started_at + std::time::Duration::from_millis(self.elapsed_ms(now))
182    }
183}
184
185/// One MCP server an agent uses, seen from either side or both: the process
186/// table has the server's pid, CPU and memory; the transcript has how often
187/// the agent called it. Claude Code names an MCP tool `mcp__<server>__<tool>`,
188/// which is where the server name and the call count come from.
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct McpServer {
191    /// The server's name as the harness configured it, or, for a process the
192    /// transcript never named, the program it is running.
193    pub name: String,
194    pub pid: Option<u32>,
195    pub cmdline: Option<String>,
196    pub cpu_percent: f32,
197    pub rss_bytes: u64,
198    pub age_secs: Option<u64>,
199    /// Tool calls the agent made to this server, from the transcript.
200    pub calls: u64,
201    /// Of those, how many the harness reported as errors.
202    pub errors: u64,
203    pub last_call: Option<SystemTime>,
204    /// How the process and the transcript's server were put together, for
205    /// the UI to label a guess as one.
206    pub matched_by: McpMatch,
207}
208
209/// How an `McpServer` row was formed.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "kebab-case")]
212pub enum McpMatch {
213    /// A process under the agent that no transcript server names. Either it
214    /// has not been called yet, or its name does not appear in its command.
215    ProcessOnly,
216    /// A server the transcript calls with no process under the agent: an HTTP
217    /// server, or one that has exited.
218    TranscriptOnly,
219    /// The server's name appears in the process's command line.
220    Name,
221    /// One unmatched process and one unmatched server were left; they are
222    /// taken to be the same. A guess.
223    Sole,
224}
225
226/// Role of a process inside an agent's tree.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "kebab-case")]
229pub enum ProcKind {
230    /// The agent's root process (the harness itself).
231    Agent,
232    /// Another agent process nested under an agent (e.g. `claude -p` run from a tool).
233    Subagent,
234    /// A Model Context Protocol server or helper.
235    Mcp,
236    /// A shell spawned to run a tool call.
237    Shell,
238    /// Anything else the agent launched (test runners, sleep, caffeinate, ...).
239    Tool,
240}
241
242impl ProcKind {
243    pub fn label(self) -> &'static str {
244        match self {
245            ProcKind::Agent => "agent",
246            ProcKind::Subagent => "subagent",
247            ProcKind::Mcp => "mcp",
248            ProcKind::Shell => "shell",
249            ProcKind::Tool => "tool",
250        }
251    }
252}
253
254/// One process, with its descendants.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct ProcNode {
257    pub pid: u32,
258    pub ppid: Option<u32>,
259    pub name: String,
260    pub cmdline: String,
261    pub kind: ProcKind,
262    pub harness: Option<Harness>,
263    pub cpu_percent: f32,
264    pub rss_bytes: u64,
265    pub age_secs: u64,
266    pub cwd: Option<PathBuf>,
267    pub children: Vec<ProcNode>,
268}
269
270impl ProcNode {
271    /// CPU, RSS, process count and MCP server count summed over the whole
272    /// subtree. An MCP process's own children (an `npx` wrapper's `node`) are
273    /// the same server, so they add to the process count and not to the
274    /// server count.
275    pub fn totals(&self) -> (f32, u64, usize, usize) {
276        let mut cpu = self.cpu_percent;
277        let mut rss = self.rss_bytes;
278        let mut count = 1;
279        let mut mcp = usize::from(self.kind == ProcKind::Mcp);
280        for c in &self.children {
281            let (ccpu, crss, ccount, cmcp) = c.totals();
282            cpu += ccpu;
283            rss += crss;
284            count += ccount;
285            if self.kind != ProcKind::Mcp {
286                mcp += cmcp;
287            }
288        }
289        (cpu, rss, count, mcp)
290    }
291
292    /// The MCP servers in this tree: each `Mcp` node whose parent is not one.
293    /// A server started through `npx` or `uvx` is two or three processes; the
294    /// top one stands for the server.
295    pub fn mcp_roots(&self) -> Vec<&ProcNode> {
296        let mut out = Vec::new();
297        self.collect_mcp_roots(&mut out);
298        out
299    }
300
301    fn collect_mcp_roots<'a>(&'a self, out: &mut Vec<&'a ProcNode>) {
302        if self.kind == ProcKind::Mcp {
303            out.push(self);
304            return;
305        }
306        for c in &self.children {
307            c.collect_mcp_roots(out);
308        }
309    }
310
311    pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a ProcNode, usize)) {
312        f(self, depth);
313        for c in &self.children {
314            c.walk(depth + 1, f);
315        }
316    }
317}
318
319/// A single row in the agent table.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct Agent {
322    /// Stable identity across refreshes: `pid:<n>` for live agents, `session:<id>` for stopped ones.
323    pub id: String,
324    pub name: String,
325    pub harness: Harness,
326    pub state: AgentState,
327    pub activity: Activity,
328    pub pid: Option<u32>,
329    pub session_id: Option<String>,
330    pub session_path: Option<PathBuf>,
331    pub cwd: Option<PathBuf>,
332    pub model: Option<String>,
333    pub harness_version: Option<String>,
334    pub usage: TokenUsage,
335    /// USD spent on messages whose model had a known price.
336    pub cost_usd: f64,
337    /// `cost_usd` by kind of token, so a figure that differs from another
338    /// tool's can be traced to the one line that differs.
339    #[serde(default)]
340    pub cost_breakdown: CostBreakdown,
341    /// Where the price of this row's model came from; `None` when it has none.
342    #[serde(default)]
343    pub price_source: Option<PriceSource>,
344    /// Tokens on messages whose model had no known price (so `cost_usd` is a floor).
345    pub unpriced_tokens: u64,
346    pub turns: u64,
347    pub subagent_turns: u64,
348    pub tool_calls: u64,
349    /// Server-side web searches the model ran, billed per search on top of
350    /// tokens. Counted for every harness; priced only where the price table
351    /// has a rate (Anthropic's, for Claude Code).
352    #[serde(default)]
353    pub web_searches: u64,
354    /// The most recent spans, oldest first: tool calls, inferences and turns.
355    /// Bounded; see `harness::MAX_SPANS`.
356    pub spans: Vec<ToolSpan>,
357    /// Seconds since the process started (live) or since the last transcript write (stopped).
358    pub age_secs: u64,
359    /// Seconds since the transcript was last written.
360    pub idle_secs: Option<u64>,
361    pub cpu_percent: f32,
362    pub rss_bytes: u64,
363    pub process_count: usize,
364    pub mcp_count: usize,
365    /// The MCP servers this agent uses, one row each, from the process tree
366    /// and the transcript. See `McpServer`.
367    #[serde(default)]
368    pub mcp_servers: Vec<McpServer>,
369    pub tree: Option<ProcNode>,
370    /// How the session was attributed to the process, for debugging attribution.
371    pub attribution: Attribution,
372    /// True when another row shares this pid and carries its CPU, memory and
373    /// process counts. One Codex app-server hosts many conversations, so its
374    /// threads each get a row while the process is only counted once.
375    /// Absent in snapshots written before 0.2.0.
376    #[serde(default)]
377    pub shares_process: bool,
378    /// Set when the transcript parsed but its usage records did not, which
379    /// means this row's tokens and cost are not to be believed. Almost always a
380    /// harness that changed its format under us.
381    pub parse_warning: Option<String>,
382}
383
384/// USD by kind of token, accumulated message by message at each message's
385/// own model price, so a session that changed model part way is still exact.
386/// The lines sum to `Agent::cost_usd`.
387#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
388pub struct CostBreakdown {
389    pub input: f64,
390    pub cache_write_5m: f64,
391    pub cache_write_1h: f64,
392    pub cache_read: f64,
393    pub output: f64,
394    /// Server-side web searches, billed per search on top of the tokens.
395    pub web_search: f64,
396}
397
398impl CostBreakdown {
399    pub fn total(&self) -> f64 {
400        self.input + self.cache_write_5m + self.cache_write_1h + self.cache_read + self.output + self.web_search
401    }
402
403    pub fn add(&mut self, o: &CostBreakdown) {
404        self.input += o.input;
405        self.cache_write_5m += o.cache_write_5m;
406        self.cache_write_1h += o.cache_write_1h;
407        self.cache_read += o.cache_read;
408        self.output += o.output;
409        self.web_search += o.web_search;
410    }
411
412    pub fn sub(&mut self, o: &CostBreakdown) {
413        self.input -= o.input;
414        self.cache_write_5m -= o.cache_write_5m;
415        self.cache_write_1h -= o.cache_write_1h;
416        self.cache_read -= o.cache_read;
417        self.output -= o.output;
418        self.web_search -= o.web_search;
419    }
420}
421
422/// Where a model's price came from. The built-in table carries list prices;
423/// a user's file is whatever they chose to write, and the UI says which.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "kebab-case")]
426pub enum PriceSource {
427    Builtin,
428    UserFile,
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(rename_all = "kebab-case")]
433pub enum Attribution {
434    /// The harness told us (Claude's `~/.claude/sessions/<pid>.json`).
435    HarnessRegistry,
436    /// A `--resume <id>` style argument on the command line.
437    CommandLine,
438    /// The process has the transcript open (Codex keeps every live thread's
439    /// rollout open); exact.
440    OpenFile,
441    /// Matched by working directory and start time; may be wrong with concurrent sessions.
442    CwdHeuristic,
443    /// No transcript found; process only.
444    None,
445    /// No process; transcript only.
446    TranscriptOnly,
447}
448
449#[derive(Debug, Clone, Default, Serialize, Deserialize)]
450pub struct HostStats {
451    pub hostname: Option<String>,
452    pub cpu_percent: f32,
453    pub cpu_count: usize,
454    pub mem_used_bytes: u64,
455    pub mem_total_bytes: u64,
456}
457
458#[derive(Debug, Clone, Default, Serialize, Deserialize)]
459pub struct Totals {
460    pub agents: usize,
461    pub running: usize,
462    pub idle: usize,
463    pub stopped: usize,
464    pub tokens: u64,
465    pub cost_usd: f64,
466    pub unpriced_tokens: u64,
467    pub processes: usize,
468    pub mcp_processes: usize,
469    pub orphaned_mcp: usize,
470    pub cpu_percent: f32,
471    pub rss_bytes: u64,
472}
473
474/// Version of the `--json` document. Bumped when a field changes meaning or
475/// disappears; new fields alone do not bump it.
476pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
477
478/// What the collector remembers about an orphaned MCP process: when it first
479/// saw it, and, if it watched the process lose its parent, which agent that
480/// was. Memory lasts for the run; a process that was already an orphan when
481/// agent-top started has no parent on record.
482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
483pub struct OrphanOrigin {
484    pub pid: u32,
485    pub first_seen: SystemTime,
486    /// When the process was first seen without its parent, if the parent was
487    /// seen before that.
488    pub orphaned_at: Option<SystemTime>,
489    pub parent: Option<OrphanParent>,
490}
491
492/// The agent an orphan used to belong to.
493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
494pub struct OrphanParent {
495    pub pid: u32,
496    pub agent_id: String,
497    pub name: String,
498}
499
500/// Everything the UI needs for one frame.
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct Snapshot {
503    pub schema_version: u32,
504    pub taken_at: SystemTime,
505    pub host: HostStats,
506    pub agents: Vec<Agent>,
507    /// MCP-looking processes with no live agent ancestor: leak candidates.
508    pub orphans: Vec<ProcNode>,
509    /// One entry per orphan, saying where it came from when that is known.
510    #[serde(default)]
511    pub orphan_origins: Vec<OrphanOrigin>,
512    pub totals: Totals,
513}
514
515impl Snapshot {
516    pub fn compute_totals(&mut self) {
517        let mut t = Totals::default();
518        for a in &self.agents {
519            t.agents += 1;
520            match a.state {
521                AgentState::Running => t.running += 1,
522                AgentState::Idle => t.idle += 1,
523                AgentState::Stopped => t.stopped += 1,
524            }
525            t.tokens += a.usage.total();
526            t.cost_usd += a.cost_usd;
527            t.unpriced_tokens += a.unpriced_tokens;
528            t.processes += a.process_count;
529            t.mcp_processes += a.mcp_count;
530            t.cpu_percent += a.cpu_percent;
531            t.rss_bytes += a.rss_bytes;
532        }
533        t.orphaned_mcp = self.orphans.len();
534        self.totals = t;
535    }
536}