agent-orchestrator-kit 0.13.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,10 +1896,10 @@ function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
1788
1896
  return true;
1789
1897
  }
1790
1898
 
1791
- function uniqueAmpSourceThreadPrefix(sources) {
1899
+ function uniqueAmpSourceThreadPrefix(sourceIds) {
1792
1900
  const prefixes = [];
1793
- for (const src of sources || []) {
1794
- const id = src && src.id != null ? String(src.id) : '';
1901
+ for (const sourceId of sourceIds || []) {
1902
+ const id = sourceId == null ? '' : String(sourceId);
1795
1903
  if (!id.startsWith('T-')) continue;
1796
1904
  const prefix = id.split(':')[0];
1797
1905
  if (!prefix) continue;
@@ -1803,14 +1911,14 @@ function uniqueAmpSourceThreadPrefix(sources) {
1803
1911
  function leftoverAmpThreadId(session) {
1804
1912
  if (!session) return null;
1805
1913
  if (session.threadId) return String(session.threadId);
1806
- return uniqueAmpSourceThreadPrefix(session.sources);
1914
+ return uniqueAmpSourceThreadPrefix(session.sourceIds);
1807
1915
  }
1808
1916
 
1809
1917
  function sessionAmpThreadKeys(session) {
1810
1918
  const keys = new Set();
1811
1919
  if (session && session.threadId) keys.add(String(session.threadId));
1812
- for (const src of (session && session.sources) || []) {
1813
- const id = src && src.id != null ? String(src.id) : '';
1920
+ for (const sourceId of (session && session.sourceIds) || []) {
1921
+ const id = sourceId == null ? '' : String(sourceId);
1814
1922
  if (!id.startsWith('T-')) continue;
1815
1923
  const prefix = id.split(':')[0];
1816
1924
  if (prefix) keys.add(prefix);
@@ -1848,11 +1956,11 @@ function sessionSpendIsFrozen(session) {
1848
1956
  return session.spendSource === 'self-report' && reportedHasSpendNumbers(session);
1849
1957
  }
1850
1958
 
1851
- function existingSourceRecords(metrics) {
1852
- const out = [];
1959
+ function existingSourceTotals(metrics) {
1960
+ const out = {};
1853
1961
  for (const session of metrics.sessions || []) {
1854
- for (const src of session.sources || []) {
1855
- 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);
1856
1964
  }
1857
1965
  }
1858
1966
  return out;
@@ -1861,13 +1969,15 @@ function existingSourceRecords(metrics) {
1861
1969
  function existingSourceIdSet(metrics) {
1862
1970
  const ids = new Set();
1863
1971
  for (const session of metrics.sessions || []) {
1864
- for (const src of session.sources || []) {
1865
- if (src && src.id != null && src.id !== '') ids.add(String(src.id));
1866
- }
1972
+ for (const id of session.sourceIds || []) ids.add(String(id));
1867
1973
  }
1868
1974
  return ids;
1869
1975
  }
1870
1976
 
1977
+ function existingSessionThreadIds(metrics) {
1978
+ return [...new Set((metrics.sessions || []).map((session) => session && session.threadId).filter(Boolean).map(String))];
1979
+ }
1980
+
1871
1981
  function lastNonArchiverSession(sessions) {
1872
1982
  const list = Array.isArray(sessions) ? sessions : [];
1873
1983
  for (let i = list.length - 1; i >= 0; i -= 1) {
@@ -1903,6 +2013,63 @@ function uniqueSourceModels(sources) {
1903
2013
  return seen;
1904
2014
  }
1905
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
+
1906
2073
  function rankSources(sources) {
1907
2074
  return [...sources].sort((a, b) => {
1908
2075
  const ta = a.totalTokens ?? 0;
@@ -1974,7 +2141,15 @@ function ampThreadTotals(threads) {
1974
2141
  if (row && row.model && !models.includes(row.model)) models.push(row.model);
1975
2142
  }
1976
2143
  }
1977
- 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 };
1978
2153
  }
1979
2154
 
1980
2155
  function resolveSessionSpend(opts, reported, sources, extra = {}) {
@@ -1988,9 +2163,9 @@ function resolveSessionSpend(opts, reported, sources, extra = {}) {
1988
2163
  const flagTotal = numOrNull(flags.totalTokens);
1989
2164
  const flagCost = numOrNull(flags.costUsd);
1990
2165
  const flagCredits = numOrNull(flags.ampCredits);
1991
- const inputTokens = firstNonNull(flagInput, self.inputTokens, fromSources.inputTokens);
1992
- const outputTokens = firstNonNull(flagOutput, self.outputTokens, fromSources.outputTokens);
1993
- 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);
1994
2169
  if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1995
2170
  totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
1996
2171
  }
@@ -2050,7 +2225,7 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
2050
2225
  windowStart,
2051
2226
  windowEnd,
2052
2227
  existingSourceIds: existingSourceIdSet(metrics),
2053
- existingSources: existingSourceRecords(metrics),
2228
+ existingSourceTotals: existingSourceTotals(metrics),
2054
2229
  env: extra.env || process.env,
2055
2230
  homedir: extra.homedir || process.env.HOME,
2056
2231
  platforms: extra.platforms,
@@ -2061,9 +2236,12 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
2061
2236
  usageAmpThread: extra.usageAmpThread,
2062
2237
  listRecentAmpThreads: extra.listRecentAmpThreads,
2063
2238
  listAmpThreads: extra.listAmpThreads,
2239
+ collectAll: extra.collectAll === true,
2240
+ existingThreadIds: existingSessionThreadIds(metrics),
2241
+ rebillThreadId: extra.rebillThreadId,
2064
2242
  });
2065
2243
  } catch {
2066
- return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
2244
+ return { sources: [], ids: [], totals: {}, byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
2067
2245
  }
2068
2246
  }
2069
2247
 
@@ -2078,7 +2256,7 @@ function leftoverCollectPlatforms(session, collectAll, ampThreadId) {
2078
2256
  }
2079
2257
 
2080
2258
  function resyncLeftoverSessionSpend(session, priorCost, priorSource) {
2081
- const fromSources = sessionTotalsFromSources(session.sources || []);
2259
+ const fromSources = sessionTotalsFromModels(session.byModel || []);
2082
2260
  session.inputTokens = fromSources.inputTokens;
2083
2261
  session.outputTokens = fromSources.outputTokens;
2084
2262
  let totalTokens = fromSources.totalTokens;
@@ -2116,6 +2294,7 @@ function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2116
2294
  env: extra.env,
2117
2295
  cwd: extra.cwd,
2118
2296
  homedir: extra.homedir,
2297
+ rebillThreadId: ampThreadId,
2119
2298
  });
2120
2299
  let incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
2121
2300
  src.at,
@@ -2131,32 +2310,43 @@ function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2131
2310
  });
2132
2311
  }
2133
2312
  if (!incoming.length) return 0;
2134
- const merged = [...(session.sources || []), ...incoming];
2313
+ const beforeIds = new Set(session.sourceIds || []);
2135
2314
  if (sessionSpendIsFrozen(session)) {
2136
- session.sources = merged;
2137
- 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);
2138
2321
  if (uniqueModels.length > 1) session.models = uniqueModels;
2139
2322
  } else {
2140
2323
  const priorCost = numOrNull(session.costUsd);
2141
2324
  const priorSource = session.spendSource;
2142
- applyCollectedSessionFields(session, merged, session.model, {}, {
2325
+ applyCollectedSessionFields(session, incoming, session.model, {}, {
2143
2326
  model: session.model,
2144
2327
  platform: session.platform,
2145
2328
  }, { ampThreads: Array.isArray(extra.ampThreads) ? extra.ampThreads : collected.ampThreads });
2146
2329
  resyncLeftoverSessionSpend(session, priorCost, priorSource);
2147
2330
  }
2148
- return incoming.length;
2331
+ return incoming.filter((source) => !beforeIds.has(String(source.id))).length;
2149
2332
  }
2150
2333
 
2151
2334
  function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported, extra = {}) {
2152
- session.sources = sources || [];
2153
- 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);
2154
2344
  const fromAmp = ampThreadTotals(extra.ampThreads);
2155
2345
  for (const model of fromAmp.models) {
2156
2346
  if (model && !uniqueModels.includes(model)) uniqueModels.push(model);
2157
2347
  }
2158
2348
  if (uniqueModels.length > 1) session.models = uniqueModels;
2159
- const sourceModel = primaryModelFromSources(session.sources);
2349
+ const sourceModel = primaryModelFromSources(incoming);
2160
2350
  if (sourceModel) {
2161
2351
  session.model = sourceModel;
2162
2352
  } else {
@@ -2164,8 +2354,8 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
2164
2354
  || fromAmp.models[0]
2165
2355
  || null;
2166
2356
  }
2167
- if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
2168
- 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);
2169
2359
  session.inputTokens = spend.inputTokens;
2170
2360
  session.outputTokens = spend.outputTokens;
2171
2361
  session.totalTokens = spend.totalTokens;
@@ -2178,10 +2368,39 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
2178
2368
  if (usageModels.length || Array.isArray(session.usageModels)) {
2179
2369
  session.usageModels = usageModels;
2180
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
+ }
2395
+ }
2396
+ session.costUsdEstimated = sessionTotalsFromModels(session.byModel).costUsdEstimated;
2397
+ } else {
2398
+ session.costUsdEstimated = null;
2399
+ }
2181
2400
  }
2182
2401
 
2183
2402
  function sessionTotalsLookOverridden(session) {
2184
- const fromSources = sessionTotalsFromSources(session.sources || []);
2403
+ const fromSources = sessionTotalsFromModels(session.byModel || []);
2185
2404
  return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].some((key) => {
2186
2405
  const sessionVal = numOrNull(session[key]);
2187
2406
  const sourceVal = numOrNull(fromSources[key]);
@@ -2212,16 +2431,16 @@ function metricsBackfillFile(filePath, changeName) {
2212
2431
  });
2213
2432
  const incoming = collected.sources || [];
2214
2433
  if (!incoming.length) return { filePath, added: 0 };
2215
- const merged = [...(last.sources || []), ...incoming];
2216
2434
  if (sessionSpendIsFrozen(last)) {
2217
- last.sources = merged;
2218
- const uniqueModels = uniqueSourceModels(merged);
2219
- if (uniqueModels.length > 1) last.models = uniqueModels;
2435
+ applyCollectedSessionFields(last, incoming, last.model, {}, last, { ampThreads: collected.ampThreads });
2220
2436
  } else {
2221
- applyCollectedSessionFields(last, merged, last.model, {}, {
2437
+ const priorCost = numOrNull(last.costUsd);
2438
+ const priorSource = last.spendSource;
2439
+ applyCollectedSessionFields(last, incoming, last.model, {}, {
2222
2440
  model: last.model,
2223
2441
  platform: last.platform,
2224
2442
  }, { ampThreads: collected.ampThreads });
2443
+ resyncLeftoverSessionSpend(last, priorCost, priorSource);
2225
2444
  }
2226
2445
  metrics.updatedAt = nowIso;
2227
2446
  recomputeMetricsAggregates(metrics);
@@ -2286,35 +2505,19 @@ function recomputeSpendMaps(metrics) {
2286
2505
  byModel.set(key, row);
2287
2506
  }
2288
2507
 
2289
- function addSourceRow(src) {
2290
- const nums = spendTuple(src);
2291
- if (src.platform && byPlatform[src.platform]) {
2292
- addSpendNums(byPlatform[src.platform], nums);
2293
- const label = adapterSourceName(src.platform, src.via);
2294
- if (label) byPlatform[src.platform].source = label;
2295
- }
2296
- addModelRow(src.model, src.platform, nums);
2297
- }
2298
-
2299
2508
  for (const session of metrics.sessions || []) {
2300
- const sources = session.sources || [];
2301
- if (sources.length > 0) {
2302
- for (const src of sources) addSourceRow(src);
2303
- let sourceCost = null;
2304
- for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
2305
- if (sourceCost == null && session.platform && byPlatform[session.platform]) {
2306
- byPlatform[session.platform].costUsd = addNullable(
2307
- byPlatform[session.platform].costUsd,
2308
- numOrNull(session.costUsd),
2309
- );
2310
- }
2311
- continue;
2312
- }
2313
2509
  const sessionNums = spendTuple(session);
2314
2510
  if (session.platform && byPlatform[session.platform]) {
2315
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);
2316
2520
  }
2317
- addModelRow(session.model, session.platform, sessionNums);
2318
2521
  }
2319
2522
  for (const key of Object.keys(byPlatform)) {
2320
2523
  byPlatform[key].costUsdEstimated = roundUsd4(byPlatform[key].costUsdEstimated);
@@ -2327,7 +2530,7 @@ function recomputeSpendMaps(metrics) {
2327
2530
  }
2328
2531
 
2329
2532
  function recomputeMetricsAggregates(metrics) {
2330
- enrichMetricsCursorEstimates(metrics, process.cwd());
2533
+ normalizeMetricsV2(metrics);
2331
2534
  const phases = {};
2332
2535
  const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
2333
2536
  const spend = emptySpendTotals();
@@ -2367,22 +2570,8 @@ function recomputeMetricsAggregates(metrics) {
2367
2570
  ? session.endedAt
2368
2571
  : laterTimestamp(phase.endedAt, session.endedAt);
2369
2572
  }
2370
- const sources = session.sources || [];
2371
- let sourceCost = null;
2372
- for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
2373
2573
  for (const spendKey of METRICS_SPEND_KEYS) {
2374
- let value = null;
2375
- if (sources.length > 0) {
2376
- if (spendKey === 'costUsd') {
2377
- value = sourceCost == null ? numOrNull(session.costUsd) : sourceCost;
2378
- } else {
2379
- for (const src of sources) {
2380
- value = addNullable(value, numOrNull(src[spendKey]));
2381
- }
2382
- }
2383
- } else {
2384
- value = sessionFieldOrSources(session, spendKey);
2385
- }
2574
+ const value = sessionFieldOrSources(session, spendKey);
2386
2575
  phase[spendKey] = addNullable(phase[spendKey], value);
2387
2576
  spend[spendKey] = addNullable(spend[spendKey], value);
2388
2577
  }
@@ -2435,19 +2624,29 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2435
2624
  const nowIso = nowUtcIso();
2436
2625
  const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
2437
2626
  const lastClosed = (metrics.sessions || [])[(metrics.sessions || []).length - 1];
2438
- const leftoverEnd = (metrics.pending && metrics.pending.startedAt)
2439
- ? metrics.pending.startedAt
2440
- : leftoverGraceEnd(lastClosed && lastClosed.endedAt);
2627
+ const leftoverEnd = sessionLeftoverEnd(lastClosed, metrics.pending && metrics.pending.startedAt);
2441
2628
  attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
2442
2629
  exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
2443
2630
  collect: opts.collect === true,
2444
2631
  });
2445
- 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.');
2446
2636
  const reported = opts.reported || fields.metrics || emptyMetricsFields();
2447
2637
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2448
- const windowStart = collectWindowStart(metrics, { startedAt });
2638
+ const windowStart = collectWindowStart(metrics, {
2639
+ explicitStartedAt,
2640
+ lastEndedAt: lastClosed && lastClosed.endedAt,
2641
+ });
2449
2642
  const pending = metrics.pending || {};
2450
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;
2451
2650
  const collectAll = opts.collect === true;
2452
2651
  const platforms = collectAll ? undefined : (platform ? [platform] : []);
2453
2652
  const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
@@ -2455,9 +2654,11 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2455
2654
  const collected = shouldCollect
2456
2655
  ? runCollectSpend(metrics, windowStart, endedAt, {
2457
2656
  platforms,
2458
- ampThreadId: opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null,
2657
+ ampThreadId,
2459
2658
  ampCli: collectAll || platform === 'amp',
2460
- cursorConversationId: platform === 'cursor' ? pending.threadId : undefined,
2659
+ cursorConversationId: cursorThreadId || undefined,
2660
+ listRecentAmpThreads: false,
2661
+ collectAll,
2461
2662
  })
2462
2663
  : { sources: [] };
2463
2664
  const startedMs = parseFlexibleIso(startedAt);
@@ -2476,9 +2677,11 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2476
2677
  agentId: fields.agentId || 'none',
2477
2678
  model: resolvedModel || null,
2478
2679
  platform: opts.platform || pending.platform || null,
2479
- threadId: opts.ampThreadId || pending.threadId || null,
2680
+ threadId: cursorThreadId || ampThreadId || pending.threadId || null,
2480
2681
  tasks: fields.tasks || null,
2481
- sources: [],
2682
+ sourceIds: [],
2683
+ sourceTotals: {},
2684
+ byModel: [],
2482
2685
  inputTokens: null,
2483
2686
  outputTokens: null,
2484
2687
  totalTokens: null,
@@ -2488,22 +2691,6 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2488
2691
  spendSource: 'unreported',
2489
2692
  };
2490
2693
  applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
2491
- if (!session.startedAt) {
2492
- let earliest = null;
2493
- for (const src of session.sources || []) {
2494
- const at = isoOrNull(src.at);
2495
- if (!at) continue;
2496
- earliest = earliest == null ? at : earlierTimestamp(earliest, at);
2497
- }
2498
- if (earliest) {
2499
- session.startedAt = earliest;
2500
- const fromMs = parseFlexibleIso(session.startedAt);
2501
- const toMs = parseFlexibleIso(session.endedAt);
2502
- session.durationMs = Number.isFinite(fromMs) && Number.isFinite(toMs)
2503
- ? Math.max(0, toMs - fromMs)
2504
- : null;
2505
- }
2506
- }
2507
2694
  metrics.sessions.push(session);
2508
2695
  metrics.pending = null;
2509
2696
  metrics.updatedAt = nowIso;
@@ -2534,12 +2721,15 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2534
2721
  const platforms = collectAll ? undefined : (platform ? [platform] : []);
2535
2722
  const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2536
2723
  const ampThreadId = opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null;
2724
+ const archiveWindowStart = collectWindowStart(metrics, {});
2537
2725
  const collected = shouldCollect
2538
- ? runCollectSpend(metrics, startedAt, endedAt, {
2726
+ ? runCollectSpend(metrics, archiveWindowStart, endedAt, {
2539
2727
  platforms,
2540
2728
  ampThreadId,
2541
2729
  ampCli: collectAll || platform === 'amp',
2542
2730
  cursorConversationId: platform === 'cursor' ? (pending.threadId || ampThreadId) : undefined,
2731
+ listRecentAmpThreads: false,
2732
+ collectAll,
2543
2733
  })
2544
2734
  : { sources: [] };
2545
2735
  const reported = dropStaleArchiveSelfReport(opts.reported, metrics.sessions);
@@ -2556,7 +2746,9 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2556
2746
  platform,
2557
2747
  threadId: ampThreadId || null,
2558
2748
  tasks: opts.tasks || null,
2559
- sources: [],
2749
+ sourceIds: [],
2750
+ sourceTotals: {},
2751
+ byModel: [],
2560
2752
  inputTokens: null,
2561
2753
  outputTokens: null,
2562
2754
  totalTokens: null,
@@ -2595,8 +2787,9 @@ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}, extra =
2595
2787
  clientSource: client.source || null,
2596
2788
  };
2597
2789
  }
2598
- const leftoverEnd = metrics.pending && metrics.pending.startedAt;
2599
- 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, {
2600
2793
  exclusiveEnd: true,
2601
2794
  collect: extra.collect === true,
2602
2795
  });
@@ -4485,6 +4678,8 @@ program
4485
4678
  .command('metrics [change-name]')
4486
4679
  .description('Show recorded session metrics for a change: time per phase, tokens, cost, roles, and models')
4487
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)
4488
4683
  .option('--collect', 'Backfill the last session from local spend adapters without adding a new session', false)
4489
4684
  .action((changeName, opts) => {
4490
4685
  const projectDir = process.cwd();
@@ -4541,6 +4736,29 @@ program
4541
4736
  }
4542
4737
 
4543
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
+ }
4544
4762
  if (opts.json) {
4545
4763
  process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
4546
4764
  return;
@@ -4574,4 +4792,6 @@ export {
4574
4792
  sessionSpendIsFrozen,
4575
4793
  attachLeftoverSources,
4576
4794
  runCollectSpend,
4795
+ normalizeMetricsV2,
4796
+ firstSpawnName,
4577
4797
  };