claude-agent-ledger 0.3.0 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to `claude-agent-ledger` will be documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] — 2026-04-22
9
+
10
+ ### Added
11
+ - `--by project` — group spend by Claude Code project directory.
12
+ Decodes `~/.claude/projects/-Users-xiangjie-clawbot` → `~/clawbot` so you can
13
+ see which project is burning budget.
14
+ - `--by session` — group by session ID. Surfaces the single most expensive
15
+ conversations (useful for hunting the one that went sideways).
16
+ - Summary dashboard now shows:
17
+ - `Projects` count
18
+ - `Top project` line
19
+ - `Project mix` block — top 3 projects with $ and %
20
+ - `decodeProjectId()` exported for reuse. Special-cases the `~/` home prefix,
21
+ falls back to slash-joined path for other roots.
22
+ - Row label column now auto-widens up to 40 chars for project paths.
23
+
24
+ ### Changed
25
+ - `formatTable` takes an optional `groupName` arg so the header column matches
26
+ the group (e.g. "project", "session").
27
+
8
28
  ## [0.3.0] — 2026-04-22
9
29
 
10
30
  ### Added
package/README.md CHANGED
@@ -29,6 +29,8 @@ npm install -g claude-agent-ledger
29
29
 
30
30
  agent-ledger week --summary # the dashboard (start here)
31
31
  agent-ledger week --by model # which Claude model burned the budget
32
+ agent-ledger week --by project # which project ate your tokens
33
+ agent-ledger week --by session # which single sessions went sideways
32
34
  agent-ledger week --by day # daily bar chart with peak/avg/variance
33
35
  agent-ledger month --md > report.md
34
36
  ```
@@ -41,7 +43,10 @@ Claude Code's built-in `/cost` shows the **current session** cost. That's it.
41
43
 
42
44
  `claude-agent-ledger` answers questions `/cost` can't:
43
45
 
44
- - Which of my **10+ subagents** burned the budget? (`--by model` / default)
46
+ - Which of my **10+ subagents** burned the budget? (default)
47
+ - Which **Claude model** burned the budget — Opus vs Sonnet vs Haiku? (`--by model`)
48
+ - Which **project** in `~/.claude/projects/` ate the tokens? (`--by project`)
49
+ - Which **single sessions** went sideways and burned $X? (`--by session`)
45
50
  - Which **day** of last week was the expensive one? (`--by day`)
46
51
  - What would my Claude Max usage have cost on **pay-as-you-go**? (the "shadow cost" framing)
47
52
  - Is my **cache reuse ratio** healthy? (auto-derived insight)
@@ -56,17 +61,24 @@ Run `agent-ledger week --summary` once and decide.
56
61
  ```
57
62
  agent-ledger week summary 2026-04-15 → 2026-04-22
58
63
 
59
- Shadow cost $13,356.27
60
- Sessions 217
64
+ Shadow cost $13,450.05
65
+ Sessions 219
66
+ Projects 9
61
67
  Plan reference $200/mo (Max)
62
- Multiplier 250× vs Max plan
68
+ Multiplier 252× vs Max plan
63
69
 
64
- Top subagent (main) $12,701.19 (95%)
65
- Top model claude-opus-4-7 $7,184.45 (54%)
66
- Peak day 2026-04-15 $3,679.80
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
67
74
 
68
- Cache 1h writes 119.45M tokens · $3583.42
69
- Cache reads 5.54B tokens · $8162.93
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
70
82
  Cache reuse 38× reads/writes
71
83
  Server tools none
72
84
  ```
@@ -166,7 +178,8 @@ If Anthropic's actual rate differs (or web_fetch has its own price), open an iss
166
178
 
167
179
  | Flag | What it does |
168
180
  |---|---|
169
- | `--by <subagent\|model\|day>` | Group rows. Default: `subagent`. `day` renders as ASCII bar chart. |
181
+ | `--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. |
170
183
  | `--md` | Markdown table output (good for committing or sharing) |
171
184
  | `--json` | Raw JSON output (pipe into jq) |
172
185
  | `--plan pro\|max` | Hide dollar columns; show token utilization only |
@@ -37,8 +37,13 @@ function usage(): never {
37
37
  Flags:
38
38
  --summary One-screen dashboard: total + top subagent + top
39
39
  model + peak day + cache reuse + leverage vs $200/mo plan
40
- --by <subagent|model|day> Group rows by subagent (default), model, or day
41
- "day" renders as a daily ASCII bar chart
40
+ --by <subagent|model|day|project|session>
41
+ Group rows by:
42
+ subagent (default) — agent attribution
43
+ model — opus vs sonnet vs haiku
44
+ day — daily ASCII bar chart
45
+ project — by Claude Code project (decoded path)
46
+ session — by session id (top spenders)
42
47
  --md Markdown table output (good for sharing / commits)
43
48
  --json Raw JSON output (for piping into jq)
44
49
  --plan pro|max Suppress dollar columns (token utilization only)
@@ -75,7 +80,13 @@ function parseArgs(argv: string[]): Args {
75
80
  }
76
81
  } else if (flag === '--by') {
77
82
  const next = rest[i + 1];
78
- if (next === 'subagent' || next === 'model' || next === 'day') {
83
+ if (
84
+ next === 'subagent' ||
85
+ next === 'model' ||
86
+ next === 'day' ||
87
+ next === 'project' ||
88
+ next === 'session'
89
+ ) {
79
90
  by = next;
80
91
  i++;
81
92
  } else {
@@ -128,12 +139,13 @@ async function main(): Promise<void> {
128
139
  const turns = await collectTurns({ from, to });
129
140
 
130
141
  if (args.summary) {
131
- const [bySub, byMod, byDay] = await Promise.all([
142
+ const [bySub, byMod, byDay, byProj] = await Promise.all([
132
143
  aggregate(iterTurns(turns), from, to, 'subagent'),
133
144
  aggregate(iterTurns(turns), from, to, 'model'),
134
145
  aggregate(iterTurns(turns), from, to, 'day'),
146
+ aggregate(iterTurns(turns), from, to, 'project'),
135
147
  ]);
136
- console.log(formatSummary(bySub, byMod, byDay, args.period));
148
+ console.log(formatSummary(bySub, byMod, byDay, byProj, args.period));
137
149
  return;
138
150
  }
139
151
 
@@ -148,7 +160,7 @@ async function main(): Promise<void> {
148
160
  } else if (args.by === 'day') {
149
161
  output = formatTrend(report, args.period);
150
162
  } else {
151
- output = formatTable(report, args.period);
163
+ output = formatTable(report, args.period, args.by);
152
164
  }
153
165
  console.log(output);
154
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-agent-ledger",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "See exactly where your Claude Code budget goes — per subagent, per task.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/aggregator.ts CHANGED
@@ -3,8 +3,26 @@ import type { AggregatedRow, LedgerReport, SessionTurn } from './types.ts';
3
3
 
4
4
  const MAIN_AGENT_LABEL = '(main)';
5
5
  const UNKNOWN_MODEL_LABEL = '(no-model)';
6
-
7
- export type GroupKey = 'subagent' | 'model' | 'day';
6
+ const UNKNOWN_PROJECT_LABEL = '(no-project)';
7
+
8
+ export type GroupKey = 'subagent' | 'model' | 'day' | 'project' | 'session';
9
+
10
+ // Decode Claude Code's project directory name to something readable.
11
+ // Encoding rule: absolute path with `/` replaced by `-` (leading `-` for root).
12
+ // "-Users-xiangjie-clawbot" → "~/clawbot"
13
+ // "-Users-xiangjie-newlive-agent-ledger" → "~/newlive/agent-ledger" (best-effort; ambiguous on dirs containing '-')
14
+ // We special-case the "$HOME" prefix (`-Users-<user>-`) to render `~` and skip the rest of the
15
+ // "-" → "/" conversion, since that's the only place we can be sure of segment boundaries.
16
+ export function decodeProjectId(encoded: string): string {
17
+ if (!encoded.startsWith('-')) return encoded;
18
+ const parts = encoded.slice(1).split('-'); // drop leading '-'
19
+ // Match common $HOME pattern: ['Users', '<user>', ...rest]
20
+ if (parts.length >= 3 && parts[0] === 'Users') {
21
+ return '~/' + parts.slice(2).join('-');
22
+ }
23
+ // Fallback: just convert all - to /
24
+ return '/' + parts.join('/');
25
+ }
8
26
 
9
27
  function emptyRow(label: string): AggregatedRow {
10
28
  return {
@@ -44,6 +62,8 @@ function splitCache(turn: SessionTurn): { fiveMin: number; oneHour: number } {
44
62
  function keyOf(turn: SessionTurn, group: GroupKey): string {
45
63
  if (group === 'model') return turn.model ?? UNKNOWN_MODEL_LABEL;
46
64
  if (group === 'day') return turn.timestamp.slice(0, 10);
65
+ if (group === 'project') return decodeProjectId(turn.projectId ?? UNKNOWN_PROJECT_LABEL);
66
+ if (group === 'session') return turn.sessionId.slice(0, 8);
47
67
  return turn.subagentType ?? MAIN_AGENT_LABEL;
48
68
  }
49
69
 
package/src/format.ts CHANGED
@@ -1,9 +1,17 @@
1
1
  import type { AggregatedRow, LedgerReport } from './types.ts';
2
2
 
3
3
  const PAD_LABEL = 22;
4
+ const PAD_LABEL_WIDE = 40; // for project paths
4
5
  const BAR_WIDTH = 40;
5
6
  const PLAN_MAX_PRICE = 200; // monthly USD reference for shadow-cost leverage
6
7
 
8
+ // Detect when we should give labels more room (project paths can be long).
9
+ function widestLabel(rows: { subagent: string }[]): number {
10
+ let w = 0;
11
+ for (const r of rows) if (r.subagent.length > w) w = r.subagent.length;
12
+ return w;
13
+ }
14
+
7
15
  // Color helpers — TTY-aware, NO_COLOR-respecting (https://no-color.org/).
8
16
  const USE_COLOR = (() => {
9
17
  if (process.env.NO_COLOR) return false;
@@ -52,8 +60,8 @@ function pct(part: number, whole: number): string {
52
60
  return `${((part / whole) * 100).toFixed(0)}%`;
53
61
  }
54
62
 
55
- function formatRow(row: AggregatedRow, isTotal = false): string {
56
- const labelText = row.subagent.padEnd(PAD_LABEL);
63
+ function formatRow(row: AggregatedRow, padWidth: number, isTotal = false): string {
64
+ const labelText = row.subagent.padEnd(padWidth);
57
65
  const label = isTotal
58
66
  ? `${c.bold}${labelText}${c.reset}`
59
67
  : `${c.cyan}${labelText}${c.reset}`;
@@ -126,19 +134,20 @@ function deriveInsights(report: LedgerReport): string[] {
126
134
  return lines;
127
135
  }
128
136
 
129
- export function formatTable(report: LedgerReport, label: string): string {
137
+ export function formatTable(report: LedgerReport, label: string, groupName: string = 'subagent'): string {
138
+ const padWidth = Math.max(PAD_LABEL, Math.min(PAD_LABEL_WIDE, widestLabel(report.rows) + 2));
130
139
  const summaryLine =
131
140
  `${c.gray}${report.period.from.toISOString().slice(0, 10)} → ${report.period.to.toISOString().slice(0, 10)}${c.reset}` +
132
141
  ` ${c.dim}·${c.reset} ` +
133
142
  `${c.bold}${fmtUsdComma(report.total.cost.totalCost)}${c.reset} ${c.dim}shadow${c.reset}` +
134
143
  ` ${c.dim}·${c.reset} ${report.total.sessionCount} sessions` +
135
- ` ${c.dim}·${c.reset} ${report.rows.length} ${label.includes('day') ? 'days' : label.includes('model') ? 'models' : 'agents'}`;
144
+ ` ${c.dim}·${c.reset} ${report.rows.length} ${groupName}s`;
136
145
 
137
146
  const header = `${c.bold}agent-ledger ${label}${c.reset} ${summaryLine}`;
138
147
 
139
148
  const colsRaw =
140
149
  ' ' +
141
- 'subagent'.padEnd(PAD_LABEL) +
150
+ groupName.padEnd(padWidth) +
142
151
  'sess'.padStart(4) +
143
152
  ' ' +
144
153
  'tokens(in/out)'.padStart(18) +
@@ -148,7 +157,7 @@ export function formatTable(report: LedgerReport, label: string): string {
148
157
  'cost'.padStart(9);
149
158
  const cols = `${c.gray}${colsRaw}${c.reset}`;
150
159
 
151
- const sepRaw = ' ' + '─'.repeat(PAD_LABEL + 4 + 2 + 18 + 2 + 32 + 2 + 9);
160
+ const sepRaw = ' ' + '─'.repeat(padWidth + 4 + 2 + 18 + 2 + 32 + 2 + 9);
152
161
  const sep = `${c.gray}${sepRaw}${c.reset}`;
153
162
 
154
163
  const lines = [
@@ -156,9 +165,9 @@ export function formatTable(report: LedgerReport, label: string): string {
156
165
  '',
157
166
  cols,
158
167
  sep,
159
- ...report.rows.map((r) => formatRow(r)),
168
+ ...report.rows.map((r) => formatRow(r, padWidth)),
160
169
  sep,
161
- formatRow({ ...report.total, subagent: 'total' }, true),
170
+ formatRow({ ...report.total, subagent: 'total' }, padWidth, true),
162
171
  ];
163
172
 
164
173
  // Server tool footer (only when present, since most rows have 0)
@@ -251,11 +260,12 @@ function deriveDailyInsights(report: LedgerReport): string[] {
251
260
  return lines;
252
261
  }
253
262
 
254
- // Single-screen dashboard. Aggregates the same period across 3 dimensions.
263
+ // Single-screen dashboard. Aggregates the same period across 4 dimensions.
255
264
  export function formatSummary(
256
265
  bySubagent: LedgerReport,
257
266
  byModel: LedgerReport,
258
267
  byDay: LedgerReport,
268
+ byProject: LedgerReport,
259
269
  label: string,
260
270
  ): string {
261
271
  const total = bySubagent.total.cost.totalCost;
@@ -267,6 +277,9 @@ export function formatSummary(
267
277
  const tSub = top(bySubagent.rows);
268
278
  const tMod = top(byModel.rows);
269
279
  const tDay = [...byDay.rows].sort((a, b) => b.cost.totalCost - a.cost.totalCost)[0];
280
+ const tProj = top(byProject.rows);
281
+ // Top 3 projects for quick distribution view
282
+ const topProjects = byProject.rows.slice(0, 3);
270
283
 
271
284
  const k = (label: string, value: string) =>
272
285
  ` ${c.gray}${label.padEnd(20)}${c.reset} ${value}`;
@@ -281,28 +294,43 @@ export function formatSummary(
281
294
  '',
282
295
  k('Shadow cost', dollar(total)),
283
296
  k('Sessions', `${bySubagent.total.sessionCount}`),
297
+ k('Projects', `${byProject.rows.length}`),
284
298
  k('Plan reference', `$${PLAN_MAX_PRICE}/mo (Max)`),
285
299
  k('Multiplier', `${c.bold}${leverage.toFixed(0)}×${c.reset} ${c.dim}vs Max plan${c.reset}`),
286
300
  '',
287
301
  k('Top subagent', tSub ? `${c.cyan}${tSub.subagent}${c.reset} ${dollar(tSub.cost.totalCost)} ${c.dim}(${pct(tSub.cost.totalCost, total)})${c.reset}` : '—'),
288
302
  k('Top model', tMod ? `${c.cyan}${tMod.subagent}${c.reset} ${dollar(tMod.cost.totalCost)} ${c.dim}(${pct(tMod.cost.totalCost, total)})${c.reset}` : '—'),
303
+ k('Top project', tProj ? `${c.cyan}${tProj.subagent}${c.reset} ${dollar(tProj.cost.totalCost)} ${c.dim}(${pct(tProj.cost.totalCost, total)})${c.reset}` : '—'),
289
304
  k('Peak day', tDay ? `${c.cyan}${tDay.subagent}${c.reset} ${dollar(tDay.cost.totalCost)}` : '—'),
290
- '',
291
- k('Cache 1h writes', `${fmtTokens(bySubagent.total.cacheCreation1hTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheCreation1hCost)}`),
292
- k('Cache reads', `${fmtTokens(bySubagent.total.cacheReadTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheReadCost)}`),
293
- k(
294
- 'Cache reuse',
295
- writeTokens > 0
296
- ? `${c.bold}${reuseRatio.toFixed(0)}×${c.reset} ${c.dim}reads/writes${c.reset}`
297
- : 'n/a',
298
- ),
299
- k(
300
- 'Server tools',
301
- bySubagent.total.webSearchRequests + bySubagent.total.webFetchRequests > 0
302
- ? `web_search ×${bySubagent.total.webSearchRequests}, web_fetch ×${bySubagent.total.webFetchRequests} (${fmtUsd(bySubagent.total.cost.serverToolUseCost)})`
303
- : `${c.dim}none${c.reset}`,
304
- ),
305
305
  ];
306
+
307
+ // If multiple projects, list top 3 as a small breakdown
308
+ if (topProjects.length > 1) {
309
+ lines.push('');
310
+ lines.push(` ${c.gray}Project mix${c.reset}`);
311
+ for (const p of topProjects) {
312
+ lines.push(
313
+ ` ${c.cyan}${p.subagent.padEnd(36)}${c.reset} ${dollar(p.cost.totalCost)} ${c.dim}(${pct(p.cost.totalCost, total)})${c.reset}`,
314
+ );
315
+ }
316
+ }
317
+
318
+ lines.push('');
319
+ lines.push(k('Cache 1h writes', `${fmtTokens(bySubagent.total.cacheCreation1hTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheCreation1hCost)}`));
320
+ lines.push(k('Cache reads', `${fmtTokens(bySubagent.total.cacheReadTokens)} tokens · ${fmtUsd(bySubagent.total.cost.cacheReadCost)}`));
321
+ lines.push(k(
322
+ 'Cache reuse',
323
+ writeTokens > 0
324
+ ? `${c.bold}${reuseRatio.toFixed(0)}×${c.reset} ${c.dim}reads/writes${c.reset}`
325
+ : 'n/a',
326
+ ));
327
+ lines.push(k(
328
+ 'Server tools',
329
+ bySubagent.total.webSearchRequests + bySubagent.total.webFetchRequests > 0
330
+ ? `web_search ×${bySubagent.total.webSearchRequests}, web_fetch ×${bySubagent.total.webFetchRequests} (${fmtUsd(bySubagent.total.cost.serverToolUseCost)})`
331
+ : `${c.dim}none${c.reset}`,
332
+ ));
333
+
306
334
  return lines.join('\n');
307
335
  }
308
336
 
package/src/parser.ts CHANGED
@@ -13,6 +13,7 @@ export interface ParseOptions {
13
13
 
14
14
  interface LogFile {
15
15
  path: string;
16
+ projectId: string;
16
17
  subagentType: string | undefined;
17
18
  }
18
19
 
@@ -34,11 +35,11 @@ export async function* findSessionLogs(
34
35
  return;
35
36
  }
36
37
  for (const project of projects) {
37
- yield* walkForJsonl(join(projectsDir, project));
38
+ yield* walkForJsonl(join(projectsDir, project), project);
38
39
  }
39
40
  }
40
41
 
41
- async function* walkForJsonl(dir: string): AsyncGenerator<LogFile> {
42
+ async function* walkForJsonl(dir: string, projectId: string): AsyncGenerator<LogFile> {
42
43
  let entries: string[] = [];
43
44
  try {
44
45
  entries = await readdir(dir);
@@ -55,11 +56,11 @@ async function* walkForJsonl(dir: string): AsyncGenerator<LogFile> {
55
56
  continue;
56
57
  }
57
58
  if (isDir) {
58
- yield* walkForJsonl(full);
59
+ yield* walkForJsonl(full, projectId);
59
60
  continue;
60
61
  }
61
62
  if (name.endsWith('.jsonl')) {
62
- yield { path: full, subagentType: await resolveSubagentType(full) };
63
+ yield { path: full, projectId, subagentType: await resolveSubagentType(full) };
63
64
  }
64
65
  }
65
66
  }
@@ -81,6 +82,7 @@ async function resolveSubagentType(jsonlPath: string): Promise<string | undefine
81
82
  export async function* parseFile(
82
83
  path: string,
83
84
  subagentType?: string,
85
+ projectId?: string,
84
86
  ): AsyncGenerator<SessionTurn> {
85
87
  const file = Bun.file(path);
86
88
  const text = await file.text();
@@ -94,7 +96,7 @@ export async function* parseFile(
94
96
  } catch {
95
97
  continue;
96
98
  }
97
- yield extractTurn(obj, sessionId, subagentType);
99
+ yield extractTurn(obj, sessionId, subagentType, projectId);
98
100
  }
99
101
  }
100
102
 
@@ -102,6 +104,7 @@ function extractTurn(
102
104
  obj: Record<string, unknown>,
103
105
  sessionId: string,
104
106
  subagentType: string | undefined,
107
+ projectId: string | undefined,
105
108
  ): SessionTurn {
106
109
  const msg = (obj.message ?? {}) as Record<string, unknown>;
107
110
  const usage = (msg.usage ?? undefined) as TokenUsage | undefined;
@@ -109,11 +112,13 @@ function extractTurn(
109
112
  // If the raw turn carries its own subagentType (our fixture format), prefer it;
110
113
  // otherwise fall back to the directory-derived one.
111
114
  const perTurnSubagent = ((obj.subagentType as string) ?? undefined) || undefined;
115
+ const perTurnProject = ((obj.projectId as string) ?? undefined) || undefined;
112
116
 
113
117
  return {
114
118
  type: ((obj.type as string) ?? 'assistant') as SessionTurn['type'],
115
119
  timestamp: (obj.timestamp as string) ?? new Date().toISOString(),
116
120
  sessionId,
121
+ projectId: perTurnProject ?? projectId,
117
122
  subagentType: perTurnSubagent ?? subagentType,
118
123
  model,
119
124
  usage,
@@ -125,7 +130,7 @@ function extractTurn(
125
130
  export async function* parseAll(opts: ParseOptions = {}): AsyncGenerator<SessionTurn> {
126
131
  const dir = opts.projectsDir ?? CLAUDE_PROJECTS_DIR;
127
132
  for await (const file of findSessionLogs(dir)) {
128
- for await (const turn of parseFile(file.path, file.subagentType)) {
133
+ for await (const turn of parseFile(file.path, file.subagentType, file.projectId)) {
129
134
  if (opts.from && new Date(turn.timestamp) < opts.from) continue;
130
135
  if (opts.to && new Date(turn.timestamp) > opts.to) continue;
131
136
  yield turn;
package/src/types.ts CHANGED
@@ -32,6 +32,7 @@ export interface SessionTurn {
32
32
  type: TurnType;
33
33
  timestamp: string;
34
34
  sessionId: string;
35
+ projectId?: string | undefined;
35
36
  subagentType?: string | undefined;
36
37
  model?: ModelId | undefined;
37
38
  usage?: TokenUsage | undefined;