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    /// The input side: everything sent to the model as the prompt, fresh input
92    /// plus cache writes plus cache reads, but not the output it produced.
93    pub fn prompt(&self) -> u64 {
94        self.input + self.cache_write() + self.cache_read
95    }
96
97    /// The share of the prompt served from cache (the cheap reads), in `0..=1`.
98    /// A high number means most of the re-sent conversation was billed at the
99    /// cache-read rate rather than full input; a low one on a long session is
100    /// money left on the table. `None` when there was no prompt to judge, or
101    /// the model does not cache at all.
102    pub fn cache_hit_rate(&self) -> Option<f64> {
103        let p = self.prompt();
104        (p > 0).then(|| self.cache_read as f64 / p as f64)
105    }
106
107    pub fn add(&mut self, other: &TokenUsage) {
108        self.input += other.input;
109        self.cache_write_5m += other.cache_write_5m;
110        self.cache_write_1h += other.cache_write_1h;
111        self.cache_read += other.cache_read;
112        self.output += other.output;
113    }
114
115    pub fn sub(&mut self, other: &TokenUsage) {
116        self.input = self.input.saturating_sub(other.input);
117        self.cache_write_5m = self.cache_write_5m.saturating_sub(other.cache_write_5m);
118        self.cache_write_1h = self.cache_write_1h.saturating_sub(other.cache_write_1h);
119        self.cache_read = self.cache_read.saturating_sub(other.cache_read);
120        self.output = self.output.saturating_sub(other.output);
121    }
122}
123
124/// What a span measures. Tool calls came first and gave the type its name;
125/// the other two label the time between them.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, PartialOrd, Ord)]
127#[serde(rename_all = "kebab-case")]
128pub enum SpanKind {
129    /// One tool call, from the harness issuing it to the result coming back.
130    #[default]
131    Tool,
132    /// The model producing a response: from a prompt or tool results being
133    /// submitted to the last block of the reply being written. The gap in a
134    /// waterfall that is not a tool call is almost always one of these.
135    Inference,
136    /// One human turn: from the prompt to the model ending its reply.
137    /// Contains every tool call and inference span issued in between.
138    Turn,
139}
140
141impl SpanKind {
142    pub const ALL: [SpanKind; 3] = [SpanKind::Tool, SpanKind::Inference, SpanKind::Turn];
143
144    pub fn label(self) -> &'static str {
145        match self {
146            SpanKind::Tool => "tool",
147            SpanKind::Inference => "inference",
148            SpanKind::Turn => "turn",
149        }
150    }
151}
152
153/// One span of an agent trace: a tool call, an inference, or a turn.
154///
155/// Every harness writes the same shape in its own vocabulary — Claude pairs a
156/// `tool_use` block with a `tool_result` block by `tool_use_id`, Codex pairs a
157/// `function_call` with a `function_call_output` by `call_id` — and both stamp
158/// each line with a timestamp. That is a span: a name, a start and a duration.
159/// Only the call's metadata is kept; arguments and output are never read.
160///
161/// The name predates `kind`: the type carried only tool calls until 0.3.1 and
162/// is kept for the sake of the published API.
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
164pub struct ToolSpan {
165    /// The harness's own call id, so a span survives across refreshes. For
166    /// inference and turn spans, a counter the parser assigns.
167    pub id: String,
168    /// Tool name as the harness reports it (`Bash`, `exec_command`, ...), or
169    /// `inference` / `turn`.
170    pub name: String,
171    pub started_at: SystemTime,
172    /// Wall-clock duration, or `None` while the call is still in flight.
173    pub duration_ms: Option<u64>,
174    /// The call was issued by a subagent (Claude's `isSidechain`).
175    pub sidechain: bool,
176    /// The harness reported the result as an error.
177    pub error: bool,
178    /// Absent in snapshots written before 0.3.1, which held tool calls only.
179    #[serde(default)]
180    pub kind: SpanKind,
181}
182
183impl ToolSpan {
184    pub fn is_open(&self) -> bool {
185        self.duration_ms.is_none()
186    }
187
188    /// Duration if closed, otherwise how long it has been running as of `now`.
189    pub fn elapsed_ms(&self, now: SystemTime) -> u64 {
190        match self.duration_ms {
191            Some(ms) => ms,
192            None => now.duration_since(self.started_at).map(|d| d.as_millis() as u64).unwrap_or(0),
193        }
194    }
195
196    pub fn ended_at(&self, now: SystemTime) -> SystemTime {
197        self.started_at + std::time::Duration::from_millis(self.elapsed_ms(now))
198    }
199}
200
201/// One MCP server an agent uses, seen from either side or both: the process
202/// table has the server's pid, CPU and memory; the transcript has how often
203/// the agent called it. Claude Code names an MCP tool `mcp__<server>__<tool>`,
204/// which is where the server name and the call count come from.
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206pub struct McpServer {
207    /// The server's name as the harness configured it, or, for a process the
208    /// transcript never named, the program it is running.
209    pub name: String,
210    pub pid: Option<u32>,
211    pub cmdline: Option<String>,
212    pub cpu_percent: f32,
213    pub rss_bytes: u64,
214    pub age_secs: Option<u64>,
215    /// Tool calls the agent made to this server, from the transcript.
216    pub calls: u64,
217    /// Of those, how many the harness reported as errors.
218    pub errors: u64,
219    pub last_call: Option<SystemTime>,
220    /// How the process and the transcript's server were put together, for
221    /// the UI to label a guess as one.
222    pub matched_by: McpMatch,
223}
224
225/// How an `McpServer` row was formed.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "kebab-case")]
228pub enum McpMatch {
229    /// A process under the agent that no transcript server names. Either it
230    /// has not been called yet, or its name does not appear in its command.
231    ProcessOnly,
232    /// A server the transcript calls with no process under the agent: an HTTP
233    /// server, or one that has exited.
234    TranscriptOnly,
235    /// The server's name appears in the process's command line.
236    Name,
237    /// One unmatched process and one unmatched server were left; they are
238    /// taken to be the same. A guess.
239    Sole,
240}
241
242/// Where a stretch of a session's context came from.
243#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
244#[serde(rename_all = "kebab-case")]
245pub enum ContextOrigin {
246    /// A built-in tool of the harness (`Bash`, `Read`, `exec_command`, ...).
247    Tool,
248    /// An MCP server; `name` is the server, not the tool.
249    Mcp,
250    /// Everything that is not a tool result: the system prompt, the user's
251    /// own messages, a compaction summary.
252    #[default]
253    Other,
254}
255
256/// One source of a session's context: what its results have added to the
257/// prompt, and what sending that on every inference since has cost.
258///
259/// The tokens are the growth of the prompt between one response and the
260/// next, attributed to the tool results submitted in between; the cost is
261/// those tokens charged at the prompt rate of every response that carried
262/// them, the first read included. The rows sum to the session's prompt-side
263/// cost. See `docs/accounting.md`.
264#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
265pub struct ContextSource {
266    pub name: String,
267    pub origin: ContextOrigin,
268    /// Tool results attributed to this source. Zero for `Other`.
269    pub calls: u64,
270    /// Tokens the source added to the prompt over the session.
271    pub tokens: u64,
272    /// USD those tokens have cost across every response that read them.
273    /// An estimate; zero when the model has no price.
274    pub cost_usd: f64,
275}
276
277/// Role of a process inside an agent's tree.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "kebab-case")]
280pub enum ProcKind {
281    /// The agent's root process (the harness itself).
282    Agent,
283    /// Another agent process nested under an agent (e.g. `claude -p` run from a tool).
284    Subagent,
285    /// A Model Context Protocol server or helper.
286    Mcp,
287    /// A shell spawned to run a tool call.
288    Shell,
289    /// Anything else the agent launched (test runners, sleep, caffeinate, ...).
290    Tool,
291}
292
293impl ProcKind {
294    pub fn label(self) -> &'static str {
295        match self {
296            ProcKind::Agent => "agent",
297            ProcKind::Subagent => "subagent",
298            ProcKind::Mcp => "mcp",
299            ProcKind::Shell => "shell",
300            ProcKind::Tool => "tool",
301        }
302    }
303}
304
305/// One process, with its descendants.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct ProcNode {
308    pub pid: u32,
309    pub ppid: Option<u32>,
310    pub name: String,
311    pub cmdline: String,
312    pub kind: ProcKind,
313    pub harness: Option<Harness>,
314    pub cpu_percent: f32,
315    pub rss_bytes: u64,
316    pub age_secs: u64,
317    pub cwd: Option<PathBuf>,
318    pub children: Vec<ProcNode>,
319}
320
321impl ProcNode {
322    /// CPU, RSS, process count and MCP server count summed over the whole
323    /// subtree. An MCP process's own children (an `npx` wrapper's `node`) are
324    /// the same server, so they add to the process count and not to the
325    /// server count.
326    pub fn totals(&self) -> (f32, u64, usize, usize) {
327        let mut cpu = self.cpu_percent;
328        let mut rss = self.rss_bytes;
329        let mut count = 1;
330        let mut mcp = usize::from(self.kind == ProcKind::Mcp);
331        for c in &self.children {
332            let (ccpu, crss, ccount, cmcp) = c.totals();
333            cpu += ccpu;
334            rss += crss;
335            count += ccount;
336            if self.kind != ProcKind::Mcp {
337                mcp += cmcp;
338            }
339        }
340        (cpu, rss, count, mcp)
341    }
342
343    /// The MCP servers in this tree: each `Mcp` node whose parent is not one.
344    /// A server started through `npx` or `uvx` is two or three processes; the
345    /// top one stands for the server.
346    pub fn mcp_roots(&self) -> Vec<&ProcNode> {
347        let mut out = Vec::new();
348        self.collect_mcp_roots(&mut out);
349        out
350    }
351
352    fn collect_mcp_roots<'a>(&'a self, out: &mut Vec<&'a ProcNode>) {
353        if self.kind == ProcKind::Mcp {
354            out.push(self);
355            return;
356        }
357        for c in &self.children {
358            c.collect_mcp_roots(out);
359        }
360    }
361
362    pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a ProcNode, usize)) {
363        f(self, depth);
364        for c in &self.children {
365            c.walk(depth + 1, f);
366        }
367    }
368}
369
370/// A single row in the agent table.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct Agent {
373    /// Stable identity across refreshes: `pid:<n>` for live agents, `session:<id>` for stopped ones.
374    pub id: String,
375    pub name: String,
376    pub harness: Harness,
377    pub state: AgentState,
378    pub activity: Activity,
379    pub pid: Option<u32>,
380    pub session_id: Option<String>,
381    pub session_path: Option<PathBuf>,
382    pub cwd: Option<PathBuf>,
383    pub model: Option<String>,
384    pub harness_version: Option<String>,
385    pub usage: TokenUsage,
386    /// USD spent on messages whose model had a known price.
387    pub cost_usd: f64,
388    /// `cost_usd` by kind of token, so a figure that differs from another
389    /// tool's can be traced to the one line that differs.
390    #[serde(default)]
391    pub cost_breakdown: CostBreakdown,
392    /// Where the price of this row's model came from; `None` when it has none.
393    #[serde(default)]
394    pub price_source: Option<PriceSource>,
395    /// Tokens on messages whose model had no known price (so `cost_usd` is a floor).
396    pub unpriced_tokens: u64,
397    pub turns: u64,
398    pub subagent_turns: u64,
399    pub tool_calls: u64,
400    /// Server-side web searches the model ran, billed per search on top of
401    /// tokens. Counted for every harness; priced only where the price table
402    /// has a rate (Anthropic's, for Claude Code).
403    #[serde(default)]
404    pub web_searches: u64,
405    /// The most recent spans, oldest first: tool calls, inferences and turns.
406    /// Bounded; see `harness::MAX_SPANS`.
407    pub spans: Vec<ToolSpan>,
408    /// Seconds since the process started (live) or since the last transcript write (stopped).
409    pub age_secs: u64,
410    /// Seconds since the transcript was last written.
411    pub idle_secs: Option<u64>,
412    pub cpu_percent: f32,
413    pub rss_bytes: u64,
414    pub process_count: usize,
415    pub mcp_count: usize,
416    /// The MCP servers this agent uses, one row each, from the process tree
417    /// and the transcript. See `McpServer`.
418    #[serde(default)]
419    pub mcp_servers: Vec<McpServer>,
420    /// What each tool's results added to the prompt and what carrying it has
421    /// cost, largest first. See `ContextSource`.
422    #[serde(default)]
423    pub context: Vec<ContextSource>,
424    pub tree: Option<ProcNode>,
425    /// How the session was attributed to the process, for debugging attribution.
426    pub attribution: Attribution,
427    /// True when another row shares this pid and carries its CPU, memory and
428    /// process counts. One Codex app-server hosts many conversations, so its
429    /// threads each get a row while the process is only counted once.
430    /// Absent in snapshots written before 0.2.0.
431    #[serde(default)]
432    pub shares_process: bool,
433    /// Set when the transcript parsed but its usage records did not, which
434    /// means this row's tokens and cost are not to be believed. Almost always a
435    /// harness that changed its format under us.
436    pub parse_warning: Option<String>,
437    /// How close the session is to its rate limit, when the harness reports it.
438    #[serde(default)]
439    pub rate_limit: Option<RateLimit>,
440}
441
442/// One rolling usage window a harness reports against a rate limit: how much
443/// of it is spent and when it rolls over. Codex writes two, a short window and
444/// a long one, on every usage record.
445#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
446pub struct RateWindow {
447    /// Percent of the window used, 0..=100.
448    pub used_percent: f64,
449    /// The window length in minutes (Codex: 300 for the short one, 10080 weekly).
450    pub window_minutes: u64,
451    /// When the window rolls over and the usage resets, if the harness says.
452    pub resets_at: Option<SystemTime>,
453}
454
455/// What a harness reports about how close a session is to its rate limit. Only
456/// the harnesses that write it (Codex today) populate this; the rest leave it
457/// `None`. Read-only, like everything else: a number the harness already wrote.
458#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
459pub struct RateLimit {
460    /// The short rolling window.
461    pub primary: Option<RateWindow>,
462    /// The long rolling window.
463    pub secondary: Option<RateWindow>,
464    /// The plan the limit is for (Codex: `plus`, `pro`, ...).
465    pub plan: Option<String>,
466    /// True when the harness says the limit is currently hit.
467    pub reached: bool,
468}
469
470impl RateLimit {
471    /// The window closest to its limit, for a one-glance figure.
472    pub fn tightest(&self) -> Option<&RateWindow> {
473        [self.primary.as_ref(), self.secondary.as_ref()]
474            .into_iter()
475            .flatten()
476            .max_by(|a, b| a.used_percent.partial_cmp(&b.used_percent).unwrap_or(std::cmp::Ordering::Equal))
477    }
478}
479
480/// USD by kind of token, accumulated message by message at each message's
481/// own model price, so a session that changed model part way is still exact.
482/// The lines sum to `Agent::cost_usd`.
483#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
484pub struct CostBreakdown {
485    pub input: f64,
486    pub cache_write_5m: f64,
487    pub cache_write_1h: f64,
488    pub cache_read: f64,
489    pub output: f64,
490    /// Server-side web searches, billed per search on top of the tokens.
491    pub web_search: f64,
492}
493
494impl CostBreakdown {
495    pub fn total(&self) -> f64 {
496        self.input + self.cache_write_5m + self.cache_write_1h + self.cache_read + self.output + self.web_search
497    }
498
499    pub fn add(&mut self, o: &CostBreakdown) {
500        self.input += o.input;
501        self.cache_write_5m += o.cache_write_5m;
502        self.cache_write_1h += o.cache_write_1h;
503        self.cache_read += o.cache_read;
504        self.output += o.output;
505        self.web_search += o.web_search;
506    }
507
508    pub fn sub(&mut self, o: &CostBreakdown) {
509        self.input -= o.input;
510        self.cache_write_5m -= o.cache_write_5m;
511        self.cache_write_1h -= o.cache_write_1h;
512        self.cache_read -= o.cache_read;
513        self.output -= o.output;
514        self.web_search -= o.web_search;
515    }
516}
517
518/// Where a model's price came from. The built-in table carries list prices;
519/// a user's file is whatever they chose to write, and the UI says which.
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(rename_all = "kebab-case")]
522pub enum PriceSource {
523    Builtin,
524    UserFile,
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(rename_all = "kebab-case")]
529pub enum Attribution {
530    /// The harness told us (Claude's `~/.claude/sessions/<pid>.json`).
531    HarnessRegistry,
532    /// A `--resume <id>` style argument on the command line.
533    CommandLine,
534    /// The process has the transcript open (Codex keeps every live thread's
535    /// rollout open); exact.
536    OpenFile,
537    /// Matched by working directory and start time; may be wrong with concurrent sessions.
538    CwdHeuristic,
539    /// No transcript found; process only.
540    None,
541    /// No process; transcript only.
542    TranscriptOnly,
543}
544
545#[derive(Debug, Clone, Default, Serialize, Deserialize)]
546pub struct HostStats {
547    pub hostname: Option<String>,
548    pub cpu_percent: f32,
549    pub cpu_count: usize,
550    pub mem_used_bytes: u64,
551    pub mem_total_bytes: u64,
552}
553
554#[derive(Debug, Clone, Default, Serialize, Deserialize)]
555pub struct Totals {
556    pub agents: usize,
557    pub running: usize,
558    pub idle: usize,
559    pub stopped: usize,
560    pub tokens: u64,
561    pub cost_usd: f64,
562    pub unpriced_tokens: u64,
563    pub processes: usize,
564    pub mcp_processes: usize,
565    pub orphaned_mcp: usize,
566    pub cpu_percent: f32,
567    pub rss_bytes: u64,
568}
569
570/// Version of the `--json` document. Bumped when a field changes meaning or
571/// disappears; new fields alone do not bump it.
572pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
573
574/// What the collector remembers about an orphaned MCP process: when it first
575/// saw it, and, if it watched the process lose its parent, which agent that
576/// was. Memory lasts for the run; a process that was already an orphan when
577/// agent-top started has no parent on record.
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
579pub struct OrphanOrigin {
580    pub pid: u32,
581    pub first_seen: SystemTime,
582    /// When the process was first seen without its parent, if the parent was
583    /// seen before that.
584    pub orphaned_at: Option<SystemTime>,
585    pub parent: Option<OrphanParent>,
586}
587
588/// The agent an orphan used to belong to.
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
590pub struct OrphanParent {
591    pub pid: u32,
592    pub agent_id: String,
593    pub name: String,
594}
595
596/// Which rule a piece of advice came from. See `advice`.
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
598#[serde(rename_all = "kebab-case")]
599pub enum AdviceRule {
600    /// A tool or MCP server whose results are large per call and have been
601    /// re-read on every response since: the biggest line in the bill hiding
602    /// behind a few calls.
603    ExpensiveSource,
604    /// An MCP server attached to a live agent that has answered no calls in
605    /// a long while.
606    IdleMcpServer,
607    /// An MCP server whose memory has climbed steadily with no calls to
608    /// explain it.
609    GrowingMcpServer,
610}
611
612impl AdviceRule {
613    pub fn label(self) -> &'static str {
614        match self {
615            AdviceRule::ExpensiveSource => "expensive source",
616            AdviceRule::IdleMcpServer => "idle mcp server",
617            AdviceRule::GrowingMcpServer => "growing mcp server",
618        }
619    }
620}
621
622/// One sentence of advice about one agent, with the numbers that back it and
623/// the thing you could do. Nothing is done for you: agent-top only points.
624#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
625pub struct Advice {
626    pub agent_id: String,
627    pub agent_name: String,
628    pub rule: AdviceRule,
629    /// The tool, or the MCP server, the advice is about.
630    pub subject: String,
631    /// The server's process, when the advice is about one.
632    pub pid: Option<u32>,
633    /// What was seen, with its numbers.
634    pub headline: String,
635    /// What you might do about it.
636    pub action: String,
637    /// The evidence as numbers, for scripts: zero where a rule has none.
638    #[serde(default)]
639    pub cost_usd: f64,
640    #[serde(default)]
641    pub tokens: u64,
642    #[serde(default)]
643    pub calls: u64,
644    #[serde(default)]
645    pub rss_bytes: u64,
646}
647
648/// Everything the UI needs for one frame.
649#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct Snapshot {
651    pub schema_version: u32,
652    pub taken_at: SystemTime,
653    pub host: HostStats,
654    pub agents: Vec<Agent>,
655    /// MCP-looking processes with no live agent ancestor: leak candidates.
656    pub orphans: Vec<ProcNode>,
657    /// One entry per orphan, saying where it came from when that is known.
658    #[serde(default)]
659    pub orphan_origins: Vec<OrphanOrigin>,
660    /// What looks like a bad deal on this machine right now, and what could
661    /// be done about it. See `advice`.
662    #[serde(default)]
663    pub advice: Vec<Advice>,
664    pub totals: Totals,
665}
666
667impl Snapshot {
668    pub fn compute_totals(&mut self) {
669        let mut t = Totals::default();
670        for a in &self.agents {
671            t.agents += 1;
672            match a.state {
673                AgentState::Running => t.running += 1,
674                AgentState::Idle => t.idle += 1,
675                AgentState::Stopped => t.stopped += 1,
676            }
677            t.tokens += a.usage.total();
678            t.cost_usd += a.cost_usd;
679            t.unpriced_tokens += a.unpriced_tokens;
680            t.processes += a.process_count;
681            t.mcp_processes += a.mcp_count;
682            t.cpu_percent += a.cpu_percent;
683            t.rss_bytes += a.rss_bytes;
684        }
685        t.orphaned_mcp = self.orphans.len();
686        self.totals = t;
687    }
688}
689
690#[cfg(test)]
691mod usage_tests {
692    use super::TokenUsage;
693
694    #[test]
695    fn cache_hit_rate_is_reads_over_the_prompt() {
696        let u = TokenUsage { input: 200, cache_read: 800, cache_write_5m: 0, cache_write_1h: 0, output: 50 };
697        // Prompt is 1000 (output excluded); 800 of it from cache.
698        assert_eq!(u.prompt(), 1000);
699        assert!((u.cache_hit_rate().unwrap() - 0.8).abs() < 1e-9);
700        // A cache write counts as prompt input, not as a hit.
701        let u = TokenUsage { input: 100, cache_read: 0, cache_write_5m: 900, cache_write_1h: 0, output: 0 };
702        assert_eq!(u.cache_hit_rate(), Some(0.0));
703        // Nothing to judge.
704        assert_eq!(TokenUsage::default().cache_hit_rate(), None);
705    }
706}