This commit is contained in:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -198,6 +198,7 @@ mod tests {
|
||||
platforms: vec![],
|
||||
healthcheck: None,
|
||||
container: None,
|
||||
cost_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user