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/// One tool call, reconstructed from a transcript: the unit of an agent trace.
109///
110/// Every harness writes the same shape in its own vocabulary — Claude pairs a
111/// `tool_use` block with a `tool_result` block by `tool_use_id`, Codex pairs a
112/// `function_call` with a `function_call_output` by `call_id` — and both stamp
113/// each line with a timestamp. That is a span: a name, a start and a duration.
114/// Only the call's metadata is kept; arguments and output are never read.
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116pub struct ToolSpan {
117    /// The harness's own call id, so a span survives across refreshes.
118    pub id: String,
119    /// Tool name as the harness reports it (`Bash`, `exec_command`, ...).
120    pub name: String,
121    pub started_at: SystemTime,
122    /// Wall-clock duration, or `None` while the call is still in flight.
123    pub duration_ms: Option<u64>,
124    /// The call was issued by a subagent (Claude's `isSidechain`).
125    pub sidechain: bool,
126    /// The harness reported the result as an error.
127    pub error: bool,
128}
129
130impl ToolSpan {
131    pub fn is_open(&self) -> bool {
132        self.duration_ms.is_none()
133    }
134
135    /// Duration if closed, otherwise how long it has been running as of `now`.
136    pub fn elapsed_ms(&self, now: SystemTime) -> u64 {
137        match self.duration_ms {
138            Some(ms) => ms,
139            None => now.duration_since(self.started_at).map(|d| d.as_millis() as u64).unwrap_or(0),
140        }
141    }
142
143    pub fn ended_at(&self, now: SystemTime) -> SystemTime {
144        self.started_at + std::time::Duration::from_millis(self.elapsed_ms(now))
145    }
146}
147
148/// Role of a process inside an agent's tree.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "kebab-case")]
151pub enum ProcKind {
152    /// The agent's root process (the harness itself).
153    Agent,
154    /// Another agent process nested under an agent (e.g. `claude -p` run from a tool).
155    Subagent,
156    /// A Model Context Protocol server or helper.
157    Mcp,
158    /// A shell spawned to run a tool call.
159    Shell,
160    /// Anything else the agent launched (test runners, sleep, caffeinate, ...).
161    Tool,
162}
163
164impl ProcKind {
165    pub fn label(self) -> &'static str {
166        match self {
167            ProcKind::Agent => "agent",
168            ProcKind::Subagent => "subagent",
169            ProcKind::Mcp => "mcp",
170            ProcKind::Shell => "shell",
171            ProcKind::Tool => "tool",
172        }
173    }
174}
175
176/// One process, with its descendants.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ProcNode {
179    pub pid: u32,
180    pub ppid: Option<u32>,
181    pub name: String,
182    pub cmdline: String,
183    pub kind: ProcKind,
184    pub harness: Option<Harness>,
185    pub cpu_percent: f32,
186    pub rss_bytes: u64,
187    pub age_secs: u64,
188    pub cwd: Option<PathBuf>,
189    pub children: Vec<ProcNode>,
190}
191
192impl ProcNode {
193    /// CPU, RSS and process count summed over the whole subtree.
194    pub fn totals(&self) -> (f32, u64, usize, usize) {
195        let mut cpu = self.cpu_percent;
196        let mut rss = self.rss_bytes;
197        let mut count = 1;
198        let mut mcp = usize::from(self.kind == ProcKind::Mcp);
199        for c in &self.children {
200            let (ccpu, crss, ccount, cmcp) = c.totals();
201            cpu += ccpu;
202            rss += crss;
203            count += ccount;
204            mcp += cmcp;
205        }
206        (cpu, rss, count, mcp)
207    }
208
209    pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a ProcNode, usize)) {
210        f(self, depth);
211        for c in &self.children {
212            c.walk(depth + 1, f);
213        }
214    }
215}
216
217/// A single row in the agent table.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Agent {
220    /// Stable identity across refreshes: `pid:<n>` for live agents, `session:<id>` for stopped ones.
221    pub id: String,
222    pub name: String,
223    pub harness: Harness,
224    pub state: AgentState,
225    pub activity: Activity,
226    pub pid: Option<u32>,
227    pub session_id: Option<String>,
228    pub session_path: Option<PathBuf>,
229    pub cwd: Option<PathBuf>,
230    pub model: Option<String>,
231    pub harness_version: Option<String>,
232    pub usage: TokenUsage,
233    /// USD spent on messages whose model had a known price.
234    pub cost_usd: f64,
235    /// Tokens on messages whose model had no known price (so `cost_usd` is a floor).
236    pub unpriced_tokens: u64,
237    pub turns: u64,
238    pub subagent_turns: u64,
239    pub tool_calls: u64,
240    /// The most recent tool calls as spans, oldest first. Bounded; see
241    /// `harness::MAX_SPANS`.
242    pub spans: Vec<ToolSpan>,
243    /// Seconds since the process started (live) or since the last transcript write (stopped).
244    pub age_secs: u64,
245    /// Seconds since the transcript was last written.
246    pub idle_secs: Option<u64>,
247    pub cpu_percent: f32,
248    pub rss_bytes: u64,
249    pub process_count: usize,
250    pub mcp_count: usize,
251    pub tree: Option<ProcNode>,
252    /// How the session was attributed to the process, for debugging attribution.
253    pub attribution: Attribution,
254    /// True when another row shares this pid and carries its CPU, memory and
255    /// process counts. One Codex app-server hosts many conversations, so its
256    /// threads each get a row while the process is only counted once.
257    pub shares_process: bool,
258    /// Set when the transcript parsed but its usage records did not, which
259    /// means this row's tokens and cost are not to be believed. Almost always a
260    /// harness that changed its format under us.
261    pub parse_warning: Option<String>,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "kebab-case")]
266pub enum Attribution {
267    /// The harness told us (Claude's `~/.claude/sessions/<pid>.json`).
268    HarnessRegistry,
269    /// A `--resume <id>` style argument on the command line.
270    CommandLine,
271    /// Matched by working directory and start time; may be wrong with concurrent sessions.
272    CwdHeuristic,
273    /// No transcript found; process only.
274    None,
275    /// No process; transcript only.
276    TranscriptOnly,
277}
278
279#[derive(Debug, Clone, Default, Serialize, Deserialize)]
280pub struct HostStats {
281    pub hostname: Option<String>,
282    pub cpu_percent: f32,
283    pub cpu_count: usize,
284    pub mem_used_bytes: u64,
285    pub mem_total_bytes: u64,
286}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289pub struct Totals {
290    pub agents: usize,
291    pub running: usize,
292    pub idle: usize,
293    pub stopped: usize,
294    pub tokens: u64,
295    pub cost_usd: f64,
296    pub unpriced_tokens: u64,
297    pub processes: usize,
298    pub mcp_processes: usize,
299    pub orphaned_mcp: usize,
300    pub cpu_percent: f32,
301    pub rss_bytes: u64,
302}
303
304/// Version of the `--json` document. Bumped when a field changes meaning or
305/// disappears; new fields alone do not bump it.
306pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
307
308/// Everything the UI needs for one frame.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct Snapshot {
311    pub schema_version: u32,
312    pub taken_at: SystemTime,
313    pub host: HostStats,
314    pub agents: Vec<Agent>,
315    /// MCP-looking processes with no live agent ancestor: leak candidates.
316    pub orphans: Vec<ProcNode>,
317    pub totals: Totals,
318}
319
320impl Snapshot {
321    pub fn compute_totals(&mut self) {
322        let mut t = Totals::default();
323        for a in &self.agents {
324            t.agents += 1;
325            match a.state {
326                AgentState::Running => t.running += 1,
327                AgentState::Idle => t.idle += 1,
328                AgentState::Stopped => t.stopped += 1,
329            }
330            t.tokens += a.usage.total();
331            t.cost_usd += a.cost_usd;
332            t.unpriced_tokens += a.unpriced_tokens;
333            t.processes += a.process_count;
334            t.mcp_processes += a.mcp_count;
335            t.cpu_percent += a.cpu_percent;
336            t.rss_bytes += a.rss_bytes;
337        }
338        t.orphaned_mcp = self.orphans.len();
339        self.totals = t;
340    }
341}