feat: phase 2 — coûts par session (stats --costs) + am monitor TUI (closes #49 #50)

This commit is contained in:
2026-08-18 11:23:50 -04:00
parent 63dc36660b
commit 1b3fe09074
22 changed files with 978 additions and 23 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -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"
+2
View File
@@ -88,6 +88,8 @@ plateforme, il compile automatiquement depuis les sources.
| am sessions --retention <jours> | purge des sessions terminées au-delà de N jours |
| am init --template <stack> | génère une config pré-remplie (web, python, rust, cli) |
| am doctor --watch <s> | 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 <agent> --autostart | service système (systemd / launchd / tâche Windows) + démarrage auto |
| am schedule add <cmd...> --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) |
+4 -4
View File
@@ -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 |
+8
View File
@@ -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:
+19
View File
@@ -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<SECONDS>\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
+4 -1
View File
@@ -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<PERIOD>\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
+5 -2
View File
@@ -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
+12
View File
@@ -256,6 +256,18 @@ pub enum Command {
/// Only events of the last period (7d, 30d, 90d, all)
#[arg(long, value_name = "PERIOD")]
period: Option<String>,
/// 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<u64>,
/// 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 {
+7 -1
View File
@@ -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<i32> {
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,
+405
View File
@@ -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<RawReading> {
#[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<f64> = 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<RawReading>,
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<String> {
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<AgentMetric> {
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<u64>, json: bool) -> Result<i32> {
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<i32> {
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<i32> {
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<ListItem> = 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);
}
}
+48 -1
View File
@@ -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<i32> {
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>, costs: bool) -> Result<i32> {
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<i32> {
Ok(0)
}
/// am stats --costs : token usage and estimated spend (issue #49).
pub fn run_costs(app: &App, period: Option<&str>) -> Result<i32> {
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,
+1
View File
@@ -198,6 +198,7 @@ mod tests {
platforms: vec![],
healthcheck: None,
container: None,
cost_model: None,
}
}
+11 -2
View File
@@ -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 <p>", "7d, 30d, 90d ou all")],
example: "top --period 30d",
options: &[("--period <p>", "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 <s>", "rafraîchissement (défaut 2s)"),
("--json", "flux JSON pour les scripts"),
],
example: "monitor --interval 5",
},
TipEntry {
usage: "timeline",
+41
View File
@@ -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<u64>,
/// Token price models used to estimate session costs (issue #49).
#[serde(default)]
pub cost_models: Option<BTreeMap<String, CostModel>>,
/// CPU/memory thresholds for 'am monitor' alerts (issue #50).
#[serde(default)]
pub monitor_thresholds: Option<MonitorThresholds>,
}
/// 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<String, CostModel> {
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<f64>,
/// Alert when a managed process exceeds this RSS in MB (0 = disabled).
#[serde(default)]
pub mem_mb: Option<u64>,
}
/// 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<ContainerSpec>,
/// 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<String>,
}
/// Health check for group orchestration (issue #57): a command that must
+323
View File
@@ -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<String, CostModel>,
) -> 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/*/<session>.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<Usage> {
match agent {
"claude-code" => claude_usage(cwd),
_ => None,
}
}
/// Claude Code stores one JSONL per session under ~/.claude/projects/<enc>/.
/// The final line with a "usage" object holds the cumulative token counts.
fn claude_usage(cwd: Option<&str>) -> Option<Usage> {
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<Usage> {
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<Vec<CostStats>> {
let cut = crate::commands::stats_cmd::cutoff(period)?;
let events = events::read_events(&app.events_dir(), 0);
let mut map: BTreeMap<String, CostStats> = 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
}
}
+33 -2
View File
@@ -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<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_s: Option<u64>,
/// Input tokens billed for the session (issue #49).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens_in: Option<u64>,
/// Output tokens billed for the session (issue #49).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens_out: Option<u64>,
/// Estimated cost in USD (issue #49).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
}
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
}
}
+26 -5
View File
@@ -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 {
+1
View File
@@ -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;
+8 -2
View File
@@ -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"),
+17
View File
@@ -29,6 +29,14 @@ pub struct SessionRecord {
/// RFC 3339 end timestamp (None while running).
#[serde(default)]
pub ended_at: Option<String>,
/// Tokens billed once the agent exposed its usage (issue #49).
#[serde(default)]
pub tokens_in: Option<u64>,
#[serde(default)]
pub tokens_out: Option<u64>,
/// Estimated cost in USD (issue #49).
#[serde(default)]
pub cost_usd: Option<f64>,
#[serde(default)]
pub exit_code: Option<i32>,
#[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<String>) -> 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<u32>, 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(())
}
+1 -1
View File
@@ -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);
}