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 /// A harness process, whether at the root or nested under another process.
282 /// Older snapshots called nested harness processes `subagent`.
283 #[serde(alias = "subagent")]
284 Agent,
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::Mcp => "mcp",
298 ProcKind::Shell => "shell",
299 ProcKind::Tool => "tool",
300 }
301 }
302}
303
304/// One process, with its descendants.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct ProcNode {
307 pub pid: u32,
308 pub ppid: Option<u32>,
309 pub name: String,
310 pub cmdline: String,
311 pub kind: ProcKind,
312 pub harness: Option<Harness>,
313 pub cpu_percent: f32,
314 pub rss_bytes: u64,
315 pub age_secs: u64,
316 pub cwd: Option<PathBuf>,
317 pub children: Vec<ProcNode>,
318}
319
320impl ProcNode {
321 /// CPU, RSS, process count and MCP server count summed over the whole
322 /// subtree. An MCP process's own children (an `npx` wrapper's `node`) are
323 /// the same server, so they add to the process count and not to the
324 /// server count.
325 pub fn totals(&self) -> (f32, u64, usize, usize) {
326 let mut cpu = self.cpu_percent;
327 let mut rss = self.rss_bytes;
328 let mut count = 1;
329 let mut mcp = usize::from(self.kind == ProcKind::Mcp);
330 for c in &self.children {
331 let (ccpu, crss, ccount, cmcp) = c.totals();
332 cpu += ccpu;
333 rss += crss;
334 count += ccount;
335 if self.kind != ProcKind::Mcp {
336 mcp += cmcp;
337 }
338 }
339 (cpu, rss, count, mcp)
340 }
341
342 /// The MCP servers in this tree: each `Mcp` node whose parent is not one.
343 /// A server started through `npx` or `uvx` is two or three processes; the
344 /// top one stands for the server.
345 pub fn mcp_roots(&self) -> Vec<&ProcNode> {
346 let mut out = Vec::new();
347 self.collect_mcp_roots(&mut out);
348 out
349 }
350
351 fn collect_mcp_roots<'a>(&'a self, out: &mut Vec<&'a ProcNode>) {
352 if self.kind == ProcKind::Mcp {
353 out.push(self);
354 return;
355 }
356 for c in &self.children {
357 c.collect_mcp_roots(out);
358 }
359 }
360
361 pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a ProcNode, usize)) {
362 f(self, depth);
363 for c in &self.children {
364 c.walk(depth + 1, f);
365 }
366 }
367}
368
369/// A logical subagent relationship explicitly recorded by the harness.
370/// This is session lineage, not an OS process relationship or a history fork.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372pub struct SubagentInfo {
373 pub parent_session_id: String,
374 #[serde(default)]
375 pub nickname: Option<String>,
376 #[serde(default)]
377 pub role: Option<String>,
378}
379
380/// A single row in the agent table.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct Agent {
383 /// Stable identity across refreshes: `pid:<n>` for live agents, `session:<id>` for stopped ones.
384 pub id: String,
385 pub name: String,
386 pub harness: Harness,
387 pub state: AgentState,
388 pub activity: Activity,
389 pub pid: Option<u32>,
390 pub session_id: Option<String>,
391 /// Explicit logical parent and identity, when the transcript records them.
392 /// Usage remains per session; adding hierarchy must not count it twice.
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub subagent: Option<SubagentInfo>,
395 pub session_path: Option<PathBuf>,
396 pub cwd: Option<PathBuf>,
397 pub model: Option<String>,
398 pub harness_version: Option<String>,
399 pub usage: TokenUsage,
400 /// USD spent on messages whose model had a known price.
401 pub cost_usd: f64,
402 /// `cost_usd` by kind of token, so a figure that differs from another
403 /// tool's can be traced to the one line that differs.
404 #[serde(default)]
405 pub cost_breakdown: CostBreakdown,
406 /// Where the price of this row's model came from; `None` when it has none.
407 #[serde(default)]
408 pub price_source: Option<PriceSource>,
409 /// Tokens on messages whose model had no known price (so `cost_usd` is a floor).
410 pub unpriced_tokens: u64,
411 pub turns: u64,
412 pub subagent_turns: u64,
413 pub tool_calls: u64,
414 /// Server-side web searches the model ran, billed per search on top of
415 /// tokens. Counted for every harness; priced only where the price table
416 /// has a rate (Anthropic's, for Claude Code).
417 #[serde(default)]
418 pub web_searches: u64,
419 /// The most recent spans, oldest first: tool calls, inferences and turns.
420 /// Bounded; see `harness::MAX_SPANS`.
421 pub spans: Vec<ToolSpan>,
422 /// Seconds since the process started (live) or since the last transcript write (stopped).
423 pub age_secs: u64,
424 /// Seconds since the transcript was last written.
425 pub idle_secs: Option<u64>,
426 pub cpu_percent: f32,
427 pub rss_bytes: u64,
428 pub process_count: usize,
429 pub mcp_count: usize,
430 /// The MCP servers this agent uses, one row each, from the process tree
431 /// and the transcript. See `McpServer`.
432 #[serde(default)]
433 pub mcp_servers: Vec<McpServer>,
434 /// What each tool's results added to the prompt and what carrying it has
435 /// cost, largest first. See `ContextSource`.
436 #[serde(default)]
437 pub context: Vec<ContextSource>,
438 pub tree: Option<ProcNode>,
439 /// How the session was attributed to the process, for debugging attribution.
440 pub attribution: Attribution,
441 /// True when another row shares this pid and carries its CPU, memory and
442 /// process counts. One Codex app-server hosts many conversations, so its
443 /// threads each get a row while the process is only counted once.
444 /// Absent in snapshots written before 0.2.0.
445 #[serde(default)]
446 pub shares_process: bool,
447 /// Set when the transcript parsed but its usage records did not, which
448 /// means this row's tokens and cost are not to be believed. Almost always a
449 /// harness that changed its format under us.
450 pub parse_warning: Option<String>,
451 /// How close the session is to its rate limit, when the harness reports it.
452 #[serde(default)]
453 pub rate_limit: Option<RateLimit>,
454}
455
456/// One rolling usage window a harness reports against a rate limit: how much
457/// of it is spent and when it rolls over. Codex writes two, a short window and
458/// a long one, on every usage record.
459#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
460pub struct RateWindow {
461 /// Percent of the window used, 0..=100.
462 pub used_percent: f64,
463 /// The window length in minutes (Codex: 300 for the short one, 10080 weekly).
464 pub window_minutes: u64,
465 /// When the window rolls over and the usage resets, if the harness says.
466 pub resets_at: Option<SystemTime>,
467}
468
469/// What a harness reports about how close a session is to its rate limit. Only
470/// the harnesses that write it (Codex today) populate this; the rest leave it
471/// `None`. Read-only, like everything else: a number the harness already wrote.
472#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
473pub struct RateLimit {
474 /// The short rolling window.
475 pub primary: Option<RateWindow>,
476 /// The long rolling window.
477 pub secondary: Option<RateWindow>,
478 /// The plan the limit is for (Codex: `plus`, `pro`, ...).
479 pub plan: Option<String>,
480 /// True when the harness says the limit is currently hit.
481 pub reached: bool,
482}
483
484impl RateLimit {
485 /// The window closest to its limit, for a one-glance figure.
486 pub fn tightest(&self) -> Option<&RateWindow> {
487 [self.primary.as_ref(), self.secondary.as_ref()]
488 .into_iter()
489 .flatten()
490 .max_by(|a, b| a.used_percent.partial_cmp(&b.used_percent).unwrap_or(std::cmp::Ordering::Equal))
491 }
492}
493
494/// USD by kind of token, accumulated message by message at each message's
495/// own model price, so a session that changed model part way is still exact.
496/// The lines sum to `Agent::cost_usd`.
497#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
498pub struct CostBreakdown {
499 pub input: f64,
500 pub cache_write_5m: f64,
501 pub cache_write_1h: f64,
502 pub cache_read: f64,
503 pub output: f64,
504 /// Server-side web searches, billed per search on top of the tokens.
505 pub web_search: f64,
506}
507
508impl CostBreakdown {
509 pub fn total(&self) -> f64 {
510 self.input + self.cache_write_5m + self.cache_write_1h + self.cache_read + self.output + self.web_search
511 }
512
513 pub fn add(&mut self, o: &CostBreakdown) {
514 self.input += o.input;
515 self.cache_write_5m += o.cache_write_5m;
516 self.cache_write_1h += o.cache_write_1h;
517 self.cache_read += o.cache_read;
518 self.output += o.output;
519 self.web_search += o.web_search;
520 }
521
522 pub fn sub(&mut self, o: &CostBreakdown) {
523 self.input -= o.input;
524 self.cache_write_5m -= o.cache_write_5m;
525 self.cache_write_1h -= o.cache_write_1h;
526 self.cache_read -= o.cache_read;
527 self.output -= o.output;
528 self.web_search -= o.web_search;
529 }
530}
531
532/// Where a model's price came from. The built-in table carries list prices;
533/// a user's file is whatever they chose to write, and the UI says which.
534#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
535#[serde(rename_all = "kebab-case")]
536pub enum PriceSource {
537 Builtin,
538 UserFile,
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(rename_all = "kebab-case")]
543pub enum Attribution {
544 /// The harness told us (Claude's `~/.claude/sessions/<pid>.json`).
545 HarnessRegistry,
546 /// A `--resume <id>` style argument on the command line.
547 CommandLine,
548 /// The process has the transcript open (Codex keeps every live thread's
549 /// rollout open); exact.
550 OpenFile,
551 /// Matched by working directory and start time; may be wrong with concurrent sessions.
552 CwdHeuristic,
553 /// No transcript found; process only.
554 None,
555 /// No process; transcript only.
556 TranscriptOnly,
557}
558
559#[derive(Debug, Clone, Default, Serialize, Deserialize)]
560pub struct HostStats {
561 pub hostname: Option<String>,
562 pub cpu_percent: f32,
563 pub cpu_count: usize,
564 pub mem_used_bytes: u64,
565 pub mem_total_bytes: u64,
566}
567
568#[derive(Debug, Clone, Default, Serialize, Deserialize)]
569pub struct Totals {
570 pub agents: usize,
571 pub running: usize,
572 pub idle: usize,
573 pub stopped: usize,
574 pub tokens: u64,
575 pub cost_usd: f64,
576 pub unpriced_tokens: u64,
577 pub processes: usize,
578 pub mcp_processes: usize,
579 pub orphaned_mcp: usize,
580 pub cpu_percent: f32,
581 pub rss_bytes: u64,
582}
583
584/// Version of the `--json` document. Bumped when a field changes meaning or
585/// disappears; new fields alone do not bump it.
586pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
587
588/// What the collector remembers about an orphaned MCP process: when it first
589/// saw it, and, if it watched the process lose its parent, which agent that
590/// was. Memory lasts for the run; a process that was already an orphan when
591/// agent-top started has no parent on record.
592#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
593pub struct OrphanOrigin {
594 pub pid: u32,
595 pub first_seen: SystemTime,
596 /// When the process was first seen without its parent, if the parent was
597 /// seen before that.
598 pub orphaned_at: Option<SystemTime>,
599 pub parent: Option<OrphanParent>,
600}
601
602/// The agent an orphan used to belong to.
603#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
604pub struct OrphanParent {
605 pub pid: u32,
606 pub agent_id: String,
607 pub name: String,
608}
609
610/// Which rule a piece of advice came from. See `advice`.
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
612#[serde(rename_all = "kebab-case")]
613pub enum AdviceRule {
614 /// A tool or MCP server whose results are large per call and have been
615 /// re-read on every response since: the biggest line in the bill hiding
616 /// behind a few calls.
617 ExpensiveSource,
618 /// An MCP server attached to a live agent that has answered no calls in
619 /// a long while.
620 IdleMcpServer,
621 /// An MCP server whose memory has climbed steadily with no calls to
622 /// explain it.
623 GrowingMcpServer,
624}
625
626impl AdviceRule {
627 pub fn label(self) -> &'static str {
628 match self {
629 AdviceRule::ExpensiveSource => "expensive source",
630 AdviceRule::IdleMcpServer => "idle mcp server",
631 AdviceRule::GrowingMcpServer => "growing mcp server",
632 }
633 }
634}
635
636/// One sentence of advice about one agent, with the numbers that back it and
637/// the thing you could do. Nothing is done for you: agent-top only points.
638#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
639pub struct Advice {
640 pub agent_id: String,
641 pub agent_name: String,
642 pub rule: AdviceRule,
643 /// The tool, or the MCP server, the advice is about.
644 pub subject: String,
645 /// The server's process, when the advice is about one.
646 pub pid: Option<u32>,
647 /// What was seen, with its numbers.
648 pub headline: String,
649 /// What you might do about it.
650 pub action: String,
651 /// The evidence as numbers, for scripts: zero where a rule has none.
652 #[serde(default)]
653 pub cost_usd: f64,
654 #[serde(default)]
655 pub tokens: u64,
656 #[serde(default)]
657 pub calls: u64,
658 #[serde(default)]
659 pub rss_bytes: u64,
660}
661
662/// Everything the UI needs for one frame.
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct Snapshot {
665 pub schema_version: u32,
666 pub taken_at: SystemTime,
667 pub host: HostStats,
668 pub agents: Vec<Agent>,
669 /// MCP-looking processes with no live agent ancestor: leak candidates.
670 pub orphans: Vec<ProcNode>,
671 /// One entry per orphan, saying where it came from when that is known.
672 #[serde(default)]
673 pub orphan_origins: Vec<OrphanOrigin>,
674 /// What looks like a bad deal on this machine right now, and what could
675 /// be done about it. See `advice`.
676 #[serde(default)]
677 pub advice: Vec<Advice>,
678 pub totals: Totals,
679}
680
681impl Snapshot {
682 pub fn compute_totals(&mut self) {
683 let mut t = Totals::default();
684 for a in &self.agents {
685 t.agents += 1;
686 match a.state {
687 AgentState::Running => t.running += 1,
688 AgentState::Idle => t.idle += 1,
689 AgentState::Stopped => t.stopped += 1,
690 }
691 t.tokens += a.usage.total();
692 t.cost_usd += a.cost_usd;
693 t.unpriced_tokens += a.unpriced_tokens;
694 t.processes += a.process_count;
695 t.mcp_processes += a.mcp_count;
696 t.cpu_percent += a.cpu_percent;
697 t.rss_bytes += a.rss_bytes;
698 }
699 t.orphaned_mcp = self.orphans.len();
700 self.totals = t;
701 }
702}
703
704#[cfg(test)]
705mod usage_tests {
706 use super::TokenUsage;
707
708 #[test]
709 fn cache_hit_rate_is_reads_over_the_prompt() {
710 let u = TokenUsage { input: 200, cache_read: 800, cache_write_5m: 0, cache_write_1h: 0, output: 50 };
711 // Prompt is 1000 (output excluded); 800 of it from cache.
712 assert_eq!(u.prompt(), 1000);
713 assert!((u.cache_hit_rate().unwrap() - 0.8).abs() < 1e-9);
714 // A cache write counts as prompt input, not as a hit.
715 let u = TokenUsage { input: 100, cache_read: 0, cache_write_5m: 900, cache_write_1h: 0, output: 0 };
716 assert_eq!(u.cache_hit_rate(), Some(0.0));
717 // Nothing to judge.
718 assert_eq!(TokenUsage::default().cache_hit_rate(), None);
719 }
720}