repospend 0.0.5 → 0.0.6

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
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to RepoSpend will be documented in this file.
4
4
 
5
+ ## 0.0.6
6
+
7
+ - Improve Codex session metadata recovery from rollout logs, including standalone Codex Desktop sessions and imported Claude model metadata.
8
+ - Add an optional source-scoped app display mode so shared surfaces can be shown as source-specific entries such as "Codex on VS Code".
9
+ - Improve sparse timeline charts and RTK unavailable-state guidance in the dashboard.
10
+ - Document why RepoSpend token totals can differ from `ccusage` while API-equivalent costs still match.
11
+ - Show a git-branch icon next to repos with a confirmed git root and a folder icon for path-inferred repos, giving a quick visual signal of data confidence.
12
+ - Fix Windows extended-length path prefix (\\?\) handling in git root detection, which caused repos on Windows to incorrectly appear as unverified.
13
+
14
+
5
15
  ## 0.0.5
6
16
 
7
17
  - Fix Codex token under-count on long sessions by summing per-turn `last_token_usage` instead of taking the max of `total_token_usage`, which Codex resets on each context compaction.
package/README.md CHANGED
@@ -156,6 +156,31 @@ Claude Code cost may show as unknown when local files do not include token count
156
156
  or a model name. RepoSpend shows those sessions with unknown tokens/cost rather
157
157
  than guessing.
158
158
 
159
+ ### Why RepoSpend's token totals can look lower than `ccusage`
160
+
161
+ If you compare RepoSpend to `ccusage` you may see matching costs but different
162
+ "total tokens", especially for Codex where cache reads dominate. This is
163
+ expected.
164
+
165
+ RepoSpend treats `cached_input_tokens` as a **subset of** `input_tokens`, which
166
+ is how the OpenAI and Anthropic APIs document the field. The "total tokens"
167
+ number reflects the tokens the model actually processed. `ccusage` adds cached
168
+ tokens as a separate line item in its "Total Tokens" column, which inflates the
169
+ total but does not change billing.
170
+
171
+ The dollar cost is the source of truth and should agree between the two tools
172
+ within rounding. If costs diverge, that is a real discrepancy worth
173
+ investigating; token-count divergence on its own usually is not.
174
+
175
+ ### Codex Desktop on Windows
176
+
177
+ RepoSpend captures Codex **CLI** usage on Windows from `~/.codex/`. The Codex
178
+ **Desktop app** (installed as the MSIX package
179
+ `OpenAI.Codex_<id>` under `%LOCALAPPDATA%\Packages\`) does not persist session
180
+ transcripts or per-turn token usage to disk. Only Electron caches and debug
181
+ logs are stored locally. Real session data lives server-side. Neither RepoSpend
182
+ nor `ccusage` can report on Codex Desktop usage from local files alone.
183
+
159
184
  ## What It Reads
160
185
 
161
186
  RepoSpend only reads local files. It does not edit Codex, Claude Code, or RTK
package/dist/cli.js CHANGED
@@ -44,10 +44,14 @@ function summarize(sessions) {
44
44
  };
45
45
  }
46
46
  function groupByRepo(sessions) {
47
- return groupBy(sessions, (session) => session.repoRoot, (session) => session.repoName);
47
+ const groups = groupBy(sessions, (session) => session.repoRoot, (session) => session.repoName);
48
+ return groups.map((group) => ({
49
+ ...group,
50
+ verified: sessions.some((s) => s.repoRoot === group.id && !s.warnings.includes("repo_unverified_no_git_root"))
51
+ }));
48
52
  }
49
53
  function groupByModel(sessions) {
50
- return groupBy(sessions, (session) => session.model ?? "unknown-model", (session) => session.model ?? "Unknown model");
54
+ return groupBy(sessions, (session) => session.model ?? "unknown-model", (session) => session.model ?? "Model not recorded");
51
55
  }
52
56
  function groupBySourceApp(sessions) {
53
57
  return groupBy(sessions, (session) => session.sourceApp, (session) => session.sourceApp);
@@ -344,7 +348,7 @@ function filterUsage(sessions, filters) {
344
348
  if (!matchesAny(session.sourceClient, filters.source)) return false;
345
349
  if (!matchesAny(session.sourceApp, filters.sourceApp)) return false;
346
350
  if (!matchesRepo(session, filters.repo)) return false;
347
- if (!matchesAny(session.model, filters.model)) return false;
351
+ if (!matchesModel(session.model, filters.model)) return false;
348
352
  const startedAt = timestamp(session.startedAt);
349
353
  const hasDateFilter = Boolean(filters.from || filters.to);
350
354
  if (hasDateFilter && startedAt === void 0) return false;
@@ -365,6 +369,11 @@ function matchesRepo(session, filter) {
365
369
  if (!values.length) return true;
366
370
  return values.includes(session.repoName) || values.includes(session.repoRoot);
367
371
  }
372
+ function matchesModel(value, filter) {
373
+ const values = filterValues(filter);
374
+ if (!values.length) return true;
375
+ return value !== void 0 ? values.includes(value) : values.includes("unknown-model");
376
+ }
368
377
  function filterValues(filter) {
369
378
  if (!filter) return [];
370
379
  return Array.isArray(filter) ? filter.filter(Boolean) : filter.split(",").map((value) => value.trim()).filter(Boolean);
@@ -849,8 +858,12 @@ function isDashboardVisibleSession(session) {
849
858
  // packages/core/src/repo.ts
850
859
  import fs3 from "node:fs";
851
860
  import path3 from "node:path";
861
+ function normalizeRoot(p) {
862
+ const stripped = p.startsWith("\\\\?\\") ? p.slice(4) : p;
863
+ return stripped.replace(/^([a-zA-Z]):/, (_, letter) => letter.toUpperCase() + ":");
864
+ }
852
865
  function findGitRoot(cwd, fileSystem = fs3) {
853
- let current = path3.resolve(cwd);
866
+ let current = normalizeRoot(path3.resolve(cwd));
854
867
  while (true) {
855
868
  const gitPath = path3.join(current, ".git");
856
869
  if (fileSystem.existsSync(gitPath)) {
@@ -897,8 +910,8 @@ function safeFindGitRoot(cwd, fileSystem) {
897
910
  }
898
911
  }
899
912
  function fallbackWorkspaceRoot(cwd) {
900
- const resolved = path3.resolve(cwd || process.cwd());
901
- const home = process.env.HOME ? path3.resolve(process.env.HOME) : void 0;
913
+ const resolved = normalizeRoot(path3.resolve(cwd || process.cwd()));
914
+ const home = process.env.HOME ? normalizeRoot(path3.resolve(process.env.HOME)) : void 0;
902
915
  if (home && (resolved === home || resolved.startsWith(`${home}${path3.sep}`))) {
903
916
  const relative = path3.relative(home, resolved).split(path3.sep).filter(Boolean);
904
917
  const first = relative[0];
@@ -1354,12 +1367,9 @@ function scanCodex(options) {
1354
1367
  if (stateExists) {
1355
1368
  const stateSessions = readThreads(statePath, sessionFiles, options, warnings);
1356
1369
  sessions.push(...stateSessions);
1357
- if (!stateSessions.length && sessionsExists) {
1358
- sessions.push(...readStandaloneSessions(sessionFiles, options));
1359
- }
1360
1370
  }
1361
- if (!stateExists && sessionsExists) {
1362
- sessions.push(...readStandaloneSessions(sessionFiles, options));
1371
+ if (sessionsExists) {
1372
+ sessions.push(...readStandaloneSessions(sessionFiles, options).filter((session) => !hasImportedSession(sessions, session)));
1363
1373
  }
1364
1374
  return {
1365
1375
  source: {
@@ -1402,8 +1412,10 @@ function readThreads(statePath, sessionFiles, options, warnings) {
1402
1412
  warnings.push("Codex threads table has no recognized columns.");
1403
1413
  return [];
1404
1414
  }
1415
+ const importedModels = readImportedModelMap(options.codexHome ?? path4.join(os2.homedir(), ".codex"), warnings);
1405
1416
  const rows = db.prepare(`SELECT ${selectedColumns.map((column) => `"${column}"`).join(", ")} FROM threads`).all();
1406
- return rows.map((row, index) => threadToUsage(row, index, sessionFiles, options));
1417
+ const sessions = rows.map((row, index) => threadToUsage(row, index, sessionFiles, options, importedModels));
1418
+ return attributeSubagentsToParentSurfaces(sessions, rows);
1407
1419
  } catch (error) {
1408
1420
  warnings.push(`Unable to read Codex SQLite state at ${statePath}: ${errorMessage(error)}`);
1409
1421
  return [];
@@ -1411,6 +1423,37 @@ function readThreads(statePath, sessionFiles, options, warnings) {
1411
1423
  db?.close();
1412
1424
  }
1413
1425
  }
1426
+ function attributeSubagentsToParentSurfaces(sessions, rows) {
1427
+ const sessionsById = new Map(sessions.map((session) => [session.id, session]));
1428
+ const parentIdsByChildId = /* @__PURE__ */ new Map();
1429
+ for (const row of rows) {
1430
+ const childId = stringValue2(row.id);
1431
+ const parentId = codexParentThreadId(stringValue2(row.source));
1432
+ if (childId && parentId) parentIdsByChildId.set(childId, parentId);
1433
+ }
1434
+ return sessions.map((session) => {
1435
+ if (session.sourceApp !== "Codex subagent") return session;
1436
+ const parent = sessionsById.get(parentIdsByChildId.get(session.id) ?? "");
1437
+ if (!parent || parent.sourceApp === "Codex subagent") return session;
1438
+ return {
1439
+ ...session,
1440
+ sourceApp: parent.sourceApp,
1441
+ sourceAppRaw: session.sourceAppRaw,
1442
+ surfaceReason: `subagent attributed to parent thread ${parent.id}: ${parent.surfaceReason}`
1443
+ };
1444
+ });
1445
+ }
1446
+ function codexParentThreadId(source) {
1447
+ if (!source?.trim().startsWith("{")) return void 0;
1448
+ try {
1449
+ const object = asRecord(JSON.parse(source));
1450
+ const subagent = asRecord(object?.subagent);
1451
+ const threadSpawn = asRecord(subagent?.thread_spawn);
1452
+ return stringValue2(threadSpawn?.parent_thread_id);
1453
+ } catch {
1454
+ return void 0;
1455
+ }
1456
+ }
1414
1457
  function openCodexStateDatabase(statePath, warnings) {
1415
1458
  try {
1416
1459
  return new Database(statePath, { readonly: true, fileMustExist: true });
@@ -1475,24 +1518,30 @@ function isNativeSqliteMismatch(error) {
1475
1518
  function exitLabel(status) {
1476
1519
  return status === null ? "" : ` with exit code ${status}`;
1477
1520
  }
1478
- function threadToUsage(row, index, sessionFiles, options) {
1521
+ function threadToUsage(row, index, sessionFiles, options, importedModels = /* @__PURE__ */ new Map()) {
1522
+ const id = stringValue2(row.id) ?? `codex-thread-${index}`;
1479
1523
  const sourcePath = stringValue2(row.rollout_path) ?? matchSessionPath(stringValue2(row.id), sessionFiles) ?? "";
1480
1524
  const sessionBreakdown = sourcePath ? readSessionBreakdown(sourcePath) : emptyBreakdown();
1481
1525
  const dbTokens = parseTokenTotal(row.tokens_used);
1482
- const cwd = stringValue2(row.cwd) ?? fallbackCwdFromPath(sourcePath);
1526
+ const cwd = stringValue2(row.cwd) ?? sessionBreakdown.cwd ?? fallbackCwdFromPath(sourcePath);
1483
1527
  const repo = resolveRepoInfo(cwd, options.config);
1528
+ const inferredModel = stringValue2(row.model) || sessionBreakdown.model ? void 0 : importedModels.get(id);
1529
+ const model = stringValue2(row.model) ?? sessionBreakdown.model ?? inferredModel?.model;
1530
+ const provider = sessionBreakdown.provider ?? inferredModel?.provider ?? stringValue2(row.model_provider);
1484
1531
  const inputTokens = sessionBreakdown.inputTokens;
1485
1532
  const cachedInputTokens = sessionBreakdown.cachedInputTokens;
1486
1533
  const outputTokens = sessionBreakdown.outputTokens;
1487
1534
  const reasoningTokens = sessionBreakdown.reasoningTokens;
1488
1535
  const totalTokens = sessionBreakdown.totalTokens || dbTokens || inputTokens + outputTokens + reasoningTokens;
1489
1536
  const hasPricedBreakdown = inputTokens + cachedInputTokens + outputTokens + reasoningTokens > 0;
1490
- const warnings = [...repo.warnings, ...sessionBreakdown.warnings];
1537
+ const warnings = [...repo.warnings, ...sessionBreakdown.warnings, ...inferredModel ? ["model_inferred_from_import_source"] : []];
1491
1538
  const threadSource = stringValue2(row.thread_source) ?? sessionBreakdown.threadSource;
1492
- const sourceAppRaw = stringValue2(row.source) ?? sessionBreakdown.source ?? sessionBreakdown.originator ?? threadSource;
1539
+ const sourceAppRaw = codexSourceRaw(stringValue2(row.source), sessionBreakdown.source, sessionBreakdown.originator, threadSource);
1493
1540
  const surface = detectSurface(sourceAppRaw, threadSource);
1541
+ const startedAt = dateValue2(row.created_at) ?? sessionBreakdown.startedAt;
1542
+ const endedAt = dateValue2(row.updated_at) ?? sessionBreakdown.endedAt;
1494
1543
  const usage = {
1495
- id: stringValue2(row.id) ?? `codex-thread-${index}`,
1544
+ id,
1496
1545
  sourceClient: "codex",
1497
1546
  sourceApp: codexSourceAppLabel(sourceAppRaw, threadSource),
1498
1547
  sourceAppRaw,
@@ -1502,11 +1551,11 @@ function threadToUsage(row, index, sessionFiles, options) {
1502
1551
  cwd,
1503
1552
  gitRemoteUrl: repo.gitRemoteUrl,
1504
1553
  gitBranch: repo.gitBranch,
1505
- title: stringValue2(row.title),
1506
- startedAt: dateValue2(row.created_at),
1507
- endedAt: dateValue2(row.updated_at),
1508
- model: stringValue2(row.model),
1509
- provider: stringValue2(row.model_provider),
1554
+ title: stringValue2(row.title) ?? sessionBreakdown.title,
1555
+ startedAt,
1556
+ endedAt,
1557
+ model,
1558
+ provider,
1510
1559
  inputTokens,
1511
1560
  cachedInputTokens,
1512
1561
  outputTokens,
@@ -1521,7 +1570,7 @@ function threadToUsage(row, index, sessionFiles, options) {
1521
1570
  rawTokenTotal: dbTokens || sessionBreakdown.totalTokens || void 0,
1522
1571
  warnings,
1523
1572
  codexHome: options.codexHome ?? path4.join(os2.homedir(), ".codex"),
1524
- durationMs: durationMs(dateValue2(row.created_at), dateValue2(row.updated_at)),
1573
+ durationMs: durationMs(startedAt, endedAt),
1525
1574
  rawEventCount: sessionBreakdown.rawEventCount,
1526
1575
  parseStatus: sessionBreakdown.parseStatus,
1527
1576
  parseErrors: sessionBreakdown.parseErrors,
@@ -1559,26 +1608,28 @@ function threadToUsage(row, index, sessionFiles, options) {
1559
1608
  function readStandaloneSessions(sessionFiles, options) {
1560
1609
  return [...sessionFiles.entries()].map(([id, sourcePath]) => {
1561
1610
  const breakdown = readSessionBreakdown(sourcePath);
1562
- const cwd = fallbackCwdFromPath(sourcePath);
1611
+ const cwd = breakdown.cwd ?? fallbackCwdFromPath(sourcePath);
1563
1612
  const repo = resolveRepoInfo(cwd, options.config);
1564
- const model = void 0;
1565
- const surface = detectSurface(breakdown.source ?? breakdown.originator, breakdown.threadSource);
1566
- return {
1567
- id,
1613
+ const model = breakdown.model;
1614
+ const provider = breakdown.provider ?? providerForModel(model ?? "");
1615
+ const sourceAppRaw = codexSourceRaw(void 0, breakdown.source, breakdown.originator, breakdown.threadSource);
1616
+ const surface = detectSurface(sourceAppRaw, breakdown.threadSource);
1617
+ const usage = {
1618
+ id: breakdown.id ?? id,
1568
1619
  sourceClient: "codex",
1569
- sourceApp: codexSourceAppLabel(breakdown.source ?? breakdown.originator, breakdown.threadSource),
1570
- sourceAppRaw: breakdown.source ?? breakdown.originator ?? breakdown.threadSource,
1620
+ sourceApp: codexSourceAppLabel(sourceAppRaw, breakdown.threadSource),
1621
+ sourceAppRaw,
1571
1622
  sourcePath,
1572
1623
  repoRoot: repo.repoRoot,
1573
1624
  repoName: repo.repoName,
1574
1625
  cwd,
1575
1626
  gitRemoteUrl: repo.gitRemoteUrl,
1576
1627
  gitBranch: repo.gitBranch,
1577
- title: void 0,
1578
- startedAt: void 0,
1579
- endedAt: void 0,
1628
+ title: breakdown.title,
1629
+ startedAt: breakdown.startedAt,
1630
+ endedAt: breakdown.endedAt,
1580
1631
  model,
1581
- provider: void 0,
1632
+ provider,
1582
1633
  inputTokens: breakdown.inputTokens,
1583
1634
  cachedInputTokens: breakdown.cachedInputTokens,
1584
1635
  outputTokens: breakdown.outputTokens,
@@ -1591,8 +1642,9 @@ function readStandaloneSessions(sessionFiles, options) {
1591
1642
  estimatedCostUsd: void 0,
1592
1643
  messageCount: breakdown.messageCount,
1593
1644
  rawTokenTotal: breakdown.totalTokens || void 0,
1594
- warnings: [...repo.warnings, ...breakdown.warnings, "unknown_pricing"],
1645
+ warnings: [...repo.warnings, ...breakdown.warnings],
1595
1646
  codexHome: options.codexHome ?? path4.join(os2.homedir(), ".codex"),
1647
+ durationMs: durationMs(breakdown.startedAt, breakdown.endedAt),
1596
1648
  rawEventCount: breakdown.rawEventCount,
1597
1649
  parseStatus: breakdown.parseStatus,
1598
1650
  parseErrors: breakdown.parseErrors,
@@ -1619,8 +1671,87 @@ function readStandaloneSessions(sessionFiles, options) {
1619
1671
  sessionOutcome: inferOutcome(breakdown),
1620
1672
  compaction: breakdown.compaction
1621
1673
  };
1674
+ const hasPricedBreakdown = breakdown.inputTokens + breakdown.cachedInputTokens + breakdown.outputTokens + breakdown.reasoningTokens > 0;
1675
+ const cost = hasPricedBreakdown ? calculateCostUsd(usage, options.pricing) : void 0;
1676
+ return {
1677
+ ...usage,
1678
+ estimatedCostUsd: cost,
1679
+ warnings: cost === void 0 ? [...usage.warnings, hasPricedBreakdown ? "unknown_pricing" : "missing_token_breakdown"] : usage.warnings
1680
+ };
1622
1681
  });
1623
1682
  }
1683
+ function hasImportedSession(sessions, candidate) {
1684
+ const candidatePath = comparableSourcePath(candidate.sourcePath);
1685
+ return sessions.some((session) => {
1686
+ if (session.id && session.id === candidate.id) return true;
1687
+ const sessionPath = comparableSourcePath(session.sourcePath);
1688
+ return Boolean(candidatePath && sessionPath && candidatePath === sessionPath);
1689
+ });
1690
+ }
1691
+ function readImportedModelMap(codexHome, warnings) {
1692
+ const importsPath = path4.join(codexHome, "external_agent_session_imports.json");
1693
+ const models = /* @__PURE__ */ new Map();
1694
+ if (!fs4.existsSync(importsPath)) return models;
1695
+ let parsed;
1696
+ try {
1697
+ parsed = JSON.parse(fs4.readFileSync(importsPath, "utf8"));
1698
+ } catch (error) {
1699
+ warnings.push(`Unable to read Codex external import index at ${importsPath}: ${errorMessage(error)}`);
1700
+ return models;
1701
+ }
1702
+ const records = Array.isArray(asRecord(parsed)?.records) ? asRecord(parsed)?.records : [];
1703
+ for (const record of records) {
1704
+ const item = asRecord(record);
1705
+ const importedThreadId = stringValue2(item?.imported_thread_id);
1706
+ const sourcePath = normalizeVerbatimPath(stringValue2(item?.source_path));
1707
+ if (!importedThreadId || !sourcePath || !fs4.existsSync(sourcePath)) continue;
1708
+ const model = readImportedSourceModel(sourcePath, warnings);
1709
+ if (model) models.set(importedThreadId, { model, provider: providerForModel(model) });
1710
+ }
1711
+ return models;
1712
+ }
1713
+ function readImportedSourceModel(sourcePath, warnings) {
1714
+ const counts = /* @__PURE__ */ new Map();
1715
+ let content;
1716
+ try {
1717
+ content = fs4.readFileSync(sourcePath, "utf8");
1718
+ } catch (error) {
1719
+ warnings.push(`Unable to read Codex external import source at ${sourcePath}: ${errorMessage(error)}`);
1720
+ return void 0;
1721
+ }
1722
+ for (const [index, line] of content.split(/\r?\n/).entries()) {
1723
+ if (!line.trim()) continue;
1724
+ try {
1725
+ const model = modelFromImportedRecord(JSON.parse(line));
1726
+ if (model) counts.set(model, (counts.get(model) ?? 0) + 1);
1727
+ } catch (error) {
1728
+ warnings.push(`Unable to parse Codex external import source ${sourcePath} line ${index + 1}: ${errorMessage(error)}`);
1729
+ }
1730
+ }
1731
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
1732
+ }
1733
+ function modelFromImportedRecord(record) {
1734
+ const object = asRecord(record);
1735
+ const type = stringValue2(object?.type) ?? stringValue2(asRecord(object?.payload)?.type);
1736
+ const message = asRecord(object?.message) ?? asRecord(asRecord(object?.payload)?.message);
1737
+ const model = stringValue2(message?.model) ?? stringValue2(object?.model);
1738
+ if (!model || model === "<synthetic>") return void 0;
1739
+ if (type && type !== "assistant") return void 0;
1740
+ return model;
1741
+ }
1742
+ function providerForModel(model) {
1743
+ const normalized = model.toLowerCase();
1744
+ if (!normalized) return void 0;
1745
+ if (normalized.startsWith("claude")) return "anthropic";
1746
+ if (normalized.startsWith("gpt") || normalized.startsWith("o")) return "openai";
1747
+ return void 0;
1748
+ }
1749
+ function normalizeVerbatimPath(value) {
1750
+ return value?.replace(/^\\\\\?\\/, "");
1751
+ }
1752
+ function comparableSourcePath(value) {
1753
+ return normalizeVerbatimPath(value)?.replace(/\\/g, "/");
1754
+ }
1624
1755
  function indexSessionFiles(sessionsPath, warnings) {
1625
1756
  const files = /* @__PURE__ */ new Map();
1626
1757
  try {
@@ -1657,6 +1788,7 @@ function readSessionBreakdown(filePath) {
1657
1788
  let responseMessageCount = 0;
1658
1789
  let lastUserMessageText;
1659
1790
  for (const record of records) {
1791
+ applySessionBounds(breakdown, record);
1660
1792
  applySessionMetadata(breakdown, record);
1661
1793
  applyPromptTimeline(breakdown, record);
1662
1794
  if (isCodexEventMessage(record)) eventMessageCount += 1;
@@ -1739,6 +1871,13 @@ function matchSessionPath(id, sessionFiles) {
1739
1871
  }
1740
1872
  function emptyBreakdown() {
1741
1873
  return {
1874
+ id: void 0,
1875
+ cwd: void 0,
1876
+ title: void 0,
1877
+ startedAt: void 0,
1878
+ endedAt: void 0,
1879
+ model: void 0,
1880
+ provider: void 0,
1742
1881
  inputTokens: 0,
1743
1882
  cachedInputTokens: 0,
1744
1883
  outputTokens: 0,
@@ -1783,10 +1922,26 @@ function applySessionMetadata(breakdown, record) {
1783
1922
  if (!record || typeof record !== "object") return;
1784
1923
  const object = record;
1785
1924
  const payload = firstObject3(object.payload) ?? object;
1925
+ const collaborationMode = firstObject3(payload.collaboration_mode);
1926
+ const collaborationSettings = firstObject3(collaborationMode?.settings);
1927
+ breakdown.id ??= stringValue2(payload.id);
1928
+ breakdown.cwd ??= stringValue2(payload.cwd);
1929
+ breakdown.title ??= stringValue2(payload.title);
1930
+ breakdown.model ??= stringValue2(payload.model) ?? stringValue2(collaborationSettings?.model);
1931
+ breakdown.provider ??= stringValue2(payload.model_provider);
1786
1932
  breakdown.source ??= stringValue2(payload.source);
1787
1933
  breakdown.originator ??= stringValue2(payload.originator);
1788
1934
  breakdown.threadSource ??= stringValue2(payload.thread_source);
1789
1935
  }
1936
+ function applySessionBounds(breakdown, record) {
1937
+ if (!record || typeof record !== "object") return;
1938
+ const object = record;
1939
+ const payload = firstObject3(object.payload);
1940
+ const timestamp3 = dateValue2(object.timestamp) ?? dateValue2(payload?.timestamp);
1941
+ const sessionStartedAt = object.type === "session_meta" ? dateValue2(payload?.timestamp) ?? timestamp3 : timestamp3;
1942
+ if (sessionStartedAt && (!breakdown.startedAt || sessionStartedAt < breakdown.startedAt)) breakdown.startedAt = sessionStartedAt;
1943
+ if (timestamp3 && (!breakdown.endedAt || timestamp3 > breakdown.endedAt)) breakdown.endedAt = timestamp3;
1944
+ }
1790
1945
  function isCodexEventMessage(record) {
1791
1946
  if (!record || typeof record !== "object") return false;
1792
1947
  const object = record;
@@ -1887,9 +2042,14 @@ function hasNearbyDuplicatePromptTimelineItem(items, item) {
1887
2042
  return Math.abs(existingTime - itemTime) <= 5e3;
1888
2043
  });
1889
2044
  }
2045
+ function codexSourceRaw(source, sessionSource, originator, threadSource) {
2046
+ if (originator && /codex\s+desktop/i.test(originator)) return originator;
2047
+ return source ?? sessionSource ?? originator ?? threadSource;
2048
+ }
1890
2049
  function detectSurface(source, threadSource) {
1891
2050
  const raw = `${source ?? ""} ${threadSource ?? ""}`.toLowerCase();
1892
2051
  if (threadSource === "subagent") return { surface: "codex_exec", confidence: "medium", reason: "thread_source is subagent" };
2052
+ if (raw.includes("codex desktop")) return { surface: "local_agent", confidence: "high", reason: "originator metadata identifies Codex Desktop" };
1893
2053
  if (raw.includes("vscode")) return { surface: "vscode_extension", confidence: "high", reason: "source metadata includes vscode" };
1894
2054
  if (raw.includes("cli") || raw.includes("terminal") || raw.includes("tui")) return { surface: "terminal_cli", confidence: "high", reason: "source metadata indicates CLI/terminal" };
1895
2055
  if (raw.includes("exec")) return { surface: "codex_exec", confidence: "medium", reason: "source metadata includes exec" };
@@ -1913,9 +2073,13 @@ function durationMs(startedAt, endedAt) {
1913
2073
  function stringValue2(value) {
1914
2074
  return typeof value === "string" && value.length > 0 ? value : void 0;
1915
2075
  }
2076
+ function asRecord(value) {
2077
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
2078
+ }
1916
2079
  function codexSourceAppLabel(source, threadSource) {
1917
2080
  if (threadSource === "subagent") return "Codex subagent";
1918
2081
  if (!source) return "Codex";
2082
+ if (/codex\s+desktop/i.test(source)) return "Codex Desktop";
1919
2083
  if (source === "vscode" || source === "codex_vscode" || source === "codex-vscode") return "VS Code";
1920
2084
  if (source === "cli" || source === "terminal" || source === "codex-tui") return "Terminal";
1921
2085
  if (source === "codex_app" || source === "codex-app") return "Codex app";
@@ -2067,13 +2231,14 @@ function claudeFileToUsage(file, index, options, seenUsageKeys) {
2067
2231
  const cwd = breakdown.cwd ?? fallbackCwd;
2068
2232
  const repo = resolveRepoInfo(cwd, options.config);
2069
2233
  const warnings = [...repo.warnings, ...breakdown.warnings];
2234
+ const entrypoint = breakdown.entrypoint ?? inferClaudeEntrypointFromPath(file.filePath);
2070
2235
  if (!breakdown.cwd) warnings.push("repo_inferred_from_claude_project_dir");
2071
2236
  const hasTokenBreakdown = breakdown.totalTokens > 0 && breakdown.usageCount > 0;
2072
2237
  const usage = {
2073
2238
  id,
2074
2239
  sourceClient: "claude",
2075
- sourceApp: claudeSourceAppLabel(breakdown.entrypoint),
2076
- sourceAppRaw: breakdown.entrypoint,
2240
+ sourceApp: claudeSourceAppLabel(entrypoint),
2241
+ sourceAppRaw: entrypoint,
2077
2242
  sourcePath: file.filePath,
2078
2243
  repoRoot: repo.repoRoot,
2079
2244
  repoName: repo.repoName,
@@ -2103,9 +2268,9 @@ function claudeFileToUsage(file, index, options, seenUsageKeys) {
2103
2268
  rawEventCount: breakdown.rawEventCount,
2104
2269
  parseStatus: breakdown.parseStatus,
2105
2270
  parseErrors: breakdown.parseErrors,
2106
- detectedSurface: claudeSurface(breakdown.entrypoint),
2271
+ detectedSurface: claudeSurface(entrypoint),
2107
2272
  surfaceConfidence: "medium",
2108
- surfaceReason: breakdown.entrypoint ? `Claude Code entrypoint: ${breakdown.entrypoint}` : "Claude Code transcript",
2273
+ surfaceReason: entrypoint ? `Claude Code entrypoint: ${entrypoint}` : "Claude Code transcript",
2109
2274
  userPromptCount: breakdown.userPromptCount,
2110
2275
  assistantMessageCount: breakdown.assistantMessageCount,
2111
2276
  toolCallCount: breakdown.toolCallCount,
@@ -2132,6 +2297,9 @@ function claudeFileToUsage(file, index, options, seenUsageKeys) {
2132
2297
  warnings: cost === void 0 ? [...usage.warnings, "unknown_pricing"] : usage.warnings
2133
2298
  };
2134
2299
  }
2300
+ function inferClaudeEntrypointFromPath(filePath) {
2301
+ return filePath.split(path5.sep).includes("local-agent-mode-sessions") ? "local-agent" : void 0;
2302
+ }
2135
2303
  function readClaudeBreakdown(filePath, seenUsageKeys) {
2136
2304
  const breakdown = emptyBreakdown2();
2137
2305
  try {
@@ -2308,13 +2476,13 @@ function inferOutcome2(breakdown) {
2308
2476
  function claudeSurface(entrypoint) {
2309
2477
  const value = entrypoint?.toLowerCase() ?? "";
2310
2478
  if (value.includes("vscode")) return "vscode_extension";
2311
- if (value.includes("local-agent")) return "local_agent";
2479
+ if (value.includes("local-agent") || value.includes("desktop")) return "local_agent";
2312
2480
  return "terminal_cli";
2313
2481
  }
2314
2482
  function claudeSourceAppLabel(entrypoint) {
2315
2483
  const value = entrypoint?.toLowerCase() ?? "";
2316
2484
  if (value.includes("vscode")) return "VS Code";
2317
- if (value.includes("local-agent")) return "Claude desktop local agent";
2485
+ if (value.includes("local-agent") || value.includes("desktop")) return "Claude Desktop App";
2318
2486
  if (value === "cli" || value.includes("terminal")) return "Terminal";
2319
2487
  return "Claude Code";
2320
2488
  }
@@ -2434,9 +2602,9 @@ function scanUsageSources(options) {
2434
2602
  import fs6 from "node:fs";
2435
2603
  var scanCacheTtlMs = 1e4;
2436
2604
  var scanCache;
2437
- function readDashboardData(filters = {}) {
2605
+ function readDashboardData(filters = {}, options = {}) {
2438
2606
  const raw = readRawUsageData();
2439
- return buildDashboardSnapshot({ ...raw, filters });
2607
+ return buildDashboardSnapshot({ ...raw, sessions: displaySessions(raw.sessions, options), filters });
2440
2608
  }
2441
2609
  function clearScanCache() {
2442
2610
  scanCache = void 0;
@@ -2473,6 +2641,11 @@ function parseFilters(query) {
2473
2641
  if (to) filters.to = to;
2474
2642
  return filters;
2475
2643
  }
2644
+ function parseDashboardOptions(query) {
2645
+ return {
2646
+ splitSourceApps: query.splitSourceApps === "true" || query.splitSourceApps === "1"
2647
+ };
2648
+ }
2476
2649
  function readPricingData() {
2477
2650
  const config = loadConfig();
2478
2651
  const pricingPath = resolvePricingPath(config);
@@ -2512,6 +2685,39 @@ function listQuery(value) {
2512
2685
  const parsed = values.flatMap((item) => typeof item === "string" ? item.split(",") : []).map((item) => item.trim()).filter(Boolean);
2513
2686
  return parsed.length ? parsed : void 0;
2514
2687
  }
2688
+ function displaySessions(sessions, options) {
2689
+ if (!options.splitSourceApps) return sessions;
2690
+ return sessions.map((session) => {
2691
+ const sourceApp = sourceScopedAppLabel(session);
2692
+ return sourceApp === session.sourceApp ? session : { ...session, sourceApp };
2693
+ });
2694
+ }
2695
+ function sourceScopedAppLabel(session) {
2696
+ const app = session.sourceApp || fallbackSurfaceLabel(session.detectedSurface);
2697
+ if (!app || app === "Unknown") return sourceClientLabel(session.sourceClient);
2698
+ if (!shouldScopeApp(app)) return app;
2699
+ return `${sourceClientLabel(session.sourceClient)} on ${app}`;
2700
+ }
2701
+ function shouldScopeApp(app) {
2702
+ const normalized = app.toLowerCase();
2703
+ return normalized.includes("vs code") || normalized.includes("vscode") || normalized.includes("terminal") || normalized.includes("cli");
2704
+ }
2705
+ function sourceClientLabel(source) {
2706
+ if (source === "codex") return "Codex";
2707
+ if (source === "claude") return "Claude Code";
2708
+ if (source === "gemini-cli") return "Gemini CLI";
2709
+ if (source === "opencode") return "OpenCode";
2710
+ if (source === "cursor") return "Cursor";
2711
+ return "Unknown";
2712
+ }
2713
+ function fallbackSurfaceLabel(surface) {
2714
+ if (surface === "terminal_cli") return "Terminal";
2715
+ if (surface === "vscode_extension") return "VS Code";
2716
+ if (surface === "local_agent") return "Desktop local agent";
2717
+ if (surface === "codex_exec") return "Codex";
2718
+ if (surface === "codex_app_cloud") return "Codex app";
2719
+ return "Unknown";
2720
+ }
2515
2721
  function runtimeCwd() {
2516
2722
  return process.env.INIT_CWD || process.cwd();
2517
2723
  }
@@ -2534,11 +2740,11 @@ var demoPricing = {
2534
2740
  function demoModeEnabled() {
2535
2741
  return process.env.REPOSPEND_DEMO_DATA === "lotr";
2536
2742
  }
2537
- function readDemoDashboardData(filters = {}) {
2743
+ function readDemoDashboardData(filters = {}, options = {}) {
2538
2744
  return buildDashboardSnapshot({
2539
2745
  sources: demoSources,
2540
2746
  sourceStats: demoSourceStats,
2541
- sessions: demoSessions,
2747
+ sessions: displaySessions(demoSessions, options),
2542
2748
  filters
2543
2749
  });
2544
2750
  }
@@ -2796,7 +3002,7 @@ var demoInputs = [
2796
3002
  repo: "rivendell-dashboard",
2797
3003
  title: "Refactor the council-of-elrond planner",
2798
3004
  sourceClient: "claude",
2799
- sourceApp: "Claude desktop local agent",
3005
+ sourceApp: "Claude Desktop App",
2800
3006
  sourceAppRaw: "claude-desktop-local-agent",
2801
3007
  model: "claude-sonnet-4-6",
2802
3008
  provider: "anthropic",
@@ -3106,7 +3312,7 @@ function detectedSurfaceForApp(sourceApp) {
3106
3312
  if (sourceApp === "VS Code") return "vscode_extension";
3107
3313
  if (sourceApp === "Terminal") return "terminal_cli";
3108
3314
  if (sourceApp === "Codex app") return "codex_app_cloud";
3109
- if (sourceApp === "Claude desktop local agent") return "local_agent";
3315
+ if (sourceApp === "Claude Desktop App") return "local_agent";
3110
3316
  return "terminal_cli";
3111
3317
  }
3112
3318
  function timestamp2(hour, minute) {
@@ -3326,9 +3532,11 @@ function createServer(options = {}) {
3326
3532
  const app = Fastify({ logger: false });
3327
3533
  app.get("/api/health", async () => ({ ok: true, app: "RepoSpend", version: readPackageVersion() }));
3328
3534
  app.get("/api/dashboard", async (request) => {
3329
- const filters = parseFilters(request.query);
3535
+ const query = request.query;
3536
+ const filters = parseFilters(query);
3537
+ const dashboardOptions = parseDashboardOptions(query);
3330
3538
  const demoMode = demoModeEnabled();
3331
- const data = demoMode ? readDemoDashboardData(filters) : readDashboardData(filters);
3539
+ const data = demoMode ? readDemoDashboardData(filters, dashboardOptions) : readDashboardData(filters, dashboardOptions);
3332
3540
  const rtkGain = demoMode ? readDemoRtkGain() : await readRtkGain();
3333
3541
  return {
3334
3542
  ...data,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repospend",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Local-first dashboard for tracking AI coding token usage by repo, source, session, model, and day.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Mehmet Mustafa Demir",