claude-agent-ledger 0.4.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,10 @@
5
5
  [![npm](https://img.shields.io/npm/v/claude-agent-ledger.svg)](https://www.npmjs.com/package/claude-agent-ledger)
6
6
  [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
7
 
8
+ > Use `--anonymize` before sharing output. It replaces project paths and
9
+ > session IDs, but it does not rename subagent or tool names — review those
10
+ > before you post a screenshot.
11
+
8
12
  ---
9
13
 
10
14
  ## Why
@@ -23,19 +27,40 @@ If you run **seven** subagents daily (reliability, release, review, iOS factory,
23
27
 
24
28
  ## Install
25
29
 
30
+ Requires [Bun](https://bun.sh/) 1.1 or newer. The CLI is Bun-native even when
31
+ you install it from npm.
32
+
26
33
  ```bash
27
- npm install -g claude-agent-ledger
28
- # or: bun install -g claude-agent-ledger
34
+ bun install -g claude-agent-ledger
35
+ # or, with Bun already available: npm install -g claude-agent-ledger
29
36
 
30
37
  agent-ledger week --summary # the dashboard (start here)
31
- agent-ledger week --by model # which Claude model burned the budget
38
+ agent-ledger week --tree # parent → child subagent attribution
39
+ agent-ledger week --by tool # which tool ate the budget (Read/Bash/Grep/MCP)
40
+ agent-ledger week --by model # opus vs sonnet vs haiku
32
41
  agent-ledger week --by project # which project ate your tokens
33
42
  agent-ledger week --by session # which single sessions went sideways
34
43
  agent-ledger week --by day # daily bar chart with peak/avg/variance
44
+ agent-ledger today --budget 50 # CI gate: exit 2 if today > $50
45
+ agent-ledger week --json | jq # JSON output for any view, including summary
35
46
  agent-ledger month --md > report.md
47
+
48
+ # Live tail — polling delta meter
49
+ agent-ledger watch # refresh every 10s
50
+ agent-ledger watch 4 # refresh every 4s
51
+
52
+ # Root-cause analysis on a single expensive session
53
+ agent-ledger explain <session-id> # heuristic: top-3 turns + tool/model breakdown
54
+ ANTHROPIC_API_KEY=sk-ant-... agent-ledger explain <session-id>
55
+ # LLM mode: Haiku 4.5 ≤4-bullet root cause (typically <$0.005/run)
36
56
  ```
37
57
 
38
- No server. No account. No data leaves your machine.
58
+ All aggregate commands run locally, require no account, and make no network
59
+ requests. `explain` is also local by default. If you explicitly set
60
+ `ANTHROPIC_API_KEY`, it sends derived session metrics — subagent/model/tool
61
+ names, timestamps, token counts, and cost totals — to the
62
+ Anthropic API. The session identifier is redacted. It does **not** send prompts,
63
+ responses, tool content, or project paths.
39
64
 
40
65
  ## Why this and not `/cost`?
41
66
 
@@ -54,78 +79,24 @@ Claude Code's built-in `/cost` shows the **current session** cost. That's it.
54
79
 
55
80
  Run `agent-ledger week --summary` once and decide.
56
81
 
57
- ## Example output (real, from the author's machine)
82
+ ## Example
58
83
 
59
- ### `--summary` (the dashboard)
84
+ This is deterministic output from the repository's synthetic fixture, using the
85
+ price snapshot documented below:
60
86
 
61
- ```
62
- agent-ledger week summary 2026-04-15 → 2026-04-22
63
-
64
- Shadow cost $13,450.05
65
- Sessions 219
66
- Projects 9
67
- Plan reference $200/mo (Max)
68
- Multiplier 252× vs Max plan
69
-
70
- Top subagent (main) $12,787.19 (95%)
71
- Top model claude-opus-4-7 $7,278.87 (54%)
72
- Top project ~/clawbot $6,657.93 (50%)
73
- Peak day 2026-04-15 $3,679.16
74
-
75
- Project mix
76
- ~/clawbot $6,657.93 (50%)
77
- ~/clawbot-work $4,433.32 (33%)
78
- ~/ZStack-zstack-workspace $1,890.02 (14%)
79
-
80
- Cache 1h writes 119.65M tokens · $3589.46
81
- Cache reads 5.59B tokens · $8236.15
82
- Cache reuse 38× reads/writes
83
- Server tools none
84
- ```
87
+ ```text
88
+ agent-ledger today summary
85
89
 
86
- ### Default — by subagent
90
+ Shadow cost $0.08
91
+ Sessions 2
92
+ Projects 1
87
93
 
88
- ```
89
- agent-ledger week · 2026-04-14 → 2026-04-21
90
-
91
- subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
92
- ───────────────────────────────────────────────────────────────────────────────────────────
93
- (main) 28 514K / 15.87M 1h:116.10M 5m: 0 r:5229.48M $12524.97
94
- general-purpose 148 113K / 955K 1h: 0 5m: 20.67M r: 183.94M $599.97
95
- Swift Developer 13 4K / 574K 1h: 0 5m: 1.61M r: 47.55M $28.93
96
- Market Researcher 10 107K / 74K 1h: 0 5m: 863K r: 8.61M $7.26
97
- Explore 13 176K / 77K 1h: 0 5m: 2.60M r: 22.38M $6.47
98
- zstackws 3 232 / 20K 1h: 0 5m: 829K r: 3.52M $4.47
99
- claude-code-guide 1 2K / 2K 1h: 0 5m: 56K r: 223K $0.08
100
- ───────────────────────────────────────────────────────────────────────────────────────────
101
- total 216 916K / 17.57M 1h:116.10M 5m: 26.62M r:5495.71M $13172.14
94
+ Top subagent Reality Checker $0.06 (74%)
95
+ Top model claude-sonnet-4-6 $0.06 (74%)
96
+ Cache reads 56K tokens · $0.02
97
+ Cache reuse 28× reads/writes
102
98
  ```
103
99
 
104
- ### `--by model`
105
-
106
- ```
107
- subagent sess tokens(in/out) cache tokens (1h/5m/read) cost
108
- ───────────────────────────────────────────────────────────────────────────────────────────
109
- claude-opus-4-7 16 59K / 8.35M 1h: 61.15M 5m: 2.32M r:2974.59M $6967.29
110
- claude-opus-4-6 127 544K / 8.25M 1h: 55.02M 5m: 13.30M r:2422.18M $6160.46
111
- claude-sonnet-4-6 65 135K / 946K 1h: 0 5m: 8.81M r: 98.12M $77.07
112
- claude-haiku-4-5 13 178K / 67K 1h: 0 5m: 2.19M r: 21.57M $4.33
113
- ```
114
-
115
- ### `--by day` (ASCII bar chart)
116
-
117
- ```
118
- 2026-04-15 $3749.84 ████████████████████████████████████ (18 sess)
119
- 2026-04-16 $2492.99 ████████████████████████ (175 sess)
120
- 2026-04-17 $2111.63 ████████████████████ (9 sess)
121
- 2026-04-18 $1840.02 ██████████████████ (18 sess)
122
- 2026-04-19 $644.97 ██████ (5 sess)
123
- 2026-04-20 $1095.58 ███████████ (6 sess)
124
- 2026-04-21 $1273.55 ████████████ (9 sess)
125
- ```
126
-
127
- Yes those are real numbers. Yes it's a lot. See [Shadow cost vs actual bill](#shadow-cost-vs-actual-bill).
128
-
129
100
  ## Shadow cost vs actual bill
130
101
 
131
102
  The `cost` column shows what you **would** pay at Anthropic's pay-as-you-go API rates.
@@ -135,9 +106,13 @@ If you're on a fixed-price subscription (Claude Pro / Max / Claude Code plan), y
135
106
  - Understanding where time-and-token budget goes, regardless of how you pay
136
107
  - Spotting runaway agents (a 10× spike is still a 10× spike, capped or not)
137
108
  - Deciding when to downshift to Haiku or up to Opus per task
138
- - Justifying a Max subscription internally ("our shadow cost is $13K/wk, Max is $200/mo — that's a 65× discount")
109
+ - Comparing usage concentration with a fixed-price plan. Shadow cost is an
110
+ estimate, not a claim that subscription usage is interchangeable with raw API
111
+ capacity.
139
112
 
140
- If you're on pay-as-you-go, the `cost` column is roughly your bill (±5–10% depending on tier / geography / `server_tool_use` calibration).
113
+ If you're on pay-as-you-go, treat the `cost` column as a directional estimate.
114
+ Batch discounts, long-context premiums, provider-specific rates, retries, and
115
+ future price changes can make an invoice differ.
141
116
 
142
117
  ## How it works
143
118
 
@@ -147,10 +122,10 @@ and any `server_tool_use` (web_search / web_fetch) requests. Subagent invocation
147
122
  under `<session>/subagents/agent-*.jsonl`, with a sidecar `.meta.json` carrying the real
148
123
  subagent name (e.g. `Reality Checker`, `ios-factory`).
149
124
 
150
- `claude-agent-ledger` walks the tree, resolves subagent types from the sidecar metadata,
151
- and aggregates by your chosen key (subagent, model, or day). Pricing is applied locally
152
- from `src/pricing.ts`. Nothing is sent anywhere — the tool never touches the network
153
- after install.
125
+ `claude-agent-ledger` walks the tree, resolves subagent types from the sidecar
126
+ metadata, and aggregates by your chosen key. Pricing is applied locally from
127
+ `src/pricing.ts`. Aggregate commands never touch the network after install; the
128
+ optional API-backed `explain` mode is disclosed in [Install](#install).
154
129
 
155
130
  ### Cache accounting
156
131
 
@@ -166,24 +141,31 @@ Naive trackers lump these together as "input" and either overstate (by counting
166
141
 
167
142
  ### Server tool pricing
168
143
 
169
- `web_search_requests` and `web_fetch_requests` are counted at $0.01/request and shown in a footer when present:
144
+ `web_search_requests` cost $0.01/request. `web_fetch_requests` are counted for
145
+ observability but have no per-request charge; fetched content still contributes
146
+ normal token costs. Both appear in the footer:
170
147
 
171
148
  ```
172
149
  server tools: web_search ×7, web_fetch ×0 ($0.07)
173
150
  ```
174
151
 
175
- If Anthropic's actual rate differs (or web_fetch has its own price), open an issue.
152
+ Rates were checked against Anthropic's official pricing page on 2026-07-23.
153
+ If a log contains a model ID without a verified rate, the CLI warns on stderr
154
+ and leaves that model's token cost at `$0.00` instead of guessing.
176
155
 
177
156
  ## Flags
178
157
 
179
158
  | Flag | What it does |
180
159
  |---|---|
181
160
  | `--summary` | One-screen dashboard with all the headline numbers |
182
- | `--by <subagent\|model\|day\|project\|session>` | Group rows. Default: `subagent`. `day` renders as ASCII bar chart. |
161
+ | `--by <subagent\|model\|day\|project\|session\|tool>` | Group rows. Default: `subagent`. `day` renders as ASCII bar chart. |
162
+ | `--tree` | Render parent → child subagent cost attribution |
163
+ | `--budget <USD>` | Exit with code 2 when estimated cost exceeds the threshold |
183
164
  | `--anonymize` | Replace project paths and session ids with `~/repo-A`, `sess-A`, ... so you can share screenshots without doxing your employer / private repos. |
184
165
  | `--md` | Markdown table output (good for committing or sharing) |
185
166
  | `--json` | Raw JSON output (pipe into jq) |
186
- | `--plan pro\|max` | Hide dollar columns; show token utilization only |
167
+ | `--plan pro\|max` | Mask estimated costs in grouped table views; `--summary` and `--tree` keep their cost-oriented output |
168
+ | `--verbose` | Print parse counts and elapsed time to stderr |
187
169
 
188
170
  | Env | What it does |
189
171
  |---|---|
@@ -205,11 +187,16 @@ If Anthropic's actual rate differs (or web_fetch has its own price), open an iss
205
187
 
206
188
  ## Status
207
189
 
208
- `v0.2.0` shipped 2026-04-21. Validated on ~200K turns of real Claude Code logs.
209
- Pricing matches Anthropic published rates as of 2026-04-21. Shadow cost values
210
- may drift ±5-10% from Anthropic's actual invoice in edge cases (retries, tier
211
- bumps, `server_tool_use` calibration). Calibration PRs welcome — see issue
212
- templates.
190
+ The npm badge above reports the current published version. See
191
+ [`CHANGELOG.md`](CHANGELOG.md) for release history.
192
+
193
+ The source price table was checked against Anthropic's published rates on
194
+ 2026-07-23. Sonnet 5 automatically switches from its introductory rate to the
195
+ published standard rate on 2026-09-01. Pricing can change, so shadow costs are
196
+ estimates rather than invoice replicas; check
197
+ [`src/pricing.ts`](src/pricing.ts) and the
198
+ [official pricing page](https://platform.claude.com/docs/en/about-claude/pricing)
199
+ before making billing decisions.
213
200
 
214
201
  - [x] Log parser (JSONL streaming, recursive subagent discovery)
215
202
  - [x] Per-subagent aggregation
@@ -219,7 +206,7 @@ templates.
219
206
  - [x] ANSI color, TTY-aware
220
207
  - [x] Cache 1h/5m TTL split pricing
221
208
  - [x] `--plan pro|max` to mask shadow cost
222
- - [x] `server_tool_use` pricing (web_search, web_fetch)
209
+ - [x] `server_tool_use` accounting (paid web search, no-fee web fetch)
223
210
  - [ ] Cost forecasting (trailing 7-day burn rate)
224
211
  - [ ] Calibration against real Anthropic invoices
225
212
  - [ ] Homebrew tap
@@ -237,14 +224,15 @@ agent-ledger today
237
224
  ## Development
238
225
 
239
226
  ```bash
240
- bun test # 22 pass, 0 fail
227
+ bun test # 35 pass, 0 fail
241
228
  bun run typecheck
229
+ bun run build
242
230
  ```
243
231
 
244
232
  ## Author
245
233
 
246
- Built by [@XJM-free](https://jiexiang.dev) while running seven production Claude Code
247
- subagents for infrastructure work. More context coming on the blog.
234
+ Built by [Jie Xiang](https://jiexiang.dev)
235
+ ([@XJM-free](https://github.com/XJM-free)).
248
236
 
249
237
  ## License
250
238
 
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env bun
2
- import { aggregate, type GroupKey } from '../src/aggregator.ts';
2
+ import { Aggregator, type GroupKey, SubagentGraph } from '../src/aggregator.ts';
3
+ import { analyzeSession, formatHeuristic, llmExplain } from '../src/explain.ts';
3
4
  import { formatMarkdown, formatSummary, formatTable, formatTrend } from '../src/format.ts';
4
5
  import { parseAll } from '../src/parser.ts';
5
- import type { LedgerReport, SessionTurn } from '../src/types.ts';
6
+ import { isKnownModel } from '../src/pricing.ts';
7
+ import { formatTree, formatTreeMarkdown } from '../src/tree.ts';
8
+ import type { LedgerReport, SubagentTreeNode } from '../src/types.ts';
6
9
 
7
10
  type Period = 'today' | 'week' | 'month';
8
11
  type Plan = 'payg' | 'pro' | 'max';
@@ -14,7 +17,10 @@ interface Args {
14
17
  plan: Plan;
15
18
  by: GroupKey;
16
19
  summary: boolean;
20
+ tree: boolean;
17
21
  anonymize: boolean;
22
+ budget?: number; // USD threshold; non-zero exit if exceeded
23
+ verbose: boolean;
18
24
  }
19
25
 
20
26
  function periodRange(period: Period): { from: Date; to: Date } {
@@ -33,35 +39,39 @@ function periodRange(period: Period): { from: Date; to: Date } {
33
39
  }
34
40
 
35
41
  function usage(): never {
36
- console.error(`Usage: agent-ledger <today|week|month> [flags]
42
+ console.error(`Usage:
43
+ agent-ledger <today|week|month> [flags] — aggregate views
44
+ agent-ledger explain <sessionId> — root-cause why one session was expensive (new in 0.6)
37
45
 
38
46
  Flags:
39
- --summary One-screen dashboard: total + top subagent + top
40
- model + peak day + cache reuse + leverage vs $200/mo plan
41
- --by <subagent|model|day|project|session>
47
+ --summary One-screen dashboard (start here).
48
+ --by <subagent|model|day|project|session|tool>
42
49
  Group rows by:
43
50
  subagent (default) — agent attribution
44
51
  model — opus vs sonnet vs haiku
45
52
  day — daily ASCII bar chart
46
53
  project — by Claude Code project (decoded path)
47
54
  session — by session id (top spenders)
55
+ tool — by Read/Bash/Write/Grep — what your turns burn on
56
+ --tree Subagent graph: parent→child cost attribution.
57
+ The MOAT view: orchestrator → 3×researcher + 6×swift-dev = $312.
58
+ --budget <USD> Exit non-zero (code 2) if total cost > USD. Pipe-friendly.
48
59
  --md Markdown table output (good for sharing / commits)
49
- --json Raw JSON output (for piping into jq)
50
- --plan pro|max Suppress dollar columns (token utilization only)
51
- --anonymize Replace project paths and session ids with safe
52
- placeholders (~/repo-A, sess-A) so the output is
53
- shareable without doxing yourself.
60
+ --json Raw JSON output (also works with --summary now)
61
+ --plan pro|max Mask estimated costs in grouped table views.
62
+ --anonymize Replace project paths and session ids with safe placeholders.
63
+ --verbose Print parse stats (files, turns, elapsed) to stderr.
54
64
 
55
65
  Environment:
56
66
  NO_COLOR=1 Disable ANSI colors
57
67
  FORCE_COLOR=1 Force colors when piping
58
68
 
59
69
  Examples:
60
- agent-ledger week --summary # the dashboard
61
- agent-ledger week --by day # daily bar chart
62
- agent-ledger week --by model # which Claude model burned the budget
63
- agent-ledger month --md > month.md
70
+ agent-ledger week --summary # the dashboard
71
+ agent-ledger week --tree # subagent fan-out tree
72
+ agent-ledger week --by tool # which Claude tool ate the budget
64
73
  agent-ledger week --json | jq '.total.cost.totalCost'
74
+ agent-ledger today --budget 50 # CI gate: fail if today > $50
65
75
  `);
66
76
  process.exit(1);
67
77
  }
@@ -72,6 +82,7 @@ function parseArgs(argv: string[]): Args {
72
82
 
73
83
  let plan: Plan = 'payg';
74
84
  let by: GroupKey = 'subagent';
85
+ let budget: number | undefined;
75
86
  for (let i = 0; i < rest.length; i++) {
76
87
  const flag = rest[i];
77
88
  if (flag === '--plan') {
@@ -89,13 +100,34 @@ function parseArgs(argv: string[]): Args {
89
100
  next === 'model' ||
90
101
  next === 'day' ||
91
102
  next === 'project' ||
92
- next === 'session'
103
+ next === 'session' ||
104
+ next === 'tool'
93
105
  ) {
94
106
  by = next;
95
107
  i++;
96
108
  } else {
109
+ console.error(`agent-ledger: unknown --by value '${next}'`);
97
110
  usage();
98
111
  }
112
+ } else if (flag === '--budget') {
113
+ const next = rest[i + 1];
114
+ const n = Number(next);
115
+ if (!Number.isFinite(n) || n <= 0) {
116
+ console.error(`agent-ledger: --budget requires a positive number (got '${next}')`);
117
+ usage();
118
+ }
119
+ budget = n;
120
+ i++;
121
+ } else if (
122
+ flag !== '--md' &&
123
+ flag !== '--json' &&
124
+ flag !== '--summary' &&
125
+ flag !== '--tree' &&
126
+ flag !== '--anonymize' &&
127
+ flag !== '--verbose'
128
+ ) {
129
+ console.error(`agent-ledger: unknown flag '${flag}'`);
130
+ usage();
99
131
  }
100
132
  }
101
133
  return {
@@ -105,19 +137,17 @@ function parseArgs(argv: string[]): Args {
105
137
  plan,
106
138
  by,
107
139
  summary: rest.includes('--summary'),
140
+ tree: rest.includes('--tree'),
108
141
  anonymize: rest.includes('--anonymize'),
142
+ budget,
143
+ verbose: rest.includes('--verbose'),
109
144
  };
110
145
  }
111
146
 
112
- // Replace identifying labels with safe placeholders. Run after aggregation,
113
- // before formatting. We anonymize project paths (which leak employer + repo
114
- // names) and session ids; leave subagent / model / day untouched (those are
115
- // either public Anthropic info or generic agent type names).
116
147
  function anonymizeReport(report: LedgerReport, group: GroupKey): LedgerReport {
117
148
  if (group !== 'project' && group !== 'session') return report;
118
149
  const prefix = group === 'project' ? '~/repo-' : 'sess-';
119
150
  const labelFor = (i: number): string => {
120
- // Use A..Z then AA..ZZ for many entries
121
151
  if (i < 26) return prefix + String.fromCharCode(65 + i);
122
152
  return prefix + String.fromCharCode(65 + Math.floor(i / 26) - 1) + String.fromCharCode(65 + (i % 26));
123
153
  };
@@ -125,9 +155,27 @@ function anonymizeReport(report: LedgerReport, group: GroupKey): LedgerReport {
125
155
  return { ...report, rows: newRows };
126
156
  }
127
157
 
128
- // For --summary we may need to anonymize multiple sub-reports.
129
- function anonymizeForSummary(byProject: LedgerReport): LedgerReport {
130
- return anonymizeReport(byProject, 'project');
158
+ function anonymizeTrees(roots: SubagentTreeNode[]): SubagentTreeNode[] {
159
+ const labels = new Map<string, string>();
160
+ const labelFor = (sessionId: string): string => {
161
+ const existing = labels.get(sessionId);
162
+ if (existing) return existing;
163
+ const i = labels.size;
164
+ const suffix =
165
+ i < 26
166
+ ? String.fromCharCode(65 + i)
167
+ : String.fromCharCode(65 + Math.floor(i / 26) - 1) +
168
+ String.fromCharCode(65 + (i % 26));
169
+ const label = `sess-${suffix}`;
170
+ labels.set(sessionId, label);
171
+ return label;
172
+ };
173
+ const visit = (node: SubagentTreeNode): SubagentTreeNode => ({
174
+ ...node,
175
+ sessionId: labelFor(node.sessionId),
176
+ children: node.children.map(visit),
177
+ });
178
+ return roots.map(visit);
131
179
  }
132
180
 
133
181
  function planMask(report: LedgerReport, plan: Plan): LedgerReport {
@@ -148,49 +196,188 @@ function planMask(report: LedgerReport, plan: Plan): LedgerReport {
148
196
  };
149
197
  }
150
198
 
151
- // Materialize once so --summary can re-aggregate by 3 dimensions cheaply.
152
- async function collectTurns(opts: { from: Date; to: Date }): Promise<SessionTurn[]> {
153
- const out: SessionTurn[] = [];
154
- for await (const t of parseAll(opts)) out.push(t);
155
- return out;
199
+ // `--watch` live-tail mode. Re-aggregates "today" every N seconds and
200
+ // prints a clean delta meter. Polling-based (no inotify) so it works on
201
+ // any platform Bun runs on. Ctrl-C to exit.
202
+ async function watchCmd(intervalSec: number): Promise<void> {
203
+ const interval = Math.max(2, intervalSec);
204
+ let prevCost = 0;
205
+ let prevTurnCount = 0;
206
+ const t0 = Date.now();
207
+ console.log(`agent-ledger watch — refreshing every ${interval}s · Ctrl-C to exit`);
208
+ console.log('');
209
+ const tick = async () => {
210
+ const { from, to } = periodRange('today');
211
+ const agg = new Aggregator('subagent', from, to);
212
+ let turns = 0;
213
+ for await (const turn of parseAll({ from, to })) {
214
+ turns++;
215
+ agg.add(turn);
216
+ }
217
+ const report = agg.finalize();
218
+ const cost = report.total.cost.totalCost;
219
+ const dCost = cost - prevCost;
220
+ const dTurns = turns - prevTurnCount;
221
+ const elapsedMin = ((Date.now() - t0) / 60000).toFixed(1);
222
+ const arrow = dCost > 0 ? '↑' : dCost < 0 ? '↓' : '·';
223
+ const ts = new Date().toISOString().slice(11, 19);
224
+ const top = report.rows[0];
225
+ const topLabel = top ? `top: ${top.subagent} ${fmtUsdInline(top.cost.totalCost)}` : 'no data';
226
+ console.log(
227
+ `[${ts}] today $${cost.toFixed(2)} ${arrow} +$${dCost.toFixed(2)} · ${turns} turns (Δ${dTurns}) · ${topLabel} · live ${elapsedMin}m`,
228
+ );
229
+ prevCost = cost;
230
+ prevTurnCount = turns;
231
+ };
232
+ await tick();
233
+ const id = setInterval(() => {
234
+ tick().catch((e) => console.error('watch tick error:', e));
235
+ }, interval * 1000);
236
+ process.on('SIGINT', () => {
237
+ clearInterval(id);
238
+ console.log('\nagent-ledger watch — exiting');
239
+ process.exit(0);
240
+ });
241
+ // Keep process alive
242
+ await new Promise(() => {});
156
243
  }
157
244
 
158
- async function* iterTurns(turns: SessionTurn[]): AsyncGenerator<SessionTurn> {
159
- for (const t of turns) yield t;
245
+ const fmtUsdInline = (n: number): string => '$' + n.toFixed(n < 0.01 ? 4 : 2);
246
+
247
+ async function explainCmd(sessionPrefix: string | undefined): Promise<void> {
248
+ if (!sessionPrefix) {
249
+ console.error('agent-ledger explain: missing sessionId argument');
250
+ console.error('Usage: agent-ledger explain <sessionId>');
251
+ console.error(' (sessionId can be a full uuid or 8-char prefix)');
252
+ process.exit(1);
253
+ }
254
+ const analysis = await analyzeSession(sessionPrefix);
255
+ if (!analysis) {
256
+ console.error(`agent-ledger explain: no session found matching '${sessionPrefix}'`);
257
+ console.error(' (try the 8-char prefix from `agent-ledger week --by session`)');
258
+ process.exit(1);
259
+ }
260
+ const heuristic = formatHeuristic(analysis);
261
+ console.log(heuristic);
262
+ const llm = await llmExplain(analysis);
263
+ if (llm) {
264
+ console.log('');
265
+ console.log(llm);
266
+ } else if (!process.env.ANTHROPIC_API_KEY) {
267
+ console.log('');
268
+ console.log(
269
+ '💡 Set ANTHROPIC_API_KEY to enable LLM root-cause analysis (typically <$0.005/run via Haiku 4.5)',
270
+ );
271
+ }
160
272
  }
161
273
 
162
274
  async function main(): Promise<void> {
275
+ // Subcommand dispatch.
276
+ if (process.argv[2] === 'explain') {
277
+ return explainCmd(process.argv[3]);
278
+ }
279
+ if (process.argv[2] === 'watch') {
280
+ const interval = Number(process.argv[3]) || 10;
281
+ return watchCmd(interval);
282
+ }
163
283
  const args = parseArgs(process.argv.slice(2));
164
284
  const { from, to } = periodRange(args.period);
165
- const turns = await collectTurns({ from, to });
166
-
167
- if (args.summary) {
168
- const [bySub, byMod, byDay, byProjRaw] = await Promise.all([
169
- aggregate(iterTurns(turns), from, to, 'subagent'),
170
- aggregate(iterTurns(turns), from, to, 'model'),
171
- aggregate(iterTurns(turns), from, to, 'day'),
172
- aggregate(iterTurns(turns), from, to, 'project'),
173
- ]);
174
- const byProj = args.anonymize ? anonymizeForSummary(byProjRaw) : byProjRaw;
175
- console.log(formatSummary(bySub, byMod, byDay, byProj, args.period));
176
- return;
285
+ const t0 = Date.now();
286
+
287
+ // Determine which aggregators we need based on flags.
288
+ // Single parser pass dispatches to all in parallel — no buffering.
289
+ const aggBySub = new Aggregator('subagent', from, to);
290
+ const aggByMod = args.summary ? new Aggregator('model', from, to) : undefined;
291
+ const aggByDay = args.summary ? new Aggregator('day', from, to) : undefined;
292
+ const aggByProj = args.summary ? new Aggregator('project', from, to) : undefined;
293
+ const aggUser = !args.summary && !args.tree ? new Aggregator(args.by, from, to) : undefined;
294
+ const graph = args.tree ? new SubagentGraph() : undefined;
295
+
296
+ let turnCount = 0;
297
+ const unknownModels = new Set<string>();
298
+ for await (const turn of parseAll({ from, to })) {
299
+ turnCount++;
300
+ if (turn.model && !isKnownModel(turn.model)) {
301
+ unknownModels.add(turn.model);
302
+ }
303
+ aggBySub.add(turn);
304
+ aggByMod?.add(turn);
305
+ aggByDay?.add(turn);
306
+ aggByProj?.add(turn);
307
+ aggUser?.add(turn);
308
+ graph?.add(turn);
309
+ }
310
+
311
+ if (unknownModels.size > 0) {
312
+ console.error(
313
+ 'agent-ledger: no verified price for model(s): ' +
314
+ [...unknownModels].sort().join(', ') +
315
+ '; their token cost is shown as $0.00',
316
+ );
177
317
  }
178
318
 
179
- let report = await aggregate(iterTurns(turns), from, to, args.by);
180
- if (args.anonymize) report = anonymizeReport(report, args.by);
181
- report = planMask(report, args.plan);
182
-
183
- let output: string;
184
- if (args.json) {
185
- output = JSON.stringify(report, null, 2);
186
- } else if (args.markdown) {
187
- output = formatMarkdown(report, args.period);
188
- } else if (args.by === 'day') {
189
- output = formatTrend(report, args.period);
319
+ if (args.verbose) {
320
+ const elapsed = Date.now() - t0;
321
+ console.error(
322
+ `agent-ledger: parsed ${turnCount} turns from ~/.claude/projects in ${elapsed}ms`,
323
+ );
324
+ }
325
+
326
+ let totalCost = aggBySub.finalize().total.cost.totalCost;
327
+
328
+ if (args.tree) {
329
+ const rawRoots = graph!.finalize(20);
330
+ const roots = args.anonymize ? anonymizeTrees(rawRoots) : rawRoots;
331
+ totalCost = roots.reduce((s, r) => s + r.totalCost, 0);
332
+ const out =
333
+ args.json
334
+ ? JSON.stringify({ roots, totalCost }, null, 2)
335
+ : args.markdown
336
+ ? formatTreeMarkdown(roots, totalCost)
337
+ : formatTree(roots, totalCost);
338
+ console.log(out);
339
+ } else if (args.summary) {
340
+ const bySubR = aggBySub.finalize();
341
+ const byModR = aggByMod!.finalize();
342
+ const byDayR = aggByDay!.finalize();
343
+ let byProjR = aggByProj!.finalize();
344
+ if (args.anonymize) byProjR = anonymizeReport(byProjR, 'project');
345
+ totalCost = bySubR.total.cost.totalCost;
346
+ if (args.json) {
347
+ console.log(
348
+ JSON.stringify(
349
+ { subagent: bySubR, model: byModR, day: byDayR, project: byProjR, totalCost },
350
+ null,
351
+ 2,
352
+ ),
353
+ );
354
+ } else {
355
+ console.log(formatSummary(bySubR, byModR, byDayR, byProjR, args.period));
356
+ }
190
357
  } else {
191
- output = formatTable(report, args.period, args.by);
358
+ let report = aggUser!.finalize();
359
+ if (args.anonymize) report = anonymizeReport(report, args.by);
360
+ // Keep the unmasked estimate for --budget even when --plan hides it.
361
+ totalCost = report.total.cost.totalCost;
362
+ report = planMask(report, args.plan);
363
+ const output =
364
+ args.json
365
+ ? JSON.stringify(report, null, 2)
366
+ : args.markdown
367
+ ? formatMarkdown(report, args.period)
368
+ : args.by === 'day'
369
+ ? formatTrend(report, args.period)
370
+ : formatTable(report, args.period, args.by);
371
+ console.log(output);
372
+ }
373
+
374
+ // Budget gate — non-zero exit code so this can be a CI/shell hook.
375
+ if (args.budget !== undefined && totalCost > args.budget) {
376
+ console.error(
377
+ `agent-ledger: BUDGET EXCEEDED — total $${totalCost.toFixed(2)} > budget $${args.budget.toFixed(2)}`,
378
+ );
379
+ process.exit(2);
192
380
  }
193
- console.log(output);
194
381
  }
195
382
 
196
383
  main().catch((err) => {