claude-agent-ledger 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ 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.1] — 2026-04-22
9
+
10
+ ### Added
11
+ - `--anonymize` flag — replaces project paths with `~/repo-A`, `~/repo-B`,
12
+ ... and session ids with `sess-A`, `sess-B`, ... ordered by spend descending.
13
+ Use this when you want to share your output publicly (HN / Bluesky /
14
+ presentation) without leaking employer names or internal repo names.
15
+ Subagent / model / day labels stay untouched (those are public info).
16
+
17
+ ## [0.4.0] — 2026-04-22
18
+
19
+ ### Added
20
+ - `--by project` — group spend by Claude Code project directory.
21
+ Decodes `~/.claude/projects/-Users-xiangjie-clawbot` → `~/clawbot` so you can
22
+ see which project is burning budget.
23
+ - `--by session` — group by session ID. Surfaces the single most expensive
24
+ conversations (useful for hunting the one that went sideways).
25
+ - Summary dashboard now shows:
26
+ - `Projects` count
27
+ - `Top project` line
28
+ - `Project mix` block — top 3 projects with $ and %
29
+ - `decodeProjectId()` exported for reuse. Special-cases the `~/` home prefix,
30
+ falls back to slash-joined path for other roots.
31
+ - Row label column now auto-widens up to 40 chars for project paths.
32
+
33
+ ### Changed
34
+ - `formatTable` takes an optional `groupName` arg so the header column matches
35
+ the group (e.g. "project", "session").
36
+
8
37
  ## [0.3.0] — 2026-04-22
9
38
 
10
39
  ### 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,9 @@ 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. |
183
+ | `--anonymize` | Replace project paths and session ids with `~/repo-A`, `sess-A`, ... so you can share screenshots without doxing your employer / private repos. |
170
184
  | `--md` | Markdown table output (good for committing or sharing) |
171
185
  | `--json` | Raw JSON output (pipe into jq) |
172
186
  | `--plan pro\|max` | Hide dollar columns; show token utilization only |
@@ -14,6 +14,7 @@ interface Args {
14
14
  plan: Plan;
15
15
  by: GroupKey;
16
16
  summary: boolean;
17
+ anonymize: boolean;
17
18
  }
18
19
 
19
20
  function periodRange(period: Period): { from: Date; to: Date } {
@@ -37,11 +38,19 @@ function usage(): never {
37
38
  Flags:
38
39
  --summary One-screen dashboard: total + top subagent + top
39
40
  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
41
+ --by <subagent|model|day|project|session>
42
+ Group rows by:
43
+ subagent (default) — agent attribution
44
+ model — opus vs sonnet vs haiku
45
+ day — daily ASCII bar chart
46
+ project — by Claude Code project (decoded path)
47
+ session — by session id (top spenders)
42
48
  --md Markdown table output (good for sharing / commits)
43
49
  --json Raw JSON output (for piping into jq)
44
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.
45
54
 
46
55
  Environment:
47
56
  NO_COLOR=1 Disable ANSI colors
@@ -75,7 +84,13 @@ function parseArgs(argv: string[]): Args {
75
84
  }
76
85
  } else if (flag === '--by') {
77
86
  const next = rest[i + 1];
78
- if (next === 'subagent' || next === 'model' || next === 'day') {
87
+ if (
88
+ next === 'subagent' ||
89
+ next === 'model' ||
90
+ next === 'day' ||
91
+ next === 'project' ||
92
+ next === 'session'
93
+ ) {
79
94
  by = next;
80
95
  i++;
81
96
  } else {
@@ -90,9 +105,31 @@ function parseArgs(argv: string[]): Args {
90
105
  plan,
91
106
  by,
92
107
  summary: rest.includes('--summary'),
108
+ anonymize: rest.includes('--anonymize'),
93
109
  };
94
110
  }
95
111
 
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
+ function anonymizeReport(report: LedgerReport, group: GroupKey): LedgerReport {
117
+ if (group !== 'project' && group !== 'session') return report;
118
+ const prefix = group === 'project' ? '~/repo-' : 'sess-';
119
+ const labelFor = (i: number): string => {
120
+ // Use A..Z then AA..ZZ for many entries
121
+ if (i < 26) return prefix + String.fromCharCode(65 + i);
122
+ return prefix + String.fromCharCode(65 + Math.floor(i / 26) - 1) + String.fromCharCode(65 + (i % 26));
123
+ };
124
+ const newRows = report.rows.map((r, i) => ({ ...r, subagent: labelFor(i) }));
125
+ return { ...report, rows: newRows };
126
+ }
127
+
128
+ // For --summary we may need to anonymize multiple sub-reports.
129
+ function anonymizeForSummary(byProject: LedgerReport): LedgerReport {
130
+ return anonymizeReport(byProject, 'project');
131
+ }
132
+
96
133
  function planMask(report: LedgerReport, plan: Plan): LedgerReport {
97
134
  if (plan === 'payg') return report;
98
135
  const zero = () => ({
@@ -128,16 +165,19 @@ async function main(): Promise<void> {
128
165
  const turns = await collectTurns({ from, to });
129
166
 
130
167
  if (args.summary) {
131
- const [bySub, byMod, byDay] = await Promise.all([
168
+ const [bySub, byMod, byDay, byProjRaw] = await Promise.all([
132
169
  aggregate(iterTurns(turns), from, to, 'subagent'),
133
170
  aggregate(iterTurns(turns), from, to, 'model'),
134
171
  aggregate(iterTurns(turns), from, to, 'day'),
172
+ aggregate(iterTurns(turns), from, to, 'project'),
135
173
  ]);
136
- console.log(formatSummary(bySub, byMod, byDay, args.period));
174
+ const byProj = args.anonymize ? anonymizeForSummary(byProjRaw) : byProjRaw;
175
+ console.log(formatSummary(bySub, byMod, byDay, byProj, args.period));
137
176
  return;
138
177
  }
139
178
 
140
179
  let report = await aggregate(iterTurns(turns), from, to, args.by);
180
+ if (args.anonymize) report = anonymizeReport(report, args.by);
141
181
  report = planMask(report, args.plan);
142
182
 
143
183
  let output: string;
@@ -148,7 +188,7 @@ async function main(): Promise<void> {
148
188
  } else if (args.by === 'day') {
149
189
  output = formatTrend(report, args.period);
150
190
  } else {
151
- output = formatTable(report, args.period);
191
+ output = formatTable(report, args.period, args.by);
152
192
  }
153
193
  console.log(output);
154
194
  }
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.1",
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;