show what pi agents cost today, this week and this month, counting forked replies once
@@ -120,6 +120,19 @@ marked: a working, blocked, or done one is left alone. Claude Code has no
`/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 @@ manifests, for agents without hooks.
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 @@ ## Usage
```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@<version>` (through `sfw`), then `tests/pi-live.sh /tmp/pi-next/node_modules/.bin/pi`.
@@ -77,10 +77,10 @@ 14. **Reach you away from the Mac.** While the screen is locked or you are
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
@@ -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"); + } +}
@@ -117,7 +117,7 @@ static WAKE: OnceLock<(Mutex<bool>, Condvar)> = OnceLock::new();
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();
@@ -34,11 +34,14 @@ Ok(pi_extension_dir()?.join("dingger-agent-state.ts"))
} 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
@@ -1,5 +1,6 @@
//! dingger: agent state watching for tmux panes. +mod cost; mod hooks; mod importance; mod integrations;@@ -172,6 +173,9 @@ no_sounds,
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 @@ }
/// 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 @@ (watcher::list_order(status, watcher::forgotten_draft(pane), importance, since), pane.display_target.clone())
}); 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 @@ importance::Importance::High => format!("! {}", pane.written_name),
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(()) }
@@ -34,6 +34,8 @@ pub written_extension: String,
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 @@ "#{@agent_session}\t",
"#{@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 @@ written_importance: fields[19].to_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)
@@ -27,6 +27,7 @@ //! - `@agent_session` the agent's session reference, for restore
//! - `@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 @@ change("@dingger_extension", &pane.written_extension, String::new());
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),
@@ -32,6 +32,8 @@ # isolated server and the test daemon's hook socket.
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 @@ hook "$A1" working; hook "$A1" idle
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]).read())' "$(latest_snapshot)" "$1"; }
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" ""@@ -1098,6 +1101,48 @@ expect "another session: none of it" "grep -c '^editor' $EXTLOG" 0
expect "dr: no draft reported" "opt $D1 @dingger_draft" "" 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
@@ -50,7 +50,7 @@ # that blocks the way extensions do), one without (screen detection).
for agent in agent agent-plain; do mkdir -p "$D/$agent/extensions" cat >"$D/$agent/models.json" <<EOF -{"providers": {"fake": {"baseUrl": "http://127.0.0.1:$PORT/v1", "api": "openai-completions", "apiKey": "fake", "models": [{"id": "fake-model"}]}}} +{"providers": {"fake": {"baseUrl": "http://127.0.0.1:$PORT/v1", "api": "openai-completions", "apiKey": "fake", "models": [{"id": "fake-model", "cost": {"input": 100000, "output": 1000000, "cacheRead": 0, "cacheWrite": 0}}]}}} EOF echo '{"defaultProvider": "fake", "defaultModel": "fake-model"}' >"$D/$agent/settings.json" done@@ -70,6 +70,10 @@ printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$D/tmux.conf"
# 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