agent-top 0.25.0

htop for local coding agents: processes, subagents, MCP servers, tokens and cost in one terminal view.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
//! agent-top: htop for local coding agents.

mod app;
mod format;
mod pane;
mod report;
mod sql;
mod theme;
mod trace;
mod ui;
mod update;

use agent_top_core::{Collector, CollectorOptions, Snapshot};
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use std::path::PathBuf;
use std::time::{Duration, Instant};

/// Where to read the full, current changelog, printed by the upgrade nudge and
/// `--whats-new` so a stale binary can still point at what is new.
pub(crate) const CHANGELOG_URL: &str = "https://github.com/kannandreams/agent-top/blob/main/CHANGELOG.md";
/// This build's version.
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
/// The changelog embedded at build time (see build.rs).
const CHANGELOG: &str = include_str!(concat!(env!("OUT_DIR"), "/changelog.md"));

/// Print the most recent changelog sections baked into this build, then the
/// link for anything newer. Honest about the limit: a binary cannot know the
/// latest version without a network call it will not make, so it shows what it
/// shipped with and points at the online changelog for the rest.
fn print_whats_new() {
    println!("agent-top {VERSION}\n");
    // The changelog is newest-first; show the first few `## [` sections.
    let mut shown = 0;
    for line in CHANGELOG.lines() {
        if line.starts_with("## [") {
            shown += 1;
            if shown > 4 {
                break;
            }
        }
        if shown >= 1 {
            println!("{line}");
        }
    }
    println!("\nNewer versions may exist; this is only what this build shipped with.");
    println!("Full and current changelog: {CHANGELOG_URL}");
}

#[derive(Parser, Debug)]
#[command(name = "agent-top", version, about = "htop for local coding agents", long_about = None,
    after_help = "Upgrade: brew upgrade agent-top | cargo install agent-top\nWhat's new: agent-top --whats-new")]
struct Cli {
    /// Print one snapshot as JSON and exit.
    #[arg(long)]
    json: bool,
    /// Print one plain-text table and exit.
    #[arg(long)]
    once: bool,
    /// Refresh interval in milliseconds.
    #[arg(long, default_value_t = 1000)]
    interval_ms: u64,
    /// Keep showing stopped sessions for this many minutes after their last write.
    #[arg(long, default_value_t = 30)]
    stopped_window_min: u64,
    /// Print the effective price table, showing which entries a user file
    /// overrode, and exit.
    #[arg(long)]
    prices: bool,
    /// Print a shell completion script and exit. Homebrew installs these for
    /// you; otherwise source the output from your shell's startup file.
    #[arg(long, value_name = "SHELL")]
    completions: Option<Shell>,
    /// Render a snapshot saved by `--json` instead of scanning this machine.
    /// Every key still works, so a bug report can be inspected as the reporter
    /// saw it. Nothing on the local machine is read.
    #[arg(long, value_name = "FILE")]
    replay: Option<PathBuf>,
    /// Print what is new in this build's changelog and how to upgrade, then
    /// exit. Reads only the changelog compiled into the binary; makes no
    /// network call.
    #[arg(long)]
    whats_new: bool,
    /// Colours: `auto` asks the terminal whether it is dark or light, `dark`
    /// is Catppuccin Mocha, `light` is Catppuccin Latte. `AGENT_TOP_THEME`
    /// sets the same thing for every run; the flag wins.
    #[arg(long, value_enum, default_value_t = theme::ThemeChoice::Auto, value_name = "THEME")]
    theme: theme::ThemeChoice,
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand, Debug)]
enum Command {
    /// Export one session's tool calls as a trace file, for Perfetto and the
    /// like. Reads the whole transcript, so it works on sessions that ended
    /// long ago and on harnesses with no telemetry of their own. Writes a
    /// file; never contacts a collector.
    Trace {
        /// A session id, a unique prefix of one, or a path to a transcript.
        #[arg(long, value_name = "ID|FILE")]
        session: String,
        /// Output format.
        #[arg(long, value_enum, default_value_t = trace::Format::Chrome)]
        format: trace::Format,
        /// Write here instead of standard output.
        #[arg(short, long, value_name = "FILE")]
        output: Option<PathBuf>,
        /// Also POST the document to this OTLP/HTTP traces URL, for example
        /// http://localhost:4318/v1/traces. The only network call agent-top
        /// ever makes, and only when you type the address. OTLP format only.
        #[arg(long, value_name = "URL")]
        endpoint: Option<String>,
    },
    /// What the agents have cost, across every harness, from the transcripts
    /// already on disk and, when there is one, the local store, which keeps
    /// sessions whose transcripts were deleted. Reads history, not the live
    /// snapshot; nothing is written and nothing leaves the machine.
    Report {
        /// How far back to look: `all`, a duration like `7d` / `12h` / `2w`,
        /// or a date `YYYY-MM-DD`.
        #[arg(long, default_value = "30d", value_name = "WHEN")]
        since: String,
        /// What to group the rows by.
        #[arg(long, value_enum, default_value_t = report::GroupBy::Harness)]
        by: report::GroupBy,
        /// Print the report as JSON instead of a table.
        #[arg(long)]
        json: bool,
        /// The local store to read; the same default as `sync`.
        #[arg(long, value_name = "FILE", conflicts_with = "no_store")]
        db: Option<PathBuf>,
        /// Read only the transcripts on disk, not the local store.
        #[arg(long)]
        no_store: bool,
    },
    /// Keep what the transcripts say in a local SQLite file, so the numbers
    /// outlive a harness deleting its history. Reads every transcript that
    /// changed since the last sync; writes only the store file.
    Sync {
        /// Only read transcripts written since then: `all`, a duration like
        /// `7d` / `12h` / `2w`, or a date `YYYY-MM-DD`.
        #[arg(long, default_value = "all", value_name = "WHEN")]
        since: String,
        /// The store file. Default: `AGENT_TOP_DB`, else
        /// `$XDG_DATA_HOME/agent-top/agent-top.db`, else
        /// `~/.local/share/agent-top/agent-top.db`.
        #[arg(long, value_name = "FILE")]
        db: Option<PathBuf>,
        /// Print what the sync did as JSON.
        #[arg(long)]
        json: bool,
    },
    /// Query the local store `sync` fills, read-only. Tables: sessions,
    /// spans, mcp_calls, sources. Views: cost_by_day, cost_by_harness,
    /// cost_by_model, cost_by_project, tool_latency, mcp_errors.
    Sql {
        /// One SQL statement, for example
        /// "select * from cost_by_project order by cost_usd desc limit 10".
        #[arg(value_name = "QUERY", required_unless_present = "schema")]
        query: Option<String>,
        /// List the tables and views with their columns, and exit.
        #[arg(long, conflicts_with = "query")]
        schema: bool,
        /// The store file; the same default as `sync`.
        #[arg(long, value_name = "FILE")]
        db: Option<PathBuf>,
        /// Print the rows as a JSON array of objects.
        #[arg(long, conflicts_with = "csv")]
        json: bool,
        /// Print the rows as CSV.
        #[arg(long)]
        csv: bool,
    },
    /// Start on the slowest-tools panel, filling the terminal. Same keys as
    /// the main view; q quits. Meant for a second pane or window.
    Slow,
    /// Start on the failed-tool-calls panel, filling the terminal.
    Fails,
    /// Start on the advice panel, filling the terminal.
    Advice,
    /// Start on the MCP servers panel, every server under every agent and
    /// the orphans, filling the terminal.
    Mcp,
}

impl Command {
    /// The panel a view subcommand starts on; `None` for the others.
    fn panel(&self) -> Option<app::Panel> {
        match self {
            Command::Slow => Some(app::Panel::SlowTools),
            Command::Fails => Some(app::Panel::FailedTools),
            Command::Advice => Some(app::Panel::Advice),
            Command::Mcp => Some(app::Panel::Mcp),
            Command::Trace { .. } | Command::Report { .. } | Command::Sync { .. } | Command::Sql { .. } => None,
        }
    }
}

/// The flags of this run that a pane opened with `o` must carry, so it shows
/// the same thing: the interval, the stopped window, and a replayed file made
/// absolute, since the pane may not start in this directory.
fn forwarded_args(cli: &Cli) -> Vec<String> {
    let mut v = Vec::new();
    if cli.interval_ms != 1000 {
        v.extend(["--interval-ms".to_string(), cli.interval_ms.to_string()]);
    }
    if cli.stopped_window_min != 30 {
        v.extend(["--stopped-window-min".to_string(), cli.stopped_window_min.to_string()]);
    }
    if let Some(path) = &cli.replay {
        let abs = std::path::absolute(path).unwrap_or_else(|_| path.clone());
        v.extend(["--replay".to_string(), abs.to_string_lossy().into_owned()]);
    }
    if let Some(theme) = cli.theme.flag() {
        v.extend(["--theme".to_string(), theme.to_string()]);
    }
    v
}

/// Where frames come from: this machine, or a snapshot someone saved earlier.
enum Source {
    Live(Box<Collector>),
    Replay(Box<Snapshot>),
}

impl Source {
    fn collect(&mut self) -> Snapshot {
        match self {
            Source::Live(c) => c.collect(),
            // A replayed snapshot is one moment in time: re-serving it keeps
            // ages, durations and the trace axis stable while keys are pressed.
            Source::Replay(s) => (**s).clone(),
        }
    }
}

/// `--db`, or the store's default path.
fn store_path(db: Option<&std::path::Path>) -> Result<PathBuf> {
    match db {
        Some(p) => Ok(p.to_path_buf()),
        None => agent_top_store::default_path().context("no store path: set HOME, XDG_DATA_HOME or AGENT_TOP_DB, or pass --db"),
    }
}

/// `agent-top sync`: fill the store and say what changed.
fn sync(since: &str, db: Option<&std::path::Path>, json: bool) -> Result<()> {
    let since = report::parse_since(since)?;
    let path = store_path(db)?;
    let mut store = agent_top_store::Store::open(&path)?;
    let stats = store.sync((since > std::time::UNIX_EPOCH).then_some(since))?;
    let sessions = store.session_count()?;
    if json {
        let failed: Vec<_> = stats.failed.iter().map(|(p, why)| serde_json::json!({ "path": p.to_string_lossy(), "error": why })).collect();
        let doc = serde_json::json!({
            "db": path.to_string_lossy(),
            "seen": stats.seen,
            "stored": stats.stored,
            "unchanged": stats.unchanged,
            "outside_window": stats.outside_window,
            "kept": stats.kept,
            "failed": failed,
            "sessions": sessions,
        });
        println!("{}", serde_json::to_string_pretty(&doc)?);
        return Ok(());
    }
    for (p, why) in &stats.failed {
        eprintln!("agent-top: could not read {}: {why}", p.display());
    }
    let mut line = format!("{} transcripts: {} stored, {} unchanged", stats.seen, stats.stored, stats.unchanged);
    for (n, what) in
        [(stats.outside_window, "outside the window"), (stats.kept, "kept (read as empty)"), (stats.failed.len() as u64, "failed")]
    {
        if n > 0 {
            line.push_str(&format!(", {n} {what}"));
        }
    }
    println!("{line}");
    println!("{} holds {sessions} sessions", path.display());
    Ok(())
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    if let Some(shell) = cli.completions {
        let mut cmd = Cli::command();
        let name = cmd.get_name().to_string();
        clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
        return Ok(());
    }
    if cli.prices {
        print!("{}", format::price_table(agent_top_core::pricing::table()));
        return Ok(());
    }
    if cli.whats_new {
        print_whats_new();
        return Ok(());
    }
    if let Some(Command::Trace { session, format, output, endpoint }) = &cli.command {
        return export_trace(session, *format, output.as_deref(), endpoint.as_deref());
    }
    if let Some(Command::Report { since, by, json, db, no_store }) = &cli.command {
        for w in &agent_top_core::pricing::table().warnings {
            eprintln!("agent-top: {w}");
        }
        let since = report::parse_since(since)?;
        // A store that exists but cannot be read is reported, and the report
        // falls back to the transcripts rather than failing.
        let path = if *no_store { None } else { Some(store_path(db.as_deref())?) };
        let reader = match path.as_deref().filter(|p| p.exists()) {
            Some(p) => match agent_top_store::Reader::open(p).and_then(|r| r.sessions()) {
                Ok(rows) => Some((p.to_path_buf(), rows)),
                Err(e) => {
                    eprintln!("agent-top: not reading the local store: {e:#}");
                    None
                }
            },
            None => None,
        };
        let hint = path.is_some() && reader.is_none();
        let rep = report::build(since, *by, reader, hint);
        if *json {
            println!("{}", serde_json::to_string_pretty(&rep.to_json())?);
        } else {
            print!("{}", rep.to_plain());
        }
        return Ok(());
    }
    if let Some(Command::Sql { query, schema, db, json, csv }) = &cli.command {
        let reader = agent_top_store::Reader::open(&store_path(db.as_deref())?)?;
        if *schema {
            print!("{}", sql::schema(&reader.describe()?));
            return Ok(());
        }
        let result = reader.query(query.as_deref().unwrap_or_default())?;
        if *json {
            println!("{}", serde_json::to_string_pretty(&sql::to_json(&result))?);
        } else if *csv {
            print!("{}", sql::to_csv(&result));
        } else {
            print!("{}", sql::to_table(&result));
        }
        return Ok(());
    }
    if let Some(Command::Sync { since, db, json }) = &cli.command {
        for w in &agent_top_core::pricing::table().warnings {
            eprintln!("agent-top: {w}");
        }
        return sync(since, db.as_deref(), *json);
    }
    // A user's price file that could not be read is the difference between a
    // real cost and a wrong one, so say so rather than quietly using defaults.
    for w in &agent_top_core::pricing::table().warnings {
        eprintln!("agent-top: {w}");
    }
    let mut source = match &cli.replay {
        Some(path) => {
            let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
            let snap: Snapshot =
                serde_json::from_str(&text).with_context(|| format!("{} is not an agent-top --json snapshot", path.display()))?;
            Source::Replay(Box::new(snap))
        }
        None => {
            let opts = CollectorOptions { stopped_window: Duration::from_secs(cli.stopped_window_min * 60), ..Default::default() };
            Source::Live(Box::new(Collector::new(opts)))
        }
    };

    // A live first pass is thrown away so per-process CPU has an interval to
    // measure over; a replay has nothing to settle.
    let mut settled = || {
        if let Source::Live(_) = source {
            let _ = source.collect();
            std::thread::sleep(Duration::from_millis(250));
        }
        source.collect()
    };

    if cli.json {
        println!("{}", serde_json::to_string_pretty(&settled())?);
        return Ok(());
    }
    if cli.once {
        print!("{}", format::plain_table(&settled()));
        return Ok(());
    }

    let start = Start {
        interval: Duration::from_millis(cli.interval_ms.max(100)),
        pinned: cli.command.as_ref().and_then(Command::panel),
        forwarded: forwarded_args(&cli),
    };
    // Query before ratatui takes ownership of terminal input/raw mode. Plain
    // text, JSON, reports and shell completions never query the terminal.
    let theme = theme::Theme::detect(cli.theme);
    let mut terminal = ratatui::init();
    let result = run(&mut terminal, &mut source, &start, &theme);
    ratatui::restore();
    match result? {
        Exit::Quit => Ok(()),
        // The user pressed `u` in the update popup: run the installer in the
        // terminal they can see, then start again on the new binary.
        Exit::Upgrade(latest) => update::upgrade(&latest),
    }
}

/// Why the TUI loop ended.
enum Exit {
    Quit,
    Upgrade(String),
}

/// How the TUI was started.
struct Start {
    interval: Duration,
    /// A view subcommand's panel: the run fills the terminal with it and has
    /// no table to go back to.
    pinned: Option<app::Panel>,
    /// The flags a pane opened with `o` repeats.
    forwarded: Vec<String>,
}

fn run(terminal: &mut ratatui::DefaultTerminal, source: &mut Source, start: &Start, theme: &theme::Theme) -> Result<Exit> {
    let interval = start.interval;
    let mut app = app::App::new(source.collect());
    if let Some(p) = start.pinned {
        app.standalone = true;
        app.pin(p);
    }
    app.multiplexer = pane::Multiplexer::detect();
    // The update check runs only for a live session, not a replayed snapshot,
    // and is the one call agent-top makes on its own (a version lookup, no data
    // sent). See the update module.
    if matches!(source, Source::Live(_)) {
        app.update = update::start();
        app.update_dismissed = update::dismissed();
        app.installer = update::Installer::detect();
        app.maybe_prompt_update();
    }
    let mut last_tick = Instant::now();
    loop {
        terminal.draw(|f| ui::draw(f, &mut app, theme))?;
        let timeout = interval.saturating_sub(last_tick.elapsed());
        if event::poll(timeout)?
            && let Event::Key(key) = event::read()?
        {
            if key.kind != KeyEventKind::Press {
                continue;
            }
            let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
            match key.code {
                KeyCode::Char('c') if ctrl => return Ok(Exit::Quit),
                // Every other key's quit/close decision lives in `on_key`,
                // testable there instead of on this real terminal loop.
                _ if app.on_key(key.code) => return Ok(Exit::Quit),
                _ => {}
            }
            if let Some(latest) = app.upgrade_requested.take() {
                return Ok(Exit::Upgrade(latest));
            }
            // `o`: open the panel in a pane of the multiplexer. The split
            // command returns at once, so it runs inline; the footer says
            // what happened either way.
            if let (Some(p), Some(mux)) = (app.open_requested.take(), app.multiplexer) {
                match pane::open(mux, &start.forwarded, p.command()) {
                    Ok(msg) | Err(msg) => app.notify(msg),
                }
            }
        }
        if last_tick.elapsed() >= interval {
            if !app.paused {
                app.update(source.collect());
            }
            last_tick = Instant::now();
        }
    }
}

fn export_trace(session: &str, format: trace::Format, output: Option<&std::path::Path>, endpoint: Option<&str>) -> Result<()> {
    if endpoint.is_some() && format != trace::Format::Otlp {
        anyhow::bail!("--endpoint posts OTLP; add --format otlp");
    }
    let src = trace::resolve(session)?;
    let summary = trace::read(&src)?;
    let doc = serde_json::to_string(&trace::render(&src, &summary, format))?;
    if let Some(url) = endpoint {
        let status = trace::post(url, &doc)?;
        eprintln!("agent-top: {url} accepted the trace ({status})");
    }
    let count = |k: agent_top_core::SpanKind| summary.spans.iter().filter(|s| s.kind == k).count();
    let open = summary.spans.iter().filter(|s| s.kind == agent_top_core::SpanKind::Tool && s.is_open()).count();
    let still_open = if open > 0 { format!(" ({open} never returned)") } else { String::new() };
    match output {
        Some(path) if path != std::path::Path::new("-") => {
            std::fs::write(path, doc).with_context(|| format!("writing {}", path.display()))?;
            eprintln!(
                "agent-top: wrote {} tool calls{still_open}, {} inferences, {} turns from the {} session to {}",
                count(agent_top_core::SpanKind::Tool),
                count(agent_top_core::SpanKind::Inference),
                count(agent_top_core::SpanKind::Turn),
                src.harness.label(),
                path.display()
            );
        }
        // Posted somewhere and no file asked for: the terminal need not get
        // the document too.
        None if endpoint.is_some() => {}
        _ => println!("{doc}"),
    }
    Ok(())
}