From 1b3fe09074fcaa80a14cd3e627f268d666841283 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 18 Aug 2026 11:23:50 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20phase=202=20=E2=80=94=20co=C3=BBts=20pa?= =?UTF-8?q?r=20session=20(stats=20--costs)=20+=20am=20monitor=20TUI=20(clo?= =?UTF-8?q?ses=20#49=20#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 + ROADMAP.md | 8 +- config.yaml | 8 + man/am-monitor.1 | 19 ++ man/am-stats.1 | 5 +- man/am.1 | 7 +- src/cli.rs | 12 ++ src/commands/mod.rs | 8 +- src/commands/monitor_cmd.rs | 405 ++++++++++++++++++++++++++++++++++++ src/commands/stats_cmd.rs | 49 ++++- src/commands/suggest_cmd.rs | 1 + src/commands/tip_cmd.rs | 13 +- src/config.rs | 41 ++++ src/costs.rs | 323 ++++++++++++++++++++++++++++ src/events.rs | 35 +++- src/help.rs | 31 ++- src/lib.rs | 1 + src/repl.rs | 10 +- src/sessions.rs | 17 ++ tests/stats_test.rs | 2 +- 22 files changed, 978 insertions(+), 23 deletions(-) create mode 100644 man/am-monitor.1 create mode 100644 src/commands/monitor_cmd.rs create mode 100644 src/costs.rs diff --git a/Cargo.lock b/Cargo.lock index 55e841a..3a3af6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 2d3159c..d8f01c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.5.0" +version = "0.5.1" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/README.md b/README.md index 1f7de51..5b74a3f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ plateforme, il compile automatiquement depuis les sources. | am sessions --retention | purge des sessions terminées au-delà de N jours | | am init --template | génère une config pré-remplie (web, python, rust, cli) | | am doctor --watch | vérifications périodiques de l'environnement + alerte en cas de panne | +| am monitor [--json] | TUI temps réel des processus gérés (CPU/RSS/uptime) + alertes de seuils | +| am stats --costs | coût estimé par agent (tokens in/out, $) — modèles de prix configurables | | am service install --autostart | service système (systemd / launchd / tâche Windows) + démarrage auto | | am schedule add --at HH:MM | planifie une commande am (ex: update --all) ; list / remove / run | | am start group:dev --parallel | orchestration de groupes : ordre, --parallel, attente de santé (healthcheck) | diff --git a/ROADMAP.md b/ROADMAP.md index be09edf..9cfe788 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -169,8 +169,8 @@ taux de succès** — et **combien ça coûte**. | am top — classement des agents par utilisation | S | P1 | | am log — filtres agent/type/date, --follow | S → M | P0 → P1 | | am report — rapport hebdo/mensuel en markdown (top agents, échecs, changements) | M | P1 | -| Suivi des coûts — tokens/€ par session quand l'agent expose son usage (Claude Code --output-format json, Codex…) | M | P2 | -| am monitor — TUI temps réel : CPU/mémoire par PID, uptime, alertes | L | P2 | +| Suivi des coûts — tokens/€ par session quand l'agent expose son usage (Claude Code --output-format json, Codex…) ✅ #49 | M | P2 | +| am monitor — TUI temps réel : CPU/mémoire par PID, uptime, alertes ✅ #50 | L | P2 | | Télémétrie anonyme **opt-in** (compteurs agrégés uniquement, jamais de chemins) | M | P3 | am stats # vue globale @@ -430,8 +430,8 @@ moins de 2 secondes. | # | Issue | Effort | |---|---|---| -| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session | M | -| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) | L | +| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session (stats --costs, cost_models) ✅ | M | +| [#50](https://git.dracodev.net/Projets/agent-manager/issues/50) | am monitor — TUI temps réel (CPU/mémoire par PID, alertes) ✅ | L | | [#51](https://git.dracodev.net/Projets/agent-manager/issues/51) | Playbooks — am history --save + am playbook | L | | [#52](https://git.dracodev.net/Projets/agent-manager/issues/52) | ✅ Templates — am init --template web/python/rust/cli (substitution + validation) | M | | [#53](https://git.dracodev.net/Projets/agent-manager/issues/53) | ✅ am sessions export + rétention configurable (sessions_retention_days) | M | diff --git a/config.yaml b/config.yaml index 7cbc21b..e4f738b 100644 --- a/config.yaml +++ b/config.yaml @@ -25,6 +25,14 @@ settings: catalog_url: null # URL officielle du catalogue distant (défaut: config.yaml du repo) (#60) backups_keep: 5 # nombre de backups conservés pour am update --rollback (#64) sessions_retention_days: 90 # purge des sessions terminées au-delà de N jours (#53) + # Prix des modèles (USD par million de tokens) pour am stats --costs (#49). + # cost_models: # décommenter pour surcharger + # claude-sonnet-4: { input_per_mtok: 3.0, output_per_mtok: 15.0 } + # deepseek-chat: { input_per_mtok: 0.27, output_per_mtok: 1.10 } + # Seuils d'alerte de am monitor (#50) : 0 désactive le seuil. + # monitor_thresholds: + # cpu_pct: 80.0 + # mem_mb: 2048 # --- Command aliases ---------------------------------------------------------- aliases: diff --git a/man/am-monitor.1 b/man/am-monitor.1 new file mode 100644 index 0000000..fa1237c --- /dev/null +++ b/man/am-monitor.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH am-monitor 1 "monitor " +.SH NAME +monitor \- Real\-time monitor of the managed processes (issue #50) +.SH SYNOPSIS +\fBmonitor\fR [\fB\-\-interval\fR] [\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Real\-time monitor of the managed processes (issue #50) +.SH OPTIONS +.TP +\fB\-\-interval\fR \fI\fR +Refresh interval in seconds (default 2) +.TP +\fB\-\-json\fR +Print one JSON document per tick instead of the TUI +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/am-stats.1 b/man/am-stats.1 index 44d1dfc..dee34f4 100644 --- a/man/am-stats.1 +++ b/man/am-stats.1 @@ -4,7 +4,7 @@ .SH NAME stats \- Show usage statistics computed from the event journal .SH SYNOPSIS -\fBstats\fR [\fB\-\-period\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] +\fBstats\fR [\fB\-\-period\fR] [\fB\-\-costs\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR] .SH DESCRIPTION Show usage statistics computed from the event journal .SH OPTIONS @@ -12,6 +12,9 @@ Show usage statistics computed from the event journal \fB\-\-period\fR \fI\fR Only events of the last period (7d, 30d, 90d, all) .TP +\fB\-\-costs\fR +Show token usage and estimated spend per agent (issue #49) +.TP \fB\-h\fR, \fB\-\-help\fR Print help .TP diff --git a/man/am.1 b/man/am.1 index 06de9d2..61521da 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 0.5.0" +.TH am 1 "am 0.5.1" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -129,6 +129,9 @@ List the sessions of every agent (and of the interactive shell) am\-stats(1) Show usage statistics computed from the event journal .TP +am\-monitor(1) +Real\-time monitor of the managed processes (issue #50) +.TP am\-top(1) Show the most used agents (top 10) .TP @@ -198,4 +201,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v0.5.0 +v0.5.1 diff --git a/src/cli.rs b/src/cli.rs index 00f2f25..ef9060b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -256,6 +256,18 @@ pub enum Command { /// Only events of the last period (7d, 30d, 90d, all) #[arg(long, value_name = "PERIOD")] period: Option, + /// Show token usage and estimated spend per agent (issue #49) + #[arg(long, action = ArgAction::SetTrue)] + costs: bool, + }, + /// Real-time monitor of the managed processes (issue #50) + Monitor { + /// Refresh interval in seconds (default 2) + #[arg(long, value_name = "SECONDS")] + interval: Option, + /// Print one JSON document per tick instead of the TUI + #[arg(long, action = ArgAction::SetTrue)] + json: bool, }, /// Show the most used agents (top 10) Top { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 03460a1..6181828 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -20,6 +20,7 @@ pub mod log_cmd; pub mod logs_cmd; pub mod man_cmd; pub mod models_cmd; +pub mod monitor_cmd; pub mod open_cmd; pub mod profile_cmd; pub mod projects_cmd; @@ -105,7 +106,12 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { timeout, } => run_cmd::restart(app, start, *force, *timeout), Command::Status { agent } => status_cmd::run(app, agent.as_deref()), - Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()), + Command::Stats { agent, period, costs } => { + stats_cmd::run(app, agent.as_deref(), period.as_deref(), *costs) + } + Command::Monitor { interval, json } => { + monitor_cmd::run(app, *interval, *json) + } Command::Top { period } => stats_cmd::run_top(app, period.as_deref()), Command::Report { last_week, diff --git a/src/commands/monitor_cmd.rs b/src/commands/monitor_cmd.rs new file mode 100644 index 0000000..7f06d8d --- /dev/null +++ b/src/commands/monitor_cmd.rs @@ -0,0 +1,405 @@ +//! monitor: real-time TUI of the managed processes (issue #50). Lists every +//! running agent with CPU %, RSS, uptime; alerts on configurable thresholds +//! (settings.monitor_thresholds) with a desktop notification + journal event. +//! `--json` prints one JSON document per tick for scripts. + +use super::*; +use crate::config::MonitorThresholds; +use crate::events::{Event, EventKind}; +use anyhow::{Context, Result}; +use serde::Serialize; +use std::process::Command; +use std::time::Duration; + +/// One measured process row. +#[derive(Debug, Clone, Default, Serialize)] +pub struct AgentMetric { + pub agent: String, + pub pid: u32, + pub cpu_pct: f64, + pub mem_mb: f64, + pub uptime_s: u64, + /// "running" when the process answers, "dead" otherwise. + pub state: String, +} + +/// Raw reading of a process (platform-dependent). +#[derive(Debug, Clone, Copy, Default)] +pub struct RawReading { + /// Total CPU time consumed so far (seconds). + pub cpu_total_s: f64, + /// Resident set size in bytes. + pub rss_bytes: u64, +} + +/// Read the raw counters of a process. Windows: PowerShell Get-Process +/// (CPU = total seconds, WorkingSet64 = RSS). Unix: ps. +fn raw_reading(pid: u32) -> Option { + #[cfg(windows)] + { + let out = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + &format!( + "Get-Process -Id {pid} | Select-Object -ExpandProperty CPU; Get-Process -Id {pid} | Select-Object -ExpandProperty WorkingSet64" + ), + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let mut lines = text.lines().filter(|l| !l.trim().is_empty()); + let cpu: f64 = lines.next()?.trim().parse().ok()?; + let rss: u64 = lines.next()?.trim().parse().ok()?; + Some(RawReading { + cpu_total_s: cpu, + rss_bytes: rss, + }) + } + #[cfg(unix)] + { + let out = Command::new("ps") + .args(["-o", "time=,rss=", "-p", &pid.to_string()]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let mut parts = text.split_whitespace(); + let time = parts.next()?; + let rss_kb: u64 = parts.next()?.parse().ok()?; + Some(RawReading { + cpu_total_s: parse_ps_time(time), + rss_bytes: rss_kb * 1024, + }) + } +} + +/// Parse `ps` cumulative time "MM:SS" or "HH:MM:SS" into seconds. +fn parse_ps_time(t: &str) -> f64 { + let parts: Vec = t + .split(':') + .filter_map(|p| p.parse().ok()) + .collect(); + match parts.as_slice() { + [m, s] => m * 60.0 + s, + [h, m, s] => h * 3600.0 + m * 60.0 + s, + _ => 0.0, + } +} + +/// Turn raw readings (taken `interval_s` apart) into a metric row. +/// Pure: unit tested. +pub fn to_metric( + agent: &str, + pid: u32, + before: Option, + after: RawReading, + started_at: Option<&str>, + interval_s: f64, +) -> AgentMetric { + let cpu_pct = match before { + Some(b) if interval_s > 0.0 => { + let delta = (after.cpu_total_s - b.cpu_total_s).max(0.0); + (delta / interval_s * 100.0).min(100.0 * 64.0) // multi-core cap, generous + } + _ => 0.0, + }; + let uptime_s = started_at + .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) + .map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_seconds().max(0) as u64) + .unwrap_or(0); + AgentMetric { + agent: agent.to_string(), + pid, + cpu_pct, + mem_mb: after.rss_bytes as f64 / (1024.0 * 1024.0), + uptime_s, + state: "running".to_string(), + } +} + +/// Decide which alerts fire for a metric against the thresholds. Pure. +pub fn alerts_for(m: &AgentMetric, t: &MonitorThresholds) -> Vec { + let mut out = Vec::new(); + if let Some(cpu) = t.cpu_pct { + if cpu > 0.0 && m.cpu_pct > cpu { + out.push(format!( + "{} cpu {:.1}% > {cpu}%", + m.agent, m.cpu_pct + )); + } + } + if let Some(mem) = t.mem_mb { + if mem > 0 && m.mem_mb > mem as f64 { + out.push(format!( + "{} rss {:.0} MB > {mem} MB", + m.agent, m.mem_mb + )); + } + } + out +} + +/// Collect the metrics of every managed agent with a live PID. +pub fn collect(app: &App, interval_s: f64) -> Vec { + let mut out = Vec::new(); + let Ok(sf) = app.state.load() else { + return out; + }; + for (name, entry) in &sf.installed { + let Some(pid) = entry.pid else { continue }; + let Some(after) = raw_reading(pid) else { + out.push(AgentMetric { + agent: name.clone(), + pid, + state: "dead".to_string(), + ..Default::default() + }); + continue; + }; + let before = if interval_s > 0.0 { + std::thread::sleep(Duration::from_secs_f64(interval_s)); + raw_reading(pid) + } else { + None + }; + out.push(to_metric( + name, + pid, + before, + after, + entry.started_at.as_deref(), + interval_s, + )); + } + out +} + +/// Emit one JSON document (one row per process) — script-friendly. +fn print_json_rows(rows: &[AgentMetric]) { + crate::output::print_json(&rows); +} + +/// Run the monitor: TUI by default, --json for scripts. +pub fn run(app: &App, interval: Option, json: bool) -> Result { + let secs = interval.unwrap_or(2).max(1) as f64; + let thresholds = app + .config + .settings + .monitor_thresholds + .clone() + .unwrap_or_default(); + if json { + // One JSON document per tick, then sleep; Ctrl-C stops. + loop { + let rows = collect(app, 0.0); + print_json_rows(&rows); + for m in &rows { + fire_alerts(app, m, &thresholds); + } + std::thread::sleep(Duration::from_secs_f64(secs)); + } + } + tui_loop(app, secs, &thresholds) +} + +/// Fire a desktop notification + journal event when a threshold is crossed. +/// Alerts are rate-limited by the caller (one per tick). +fn fire_alerts(app: &App, m: &AgentMetric, t: &MonitorThresholds) { + for alert in alerts_for(m, t) { + let _ = crate::process::desktop_notify("agent-manager monitor", &alert); + app.emit( + &Event::now(EventKind::Alert) + .with_agent(m.agent.clone()) + .with_pid(m.pid) + .with_reason(format!("monitor: {alert}")), + ); + } +} + +/// ratatui loop: list of managed processes, refresh every `secs`, q/Esc quits. +fn tui_loop(app: &App, secs: f64, thresholds: &MonitorThresholds) -> Result { + use crossterm::event::{self, Event, KeyCode, KeyEventKind}; + use ratatui::layout::{Constraint, Layout}; + use ratatui::style::{Color, Modifier, Style}; + use ratatui::text::{Line, Span}; + use ratatui::widgets::{Block, Borders, List, ListItem}; + use ratatui::DefaultTerminal; + + let mut terminal = ratatui::init(); + let result = (|| -> Result { + loop { + let rows = collect(app, secs); + for m in &rows { + fire_alerts(app, m, thresholds); + } + terminal.draw(|f| { + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(f.area()); + let title = Line::from(vec![ + Span::styled( + " am monitor ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{} managed process(es)", rows.len()), + Style::default().fg(Color::Gray), + ), + ]); + f.render_widget( + Block::default().borders(Borders::TOP).title(title), + chunks[0], + ); + let items: Vec = rows + .iter() + .map(|m| { + let state = if m.state == "dead" { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::Green) + }; + ListItem::new(Line::from(vec![ + Span::styled( + format!( + "{:<20} pid {:>7} cpu {:>6.1}% rss {:>8.0} MB up {}", + m.agent, + m.pid, + m.cpu_pct, + m.mem_mb, + human_uptime(m.uptime_s), + ), + state, + ), + ])) + }) + .collect(); + f.render_widget( + List::new(items).block(Block::default().borders(Borders::ALL)), + chunks[1], + ); + let hint = Line::from(Span::styled( + " q / Esc: quit ", + Style::default().fg(Color::DarkGray), + )); + f.render_widget(hint, chunks[2]); + })?; + if event::poll(Duration::from_millis(100))? { + if let Event::Key(k) = event::read()? { + if k.kind == KeyEventKind::Press + && matches!(k.code, KeyCode::Char('q') | KeyCode::Esc) + { + return Ok(0); + } + } + } + } + })(); + ratatui::restore(); + result +} + +fn human_uptime(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m{:02}s", secs / 60, secs % 60) + } else { + format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn to_metric_computes_cpu_and_mem() { + let before = RawReading { + cpu_total_s: 10.0, + rss_bytes: 2 * 1024 * 1024, + }; + let after = RawReading { + cpu_total_s: 12.0, + rss_bytes: 4 * 1024 * 1024, + }; + let m = to_metric("a", 42, Some(before), after, None, 2.0); + assert_eq!(m.pid, 42); + assert!((m.cpu_pct - 100.0).abs() < 1e-6, "{}", m.cpu_pct); + assert!((m.mem_mb - 4.0).abs() < 1e-6); + assert_eq!(m.state, "running"); + } + + #[test] + fn to_metric_first_sample_zero_cpu() { + let after = RawReading { + cpu_total_s: 5.0, + rss_bytes: 1024 * 1024, + }; + let m = to_metric("a", 1, None, after, None, 2.0); + assert_eq!(m.cpu_pct, 0.0); + } + + #[test] + fn to_metric_parses_uptime() { + let now = chrono::Utc::now(); + let started = now - chrono::Duration::minutes(5); + let after = RawReading { + cpu_total_s: 1.0, + rss_bytes: 1024 * 1024, + }; + let m = to_metric("a", 1, None, after, Some(&started.to_rfc3339()), 0.0); + assert!((260..=320).contains(&m.uptime_s), "{}", m.uptime_s); + } + + #[test] + fn alerts_fire_on_thresholds() { + let m = AgentMetric { + agent: "a".into(), + pid: 1, + cpu_pct: 95.0, + mem_mb: 2000.0, + uptime_s: 0, + state: "running".into(), + }; + let t = MonitorThresholds { + cpu_pct: Some(80.0), + mem_mb: Some(1024), + }; + let alerts = alerts_for(&m, &t); + assert_eq!(alerts.len(), 2); + // Below thresholds → nothing. + let quiet = AgentMetric { + cpu_pct: 10.0, + mem_mb: 100.0, + ..m.clone() + }; + assert!(alerts_for(&quiet, &t).is_empty()); + // 0 disables the threshold. + let off = MonitorThresholds { + cpu_pct: Some(0.0), + mem_mb: Some(0), + }; + assert!(alerts_for(&m, &off).is_empty()); + } + + #[test] + fn parse_ps_time_formats() { + assert!((parse_ps_time("01:30") - 90.0).abs() < 1e-9); + assert!((parse_ps_time("1:02:03") - 3723.0).abs() < 1e-9); + } +} diff --git a/src/commands/stats_cmd.rs b/src/commands/stats_cmd.rs index 5b1fd0a..b17e66b 100644 --- a/src/commands/stats_cmd.rs +++ b/src/commands/stats_cmd.rs @@ -132,10 +132,23 @@ pub fn report_md(app: &App, days: u32) -> String { )); } } + // Issue #49: cost section when usage was recorded. + if let Ok(rows) = crate::costs::aggregate(app, Some(&format!("{days}d"))) { + if !rows.is_empty() { + let t = crate::costs::totals(&rows); + md.push_str(&format!( + "\n## Coûts estimés\n\n- Sessions facturées : {}\n- Tokens : {} in / {} out\n- Coût estimé : ${:.4}\n", + t.sessions, t.tokens_in, t.tokens_out, t.cost_usd + )); + } + } md } -pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result { +pub fn run(app: &App, agent: Option<&str>, period: Option<&str>, costs: bool) -> Result { + if costs { + return run_costs(app, period); + } let rows = aggregate(app, agent, period)?; if app.json() { print_json(&rows); @@ -201,6 +214,40 @@ pub fn run_top(app: &App, period: Option<&str>) -> Result { Ok(0) } +/// am stats --costs : token usage and estimated spend (issue #49). +pub fn run_costs(app: &App, period: Option<&str>) -> Result { + let rows = crate::costs::aggregate(app, period)?; + if app.json() { + print_json(&rows); + return Ok(0); + } + if rows.is_empty() { + app.log + .info("no cost recorded yet — agents exposing their usage (claude-code) are priced at session end"); + return Ok(0); + } + let total = crate::costs::totals(&rows); + let mut table = Table::new(vec!["AGENT", "SESSIONS", "TOKENS IN", "TOKENS OUT", "COST $"]); + for r in &rows { + table.row(vec![ + r.agent.clone(), + r.sessions.to_string(), + r.tokens_in.to_string(), + r.tokens_out.to_string(), + format!("{:.4}", r.cost_usd), + ]); + } + table.row(vec![ + "TOTAL".to_string(), + total.sessions.to_string(), + total.tokens_in.to_string(), + total.tokens_out.to_string(), + format!("{:.4}", total.cost_usd), + ]); + print!("{}", table.render()); + Ok(0) +} + /// am report : write the markdown digest (issue #37). pub fn run_report( app: &App, diff --git a/src/commands/suggest_cmd.rs b/src/commands/suggest_cmd.rs index 2aae165..01282fc 100644 --- a/src/commands/suggest_cmd.rs +++ b/src/commands/suggest_cmd.rs @@ -198,6 +198,7 @@ mod tests { platforms: vec![], healthcheck: None, container: None, + cost_model: None, } } diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index f2e9209..b11af7a 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -230,8 +230,17 @@ pub static SECTIONS: &[TipSection] = &[ TipEntry { usage: "sessions · stats · top", about: "historique des sessions · statistiques · agents les plus utilisés", - options: &[("--period

", "7d, 30d, 90d ou all")], - example: "top --period 30d", + options: &[("--period

", "7d, 30d, 90d ou all"), ("stats --costs", "tokens + coût estimé par agent (issue #49)")], + example: "stats --period 30d --costs", + }, + TipEntry { + usage: "monitor", + about: "TUI temps réel des processus gérés (CPU, mémoire, uptime) avec alertes de seuils", + options: &[ + ("--interval ", "rafraîchissement (défaut 2s)"), + ("--json", "flux JSON pour les scripts"), + ], + example: "monitor --interval 5", }, TipEntry { usage: "timeline", diff --git a/src/config.rs b/src/config.rs index 9d77b14..0256bba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -124,6 +124,43 @@ pub struct Settings { /// Purge sessions finished more than N days ago (issue #53, default 90). #[serde(default)] pub sessions_retention_days: Option, + /// Token price models used to estimate session costs (issue #49). + #[serde(default)] + pub cost_models: Option>, + /// CPU/memory thresholds for 'am monitor' alerts (issue #50). + #[serde(default)] + pub monitor_thresholds: Option, +} + +/// Token price model: USD per million tokens (issue #49). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CostModel { + pub input_per_mtok: f64, + pub output_per_mtok: f64, +} + +/// Default price sheet (USD / MTok) used when settings.cost_models is absent. +pub fn default_cost_models() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert("claude-sonnet-4".into(), CostModel { input_per_mtok: 3.0, output_per_mtok: 15.0 }); + m.insert("claude-opus-4".into(), CostModel { input_per_mtok: 15.0, output_per_mtok: 75.0 }); + m.insert("claude-haiku-3.5".into(), CostModel { input_per_mtok: 0.8, output_per_mtok: 4.0 }); + m.insert("gpt-4o".into(), CostModel { input_per_mtok: 2.5, output_per_mtok: 10.0 }); + m.insert("gpt-4o-mini".into(), CostModel { input_per_mtok: 0.15, output_per_mtok: 0.6 }); + m.insert("deepseek-chat".into(), CostModel { input_per_mtok: 0.27, output_per_mtok: 1.10 }); + m.insert("llama-3.1-8b".into(), CostModel { input_per_mtok: 0.05, output_per_mtok: 0.05 }); + m +} + +/// Alert thresholds for 'am monitor' (issue #50). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MonitorThresholds { + /// Alert when a managed process exceeds this CPU % (0 = disabled). + #[serde(default)] + pub cpu_pct: Option, + /// Alert when a managed process exceeds this RSS in MB (0 = disabled). + #[serde(default)] + pub mem_mb: Option, } /// Policy controlling how aggressively 'am update' upgrades installed agents. @@ -250,6 +287,10 @@ pub struct AgentDef { /// Container profile used by 'am run --container' (issue #58). #[serde(default)] pub container: Option, + /// Price model key (settings.cost_models) used to estimate session costs + /// (issue #49; defaults to the first model of the price sheet). + #[serde(default)] + pub cost_model: Option, } /// Health check for group orchestration (issue #57): a command that must diff --git a/src/costs.rs b/src/costs.rs new file mode 100644 index 0000000..0003ce1 --- /dev/null +++ b/src/costs.rs @@ -0,0 +1,323 @@ +//! Cost tracking (issue #49): collect token usage exposed by agents, price +//! it against the configured model sheet, and aggregate per agent/session. +//! No runtime dependency is added — everything is std + the existing config. + +use crate::config::{self, CostModel}; +use crate::events::{self, Event, EventKind}; +use anyhow::Result; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Token usage collected from an agent. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] +pub struct Usage { + pub tokens_in: u64, + pub tokens_out: u64, +} + +/// One aggregated cost row (per agent, or global totals). +#[derive(Debug, Clone, Default, Serialize)] +pub struct CostStats { + pub agent: String, + pub sessions: u64, + pub tokens_in: u64, + pub tokens_out: u64, + pub cost_usd: f64, +} + +/// Price the usage against a model of the sheet (issue #49). +/// Unknown models fall back to the first model of the sheet. +pub fn estimate_cost( + model: Option<&str>, + usage: Usage, + sheet: &BTreeMap, +) -> f64 { + let m = model + .and_then(|k| sheet.get(k)) + .or_else(|| sheet.values().next()); + match m { + Some(cm) => { + usage.tokens_in as f64 * cm.input_per_mtok / 1_000_000.0 + + usage.tokens_out as f64 * cm.output_per_mtok / 1_000_000.0 + } + None => 0.0, + } +} + +/// Collect the usage an agent exposed for its latest session (issue #49). +/// +/// Supported sources today: +/// - Claude Code: `~/.claude/projects/*/.jsonl` — the last JSON +/// line carrying an `usage` object with `input_tokens`/`output_tokens`. +/// +/// Returns None when the agent exposes nothing (safe, silent). +pub fn collect_usage(agent: &str, cwd: Option<&str>) -> Option { + match agent { + "claude-code" => claude_usage(cwd), + _ => None, + } +} + +/// Claude Code stores one JSONL per session under ~/.claude/projects//. +/// The final line with a "usage" object holds the cumulative token counts. +fn claude_usage(cwd: Option<&str>) -> Option { + let root = config::home_dir()?.join(".claude/projects"); + let mut candidates: Vec<(String, PathBuf)> = Vec::new(); + for entry in std::fs::read_dir(&root).ok()?.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + for f in std::fs::read_dir(&dir).ok()?.flatten() { + let p = f.path(); + if p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + if let Ok(mtime) = f.metadata().and_then(|m| m.modified()) { + candidates.push((mtime_key(mtime), p)); + } + } + } + } + candidates.sort(); + let last = candidates.last()?.1.clone(); + parse_claude_file(&last, cwd) +} + +fn mtime_key(t: std::time::SystemTime) -> String { + format!( + "{:?}", + t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default() + ) +} + +/// Parse a Claude Code session file: the last line whose "usage" object has +/// input/output token counts. Filters by cwd when given (the file embeds it +/// as `cwd` on summary lines). +fn parse_claude_file(path: &std::path::Path, cwd: Option<&str>) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let mut usage = None; + for line in text.lines() { + let v: serde_json::Value = serde_json::from_str(line).ok()?; + if v.get("usage").is_none() { + continue; + } + if let Some(want) = cwd { + if v.get("cwd").and_then(|c| c.as_str()) != Some(want) { + continue; + } + } + let u = v.get("usage")?; + let tin = u.get("input_tokens").and_then(|n| n.as_u64())?; + let tout = u.get("output_tokens").and_then(|n| n.as_u64())?; + usage = Some(Usage { + tokens_in: tin, + tokens_out: tout, + }); + } + usage +} + +/// Record a session's usage: persist it on the session record and emit a +/// `cost` event (axe 8 journal). Called when an agent session finishes. +pub fn record_session_usage( + app: &crate::app::App, + agent: &str, + session_id: &str, + cwd: Option<&str>, +) -> Result<()> { + let Some(usage) = collect_usage(agent, cwd) else { + return Ok(()); + }; + let model = app + .catalog + .resolve(agent) + .and_then(|a| a.cost_model.as_deref()); + let sheet = app + .config + .settings + .cost_models + .clone() + .unwrap_or_else(config::default_cost_models); + let cost = estimate_cost(model, usage, &sheet); + + // Persist on the session record. + let mut sf = crate::sessions::load(app)?; + if let Some(r) = sf.sessions.get_mut(session_id) { + r.tokens_in = Some(usage.tokens_in); + r.tokens_out = Some(usage.tokens_out); + r.cost_usd = Some(cost); + crate::sessions::save(app, &sf)?; + } + + // Journal a dedicated event (stable --json contract, additive fields). + app.emit( + &Event::now(EventKind::Cost) + .with_agent(agent.to_string()) + .with_session(session_id) + .with_usage(usage.tokens_in, usage.tokens_out) + .with_cost(cost), + ); + Ok(()) +} + +/// Aggregate cost events into per-agent rows (issue #49). `--json` output +/// stays stable: agent, sessions, tokens_in, tokens_out, cost_usd. +pub fn aggregate( + app: &crate::app::App, + period: Option<&str>, +) -> Result> { + let cut = crate::commands::stats_cmd::cutoff(period)?; + let events = events::read_events(&app.events_dir(), 0); + let mut map: BTreeMap = BTreeMap::new(); + for ev in events { + if ev.kind != EventKind::Cost { + continue; + } + if let Some(cut) = &cut { + if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) { + if ts.with_timezone(&chrono::Utc) < *cut { + continue; + } + } + } + let agent = ev.agent.clone().unwrap_or_else(|| "unknown".to_string()); + let row = map.entry(agent.clone()).or_default(); + row.agent = agent; + row.sessions += 1; + row.tokens_in += ev.tokens_in.unwrap_or(0); + row.tokens_out += ev.tokens_out.unwrap_or(0); + row.cost_usd += ev.cost_usd.unwrap_or(0.0); + } + Ok(map.into_values().collect()) +} + +/// Total across every row (used by the weekly/monthly report). +pub fn totals(rows: &[CostStats]) -> CostStats { + let mut t = CostStats { + agent: "total".to_string(), + ..Default::default() + }; + for r in rows { + t.sessions += r.sessions; + t.tokens_in += r.tokens_in; + t.tokens_out += r.tokens_out; + t.cost_usd += r.cost_usd; + } + t +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn estimate_cost_uses_model_prices() { + let sheet = config::default_cost_models(); + // 1M input @ $3 + 500k output @ $15 = 3 + 7.5 = $10.5 + let u = Usage { + tokens_in: 1_000_000, + tokens_out: 500_000, + }; + let c = estimate_cost(Some("claude-sonnet-4"), u, &sheet); + assert!((c - 10.5).abs() < 1e-9, "got {c}"); + } + + #[test] + fn estimate_cost_falls_back_to_first_model() { + let sheet = config::default_cost_models(); + let c = estimate_cost(Some("nope-model"), Usage { tokens_in: 0, tokens_out: 0 }, &sheet); + assert_eq!(c, 0.0); + } + + #[test] + fn estimate_cost_unknown_sheet_is_zero() { + let sheet = BTreeMap::new(); + let c = estimate_cost(None, Usage { tokens_in: 1000, tokens_out: 1000 }, &sheet); + assert_eq!(c, 0.0); + } + + #[test] + fn parse_claude_file_extracts_last_usage() { + let dir = std::env::temp_dir().join(format!("am-costs-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("session.jsonl"); + std::fs::write( + &f, + concat!( + "{\"type\":\"assistant\",\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":5}},\"cwd\":\"/x\"}\n", + "{\"type\":\"summary\",\"usage\":{\"input_tokens\":100,\"output_tokens\":40},\"cwd\":\"/x\"}\n", + ), + ) + .unwrap(); + let u = parse_claude_file(&f, Some("/x")).unwrap(); + assert_eq!(u.tokens_in, 100); + assert_eq!(u.tokens_out, 40); + // Wrong cwd → filtered out. + assert!(parse_claude_file(&f, Some("/other")).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn aggregate_sums_per_agent() { + let app = test_app("costs-agg"); + app.emit( + &Event::now(EventKind::Cost) + .with_agent("a".to_string()) + .with_usage(10, 5) + .with_cost(0.5), + ); + app.emit( + &Event::now(EventKind::Cost) + .with_agent("a".to_string()) + .with_usage(20, 10) + .with_cost(1.0), + ); + app.emit( + &Event::now(EventKind::Cost) + .with_agent("b".to_string()) + .with_usage(100, 50) + .with_cost(2.0), + ); + let rows = aggregate(&app, None).unwrap(); + assert_eq!(rows.len(), 2); + let a = rows.iter().find(|r| r.agent == "a").unwrap(); + assert_eq!(a.sessions, 2); + assert_eq!(a.tokens_in, 30); + assert_eq!(a.tokens_out, 15); + assert!((a.cost_usd - 1.5).abs() < 1e-9); + let t = totals(&rows); + assert!((t.cost_usd - 3.5).abs() < 1e-9); + assert_eq!(t.sessions, 3); + } + + fn test_app(tag: &str) -> crate::app::App { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.yaml"); + std::fs::write( + &cfg, + concat!( + "version: \"1.0\"\n", + "settings:\n", + " auto_install_deps: false\n", + " confirm_before_run: false\n", + "agents: []\n", + ), + ) + .unwrap(); + use clap::Parser; + let cli = crate::cli::Cli::parse_from([ + "am", + "--config", + cfg.to_str().unwrap(), + "list", + ]); + let mut app = crate::app::App::from_cli(cli).expect("app should build"); + app.paths.state_file = dir.path().join(format!("state-{tag}.json")); + app.paths.log_dir = dir.path().join("logs"); + app + } +} diff --git a/src/events.rs b/src/events.rs index 2f432d4..458b46d 100644 --- a/src/events.rs +++ b/src/events.rs @@ -42,6 +42,10 @@ pub enum EventKind { Rollback, /// Derived index pruned (sessions retention, issue #53). Prune, + /// Session usage recorded: tokens + estimated cost (issue #49). + Cost, + /// Threshold alert from 'am monitor' (issue #50). + Alert, } impl EventKind { @@ -64,6 +68,8 @@ impl EventKind { EventKind::Backup => "backup", EventKind::Rollback => "rollback", EventKind::Prune => "prune", + EventKind::Cost => "cost", + EventKind::Alert => "alert", } } } @@ -95,6 +101,15 @@ pub struct Event { pub exit_code: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub duration_s: Option, + /// Input tokens billed for the session (issue #49). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens_in: Option, + /// Output tokens billed for the session (issue #49). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens_out: Option, + /// Estimated cost in USD (issue #49). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_usd: Option, } impl Event { @@ -113,6 +128,9 @@ impl Event { env_keys: Vec::new(), exit_code: None, duration_s: None, + tokens_in: None, + tokens_out: None, + cost_usd: None, } } @@ -161,8 +179,21 @@ impl Event { self } - pub fn with_duration(mut self, secs: u64) -> Self { - self.duration_s = Some(secs); + pub fn with_duration(mut self, d: u64) -> Self { + self.duration_s = Some(d); + self + } + + /// Record token usage (issue #49). + pub fn with_usage(mut self, tokens_in: u64, tokens_out: u64) -> Self { + self.tokens_in = Some(tokens_in); + self.tokens_out = Some(tokens_out); + self + } + + /// Record the estimated cost in USD (issue #49). + pub fn with_cost(mut self, usd: f64) -> Self { + self.cost_usd = Some(usd); self } } diff --git a/src/help.rs b/src/help.rs index 3ade304..1304000 100644 --- a/src/help.rs +++ b/src/help.rs @@ -220,10 +220,11 @@ pub static HELP_SPECS: &[HelpSpec] = &[ name: "stats", category: "Commands", usage: "stats {flags} [agent]", - about: "Show usage statistics computed from the event journal.", - search_terms: &["usage", "metrics", "duration"], + about: "Usage statistics from the event journal; --costs prices the token usage.", + search_terms: &["usage", "cost", "tokens", "spend", "period"], flags: &[ - HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "Only events of the last period (7d, 30d, 90d, all)" }, + HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "7d, 30d, 90d or all" }, + HelpFlag { short: "", long: "--costs", value: "", desc: "Show token usage and estimated spend per agent (issue #49)" }, ], subcommands: &[], parameters: &[ @@ -231,8 +232,28 @@ pub static HELP_SPECS: &[HelpSpec] = &[ ], io: None, examples: &[ - HelpExample { desc: "Global usage statistics.", code: "stats" }, - HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" }, + HelpExample { desc: "Global statistics.", code: "stats" }, + HelpExample { desc: "Last 30 days.", code: "stats --period 30d" }, + HelpExample { desc: "Estimated spend.", code: "stats --period 30d --costs" }, + ], + }, + HelpSpec { + name: "monitor", + category: "Commands", + usage: "monitor {flags}", + about: "Real-time TUI of the managed processes (CPU, RSS, uptime) with threshold alerts.", + search_terms: &["process", "cpu", "memory", "rss", "alert", "top", "surveillance"], + flags: &[ + HelpFlag { short: "", long: "--interval", value: "SECONDS", desc: "Refresh interval (default 2)" }, + HelpFlag { short: "", long: "--json", value: "", desc: "Print one JSON document per tick (for scripts)" }, + ], + subcommands: &[], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "Live TUI (q / Esc quits).", code: "monitor" }, + HelpExample { desc: "Refresh every 5s.", code: "monitor --interval 5" }, + HelpExample { desc: "JSON stream for scripts.", code: "monitor --json --interval 10" }, ], }, HelpSpec { diff --git a/src/lib.rs b/src/lib.rs index 36342cb..75510b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod app; pub mod automation; pub mod backup; pub mod catalog; +pub mod costs; pub mod templates; pub mod catalog_remote; pub mod cli; diff --git a/src/repl.rs b/src/repl.rs index 757e088..4f24839 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -112,6 +112,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("audit", "who changed what, when (config checksums)"), ("service", "register an agent as a system service (autostart)"), ("schedule", "plan am commands (daily) and check the fleet health"), + ("monitor", "real-time TUI of managed processes (cpu/rss/uptime)"), ("shell", "show or switch the system shell"), ("theme", "show or switch the color theme"), ("tip", "cheat sheet of the most useful commands"), @@ -226,7 +227,7 @@ impl AmCompleter { "self-update", "self-uninstall", "export", "import", "shell", "theme", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", "profile", "man", "models", "catalog", "suggest", "audit", - "service", "schedule", + "service", "schedule", "monitor", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], @@ -776,7 +777,7 @@ pub fn banner_box( " models models · models --prune · catalog · suggest · audit".to_string(), )); rows.push(inner( - " automate service install · schedule add · doctor --watch".to_string(), + " automate service install · schedule add · doctor --watch · monitor".to_string(), )); rows.push(inner( " system self-update · self-uninstall · export · import".to_string(), @@ -1532,6 +1533,11 @@ fn handle_line( "stats" => Command::Stats { agent: rest.first().cloned(), period: opt_value("--period"), + costs: flag("--costs"), + }, + "monitor" => Command::Monitor { + interval: opt_value("--interval").and_then(|v| v.parse().ok()), + json: flag("--json"), }, "top" => Command::Top { period: opt_value("--period"), diff --git a/src/sessions.rs b/src/sessions.rs index bcbe256..31922a2 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -29,6 +29,14 @@ pub struct SessionRecord { /// RFC 3339 end timestamp (None while running). #[serde(default)] pub ended_at: Option, + /// Tokens billed once the agent exposed its usage (issue #49). + #[serde(default)] + pub tokens_in: Option, + #[serde(default)] + pub tokens_out: Option, + /// Estimated cost in USD (issue #49). + #[serde(default)] + pub cost_usd: Option, #[serde(default)] pub exit_code: Option, #[serde(default)] @@ -125,6 +133,9 @@ pub fn start_agent( project, started_at: crate::installers::now_rfc3339(), ended_at: None, + tokens_in: None, + tokens_out: None, + cost_usd: None, exit_code: None, log, args, @@ -151,6 +162,9 @@ pub fn start_repl(app: &App, id: &str, cwd: Option) -> Result<()> { project: None, started_at: crate::installers::now_rfc3339(), ended_at: None, + tokens_in: None, + tokens_out: None, + cost_usd: None, exit_code: None, log: None, args: Vec::new(), @@ -184,12 +198,15 @@ pub fn finish_agent(app: &App, agent: &str, pid: Option, exit_code: i32, ok candidates.first().cloned() }; if let Some(k) = key { + let cwd = sf.sessions.get(&k).and_then(|r| r.cwd.clone()); if let Some(r) = sf.sessions.get_mut(&k) { r.ended_at = Some(crate::installers::now_rfc3339()); r.exit_code = Some(exit_code); r.status = if ok { "stopped" } else { "failed" }.to_string(); } save(app, &sf)?; + // Issue #49: collect the token usage the agent exposed and price it. + let _ = crate::costs::record_session_usage(app, agent, &k, cwd.as_deref()); } Ok(()) } diff --git a/tests/stats_test.rs b/tests/stats_test.rs index c9f6c2f..53bbc85 100644 --- a/tests/stats_test.rs +++ b/tests/stats_test.rs @@ -91,7 +91,7 @@ fn stats_command_runs() { "cmd", &[r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#], ); - let code = stats_cmd::run(&app, None, None).unwrap(); + let code = stats_cmd::run(&app, None, None, false).unwrap(); assert_eq!(code, 0); }