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]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.5.0" version = "0.5.1"
edition = "2021" edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT" 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 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 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 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 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 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) | | 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 top — classement des agents par utilisation | S | P1 |
| am log — filtres agent/type/date, --follow | S → M | P0 → 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 | | 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 | | 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 | L | 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 | | Télémétrie anonyme **opt-in** (compteurs agrégés uniquement, jamais de chemins) | M | P3 |
am stats # vue globale am stats # vue globale
@@ -430,8 +430,8 @@ moins de 2 secondes.
| # | Issue | Effort | | # | Issue | Effort |
|---|---|---| |---|---|---|
| [#49](https://git.dracodev.net/Projets/agent-manager/issues/49) | Suivi des coûts — tokens/€ par session | M | | [#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 | | [#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 | | [#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 | | [#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 | | [#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) 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) 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) 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 ---------------------------------------------------------- # --- Command aliases ----------------------------------------------------------
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 .SH NAME
stats \- Show usage statistics computed from the event journal stats \- Show usage statistics computed from the event journal
.SH SYNOPSIS .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 .SH DESCRIPTION
Show usage statistics computed from the event journal Show usage statistics computed from the event journal
.SH OPTIONS .SH OPTIONS
@@ -12,6 +12,9 @@ Show usage statistics computed from the event journal
\fB\-\-period\fR \fI<PERIOD>\fR \fB\-\-period\fR \fI<PERIOD>\fR
Only events of the last period (7d, 30d, 90d, all) Only events of the last period (7d, 30d, 90d, all)
.TP .TP
\fB\-\-costs\fR
Show token usage and estimated spend per agent (issue #49)
.TP
\fB\-h\fR, \fB\-\-help\fR \fB\-h\fR, \fB\-\-help\fR
Print help Print help
.TP .TP
+5 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq .ie \n(.g .ds Aq \(aq
.el .ds Aq ' .el .ds Aq '
.TH am 1 "am 0.5.0" .TH am 1 "am 0.5.1"
.SH NAME .SH NAME
am \- agent\-manager (am) — manage local AI coding agents am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS .SH SYNOPSIS
@@ -129,6 +129,9 @@ List the sessions of every agent (and of the interactive shell)
am\-stats(1) am\-stats(1)
Show usage statistics computed from the event journal Show usage statistics computed from the event journal
.TP .TP
am\-monitor(1)
Real\-time monitor of the managed processes (issue #50)
.TP
am\-top(1) am\-top(1)
Show the most used agents (top 10) Show the most used agents (top 10)
.TP .TP
@@ -198,4 +201,4 @@ Export the configuration and installation state (backup)
am\-import(1) am\-import(1)
Import a previously exported configuration and state Import a previously exported configuration and state
.SH VERSION .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) /// Only events of the last period (7d, 30d, 90d, all)
#[arg(long, value_name = "PERIOD")] #[arg(long, value_name = "PERIOD")]
period: Option<String>, 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) /// Show the most used agents (top 10)
Top { Top {
+7 -1
View File
@@ -20,6 +20,7 @@ pub mod log_cmd;
pub mod logs_cmd; pub mod logs_cmd;
pub mod man_cmd; pub mod man_cmd;
pub mod models_cmd; pub mod models_cmd;
pub mod monitor_cmd;
pub mod open_cmd; pub mod open_cmd;
pub mod profile_cmd; pub mod profile_cmd;
pub mod projects_cmd; pub mod projects_cmd;
@@ -105,7 +106,12 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
timeout, timeout,
} => run_cmd::restart(app, start, *force, *timeout), } => run_cmd::restart(app, start, *force, *timeout),
Command::Status { agent } => status_cmd::run(app, agent.as_deref()), 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::Top { period } => stats_cmd::run_top(app, period.as_deref()),
Command::Report { Command::Report {
last_week, 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 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)?; let rows = aggregate(app, agent, period)?;
if app.json() { if app.json() {
print_json(&rows); print_json(&rows);
@@ -201,6 +214,40 @@ pub fn run_top(app: &App, period: Option<&str>) -> Result<i32> {
Ok(0) 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). /// am report : write the markdown digest (issue #37).
pub fn run_report( pub fn run_report(
app: &App, app: &App,
+1
View File
@@ -198,6 +198,7 @@ mod tests {
platforms: vec![], platforms: vec![],
healthcheck: None, healthcheck: None,
container: None, container: None,
cost_model: None,
} }
} }
+11 -2
View File
@@ -230,8 +230,17 @@ pub static SECTIONS: &[TipSection] = &[
TipEntry { TipEntry {
usage: "sessions · stats · top", usage: "sessions · stats · top",
about: "historique des sessions · statistiques · agents les plus utilisés", about: "historique des sessions · statistiques · agents les plus utilisés",
options: &[("--period <p>", "7d, 30d, 90d ou all")], options: &[("--period <p>", "7d, 30d, 90d ou all"), ("stats --costs", "tokens + coût estimé par agent (issue #49)")],
example: "top --period 30d", 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 { TipEntry {
usage: "timeline", usage: "timeline",
+41
View File
@@ -124,6 +124,43 @@ pub struct Settings {
/// Purge sessions finished more than N days ago (issue #53, default 90). /// Purge sessions finished more than N days ago (issue #53, default 90).
#[serde(default)] #[serde(default)]
pub sessions_retention_days: Option<u64>, 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. /// 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). /// Container profile used by 'am run --container' (issue #58).
#[serde(default)] #[serde(default)]
pub container: Option<ContainerSpec>, 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 /// 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, Rollback,
/// Derived index pruned (sessions retention, issue #53). /// Derived index pruned (sessions retention, issue #53).
Prune, Prune,
/// Session usage recorded: tokens + estimated cost (issue #49).
Cost,
/// Threshold alert from 'am monitor' (issue #50).
Alert,
} }
impl EventKind { impl EventKind {
@@ -64,6 +68,8 @@ impl EventKind {
EventKind::Backup => "backup", EventKind::Backup => "backup",
EventKind::Rollback => "rollback", EventKind::Rollback => "rollback",
EventKind::Prune => "prune", EventKind::Prune => "prune",
EventKind::Cost => "cost",
EventKind::Alert => "alert",
} }
} }
} }
@@ -95,6 +101,15 @@ pub struct Event {
pub exit_code: Option<i32>, pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_s: Option<u64>, 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 { impl Event {
@@ -113,6 +128,9 @@ impl Event {
env_keys: Vec::new(), env_keys: Vec::new(),
exit_code: None, exit_code: None,
duration_s: None, duration_s: None,
tokens_in: None,
tokens_out: None,
cost_usd: None,
} }
} }
@@ -161,8 +179,21 @@ impl Event {
self self
} }
pub fn with_duration(mut self, secs: u64) -> Self { pub fn with_duration(mut self, d: u64) -> Self {
self.duration_s = Some(secs); 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 self
} }
} }
+26 -5
View File
@@ -220,10 +220,11 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "stats", name: "stats",
category: "Commands", category: "Commands",
usage: "stats {flags} [agent]", usage: "stats {flags} [agent]",
about: "Show usage statistics computed from the event journal.", about: "Usage statistics from the event journal; --costs prices the token usage.",
search_terms: &["usage", "metrics", "duration"], search_terms: &["usage", "cost", "tokens", "spend", "period"],
flags: &[ 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: &[], subcommands: &[],
parameters: &[ parameters: &[
@@ -231,8 +232,28 @@ pub static HELP_SPECS: &[HelpSpec] = &[
], ],
io: None, io: None,
examples: &[ examples: &[
HelpExample { desc: "Global usage statistics.", code: "stats" }, HelpExample { desc: "Global statistics.", code: "stats" },
HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" }, 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 { HelpSpec {
+1
View File
@@ -15,6 +15,7 @@ pub mod app;
pub mod automation; pub mod automation;
pub mod backup; pub mod backup;
pub mod catalog; pub mod catalog;
pub mod costs;
pub mod templates; pub mod templates;
pub mod catalog_remote; pub mod catalog_remote;
pub mod cli; pub mod cli;
+8 -2
View File
@@ -112,6 +112,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("audit", "who changed what, when (config checksums)"), ("audit", "who changed what, when (config checksums)"),
("service", "register an agent as a system service (autostart)"), ("service", "register an agent as a system service (autostart)"),
("schedule", "plan am commands (daily) and check the fleet health"), ("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"), ("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"), ("theme", "show or switch the color theme"),
("tip", "cheat sheet of the most useful commands"), ("tip", "cheat sheet of the most useful commands"),
@@ -226,7 +227,7 @@ impl AmCompleter {
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man", "models", "catalog", "suggest", "audit", "profile", "man", "models", "catalog", "suggest", "audit",
"service", "schedule", "service", "schedule", "monitor",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
], ],
config_sub: vec!["show", "path", "edit", "validate", "add"], 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(), " models models · models --prune · catalog · suggest · audit".to_string(),
)); ));
rows.push(inner( 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( rows.push(inner(
" system self-update · self-uninstall · export · import".to_string(), " system self-update · self-uninstall · export · import".to_string(),
@@ -1532,6 +1533,11 @@ fn handle_line(
"stats" => Command::Stats { "stats" => Command::Stats {
agent: rest.first().cloned(), agent: rest.first().cloned(),
period: opt_value("--period"), 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 { "top" => Command::Top {
period: opt_value("--period"), period: opt_value("--period"),
+17
View File
@@ -29,6 +29,14 @@ pub struct SessionRecord {
/// RFC 3339 end timestamp (None while running). /// RFC 3339 end timestamp (None while running).
#[serde(default)] #[serde(default)]
pub ended_at: Option<String>, 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)] #[serde(default)]
pub exit_code: Option<i32>, pub exit_code: Option<i32>,
#[serde(default)] #[serde(default)]
@@ -125,6 +133,9 @@ pub fn start_agent(
project, project,
started_at: crate::installers::now_rfc3339(), started_at: crate::installers::now_rfc3339(),
ended_at: None, ended_at: None,
tokens_in: None,
tokens_out: None,
cost_usd: None,
exit_code: None, exit_code: None,
log, log,
args, args,
@@ -151,6 +162,9 @@ pub fn start_repl(app: &App, id: &str, cwd: Option<String>) -> Result<()> {
project: None, project: None,
started_at: crate::installers::now_rfc3339(), started_at: crate::installers::now_rfc3339(),
ended_at: None, ended_at: None,
tokens_in: None,
tokens_out: None,
cost_usd: None,
exit_code: None, exit_code: None,
log: None, log: None,
args: Vec::new(), 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() candidates.first().cloned()
}; };
if let Some(k) = key { 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) { if let Some(r) = sf.sessions.get_mut(&k) {
r.ended_at = Some(crate::installers::now_rfc3339()); r.ended_at = Some(crate::installers::now_rfc3339());
r.exit_code = Some(exit_code); r.exit_code = Some(exit_code);
r.status = if ok { "stopped" } else { "failed" }.to_string(); r.status = if ok { "stopped" } else { "failed" }.to_string();
} }
save(app, &sf)?; 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(()) Ok(())
} }
+1 -1
View File
@@ -91,7 +91,7 @@ fn stats_command_runs() {
"cmd", "cmd",
&[r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#], &[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); assert_eq!(code, 0);
} }