aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-01 10:22:01 +0200
committerIgor Soarez <igor@soarez.org>2026-11-01 10:22:01 +0200
commit8146d1db419e8bdacd75920da6f3761abf724853 (patch)
tree921a1d609d96bb2129f7ac2d80635fd3c0c41691 /crates
parent959f0d46cac1a09d0821da599d8dff54d1147892 (diff)
show what pi agents cost today, this week and this month, counting forked replies once
Diffstat (limited to 'crates')
-rw-r--r--crates/dinggerd/src/cost.rs493
-rw-r--r--crates/dinggerd/src/hooks.rs2
-rw-r--r--crates/dinggerd/src/integrations.rs9
-rw-r--r--crates/dinggerd/src/main.rs19
-rw-r--r--crates/dinggerd/src/tmux.rs8
-rw-r--r--crates/dinggerd/src/watcher.rs12
6 files changed, 534 insertions, 9 deletions
diff --git a/crates/dinggerd/src/cost.rs b/crates/dinggerd/src/cost.rs
new file mode 100644
index 0000000..45d8bbe
--- /dev/null
+++ b/crates/dinggerd/src/cost.rs
@@ -0,0 +1,493 @@
+//! What pi agents cost: today, this week, and this month, in UTC (a day
+//! starts at 00:00 UTC, a week on Monday, a month on its 1st).
+//!
+//! pi records each reply's cost in its session file (`message.usage.cost.
+//! total`, at `message.timestamp`). A thread of its own reads every session
+//! file changed this period, then only what was appended, and counts each
+//! reply once: a fork (`/fork`, `--fork`) copies its parent's entries into a
+//! new file unchanged, so a reply is known by its entry id and time, and
+//! counts for the session it first appeared in (files are read oldest
+//! first). The totals include agents since closed, or started over.
+//!
+//! Published as global tmux options (`@dingger_cost_today`, `_week`,
+//! `_month`, dollars with two decimals); each pi pane's own today is `@dingger_cost`, written
+//! by the watcher from [`session_today`].
+
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::fs::File;
+use std::io::{Read, Seek, SeekFrom};
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, MutexGuard, OnceLock};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use serde::Deserialize;
+use tracing::{info, warn};
+
+const DAY_MS: i64 = 86_400_000;
+
+/// A UTC day: days since 1970-01-01.
+type Day = i64;
+
+/// The days a period covers, from its first day through today.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Periods {
+ pub today: Day,
+ pub week: Day,
+ pub month: Day,
+}
+
+impl Periods {
+ pub fn at(now_ms: i64) -> Self {
+ let today = now_ms.div_euclid(DAY_MS);
+ // 1970-01-01 was a Thursday: Monday is 3 days before it.
+ let week = today - (today + 3).rem_euclid(7);
+ let (year, month, _) = civil_from_days(today);
+ Periods { today, week, month: days_from_civil(year, month, 1) }
+ }
+
+ /// The first day any period needs.
+ fn start(&self) -> Day {
+ self.week.min(self.month)
+ }
+}
+
+/// Howard Hinnant's `civil_from_days`: (year, month, day).
+fn civil_from_days(days: Day) -> (i64, u32, u32) {
+ let z = days + 719_468;
+ let era = z.div_euclid(146_097);
+ let doe = z.rem_euclid(146_097);
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
+ let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
+ let year = yoe + era * 400 + i64::from(month <= 2);
+ (year, month, day)
+}
+
+fn days_from_civil(year: i64, month: u32, day: u32) -> Day {
+ let year = if month <= 2 { year - 1 } else { year };
+ let era = year.div_euclid(400);
+ let yoe = year.rem_euclid(400);
+ let month = i64::from(month);
+ let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + i64::from(day) - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ era * 146_097 + doe - 719_468
+}
+
+/// Dollars spent in each period.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub struct Totals {
+ pub today: f64,
+ pub week: f64,
+ pub month: f64,
+}
+
+impl Totals {
+ fn add(&mut self, day: Day, cost: f64, periods: &Periods) {
+ if day == periods.today {
+ self.today += cost;
+ }
+ if day >= periods.week && day <= periods.today {
+ self.week += cost;
+ }
+ if day >= periods.month && day <= periods.today {
+ self.month += cost;
+ }
+ }
+}
+
+/// One session file, read up to `offset` (the end of its last full line).
+#[derive(Debug, Default)]
+struct FileCost {
+ offset: u64,
+ /// The header's time, which orders files: parents before their forks.
+ started: String,
+ by_day: BTreeMap<Day, f64>,
+}
+
+/// Every reply counted, by session file.
+#[derive(Debug, Default)]
+pub struct Ledger {
+ files: HashMap<PathBuf, FileCost>,
+ /// Each reply counted, by its entry id and time: the file it counts for.
+ counted: HashMap<(String, i64), PathBuf>,
+}
+
+#[derive(Deserialize)]
+struct Entry {
+ #[serde(default)]
+ id: Option<String>,
+ #[serde(default)]
+ message: Option<Message>,
+}
+
+#[derive(Deserialize)]
+struct Message {
+ #[serde(default)]
+ role: Option<String>,
+ #[serde(default)]
+ timestamp: Option<i64>,
+ #[serde(default)]
+ usage: Option<Usage>,
+}
+
+#[derive(Deserialize)]
+struct Usage {
+ #[serde(default)]
+ cost: Option<CostField>,
+}
+
+#[derive(Deserialize)]
+struct CostField {
+ #[serde(default)]
+ total: Option<f64>,
+}
+
+#[derive(Deserialize)]
+struct Header {
+ #[serde(default)]
+ timestamp: Option<String>,
+}
+
+/// A reply's cost, its time, and its id ("" for old entries without one).
+fn reply(line: &[u8]) -> Option<(String, i64, f64)> {
+ // Most lines are not replies; a cost is rare enough to look for first.
+ if !line.windows(6).any(|w| w == b"\"cost\"") {
+ return None;
+ }
+ let entry: Entry = serde_json::from_slice(line).ok()?;
+ let message = entry.message?;
+ if message.role.as_deref() != Some("assistant") {
+ return None;
+ }
+ let cost = message.usage?.cost?.total?;
+ let at = message.timestamp?;
+ (cost > 0.0 && cost.is_finite()).then(|| (entry.id.unwrap_or_default(), at, cost))
+}
+
+fn header_time(path: &Path) -> String {
+ let mut first = String::new();
+ if let Ok(mut file) = File::open(path) {
+ let mut buf = vec![0u8; 4096];
+ if let Ok(n) = file.read(&mut buf) {
+ let line = buf[..n].split(|b| *b == b'\n').next().unwrap_or_default();
+ first = serde_json::from_slice::<Header>(line).ok().and_then(|h| h.timestamp).unwrap_or_default();
+ }
+ }
+ first
+}
+
+impl Ledger {
+ /// Read what changed in `files` (each with its modification time):
+ /// files changed before the periods began are left out.
+ pub fn scan(&mut self, files: Vec<(PathBuf, SystemTime)>, periods: &Periods) {
+ let start_ms = periods.start() * DAY_MS;
+ let mut files: Vec<(String, PathBuf)> = files
+ .into_iter()
+ .filter(|(_, modified)| millis(*modified) >= start_ms)
+ .map(|(path, _)| {
+ let started = match self.files.get(&path) {
+ Some(known) => known.started.clone(),
+ None => header_time(&path),
+ };
+ (started, path)
+ })
+ .collect();
+ // Parents before their forks: a fork's copies count for the parent.
+ files.sort();
+ for (started, path) in files {
+ if let Err(err) = self.read(&path, started, periods) {
+ warn!(path = %path.display(), %err, "cost: cannot read a session file");
+ }
+ }
+ // Days before every period are no longer needed.
+ for file in self.files.values_mut() {
+ file.by_day.retain(|day, _| *day >= periods.start());
+ }
+ self.counted.retain(|(_, at), _| at.div_euclid(DAY_MS) >= periods.start());
+ }
+
+ fn read(&mut self, path: &Path, started: String, periods: &Periods) -> std::io::Result<()> {
+ let mut file = File::open(path)?;
+ let len = file.metadata()?.len();
+ let known = self.files.get(path).map(|f| f.offset).unwrap_or(0);
+ if len < known {
+ // Rewritten: count it again from the start.
+ self.forget(path);
+ }
+ let offset = self.files.get(path).map(|f| f.offset).unwrap_or(0);
+ if len == offset {
+ self.files.entry(path.to_path_buf()).or_insert_with(|| FileCost { started, ..Default::default() });
+ return Ok(());
+ }
+ file.seek(SeekFrom::Start(offset))?;
+ let mut bytes = Vec::with_capacity((len - offset) as usize);
+ file.take(len - offset).read_to_end(&mut bytes)?;
+ // Only full lines: the rest is still being written.
+ let Some(end) = bytes.iter().rposition(|b| *b == b'\n') else {
+ return Ok(());
+ };
+ let mut counted = Vec::new();
+ for line in bytes[..end].split(|b| *b == b'\n') {
+ let Some((id, at, cost)) = reply(line) else { continue };
+ let day = at.div_euclid(DAY_MS);
+ if day < periods.start() || self.counted.contains_key(&(id.clone(), at)) {
+ continue;
+ }
+ self.counted.insert((id, at), path.to_path_buf());
+ counted.push((day, cost));
+ }
+ let entry = self.files.entry(path.to_path_buf()).or_insert_with(|| FileCost { started, ..Default::default() });
+ entry.offset = offset + end as u64 + 1;
+ for (day, cost) in counted {
+ *entry.by_day.entry(day).or_default() += cost;
+ }
+ Ok(())
+ }
+
+ fn forget(&mut self, path: &Path) {
+ self.files.remove(path);
+ self.counted.retain(|_, file| file != path);
+ }
+
+ pub fn totals(&self, periods: &Periods) -> Totals {
+ let mut totals = Totals::default();
+ for file in self.files.values() {
+ for (day, cost) in &file.by_day {
+ totals.add(*day, *cost, periods);
+ }
+ }
+ totals
+ }
+
+ /// What a session spent today (its own replies, not a parent's).
+ pub fn today(&self, path: &Path, periods: &Periods) -> f64 {
+ self.files.get(path).and_then(|f| f.by_day.get(&periods.today)).copied().unwrap_or(0.0)
+ }
+}
+
+fn millis(time: SystemTime) -> i64 {
+ time.duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
+}
+
+/// pi's session files: `<agent dir>/sessions/<directory>/<session>.jsonl`.
+fn session_files(root: &Path) -> Vec<(PathBuf, SystemTime)> {
+ let mut found = Vec::new();
+ let Ok(dirs) = std::fs::read_dir(root) else { return found };
+ for dir in dirs.flatten() {
+ let Ok(entries) = std::fs::read_dir(dir.path()) else { continue };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().is_some_and(|ext| ext == "jsonl") {
+ if let Ok(modified) = entry.metadata().and_then(|m| m.modified()) {
+ found.push((path, modified));
+ }
+ }
+ }
+ }
+ found
+}
+
+/// Session files of running agents, wherever they are (reported by the
+/// watcher): those outside pi's own directory are read too.
+fn agent_sessions() -> MutexGuard<'static, HashSet<PathBuf>> {
+ static SESSIONS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
+ SESSIONS.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+pub fn note_session(path: &str) {
+ if !path.is_empty() {
+ agent_sessions().insert(PathBuf::from(path));
+ }
+}
+
+/// Today's spend of each session file, as of the last scan.
+fn today_by_session() -> MutexGuard<'static, HashMap<PathBuf, f64>> {
+ static TODAY: OnceLock<Mutex<HashMap<PathBuf, f64>>> = OnceLock::new();
+ TODAY.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+/// What the session spent today, as of the last scan.
+pub fn session_today(path: &str) -> f64 {
+ today_by_session().get(Path::new(path)).copied().unwrap_or(0.0)
+}
+
+/// Dollars as a tmux option: "" for nothing.
+pub fn option_value(dollars: f64) -> String {
+ if dollars < 0.005 {
+ String::new()
+ } else {
+ format!("{dollars:.2}")
+ }
+}
+
+const SCAN_EVERY: Duration = Duration::from_secs(15);
+
+/// Scan now and every [`SCAN_EVERY`] (tests: `DINGGER_COST_SCAN_SECS`),
+/// publishing the totals when they change.
+pub fn spawn(root: PathBuf) {
+ let every = std::env::var("DINGGER_COST_SCAN_SECS")
+ .ok()
+ .and_then(|secs| secs.parse().ok())
+ .map(Duration::from_secs_f64)
+ .unwrap_or(SCAN_EVERY);
+ std::thread::spawn(move || {
+ let mut ledger = Ledger::default();
+ let mut published: Option<[String; 3]> = None;
+ loop {
+ let started = std::time::Instant::now();
+ let periods = Periods::at(millis(SystemTime::now()));
+ let mut files = session_files(&root);
+ let extra: Vec<PathBuf> = agent_sessions().iter().filter(|p| !p.starts_with(&root)).cloned().collect();
+ for path in extra {
+ if let Ok(modified) = std::fs::metadata(&path).and_then(|m| m.modified()) {
+ files.push((path, modified));
+ }
+ }
+ ledger.scan(files, &periods);
+ let totals = ledger.totals(&periods);
+ let today: HashMap<PathBuf, f64> = ledger
+ .files
+ .keys()
+ .map(|path| (path.clone(), ledger.today(path, &periods)))
+ .filter(|(_, today)| *today > 0.0)
+ .collect();
+ let changed = {
+ let mut known = today_by_session();
+ let changed = *known != today;
+ *known = today;
+ changed
+ };
+ // The watcher writes each agent's amount on its pane.
+ if changed {
+ crate::hooks::wake();
+ }
+ // Always a value: tmux refuses to set an empty one.
+ let values = [totals.today, totals.week, totals.month].map(|dollars| format!("{dollars:.2}"));
+ if published.as_ref() != Some(&values) {
+ if published.is_none() {
+ info!(files = ledger.files.len(), took_ms = started.elapsed().as_millis() as u64, "cost: first scan");
+ }
+ for (name, value) in ["@dingger_cost_today", "@dingger_cost_week", "@dingger_cost_month"].iter().zip(&values) {
+ if let Err(err) = crate::tmux::set_global(name, value) {
+ warn!(%err, "failed to write {name}");
+ }
+ }
+ published = Some(values);
+ }
+ std::thread::sleep(every);
+ }
+ });
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn ms(date: &str) -> i64 {
+ // "2026-11-01T09:30" in UTC
+ let (d, t) = date.split_once('T').unwrap();
+ let p: Vec<i64> = d.split('-').map(|x| x.parse().unwrap()).collect();
+ let (h, m) = t.split_once(':').unwrap();
+ days_from_civil(p[0], p[1] as u32, p[2] as u32) * DAY_MS
+ + h.parse::<i64>().unwrap() * 3_600_000
+ + m.parse::<i64>().unwrap() * 60_000
+ }
+
+ #[test]
+ fn periods_start_at_utc_midnight_monday_and_the_first() {
+ let p = Periods::at(ms("2026-11-01T09:30")); // a Sunday
+ assert_eq!(civil_from_days(p.today), (2026, 11, 1));
+ assert_eq!(civil_from_days(p.week), (2026, 10, 26), "the Monday before");
+ assert_eq!(civil_from_days(p.month), (2026, 11, 1));
+ let p = Periods::at(ms("2026-11-02T00:00")); // Monday, at midnight UTC
+ assert_eq!(civil_from_days(p.week), (2026, 11, 2));
+ assert_eq!(civil_from_days(Periods::at(ms("2026-11-01T23:59")).today), (2026, 11, 1));
+ let p = Periods::at(ms("2024-03-01T12:00"));
+ assert_eq!(civil_from_days(p.month), (2024, 3, 1));
+ assert_eq!(civil_from_days(p.today - 1), (2024, 2, 29), "a leap day");
+ for day in [-1000, 0, 19_000, 20_759] {
+ let (y, m, d) = civil_from_days(day);
+ assert_eq!(days_from_civil(y, m, d), day);
+ }
+ }
+
+ fn line(id: &str, at: i64, cost: f64) -> String {
+ format!(
+ "{{\"type\":\"message\",\"id\":\"{id}\",\"message\":{{\"role\":\"assistant\",\"timestamp\":{at},\"usage\":{{\"cost\":{{\"total\":{cost}}}}}}}}}\n"
+ )
+ }
+
+ fn session(dir: &Path, name: &str, started: &str, lines: &[String]) -> PathBuf {
+ let path = dir.join(name);
+ let mut text = format!("{{\"type\":\"session\",\"timestamp\":\"{started}\"}}\n");
+ text.extend(lines.iter().cloned());
+ std::fs::write(&path, text).unwrap();
+ path
+ }
+
+ fn files(paths: &[&PathBuf]) -> Vec<(PathBuf, SystemTime)> {
+ paths.iter().map(|p| ((*p).clone(), SystemTime::now())).collect()
+ }
+
+ #[test]
+ fn each_reply_counts_once_for_the_session_it_first_appeared_in() {
+ let dir = std::env::temp_dir().join(format!("dingger-cost-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let now = ms("2026-11-01T12:00");
+ let periods = Periods::at(now);
+ let old = line("a1", ms("2026-10-27T10:00"), 2.0); // this week, last month
+ let today = line("a2", ms("2026-11-01T08:00"), 1.5);
+ let before = line("a0", ms("2026-10-01T08:00"), 100.0); // before both periods
+ let user = "{\"type\":\"message\",\"id\":\"u1\",\"message\":{\"role\":\"user\",\"timestamp\":1,\"usage\":{\"cost\":{\"total\":9}}}}\n".to_string();
+ // The fork copies the parent's entries, then has its own reply.
+ let fork_own = line("f1", ms("2026-11-01T09:00"), 0.25);
+ let fork = session(&dir, "b-fork.jsonl", "2026-11-01T08:30:00Z", &[before.clone(), old.clone(), today.clone(), fork_own]);
+ let parent = session(&dir, "a-parent.jsonl", "2026-10-20T00:00:00Z", &[before, old, user, today]);
+ let mut ledger = Ledger::default();
+ // Listed fork first: the order read is by start, not by listing.
+ ledger.scan(files(&[&fork, &parent]), &periods);
+ assert_eq!(ledger.totals(&periods), Totals { today: 1.75, week: 3.75, month: 1.75 });
+ assert_eq!(ledger.today(&parent, &periods), 1.5, "the parent's own reply");
+ assert_eq!(ledger.today(&fork, &periods), 0.25, "the fork: only its own reply");
+
+ // Appended: read from where it stopped; a line still being written waits.
+ let more = line("a3", ms("2026-11-01T11:00"), 0.5);
+ let mut f = std::fs::OpenOptions::new().append(true).open(&parent).unwrap();
+ use std::io::Write;
+ f.write_all(more.as_bytes()).unwrap();
+ f.write_all(&line("a4", ms("2026-11-01T11:30"), 4.0).as_bytes()[..20]).unwrap();
+ ledger.scan(files(&[&fork, &parent]), &periods);
+ assert_eq!(ledger.today(&parent, &periods), 2.0);
+ f.write_all(&line("a4", ms("2026-11-01T11:30"), 4.0).as_bytes()[20..]).unwrap();
+ ledger.scan(files(&[&fork, &parent]), &periods);
+ assert_eq!(ledger.today(&parent, &periods), 6.0, "the line, once finished");
+
+ // The next day: today starts over, the week and month go on.
+ let tomorrow = Periods::at(ms("2026-11-02T00:01"));
+ let totals = ledger.totals(&tomorrow);
+ assert_eq!((totals.today, totals.month), (0.0, 6.25));
+ assert_eq!(totals.week, 0.0, "a new week on Monday");
+
+ // A file changed before the periods began is not read.
+ let stale = session(&dir, "c.jsonl", "2026-09-01T00:00:00Z", &[line("s1", ms("2026-11-01T01:00"), 7.0)]);
+ ledger.scan(vec![(stale, UNIX_EPOCH + Duration::from_secs(1))], &periods);
+ assert_eq!(ledger.totals(&periods).today, 6.25);
+
+ // Rewritten shorter: counted again from the start, still once.
+ session(&dir, "a-parent.jsonl", "2026-10-20T00:00:00Z", &[line("a2", ms("2026-11-01T08:00"), 1.5)]);
+ ledger.scan(files(&[&fork, &parent]), &periods);
+ assert_eq!(ledger.today(&parent, &periods), 1.5);
+ assert_eq!(ledger.totals(&periods).today, 1.75);
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
+
+ #[test]
+ fn dollars_as_an_option() {
+ assert_eq!(option_value(0.0), "");
+ assert_eq!(option_value(0.004), "");
+ assert_eq!(option_value(19.849), "19.85");
+ assert_eq!(option_value(1967.14), "1967.14");
+ }
+}
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs
index 072735f..1718ff8 100644
--- a/crates/dinggerd/src/hooks.rs
+++ b/crates/dinggerd/src/hooks.rs
@@ -117,7 +117,7 @@ fn wake_signal() -> &'static (Mutex<bool>, Condvar) {
WAKE.get_or_init(|| (Mutex::new(false), Condvar::new()))
}
-fn wake() {
+pub fn wake() {
let (flag, condvar) = wake_signal();
*flag.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = true;
condvar.notify_all();
diff --git a/crates/dinggerd/src/integrations.rs b/crates/dinggerd/src/integrations.rs
index 5d9cf7a..93f46ce 100644
--- a/crates/dinggerd/src/integrations.rs
+++ b/crates/dinggerd/src/integrations.rs
@@ -34,11 +34,14 @@ fn pi_extension_path() -> Result<PathBuf, String> {
}
fn pi_extension_dir() -> Result<PathBuf, String> {
- let base = std::env::var_os("PI_CODING_AGENT_DIR")
+ Ok(pi_agent_dir().ok_or("cannot determine pi agent dir (no HOME)")?.join("extensions"))
+}
+
+/// pi's agent directory: `PI_CODING_AGENT_DIR`, else `~/.pi/agent`.
+pub fn pi_agent_dir() -> Option<PathBuf> {
+ std::env::var_os("PI_CODING_AGENT_DIR")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".pi").join("agent")))
- .ok_or("cannot determine pi agent dir (no HOME)")?;
- Ok(base.join("extensions"))
}
/// zsh hook: records the command line running in each tmux pane exactly as
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 44db683..99c10a7 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -1,5 +1,6 @@
//! dingger: agent state watching for tmux panes.
+mod cost;
mod hooks;
mod importance;
mod integrations;
@@ -172,6 +173,9 @@ fn main() {
no_notifications,
} => {
integrations::update_pi_extension();
+ if let Some(dir) = integrations::pi_agent_dir() {
+ cost::spawn(dir.join("sessions"));
+ }
if let Err(err) = hooks::spawn_listener() {
eprintln!("dingger: {err}");
std::process::exit(1);
@@ -444,7 +448,8 @@ fn mark_unread(pane: Option<String>) -> Result<(), String> {
/// Every agent pane in herdr's priority order (blocked, done, working,
/// idle), oldest-waiting first within a status. ✎ marks a draft left in
-/// the agent's input box.
+/// the agent's input box. COST is what a pi agent spent today (UTC); the
+/// last line totals today, this week, and this month, closed agents included.
fn status() -> Result<(), String> {
let mut panes: Vec<_> = tmux::list_panes()?
.into_iter()
@@ -462,7 +467,7 @@ fn status() -> Result<(), String> {
});
let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1);
let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5);
- println!("{:<8} {:<width$} {:<7} NAME", "STATUS", "WHERE", "AGENT");
+ println!("{:<8} {:<width$} {:<7} {:>8} NAME", "STATUS", "WHERE", "AGENT", "COST");
for pane in &panes {
let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state };
let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-");
@@ -472,7 +477,15 @@ fn status() -> Result<(), String> {
importance::Importance::Low => format!("{} (low)", pane.written_name),
importance::Importance::Normal => pane.written_name.clone(),
};
- println!("{state:<8} {:<width$} {agent:<7} {draft}{}", place(pane), name.trim());
+ let cost = if pane.written_cost.is_empty() { String::new() } else { format!("${}", pane.written_cost) };
+ println!("{state:<8} {:<width$} {agent:<7} {cost:>8} {draft}{}", place(pane), name.trim());
}
+ let global = |name: &str| tmux::show_global(name).unwrap_or_default();
+ let spent = [("today", "@dingger_cost_today"), ("this week", "@dingger_cost_week"), ("this month", "@dingger_cost_month")]
+ .map(|(period, name)| {
+ let value = global(name);
+ format!("${} {period}", if value.is_empty() { "0.00" } else { &value })
+ });
+ println!("\nspent: {} (UTC)", spent.join(" · "));
Ok(())
}
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index 510c54b..136260e 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -34,6 +34,8 @@ pub struct Pane {
pub written_control: String,
/// The draft in the agent's input box, as one line.
pub written_draft: String,
+ /// What the agent's session spent today (UTC), in dollars.
+ pub written_cost: String,
}
@@ -118,14 +120,15 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
"#{@dingger_importance}\t",
"#{@dingger_extension}\t",
"#{@dingger_control}\t",
- "#{@dingger_draft}",
+ "#{@dingger_draft}\t",
+ "#{@dingger_cost}",
);
let out = run_tmux(&["list-panes", "-a", "-F", format])?;
let mut panes: Vec<Pane> = Vec::new();
let mut process_tree = None;
for line in out.lines() {
let fields: Vec<&str> = line.split('\t').collect();
- if fields.len() < 23 {
+ if fields.len() < 24 {
continue;
}
let attended = fields[4] == "1" && fields[5] != "0";
@@ -171,6 +174,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
written_extension: fields[20].to_string(),
written_control: fields[21].to_string(),
written_draft: fields[22].to_string(),
+ written_cost: fields[23].to_string(),
});
}
Ok(panes)
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index b0acc1d..1aec88e 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -27,6 +27,7 @@
//! - `@dingger_extension`, `@dingger_control` the version of pi's extension
//! and its control socket, for reloading agents on an old one
//! - `@dingger_draft` the draft in the agent's input box, as one line
+//! - `@dingger_cost` what the agent's session spent today (see `cost`)
use std::collections::HashMap;
@@ -509,6 +510,17 @@ impl Watcher {
change("@dingger_control", &pane.written_control, String::new());
}
}
+ // What its session spent today; the cost thread reads every session
+ // file in pi's directory, and these wherever they are.
+ let session = hooks::session(&pane.pane_id).unwrap_or_else(|| pane.written_session.clone());
+ let cost = match pane.agent {
+ Some(_) if !session.is_empty() => {
+ crate::cost::note_session(&session);
+ crate::cost::option_value(crate::cost::session_today(&session))
+ }
+ _ => String::new(),
+ };
+ change("@dingger_cost", &pane.written_cost, cost);
// The draft goes with the agent too.
match (pane.agent, hooks::draft(&pane.pane_id)) {
(Some(_), Some(draft)) => change("@dingger_draft", &pane.written_draft, draft),