aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md22
-rw-r--r--ROADMAP.md8
-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
-rw-r--r--menubar/main.swift10
-rw-r--r--menubar/model.swift47
-rw-r--r--menubar/panel.swift11
-rwxr-xr-xtests/e2e.sh49
-rwxr-xr-xtests/pi-live.sh23
13 files changed, 688 insertions, 25 deletions
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@<version>`
(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<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),
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<String>()
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" <<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/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