agent-orchestrator-kit 0.12.0 → 0.14.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.
@@ -5,7 +5,8 @@ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSyn
5
5
  import { join, dirname, basename, resolve } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
8
- import { collectSpend, enrichMetricsCursorEstimates } from './spend-collect.js';
8
+ import { collectSpend } from './spend-collect.js';
9
+ import { describeClaudeCostEstimate } from './claude-cost-estimate.js';
9
10
  import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
10
11
  import {
11
12
  earlierTimestamp,
@@ -968,8 +969,14 @@ function firstLineCommand(value) {
968
969
  function firstSpawnName(value) {
969
970
  const tick = String(value || '').match(/`([a-z0-9-]+)`/i);
970
971
  if (tick) return tick[1];
971
- const word = String(value || '').match(/\b([a-z][a-z0-9-]{2,})\b/i);
972
- return word ? word[1] : '';
972
+ const role = canonicalRole(value);
973
+ return {
974
+ Explorer: 'explorer',
975
+ Architect: 'spec-architect',
976
+ 'Spec Reviewer': 'spec-reviewer',
977
+ Implementer: '',
978
+ Archiver: '',
979
+ }[role] || '';
973
980
  }
974
981
 
975
982
  function sectionOr(sections, title, fallback = '') {
@@ -1611,7 +1618,7 @@ function readHandoffFields(projectDir, changeName) {
1611
1618
  return { filePath, fields: fieldsFromSections(changeName, sections) };
1612
1619
  }
1613
1620
 
1614
- const METRICS_VERSION = 1;
1621
+ const METRICS_VERSION = 2;
1615
1622
  const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
1616
1623
  const LEFTOVER_GRACE_MS = 120000;
1617
1624
 
@@ -1675,6 +1682,92 @@ function defaultMetrics(changeName, nowIso) {
1675
1682
  };
1676
1683
  }
1677
1684
 
1685
+ const VALID_COST_SOURCES = new Set(['api-estimate', 'api-estimate-fallback', 'amp-usage']);
1686
+
1687
+ function compactModelRows(sources) {
1688
+ const byModel = new Map();
1689
+ for (const source of sources || []) {
1690
+ if (!source) continue;
1691
+ const platform = VALID_PLATFORMS.has(source.platform) ? source.platform : null;
1692
+ const model = source.model == null || source.model === '' ? null : String(source.model);
1693
+ const key = `${platform || ''}::${model || ''}`;
1694
+ const row = byModel.get(key) || {
1695
+ model,
1696
+ platform,
1697
+ inputTokens: null,
1698
+ outputTokens: null,
1699
+ totalTokens: null,
1700
+ costUsd: null,
1701
+ costUsdEstimated: null,
1702
+ };
1703
+ for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
1704
+ row[field] = addNullable(row[field], numOrNull(source[field]));
1705
+ }
1706
+ row.costUsdEstimated = roundUsd4(addNullable(row.costUsdEstimated, numOrNull(source.costUsdEstimated)));
1707
+ if (VALID_COST_SOURCES.has(source.costSource)) {
1708
+ if (!row.costSource || row.costSource === source.costSource) row.costSource = source.costSource;
1709
+ else delete row.costSource;
1710
+ }
1711
+ byModel.set(key, row);
1712
+ }
1713
+ return [...byModel.values()];
1714
+ }
1715
+
1716
+ function compactSourceTotals(sources) {
1717
+ const sourceIds = [];
1718
+ const sourceTotals = {};
1719
+ for (const source of sources || []) {
1720
+ if (!source || source.id == null || source.id === '') continue;
1721
+ const id = String(source.id);
1722
+ if (!sourceIds.includes(id)) sourceIds.push(id);
1723
+ const total = numOrNull(source.totalTokens);
1724
+ if (total != null) sourceTotals[id] = Math.max(numOrNull(sourceTotals[id]) ?? 0, total);
1725
+ }
1726
+ return { sourceIds, sourceTotals };
1727
+ }
1728
+
1729
+ function normalizeSessionV2(session) {
1730
+ const normalized = session && typeof session === 'object' && !Array.isArray(session) ? { ...session } : {};
1731
+ const legacySources = Array.isArray(normalized.sources) ? normalized.sources : null;
1732
+ if (legacySources) {
1733
+ const bestById = new Map();
1734
+ for (const source of legacySources) {
1735
+ if (!source || source.id == null || source.id === '') continue;
1736
+ const id = String(source.id);
1737
+ const previous = bestById.get(id);
1738
+ if (!previous || (numOrNull(source.totalTokens) ?? 0) >= (numOrNull(previous.totalTokens) ?? 0)) bestById.set(id, source);
1739
+ }
1740
+ const compactSources = [...bestById.values()].map((source) => ({
1741
+ ...source,
1742
+ model: source.model || normalized.model || null,
1743
+ platform: source.platform || normalized.platform || null,
1744
+ }));
1745
+ const compact = compactSourceTotals(compactSources);
1746
+ normalized.sourceIds = compact.sourceIds;
1747
+ normalized.sourceTotals = compact.sourceTotals;
1748
+ normalized.byModel = compactModelRows(compactSources);
1749
+ if (!normalized.platform && compactSources[0] && compactSources[0].platform) normalized.platform = compactSources[0].platform;
1750
+ if (!normalized.model && compactSources[0] && compactSources[0].model) normalized.model = compactSources[0].model;
1751
+ delete normalized.sources;
1752
+ } else {
1753
+ normalized.sourceIds = Array.isArray(normalized.sourceIds)
1754
+ ? [...new Set(normalized.sourceIds.filter((id) => id != null && id !== '').map(String))]
1755
+ : [];
1756
+ normalized.sourceTotals = normalized.sourceTotals && typeof normalized.sourceTotals === 'object' && !Array.isArray(normalized.sourceTotals)
1757
+ ? { ...normalized.sourceTotals }
1758
+ : {};
1759
+ normalized.byModel = Array.isArray(normalized.byModel) ? normalized.byModel.map((row) => ({ ...row })) : [];
1760
+ }
1761
+ return normalized;
1762
+ }
1763
+
1764
+ function normalizeMetricsV2(metrics) {
1765
+ if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) return metrics;
1766
+ metrics.version = METRICS_VERSION;
1767
+ metrics.sessions = Array.isArray(metrics.sessions) ? metrics.sessions.map(normalizeSessionV2) : [];
1768
+ return metrics;
1769
+ }
1770
+
1678
1771
  function loadMetricsFile(filePath, changeName, nowIso) {
1679
1772
  if (!existsSync(filePath)) return defaultMetrics(changeName, nowIso);
1680
1773
  let parsed;
@@ -1687,7 +1780,7 @@ function loadMetricsFile(filePath, changeName, nowIso) {
1687
1780
  return defaultMetrics(changeName, nowIso);
1688
1781
  }
1689
1782
  const base = defaultMetrics(changeName, parsed.createdAt || nowIso);
1690
- return {
1783
+ return normalizeMetricsV2({
1691
1784
  ...base,
1692
1785
  ...parsed,
1693
1786
  version: METRICS_VERSION,
@@ -1698,12 +1791,12 @@ function loadMetricsFile(filePath, changeName, nowIso) {
1698
1791
  totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
1699
1792
  phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
1700
1793
  sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
1701
- };
1794
+ });
1702
1795
  }
1703
1796
 
1704
1797
  function saveMetricsFile(filePath, metrics) {
1705
1798
  mkdirSync(dirname(filePath), { recursive: true });
1706
- const out = { ...metrics };
1799
+ const out = normalizeMetricsV2({ ...metrics });
1707
1800
  delete out.timezone;
1708
1801
  writeFileSync(filePath, `${JSON.stringify(out, null, 2)}\n`);
1709
1802
  }
@@ -1750,7 +1843,7 @@ function phaseForRole(role) {
1750
1843
  function sessionFieldOrSources(session, key) {
1751
1844
  if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
1752
1845
  let sum = null;
1753
- for (const src of session.sources || []) {
1846
+ for (const src of session.byModel || []) {
1754
1847
  sum = addNullable(sum, numOrNull(src[key]));
1755
1848
  }
1756
1849
  return sum;
@@ -1764,8 +1857,14 @@ function lastSessionEndedAt(metrics) {
1764
1857
  return last;
1765
1858
  }
1766
1859
 
1767
- function collectWindowStart(metrics, extra) {
1768
- return (extra && extra.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
1860
+ function collectWindowStart(metrics, extra = {}) {
1861
+ const explicit = isoOrNull(extra.explicitStartedAt);
1862
+ const pending = isoOrNull(metrics.pending && metrics.pending.startedAt);
1863
+ const selected = explicit || pending || isoOrNull(extra.lastEndedAt) || isoOrNull(metrics.createdAt);
1864
+ if (!selected) return nowUtcIso();
1865
+ if (!explicit && !pending) return selected;
1866
+ const selectedMs = parseFlexibleIso(selected);
1867
+ return Number.isFinite(selectedMs) ? new Date(selectedMs - LEFTOVER_GRACE_MS).toISOString() : selected;
1769
1868
  }
1770
1869
 
1771
1870
  function leftoverGraceEnd(endedAt) {
@@ -1774,6 +1873,15 @@ function leftoverGraceEnd(endedAt) {
1774
1873
  return new Date(ms + LEFTOVER_GRACE_MS).toISOString();
1775
1874
  }
1776
1875
 
1876
+ function sessionLeftoverEnd(session, pendingStartedAt) {
1877
+ const pending = isoOrNull(pendingStartedAt);
1878
+ const grace = leftoverGraceEnd(session && session.endedAt);
1879
+ if (session && session.threadId) return pending || grace;
1880
+ if (!pending) return grace;
1881
+ if (!grace) return pending;
1882
+ return earlierTimestamp(pending, grace);
1883
+ }
1884
+
1777
1885
  function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
1778
1886
  const t = parseFlexibleIso(at);
1779
1887
  if (!Number.isFinite(t)) return false;
@@ -1788,19 +1896,71 @@ function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
1788
1896
  return true;
1789
1897
  }
1790
1898
 
1899
+ function uniqueAmpSourceThreadPrefix(sourceIds) {
1900
+ const prefixes = [];
1901
+ for (const sourceId of sourceIds || []) {
1902
+ const id = sourceId == null ? '' : String(sourceId);
1903
+ if (!id.startsWith('T-')) continue;
1904
+ const prefix = id.split(':')[0];
1905
+ if (!prefix) continue;
1906
+ if (!prefixes.includes(prefix)) prefixes.push(prefix);
1907
+ }
1908
+ return prefixes.length === 1 ? prefixes[0] : null;
1909
+ }
1910
+
1911
+ function leftoverAmpThreadId(session) {
1912
+ if (!session) return null;
1913
+ if (session.threadId) return String(session.threadId);
1914
+ return uniqueAmpSourceThreadPrefix(session.sourceIds);
1915
+ }
1916
+
1917
+ function sessionAmpThreadKeys(session) {
1918
+ const keys = new Set();
1919
+ if (session && session.threadId) keys.add(String(session.threadId));
1920
+ for (const sourceId of (session && session.sourceIds) || []) {
1921
+ const id = sourceId == null ? '' : String(sourceId);
1922
+ if (!id.startsWith('T-')) continue;
1923
+ const prefix = id.split(':')[0];
1924
+ if (prefix) keys.add(prefix);
1925
+ }
1926
+ return keys;
1927
+ }
1928
+
1929
+ function usageModelTokenCount(row) {
1930
+ const total = numOrNull(row && row.totalTokens);
1931
+ if (total != null) return total;
1932
+ return (numOrNull(row && row.inputTokens) ?? 0) + (numOrNull(row && row.outputTokens) ?? 0);
1933
+ }
1934
+
1935
+ function usageModelsForSession(session, ampThreads) {
1936
+ const allowed = sessionAmpThreadKeys(session);
1937
+ const byModel = new Map();
1938
+ for (const thread of ampThreads || []) {
1939
+ const id = thread && thread.id != null ? String(thread.id) : '';
1940
+ if (!allowed.has(id)) continue;
1941
+ for (const row of (thread && thread.models) || []) {
1942
+ if (!row || !row.model) continue;
1943
+ const key = String(row.model);
1944
+ const prev = byModel.get(key);
1945
+ if (!prev || usageModelTokenCount(row) >= usageModelTokenCount(prev)) {
1946
+ byModel.set(key, row);
1947
+ }
1948
+ }
1949
+ }
1950
+ return [...byModel.values()];
1951
+ }
1952
+
1791
1953
  function sessionSpendIsFrozen(session) {
1792
1954
  if (!session) return false;
1793
1955
  if (session.spendSource === 'flag') return true;
1794
- if (!reportedHasSpendNumbers(session)) return false;
1795
- if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
1796
- return true;
1956
+ return session.spendSource === 'self-report' && reportedHasSpendNumbers(session);
1797
1957
  }
1798
1958
 
1799
- function existingSourceRecords(metrics) {
1800
- const out = [];
1959
+ function existingSourceTotals(metrics) {
1960
+ const out = {};
1801
1961
  for (const session of metrics.sessions || []) {
1802
- for (const src of session.sources || []) {
1803
- if (src) out.push(src);
1962
+ for (const [id, total] of Object.entries(session.sourceTotals || {})) {
1963
+ out[id] = Math.max(numOrNull(out[id]) ?? 0, numOrNull(total) ?? 0);
1804
1964
  }
1805
1965
  }
1806
1966
  return out;
@@ -1809,13 +1969,15 @@ function existingSourceRecords(metrics) {
1809
1969
  function existingSourceIdSet(metrics) {
1810
1970
  const ids = new Set();
1811
1971
  for (const session of metrics.sessions || []) {
1812
- for (const src of session.sources || []) {
1813
- if (src && src.id != null && src.id !== '') ids.add(String(src.id));
1814
- }
1972
+ for (const id of session.sourceIds || []) ids.add(String(id));
1815
1973
  }
1816
1974
  return ids;
1817
1975
  }
1818
1976
 
1977
+ function existingSessionThreadIds(metrics) {
1978
+ return [...new Set((metrics.sessions || []).map((session) => session && session.threadId).filter(Boolean).map(String))];
1979
+ }
1980
+
1819
1981
  function lastNonArchiverSession(sessions) {
1820
1982
  const list = Array.isArray(sessions) ? sessions : [];
1821
1983
  for (let i = list.length - 1; i >= 0; i -= 1) {
@@ -1851,6 +2013,63 @@ function uniqueSourceModels(sources) {
1851
2013
  return seen;
1852
2014
  }
1853
2015
 
2016
+ function sessionTotalsFromModels(byModel) {
2017
+ const totals = { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
2018
+ for (const row of byModel || []) {
2019
+ for (const key of Object.keys(totals)) totals[key] = addNullable(totals[key], numOrNull(row[key]));
2020
+ }
2021
+ totals.costUsdEstimated = roundUsd4(totals.costUsdEstimated);
2022
+ return totals;
2023
+ }
2024
+
2025
+ function mergeModelRows(current, incoming) {
2026
+ const merged = new Map();
2027
+ for (const raw of [...(current || []), ...(incoming || [])]) {
2028
+ if (!raw) continue;
2029
+ const model = raw.model == null || raw.model === '' ? null : String(raw.model);
2030
+ const key = `${raw.platform || ''}::${model || ''}`;
2031
+ const row = merged.get(key) || {
2032
+ model,
2033
+ platform: raw.platform || null,
2034
+ inputTokens: null,
2035
+ outputTokens: null,
2036
+ totalTokens: null,
2037
+ costUsd: null,
2038
+ costUsdEstimated: null,
2039
+ };
2040
+ for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd']) {
2041
+ row[field] = addNullable(row[field], numOrNull(raw[field]));
2042
+ }
2043
+ row.costUsdEstimated = roundUsd4(addNullable(row.costUsdEstimated, numOrNull(raw.costUsdEstimated)));
2044
+ if (raw.costSource) row.costSource = raw.costSource;
2045
+ merged.set(key, row);
2046
+ }
2047
+ return [...merged.values()];
2048
+ }
2049
+
2050
+ function compactCollectedDelta(session, sources) {
2051
+ const adjusted = [];
2052
+ for (const source of sources || []) {
2053
+ if (!source || source.id == null) continue;
2054
+ const previous = numOrNull(session.sourceTotals && session.sourceTotals[source.id]);
2055
+ const next = numOrNull(source.totalTokens);
2056
+ if (previous == null || next == null) {
2057
+ adjusted.push(source);
2058
+ continue;
2059
+ }
2060
+ const delta = Math.max(0, next - previous);
2061
+ if (delta === 0) continue;
2062
+ const ratio = next > 0 ? delta / next : 0;
2063
+ const copy = { ...source };
2064
+ for (const field of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
2065
+ const value = numOrNull(copy[field]);
2066
+ if (value != null) copy[field] = field.includes('Usd') ? roundUsd4(value * ratio) : Math.round(value * ratio);
2067
+ }
2068
+ adjusted.push(copy);
2069
+ }
2070
+ return adjusted;
2071
+ }
2072
+
1854
2073
  function rankSources(sources) {
1855
2074
  return [...sources].sort((a, b) => {
1856
2075
  const ta = a.totalTokens ?? 0;
@@ -1922,7 +2141,15 @@ function ampThreadTotals(threads) {
1922
2141
  if (row && row.model && !models.includes(row.model)) models.push(row.model);
1923
2142
  }
1924
2143
  }
1925
- return { costUsd, agentMode, models };
2144
+ let inputTokens = null;
2145
+ let outputTokens = null;
2146
+ let totalTokens = null;
2147
+ for (const thread of list) {
2148
+ inputTokens = addNullable(inputTokens, numOrNull(thread && thread.inputTokens));
2149
+ outputTokens = addNullable(outputTokens, numOrNull(thread && thread.outputTokens));
2150
+ totalTokens = addNullable(totalTokens, numOrNull(thread && thread.totalTokens));
2151
+ }
2152
+ return { costUsd, agentMode, models, inputTokens, outputTokens, totalTokens };
1926
2153
  }
1927
2154
 
1928
2155
  function resolveSessionSpend(opts, reported, sources, extra = {}) {
@@ -1936,9 +2163,9 @@ function resolveSessionSpend(opts, reported, sources, extra = {}) {
1936
2163
  const flagTotal = numOrNull(flags.totalTokens);
1937
2164
  const flagCost = numOrNull(flags.costUsd);
1938
2165
  const flagCredits = numOrNull(flags.ampCredits);
1939
- const inputTokens = firstNonNull(flagInput, self.inputTokens, fromSources.inputTokens);
1940
- const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromSources.outputTokens);
1941
- let totalTokens = firstNonNull(flagTotal, self.totalTokens, fromSources.totalTokens);
2166
+ const inputTokens = firstNonNull(flagInput, self.inputTokens, fromAmp.inputTokens, fromSources.inputTokens);
2167
+ const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromAmp.outputTokens, fromSources.outputTokens);
2168
+ let totalTokens = firstNonNull(flagTotal, self.totalTokens, fromAmp.totalTokens, fromSources.totalTokens);
1942
2169
  if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1943
2170
  totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
1944
2171
  }
@@ -1994,65 +2221,132 @@ function sessionTotalsFromSources(sources) {
1994
2221
  function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
1995
2222
  try {
1996
2223
  return collectSpend({
1997
- cwd: process.cwd(),
2224
+ cwd: extra.cwd || process.cwd(),
1998
2225
  windowStart,
1999
2226
  windowEnd,
2000
2227
  existingSourceIds: existingSourceIdSet(metrics),
2001
- existingSources: existingSourceRecords(metrics),
2002
- env: process.env,
2003
- homedir: process.env.HOME,
2228
+ existingSourceTotals: existingSourceTotals(metrics),
2229
+ env: extra.env || process.env,
2230
+ homedir: extra.homedir || process.env.HOME,
2004
2231
  platforms: extra.platforms,
2005
2232
  ampThreadId: extra.ampThreadId,
2006
2233
  ampCli: extra.ampCli === true,
2007
2234
  cursorConversationId: extra.cursorConversationId,
2008
2235
  exportAmpThread: extra.exportAmpThread,
2009
2236
  usageAmpThread: extra.usageAmpThread,
2237
+ listRecentAmpThreads: extra.listRecentAmpThreads,
2238
+ listAmpThreads: extra.listAmpThreads,
2239
+ collectAll: extra.collectAll === true,
2240
+ existingThreadIds: existingSessionThreadIds(metrics),
2241
+ rebillThreadId: extra.rebillThreadId,
2010
2242
  });
2011
2243
  } catch {
2012
- return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
2244
+ return { sources: [], ids: [], totals: {}, byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
2245
+ }
2246
+ }
2247
+
2248
+ function leftoverCollectPlatforms(session, collectAll, ampThreadId) {
2249
+ if (collectAll) return undefined;
2250
+ if (session.platform === 'amp' || session.platform === 'cursor' || session.platform === 'claude') {
2251
+ if (session.platform === 'amp' && !ampThreadId) return [];
2252
+ return [session.platform];
2253
+ }
2254
+ if (session.platform == null && ampThreadId) return ['amp'];
2255
+ return [];
2256
+ }
2257
+
2258
+ function resyncLeftoverSessionSpend(session, priorCost, priorSource) {
2259
+ const fromSources = sessionTotalsFromModels(session.byModel || []);
2260
+ session.inputTokens = fromSources.inputTokens;
2261
+ session.outputTokens = fromSources.outputTokens;
2262
+ let totalTokens = fromSources.totalTokens;
2263
+ if (totalTokens == null && (fromSources.inputTokens != null || fromSources.outputTokens != null)) {
2264
+ totalTokens = (fromSources.inputTokens ?? 0) + (fromSources.outputTokens ?? 0);
2265
+ }
2266
+ session.totalTokens = totalTokens;
2267
+ if (fromSources.costUsd == null && priorCost != null) {
2268
+ session.costUsd = priorCost;
2269
+ }
2270
+ if (priorSource === 'amp-usage' && numOrNull(session.costUsd) != null) {
2271
+ session.spendSource = 'amp-usage';
2013
2272
  }
2014
2273
  }
2015
2274
 
2016
2275
  function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2017
2276
  if (!session || !session.endedAt || !leftoverEnd) return 0;
2018
2277
  const exclusiveEnd = extra.exclusiveEnd === true;
2278
+ const collectAll = extra.collect === true;
2279
+ const ampThreadId = leftoverAmpThreadId(session);
2280
+ const platforms = leftoverCollectPlatforms(session, collectAll, ampThreadId);
2281
+ if (!collectAll && (!Array.isArray(platforms) || platforms.length === 0)) return 0;
2282
+ const collectAmp = collectAll || (Array.isArray(platforms) && platforms.includes('amp'));
2283
+ const collectCursor = collectAll || (Array.isArray(platforms) && platforms.includes('cursor'));
2019
2284
  const collected = runCollectSpend(metrics, session.endedAt, leftoverEnd, {
2020
- platforms: extra.platforms,
2021
- ampThreadId: session.threadId || extra.ampThreadId || ampThreadIdFromEnv(process.env) || null,
2022
- ampCli: extra.ampCli === true || session.platform === 'amp',
2285
+ platforms,
2286
+ ...(ampThreadId ? { ampThreadId } : {}),
2287
+ listRecentAmpThreads: false,
2288
+ ampCli: extra.ampCli === true || collectAmp,
2023
2289
  cursorConversationId: extra.cursorConversationId
2024
- || (session.platform === 'cursor' ? session.threadId : undefined),
2290
+ || (collectCursor && session.platform === 'cursor' ? session.threadId : undefined),
2291
+ exportAmpThread: extra.exportAmpThread,
2292
+ usageAmpThread: extra.usageAmpThread,
2293
+ listAmpThreads: extra.listAmpThreads,
2294
+ env: extra.env,
2295
+ cwd: extra.cwd,
2296
+ homedir: extra.homedir,
2297
+ rebillThreadId: ampThreadId,
2025
2298
  });
2026
- const incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
2299
+ let incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
2027
2300
  src.at,
2028
2301
  session.endedAt,
2029
2302
  leftoverEnd,
2030
2303
  exclusiveEnd,
2031
2304
  ));
2305
+ if (ampThreadId) {
2306
+ const prefix = `${ampThreadId}:`;
2307
+ incoming = incoming.filter((src) => {
2308
+ if (src && src.platform === 'amp') return String(src.id || '').startsWith(prefix);
2309
+ return true;
2310
+ });
2311
+ }
2032
2312
  if (!incoming.length) return 0;
2033
- const merged = [...(session.sources || []), ...incoming];
2313
+ const beforeIds = new Set(session.sourceIds || []);
2034
2314
  if (sessionSpendIsFrozen(session)) {
2035
- session.sources = merged;
2036
- const uniqueModels = uniqueSourceModels(merged);
2315
+ const delta = compactCollectedDelta(session, incoming);
2316
+ session.sourceIds = [...new Set([...(session.sourceIds || []), ...incoming.map((source) => String(source.id))])];
2317
+ session.sourceTotals = { ...(session.sourceTotals || {}) };
2318
+ for (const source of incoming) session.sourceTotals[source.id] = Math.max(numOrNull(session.sourceTotals[source.id]) ?? 0, numOrNull(source.totalTokens) ?? 0);
2319
+ session.byModel = mergeModelRows(session.byModel, compactModelRows(delta));
2320
+ const uniqueModels = uniqueSourceModels(session.byModel);
2037
2321
  if (uniqueModels.length > 1) session.models = uniqueModels;
2038
2322
  } else {
2039
- applyCollectedSessionFields(session, merged, session.model, {}, {
2323
+ const priorCost = numOrNull(session.costUsd);
2324
+ const priorSource = session.spendSource;
2325
+ applyCollectedSessionFields(session, incoming, session.model, {}, {
2040
2326
  model: session.model,
2041
2327
  platform: session.platform,
2042
- }, { ampThreads: collected.ampThreads });
2328
+ }, { ampThreads: Array.isArray(extra.ampThreads) ? extra.ampThreads : collected.ampThreads });
2329
+ resyncLeftoverSessionSpend(session, priorCost, priorSource);
2043
2330
  }
2044
- return incoming.length;
2331
+ return incoming.filter((source) => !beforeIds.has(String(source.id))).length;
2045
2332
  }
2046
2333
 
2047
2334
  function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported, extra = {}) {
2048
- session.sources = sources || [];
2049
- const uniqueModels = uniqueSourceModels(session.sources);
2335
+ const incoming = sources || [];
2336
+ const delta = compactCollectedDelta(session, incoming);
2337
+ session.sourceIds = [...new Set([...(session.sourceIds || []), ...incoming.map((source) => String(source.id))])];
2338
+ session.sourceTotals = { ...(session.sourceTotals || {}) };
2339
+ for (const source of incoming) {
2340
+ session.sourceTotals[source.id] = Math.max(numOrNull(session.sourceTotals[source.id]) ?? 0, numOrNull(source.totalTokens) ?? 0);
2341
+ }
2342
+ session.byModel = mergeModelRows(session.byModel, compactModelRows(delta));
2343
+ const uniqueModels = uniqueSourceModels(session.byModel);
2050
2344
  const fromAmp = ampThreadTotals(extra.ampThreads);
2051
2345
  for (const model of fromAmp.models) {
2052
2346
  if (model && !uniqueModels.includes(model)) uniqueModels.push(model);
2053
2347
  }
2054
2348
  if (uniqueModels.length > 1) session.models = uniqueModels;
2055
- const sourceModel = primaryModelFromSources(session.sources);
2349
+ const sourceModel = primaryModelFromSources(incoming);
2056
2350
  if (sourceModel) {
2057
2351
  session.model = sourceModel;
2058
2352
  } else {
@@ -2060,8 +2354,8 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
2060
2354
  || fromAmp.models[0]
2061
2355
  || null;
2062
2356
  }
2063
- if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
2064
- const spend = resolveSessionSpend(opts, reported, session.sources, extra);
2357
+ if (!session.platform) session.platform = primaryPlatformFromSources(incoming) || null;
2358
+ const spend = resolveSessionSpend(opts, reported, incoming, extra);
2065
2359
  session.inputTokens = spend.inputTokens;
2066
2360
  session.outputTokens = spend.outputTokens;
2067
2361
  session.totalTokens = spend.totalTokens;
@@ -2070,17 +2364,43 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
2070
2364
  session.costUsdEstimated = spend.costUsdEstimated;
2071
2365
  session.spendSource = spend.spendSource;
2072
2366
  if (spend.agentMode) session.agentMode = spend.agentMode;
2073
- const usageModels = [];
2074
- for (const thread of extra.ampThreads || []) {
2075
- for (const row of thread.models || []) {
2076
- if (row && row.model) usageModels.push(row);
2367
+ const usageModels = usageModelsForSession(session, extra.ampThreads);
2368
+ if (usageModels.length || Array.isArray(session.usageModels)) {
2369
+ session.usageModels = usageModels;
2370
+ }
2371
+ if (session.platform === 'amp' && usageModels.length) {
2372
+ session.byModel = usageModels.map((row) => ({
2373
+ model: row.model,
2374
+ platform: 'amp',
2375
+ inputTokens: numOrNull(row.inputTokens),
2376
+ outputTokens: numOrNull(row.outputTokens),
2377
+ totalTokens: numOrNull(row.totalTokens) ?? ((numOrNull(row.inputTokens) ?? 0) + (numOrNull(row.outputTokens) ?? 0)),
2378
+ costUsd: numOrNull(row.costUsd),
2379
+ costUsdEstimated: null,
2380
+ ...(numOrNull(row.costUsd) != null ? { costSource: 'amp-usage' } : {}),
2381
+ }));
2382
+ }
2383
+ if (session.costUsd == null) {
2384
+ for (const row of session.byModel) {
2385
+ if (row.costUsd != null) continue;
2386
+ const described = describeClaudeCostEstimate({
2387
+ model: String(row.model || '').replace(/^Claude\s+/i, 'claude-').replaceAll(' ', '-').toLowerCase(),
2388
+ inputTokens: row.inputTokens,
2389
+ outputTokens: row.outputTokens,
2390
+ });
2391
+ if (described) {
2392
+ row.costUsdEstimated = described.usd;
2393
+ row.costSource = described.costSource;
2394
+ }
2077
2395
  }
2396
+ session.costUsdEstimated = sessionTotalsFromModels(session.byModel).costUsdEstimated;
2397
+ } else {
2398
+ session.costUsdEstimated = null;
2078
2399
  }
2079
- if (usageModels.length) session.usageModels = usageModels;
2080
2400
  }
2081
2401
 
2082
2402
  function sessionTotalsLookOverridden(session) {
2083
- const fromSources = sessionTotalsFromSources(session.sources || []);
2403
+ const fromSources = sessionTotalsFromModels(session.byModel || []);
2084
2404
  return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
2085
2405
  const sessionVal = numOrNull(session[key]);
2086
2406
  const sourceVal = numOrNull(fromSources[key]);
@@ -2111,16 +2431,16 @@ function metricsBackfillFile(filePath, changeName) {
2111
2431
  });
2112
2432
  const incoming = collected.sources || [];
2113
2433
  if (!incoming.length) return { filePath, added: 0 };
2114
- const merged = [...(last.sources || []), ...incoming];
2115
2434
  if (sessionSpendIsFrozen(last)) {
2116
- last.sources = merged;
2117
- const uniqueModels = uniqueSourceModels(merged);
2118
- if (uniqueModels.length > 1) last.models = uniqueModels;
2435
+ applyCollectedSessionFields(last, incoming, last.model, {}, last, { ampThreads: collected.ampThreads });
2119
2436
  } else {
2120
- applyCollectedSessionFields(last, merged, last.model, {}, {
2437
+ const priorCost = numOrNull(last.costUsd);
2438
+ const priorSource = last.spendSource;
2439
+ applyCollectedSessionFields(last, incoming, last.model, {}, {
2121
2440
  model: last.model,
2122
2441
  platform: last.platform,
2123
2442
  }, { ampThreads: collected.ampThreads });
2443
+ resyncLeftoverSessionSpend(last, priorCost, priorSource);
2124
2444
  }
2125
2445
  metrics.updatedAt = nowIso;
2126
2446
  recomputeMetricsAggregates(metrics);
@@ -2185,27 +2505,19 @@ function recomputeSpendMaps(metrics) {
2185
2505
  byModel.set(key, row);
2186
2506
  }
2187
2507
 
2188
- function addSourceRow(src) {
2189
- const nums = spendTuple(src);
2190
- if (src.platform && byPlatform[src.platform]) {
2191
- addSpendNums(byPlatform[src.platform], nums);
2192
- const label = adapterSourceName(src.platform, src.via);
2193
- if (label) byPlatform[src.platform].source = label;
2194
- }
2195
- addModelRow(src.model, src.platform, nums);
2196
- }
2197
-
2198
2508
  for (const session of metrics.sessions || []) {
2199
- const sources = session.sources || [];
2200
- if (sources.length > 0) {
2201
- for (const src of sources) addSourceRow(src);
2202
- continue;
2203
- }
2204
2509
  const sessionNums = spendTuple(session);
2205
2510
  if (session.platform && byPlatform[session.platform]) {
2206
2511
  addSpendNums(byPlatform[session.platform], sessionNums);
2512
+ if ((session.sourceIds || []).length) {
2513
+ byPlatform[session.platform].source = adapterSourceName(session.platform, session.platform === 'amp' ? 'amp-cli' : null);
2514
+ }
2515
+ }
2516
+ if ((session.byModel || []).length) {
2517
+ for (const row of session.byModel) addModelRow(row.model, row.platform || session.platform, spendTuple(row));
2518
+ } else {
2519
+ addModelRow(session.model, session.platform, sessionNums);
2207
2520
  }
2208
- addModelRow(session.model, session.platform, sessionNums);
2209
2521
  }
2210
2522
  for (const key of Object.keys(byPlatform)) {
2211
2523
  byPlatform[key].costUsdEstimated = roundUsd4(byPlatform[key].costUsdEstimated);
@@ -2218,7 +2530,7 @@ function recomputeSpendMaps(metrics) {
2218
2530
  }
2219
2531
 
2220
2532
  function recomputeMetricsAggregates(metrics) {
2221
- enrichMetricsCursorEstimates(metrics, process.cwd());
2533
+ normalizeMetricsV2(metrics);
2222
2534
  const phases = {};
2223
2535
  const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
2224
2536
  const spend = emptySpendTotals();
@@ -2259,14 +2571,7 @@ function recomputeMetricsAggregates(metrics) {
2259
2571
  : laterTimestamp(phase.endedAt, session.endedAt);
2260
2572
  }
2261
2573
  for (const spendKey of METRICS_SPEND_KEYS) {
2262
- let value = null;
2263
- if ((session.sources || []).length > 0) {
2264
- for (const src of session.sources) {
2265
- value = addNullable(value, numOrNull(src[spendKey]));
2266
- }
2267
- } else {
2268
- value = sessionFieldOrSources(session, spendKey);
2269
- }
2574
+ const value = sessionFieldOrSources(session, spendKey);
2270
2575
  phase[spendKey] = addNullable(phase[spendKey], value);
2271
2576
  spend[spendKey] = addNullable(spend[spendKey], value);
2272
2577
  }
@@ -2303,7 +2608,7 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
2303
2608
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2304
2609
  metrics.pending = {
2305
2610
  startedAt: nowIso,
2306
- role: role || '',
2611
+ role: canonicalRole(role) || role || '',
2307
2612
  platform: client.platform || null,
2308
2613
  threadId: client.threadId || null,
2309
2614
  clientSource: client.source || null,
@@ -2319,18 +2624,29 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2319
2624
  const nowIso = nowUtcIso();
2320
2625
  const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
2321
2626
  const lastClosed = (metrics.sessions || [])[(metrics.sessions || []).length - 1];
2322
- const leftoverEnd = (metrics.pending && metrics.pending.startedAt)
2323
- ? metrics.pending.startedAt
2324
- : leftoverGraceEnd(lastClosed && lastClosed.endedAt);
2627
+ const leftoverEnd = sessionLeftoverEnd(lastClosed, metrics.pending && metrics.pending.startedAt);
2325
2628
  attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
2326
2629
  exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
2630
+ collect: opts.collect === true,
2327
2631
  });
2328
- let startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
2632
+ const explicitStartedAt = isoOrNull(opts.startedAt);
2633
+ const pendingStartedAt = isoOrNull(metrics.pending && metrics.pending.startedAt);
2634
+ const startedAt = explicitStartedAt || pendingStartedAt || isoOrNull(lastClosed && lastClosed.endedAt) || isoOrNull(metrics.createdAt) || nowIso;
2635
+ if (!explicitStartedAt && !pendingStartedAt) console.error('Warning: persist without restore; collecting from the last closed session boundary.');
2329
2636
  const reported = opts.reported || fields.metrics || emptyMetricsFields();
2330
2637
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2331
- const windowStart = collectWindowStart(metrics, { startedAt });
2638
+ const windowStart = collectWindowStart(metrics, {
2639
+ explicitStartedAt,
2640
+ lastEndedAt: lastClosed && lastClosed.endedAt,
2641
+ });
2332
2642
  const pending = metrics.pending || {};
2333
2643
  const platform = opts.platform || pending.platform || null;
2644
+ const cursorThreadId = platform === 'cursor'
2645
+ ? (pending.threadId || String(process.env.CURSOR_CONVERSATION_ID || '').trim() || null)
2646
+ : null;
2647
+ const ampThreadId = platform === 'amp'
2648
+ ? (opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null)
2649
+ : null;
2334
2650
  const collectAll = opts.collect === true;
2335
2651
  const platforms = collectAll ? undefined : (platform ? [platform] : []);
2336
2652
  const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
@@ -2338,9 +2654,11 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2338
2654
  const collected = shouldCollect
2339
2655
  ? runCollectSpend(metrics, windowStart, endedAt, {
2340
2656
  platforms,
2341
- ampThreadId: opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null,
2657
+ ampThreadId,
2342
2658
  ampCli: collectAll || platform === 'amp',
2343
- cursorConversationId: platform === 'cursor' ? pending.threadId : undefined,
2659
+ cursorConversationId: cursorThreadId || undefined,
2660
+ listRecentAmpThreads: false,
2661
+ collectAll,
2344
2662
  })
2345
2663
  : { sources: [] };
2346
2664
  const startedMs = parseFlexibleIso(startedAt);
@@ -2359,9 +2677,11 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2359
2677
  agentId: fields.agentId || 'none',
2360
2678
  model: resolvedModel || null,
2361
2679
  platform: opts.platform || pending.platform || null,
2362
- threadId: opts.ampThreadId || pending.threadId || null,
2680
+ threadId: cursorThreadId || ampThreadId || pending.threadId || null,
2363
2681
  tasks: fields.tasks || null,
2364
- sources: [],
2682
+ sourceIds: [],
2683
+ sourceTotals: {},
2684
+ byModel: [],
2365
2685
  inputTokens: null,
2366
2686
  outputTokens: null,
2367
2687
  totalTokens: null,
@@ -2371,22 +2691,6 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2371
2691
  spendSource: 'unreported',
2372
2692
  };
2373
2693
  applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
2374
- if (!session.startedAt) {
2375
- let earliest = null;
2376
- for (const src of session.sources || []) {
2377
- const at = isoOrNull(src.at);
2378
- if (!at) continue;
2379
- earliest = earliest == null ? at : earlierTimestamp(earliest, at);
2380
- }
2381
- if (earliest) {
2382
- session.startedAt = earliest;
2383
- const fromMs = parseFlexibleIso(session.startedAt);
2384
- const toMs = parseFlexibleIso(session.endedAt);
2385
- session.durationMs = Number.isFinite(fromMs) && Number.isFinite(toMs)
2386
- ? Math.max(0, toMs - fromMs)
2387
- : null;
2388
- }
2389
- }
2390
2694
  metrics.sessions.push(session);
2391
2695
  metrics.pending = null;
2392
2696
  metrics.updatedAt = nowIso;
@@ -2417,12 +2721,15 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2417
2721
  const platforms = collectAll ? undefined : (platform ? [platform] : []);
2418
2722
  const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2419
2723
  const ampThreadId = opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null;
2724
+ const archiveWindowStart = collectWindowStart(metrics, {});
2420
2725
  const collected = shouldCollect
2421
- ? runCollectSpend(metrics, startedAt, endedAt, {
2726
+ ? runCollectSpend(metrics, archiveWindowStart, endedAt, {
2422
2727
  platforms,
2423
2728
  ampThreadId,
2424
2729
  ampCli: collectAll || platform === 'amp',
2425
2730
  cursorConversationId: platform === 'cursor' ? (pending.threadId || ampThreadId) : undefined,
2731
+ listRecentAmpThreads: false,
2732
+ collectAll,
2426
2733
  })
2427
2734
  : { sources: [] };
2428
2735
  const reported = dropStaleArchiveSelfReport(opts.reported, metrics.sessions);
@@ -2439,7 +2746,9 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2439
2746
  platform,
2440
2747
  threadId: ampThreadId || null,
2441
2748
  tasks: opts.tasks || null,
2442
- sources: [],
2749
+ sourceIds: [],
2750
+ sourceTotals: {},
2751
+ byModel: [],
2443
2752
  inputTokens: null,
2444
2753
  outputTokens: null,
2445
2754
  totalTokens: null,
@@ -2464,7 +2773,7 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2464
2773
  return filePath;
2465
2774
  }
2466
2775
 
2467
- function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
2776
+ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}, extra = {}) {
2468
2777
  const filePath = join(changeRoot, 'metrics.json');
2469
2778
  if (!existsSync(filePath)) return;
2470
2779
  const nowIso = nowUtcIso();
@@ -2478,9 +2787,11 @@ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
2478
2787
  clientSource: client.source || null,
2479
2788
  };
2480
2789
  }
2481
- const leftoverEnd = metrics.pending && metrics.pending.startedAt;
2482
- attachLeftoverSources(metrics, lastNonArchiverSession(metrics.sessions), leftoverEnd, {
2790
+ const previous = lastNonArchiverSession(metrics.sessions);
2791
+ const leftoverEnd = sessionLeftoverEnd(previous, metrics.pending && metrics.pending.startedAt);
2792
+ attachLeftoverSources(metrics, previous, leftoverEnd, {
2483
2793
  exclusiveEnd: true,
2794
+ collect: extra.collect === true,
2484
2795
  });
2485
2796
  metrics.updatedAt = nowIso;
2486
2797
  recomputeMetricsAggregates(metrics);
@@ -3876,7 +4187,7 @@ program
3876
4187
  homedir: process.env.HOME,
3877
4188
  platform: opts.platform,
3878
4189
  });
3879
- metricsPrepareArchiveStart(changeRoot, name, archiveClient);
4190
+ metricsPrepareArchiveStart(changeRoot, name, archiveClient, { collect: opts.collect === true });
3880
4191
  mkdirSync(dirname(targetDir), { recursive: true });
3881
4192
  renameSync(changeRoot, targetDir);
3882
4193
 
@@ -4367,6 +4678,8 @@ program
4367
4678
  .command('metrics [change-name]')
4368
4679
  .description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
4369
4680
  .option('--json', 'Print raw metrics.json', false)
4681
+ .option('--summary-json', 'Print compact aggregate JSON for dashboards', false)
4682
+ .option('--migrate', 'Rewrite metrics.json using the current schema without recomputing numbers', false)
4370
4683
  .option('--collect', 'Backfill the last session from local spend adapters without adding a new session', false)
4371
4684
  .action((changeName, opts) => {
4372
4685
  const projectDir = process.cwd();
@@ -4423,6 +4736,29 @@ program
4423
4736
  }
4424
4737
 
4425
4738
  const metrics = loadMetricsFile(filePath, name, nowUtcIso());
4739
+ if (opts.migrate) {
4740
+ saveMetricsFile(filePath, metrics);
4741
+ if (!opts.json && !opts.summaryJson) {
4742
+ log.ok(`migrated: ${filePath.replace(`${projectDir}/`, '')}`);
4743
+ return;
4744
+ }
4745
+ }
4746
+ if (opts.summaryJson) {
4747
+ recomputeMetricsAggregates(metrics);
4748
+ const summary = {
4749
+ version: metrics.version,
4750
+ change: metrics.change,
4751
+ createdAt: metrics.createdAt,
4752
+ archivedAt: metrics.archivedAt,
4753
+ totals: metrics.totals,
4754
+ phases: metrics.phases,
4755
+ spend: metrics.spend,
4756
+ spendByPlatform: metrics.spendByPlatform,
4757
+ spendByModel: metrics.spendByModel,
4758
+ };
4759
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
4760
+ return;
4761
+ }
4426
4762
  if (opts.json) {
4427
4763
  process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
4428
4764
  return;
@@ -4447,4 +4783,15 @@ if (isDirectCliRun()) {
4447
4783
  program.parse();
4448
4784
  }
4449
4785
 
4450
- export { formatMetricsCostLine, resolveSessionSpend, canonicalRole, phaseForRole };
4786
+ export {
4787
+ formatMetricsCostLine,
4788
+ resolveSessionSpend,
4789
+ canonicalRole,
4790
+ phaseForRole,
4791
+ recomputeMetricsAggregates,
4792
+ sessionSpendIsFrozen,
4793
+ attachLeftoverSources,
4794
+ runCollectSpend,
4795
+ normalizeMetricsV2,
4796
+ firstSpawnName,
4797
+ };