From 8146d1db419e8bdacd75920da6f3761abf724853 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Sun, 1 Nov 2026 10:22:01 +0200 Subject: show what pi agents cost today, this week and this month, counting forked replies once --- README.md | 22 +- ROADMAP.md | 8 +- crates/dinggerd/src/cost.rs | 493 ++++++++++++++++++++++++++++++++++++ crates/dinggerd/src/hooks.rs | 2 +- crates/dinggerd/src/integrations.rs | 9 +- crates/dinggerd/src/main.rs | 19 +- crates/dinggerd/src/tmux.rs | 8 +- crates/dinggerd/src/watcher.rs | 12 + menubar/main.swift | 10 + menubar/model.swift | 47 +++- menubar/panel.swift | 11 + tests/e2e.sh | 49 +++- tests/pi-live.sh | 23 +- 13 files changed, 688 insertions(+), 25 deletions(-) create mode 100644 crates/dinggerd/src/cost.rs diff --git a/README.md b/README.md index 9f3a9af..4b1eeb2 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,19 @@ than a poll is still a completion. `/unread`, and `! dingger unread` does not work in it: Claude (2.1.311) answers a `!` command with a model turn, so the agent is working and the mark is refused. Use the panel or search for Claude agents. +- **What agents cost** (pi): the menu's top line totals what pi agents + spent today, this week, and this month (`$8.86 today · $457 this week · + $8.86 this month`); each agent's line adds what its session spent today + (menu, panel, search), and `dingger status` shows both. Periods start at + 00:00 UTC (a week on Monday, a month on its 1st). The daemon reads pi's + session files (`~/.pi/agent/sessions`, and those of running agents + elsewhere) in a thread of its own every 15 seconds, only what was + appended since. Each reply counts once, for the session it first appeared + in: a fork (`/fork`, `pi --fork`) copies its parent's replies into its + own file, and summing every file would count them again (in one month + here, $1,024 of $2,991). The totals include agents since closed or + started over. Amounts are what pi prices each reply at (its model + configuration); Claude Code records no prices, so it is not counted. - **Forgotten drafts** (pi): an agent with text left in its input box shows `✎` before its name wherever you are not looking at it (where you look, you may be typing it). Idle agents with one come first among idle agents; @@ -148,8 +161,8 @@ than a poll is still a completion. All per-pane state lives in tmux pane options (`@agent_state`, `@dingger_since`, `@dingger_hook`, `@dingger_importance`, -`@dingger_extension`, `@dingger_control`, `@dingger_draft`), so -restarting dingger loses nothing. +`@dingger_extension`, `@dingger_control`, `@dingger_draft`, +`@dingger_cost`), so restarting dingger loses nothing. ### Keeping pi agents on the current extension @@ -184,7 +197,7 @@ session as a custom entry of type `dingger-importance`. ```bash dingger watch # the daemon (run by launchd, see below) -dingger status # agents in priority order +dingger status # agents in priority order, with what they spent dingger focus [--pane ID] # jump to an agent (default: the top waiter) dingger importance [LEVEL] # an agent's importance: high, normal, low dingger unread [--pane ID] # mark an idle agent unread (done again) @@ -257,7 +270,8 @@ covers identification, the extension's state and session reports, a turn, `/reload`, an extension's blocked event, screen detection without the extension, restore, reloading an agent on an old extension (and refusing while busy or with a draft), drafts (reported, cleared, kept across a -crash and put back by restore), and exit. To try a release without installing it: +crash and put back by restore), cost (priced replies, counted once across +a real `pi --fork`), and exit. To try a release without installing it: `npm install --prefix /tmp/pi-next @earendil-works/pi-coding-agent@` (through `sfw`), then `tests/pi-live.sh /tmp/pi-next/node_modules/.bin/pi`. diff --git a/ROADMAP.md b/ROADMAP.md index d8f3563..a8bd1be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,10 +77,10 @@ Ideas, not yet prioritized: idle, send blocked agents to your phone (a push service such as ntfy or Pushover). Sends data off the machine: needs explicit setup. -15. **What each agent costs.** pi records each reply's cost in its - session file. Show today's spend per agent (panel, menu, search) and - a total in the menu; optionally warn when one agent passes a limit. - One day's sample: $37.44 across 9 agents, 77% from two of them. +15. ~~**What each agent costs.**~~ Done for pi (see the README): today, + this week and this month in the menu, each agent's today on its line, + UTC periods, forks counted once, closed agents included. No limits or + warnings, by choice. 16. ~~**Keep agents on the current extension.**~~ Done (see the README): the daemon updates the installed extension, and reloads agents on an old one over a control socket, from the menu or `dingger 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, +} + +/// Every reply counted, by session file. +#[derive(Debug, Default)] +pub struct Ledger { + files: HashMap, + /// 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, + #[serde(default)] + message: Option, +} + +#[derive(Deserialize)] +struct Message { + #[serde(default)] + role: Option, + #[serde(default)] + timestamp: Option, + #[serde(default)] + usage: Option, +} + +#[derive(Deserialize)] +struct Usage { + #[serde(default)] + cost: Option, +} + +#[derive(Deserialize)] +struct CostField { + #[serde(default)] + total: Option, +} + +#[derive(Deserialize)] +struct Header { + #[serde(default)] + timestamp: Option, +} + +/// 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::
(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: `/sessions//.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> { + static SESSIONS: OnceLock>> = 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> { + static TODAY: OnceLock>> = 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 = 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 = 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 = 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::().unwrap() * 3_600_000 + + m.parse::().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, 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 { } fn pi_extension_dir() -> Result { - 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 { + 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) -> 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} {: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} {: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, 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 = 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, 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), diff --git a/menubar/main.swift b/menubar/main.swift index 82931ab..77e2b5e 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -284,6 +284,14 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { menu.removeAllItems() rows = fetchRows() restoreOffer = fetchRestoreOffer() + let spend = fetchSpend() + if !spend.isEmpty { + let item = NSMenuItem(title: spend.line, action: nil, keyEquivalent: "") + item.isEnabled = false + item.toolTip = "What pi agents spent, closed ones included; days start at 00:00 UTC" + menu.addItem(item) + menu.addItem(.separator()) + } if let offer = restoreOffer { let item = NSMenuItem(title: restoring ? "Restoring tmux Sessions…" : "↺ \(offer.title)", action: nil, keyEquivalent: "") @@ -463,6 +471,8 @@ if arguments.contains("--selftest") { if arguments.contains("--print") { let rows = fetchRows() print("title: \(plainTitle(for: rows))") + let spend = fetchSpend() + if !spend.isEmpty { print("$ \(spend.line)") } let outdated = fetchOutdated() if !outdated.isEmpty { print("⟳ \(outdated.title)\t\(outdated.detail)\(outdated.canReload ? "" : "\t(disabled)")") diff --git a/menubar/model.swift b/menubar/model.swift index 3ed1a7e..d0bbd7e 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -97,6 +97,8 @@ struct Row: Equatable { var draft: String = "" /// The user looks at this pane: its window is active in an attached session. var attended: Bool = false + /// What its session spent today (UTC), in dollars (`@dingger_cost`). + var cost: Double = 0 var location: String { "\(session) · \(window)" } /// A draft left behind: where the user looks, they may be typing it. @@ -116,10 +118,11 @@ struct Row: Equatable { var markedLabel: String { (importance == .high ? "! " : "") + (forgottenDraft ? "✎ " : "") + label } /// Dim second line: where it runs, which harness, whether it is low, - /// and how long it waits. + /// what it spent today, and how long it waits. var subtitle: String { var parts = [location, agent] if importance == .low { parts.append("low") } + if cost >= 0.005 { parts.append(dollars(cost)) } if waiting, since != Int.max { parts.append("\(status.rawValue) \(elapsed(sinceMs: since))") } @@ -127,6 +130,39 @@ struct Row: Equatable { } } +/// "$0.42", "$19.85"; whole dollars from $100: "$1,967". +func dollars(_ amount: Double) -> String { + if amount < 99.995 { return String(format: "$%.2f", amount) } + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale(identifier: "en_US") + formatter.maximumFractionDigits = 0 + return "$" + (formatter.string(from: NSNumber(value: amount.rounded())) ?? String(Int(amount.rounded()))) +} + +/// What pi agents spent today, this week, and this month (UTC), closed +/// agents included, as the daemon counts it (`@dingger_cost_today`, ...). +struct Spend: Equatable { + let today: Double + let week: Double + let month: Double + + var isEmpty: Bool { today < 0.005 && week < 0.005 && month < 0.005 } + + /// "$8.86 today · $457 this week · $8.86 this month" + var line: String { + "\(dollars(today)) today · \(dollars(week)) this week · \(dollars(month)) this month" + } +} + +func fetchSpend() -> Spend { + let amount = { (name: String) in + Double(tmux(["show-options", "-gv", name])?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") ?? 0 + } + return Spend(today: amount("@dingger_cost_today"), week: amount("@dingger_cost_week"), + month: amount("@dingger_cost_month")) +} + /// Compact age: "now", "4m", "2h", "3d". func elapsed(sinceMs: Int) -> String { let seconds = max(0, Int(Date().timeIntervalSince1970) - sinceMs / 1000) @@ -290,26 +326,27 @@ func fetchRows() -> [Row] { let format = [ "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}", "#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}", - "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}", + "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}", "#{@dingger_cost}", ].joined(separator: "\t") guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] } let lines = out.split(separator: "\n").map { $0.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) } // Looked at in any of its sessions, as the daemon counts it. - let attended = Set(lines.filter { $0.count >= 14 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] }) + let attended = Set(lines.filter { $0.count >= 15 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] }) // A pane in several sessions (a session group, a linked window) is // listed once, under its first session, as the daemon counts it. var listed = Set() let rows: [Row] = lines.compactMap { f in - guard f.count >= 14, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil } + guard f.count >= 15, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil } let status = Status(rawValue: f[2]) ?? .unknown guard status != .unknown else { return nil } let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6] return Row(paneId: f[0], agent: f[1], name: f[4], status: status, session: f[5], window: window, since: Int(f[3]) ?? Int.max, path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9], - importance: Importance(option: f[10]), draft: f[13], attended: attended.contains(f[0])) + importance: Importance(option: f[10]), draft: f[13], attended: attended.contains(f[0]), + cost: Double(f[14]) ?? 0) } return rows.sorted(by: listedBefore) } diff --git a/menubar/panel.swift b/menubar/panel.swift index 5362d32..21b4644 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -718,6 +718,17 @@ func panelSelftest() -> [String] { check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"), "toggle offered only when allowed, labelled by layout") + // Spend. + check(dollars(0.42) == "$0.42" && dollars(19.849) == "$19.85" && dollars(99.994) == "$99.99" + && dollars(99.996) == "$100" && dollars(1967.14) == "$1,967", "spend: cents below $100, then whole dollars") + var spender = row("s", .done) + spender.cost = 19.85 + check(spender.subtitle.hasPrefix("s · s · pi · $19.85 · done "), "spend: today's in the subtitle, before the wait") + spender.cost = 0.004 + check(!spender.subtitle.contains("$"), "spend: nothing spent, nothing shown") + check(Spend(today: 8.86, week: 456.87, month: 8.86).line == "$8.86 today · $457 this week · $8.86 this month" + && Spend(today: 0, week: 0, month: 0).isEmpty, "spend: the menu's line") + // Forgotten drafts: a draft the user is not looking at. var drafty = [row("d", .done), row("w", .working), row("i1", .idle), row("i2", .idle), row("i3", .idle)] drafty[0].draft = "x"; drafty[1].draft = "x"; drafty[3].draft = "x"; drafty[4].draft = "x" diff --git a/tests/e2e.sh b/tests/e2e.sh index e73dc73..e5b5865 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -32,6 +32,8 @@ PASSES=0 export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" DINGGER_STATE_DIR="$DIR/state" # The daemon updates an installed pi extension when it starts: not the user's. export PI_CODING_AGENT_DIR="$DIR/pi-agent" +# What pi agents cost: the daemons read session files here every 0.3 s. +export DINGGER_COST_SCAN_SECS=0.3 t() { tmux -u -L "$SOCK" "$@"; } @@ -260,14 +262,14 @@ if [ -x "$MENUBAR" ]; then expect "A1 done" "opt $A1 @agent_state" done expect "menu title" "mb --print | head -1" "title: ×1 ✓1" expect "menu rows: herdr priority; idle most recent first (C1 since its jump in step 4, B1 since the start)" \ - "mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;" + "mb --print | tail -n +2 | grep -v '^⟳\|^\$ ' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;" # Wait for working: B1 was idle before, so idle alone would not show the turn. hook "$B1" working expect "B1 working" "opt $B1 @agent_state" working hook "$B1" idle expect "B1 finishes a turn while watched: idle" "opt $B1 @agent_state" idle expect "and is now the most recent idle agent: listed first" \ - "mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;" + "mb --print | tail -n +2 | grep -v '^⟳\|^\$ ' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;" expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now" expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi" mb --click "$A1" @@ -516,6 +518,7 @@ in_snapshot() { python3 -c 'import sys; print(sys.argv[2] in open(sys.argv[1]).r expect "snapshot: the agent's session" "in_snapshot '\"session\": \"$W/sessions/a b.jsonl\"'" True 80 expect "snapshot: a running command's exact arguments" "in_snapshot \"'sleep 100000; :' 'two words'\"" True 80 expect "snapshot: the zsh hook's text" "in_snapshot '\"command\": \"slp 200000 | cat\"'" True 80 +expect "snapshot: the other agent's session" "in_snapshot gone.jsonl" True 80 # An agent that exits just before tmux dies (a shutdown stops programs first). kill "$(ps -o tpgid= -p "$(r display -p -t "$GONE" '#{pane_pid}')" | tr -d ' ')" expect "the exited agent is noticed" "r show -pv -t $GONE @agent 2>/dev/null" "" @@ -1099,6 +1102,48 @@ if command -v node >/dev/null; then fi t kill-session -t dr +# --- 25. what agents cost ----------------------------------------------------- +# The daemon reads pi's session files: each reply once, for the session it +# first appeared in (a fork copies its parent's), closed agents included. +echo "25. what agents cost" +SESS="$PI_CODING_AGENT_DIR/sessions/--work--" +mkdir -p "$SESS" +reply() { # id cost [days ago] + python3 -c 'import json,sys,time; print(json.dumps({"type":"message","id":sys.argv[1],"message":{"role":"assistant","timestamp":int((time.time()-86400*float(sys.argv[3]))*1000),"usage":{"cost":{"total":float(sys.argv[2])}}}}))' "$1" "$2" "${3:-0}" +} +header() { echo "{\"type\":\"session\",\"timestamp\":\"$1\"}"; } +{ header 2026-01-01T00:00:00Z; reply p0 100 40; reply p1 1.25; reply p2 0.25; } >"$SESS/parent.jsonl" +# The fork: its parent's entries, then its own. +{ header 2026-01-02T00:00:00Z; tail -n +2 "$SESS/parent.jsonl"; reply f1 0.5; } >"$SESS/fork.jsonl" +expect "today, each reply once (not 40 days ago)" "gopt @dingger_cost_today" 2.00 50 +expect "this week" "gopt @dingger_cost_week" 2.00 +expect "this month" "gopt @dingger_cost_month" 2.00 +session_report() { # pane session-file + python3 - "$HOOK" "$1" "$2" <<'PY' +import json, socket, sys +params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "agent_session_path": sys.argv[3]} +s = socket.socket(socket.AF_UNIX) +s.connect(sys.argv[1]) +s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode()) +s.close() +PY +} +t new-session -d -s cost -n c1 -c "$DIR" "$DIR/bin/pi" +t new-window -d -t cost: -n c2 -c "$DIR" "$DIR/bin/pi" +C1=$(t display -p -t cost:c1 '#{pane_id}'); C2=$(t display -p -t cost:c2 '#{pane_id}') +session_report "$C1" "$SESS/parent.jsonl"; session_report "$C2" "$SESS/fork.jsonl" +expect "an agent: what its session spent today" "opt $C1 @dingger_cost" 1.50 +expect "a fork: only its own replies" "opt $C2 @dingger_cost" 0.50 +expect "the menu's top line" "mb --print | grep '^\\$ '" '$ $2.00 today · $2.00 this week · $2.00 this month' +expect "an agent's line" "mb --print | grep -F 'cost · c1' | cut -f3" "cost · c1 · pi · \$1.50" +expect "dingger status: per agent" "$DINGGER status | grep -F 'cost · c2' | grep -c ' \$0.50 '" 1 +expect "and in total" "$DINGGER status | tail -1" 'spent: $2.00 today · $2.00 this week · $2.00 this month (UTC)' +reply f2 0.75 >>"$SESS/fork.jsonl" +expect "a new reply: counted" "opt $C2 @dingger_cost" 1.25 50 +expect "in the total too" "gopt @dingger_cost_today" 2.75 +t kill-session -t cost +expect "closed agents still count" "gopt @dingger_cost_today" 2.75 10 + # Every daemon this run started must stop with it. kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0 diff --git a/tests/pi-live.sh b/tests/pi-live.sh index 6dff42f..95c5885 100755 --- a/tests/pi-live.sh +++ b/tests/pi-live.sh @@ -50,7 +50,7 @@ PORT=$(cat "$D/port") for agent in agent agent-plain; do mkdir -p "$D/$agent/extensions" cat >"$D/$agent/models.json" <"$D/$agent/settings.json" done @@ -70,6 +70,10 @@ printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$D/tmu # Every pi here uses the test's agent directory, including one a restore # starts (`pi --session`, without the command line's own variables). export PATH="$D/bin:$PATH" PI_CODING_AGENT_DIR="$D/agent" PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 PI_TELEMETRY=0 +# The fake model's prices make each reply cost $4.00 (10 input tokens at +# $100,000 per million, 3 output at $1,000,000); the daemon reads costs +# from session files every 0.3 s. +export DINGGER_COST_SCAN_SECS=0.3 export DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$D/hook.sock" DINGGER_STATE_DIR="$D/state" DINGGER_TMUX_CONFIG="$D/tmux.conf" t -f "$D/tmux.conf" new-session -d -s main -n agent -x 140 -y 40 -c "$D/work" t new-window -d -t main: -n plain -c "$D/work2" @@ -196,6 +200,23 @@ expect "it still works: working" "st agent" working 50 expect "and done" "st agent" done 60 expect "a message sent is no draft" "draft" "" 30 expect "nor kept" "[ -e \"\$(draft_file)\" ] && echo left || echo gone" gone 30 +# What it cost: pi prices each reply; dingger counts them. +replies() { grep -c '"role":"assistant"' "$1"; } +SESSION_FILE=$(t show -pv -t main:agent @agent_session) +N=$(replies "$SESSION_FILE") +[ "$N" -ge 3 ] && pass "$N replies so far" || fail "only $N replies" +WANT=$(python3 -c "print(f'{4*$N:.2f}')" ) +expect "the agent's spend today" "t show -pv -t main:agent @dingger_cost 2>/dev/null" "$WANT" 30 +expect "in total" "t show -gv @dingger_cost_today 2>/dev/null" "$WANT" 30 +expect "and this week and month" "t show -gv @dingger_cost_week; t show -gv @dingger_cost_month" "$WANT +$WANT" +# A fork copies the session's replies into a new file, then adds its own. +(cd "$D/work" && pi --fork "$SESSION_FILE" -p "slow 1 forked" $NO_MCP >"$D/fork.out" 2>&1) +FORK=$(ls -t "$(dirname "$SESSION_FILE")"/*.jsonl | head -1) +[ "$FORK" != "$SESSION_FILE" ] && [ "$(replies "$FORK")" -eq $((N + 1)) ] \ + && pass "pi --fork: a new file with the $N replies copied and its own" || fail "fork: $FORK ($(replies "$FORK") replies)" +expect "the fork adds only its own reply" "t show -gv @dingger_cost_today 2>/dev/null" "$(python3 -c "print(f'{4*($N+1):.2f}')")" 30 +expect "the agent's own spend is unchanged" "t show -pv -t main:agent @dingger_cost 2>/dev/null" "$WANT" echo "2. screen detection (pi without the extension)" t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter -- cgit v1.2.3