agent-orchestrator-kit 0.7.0 → 0.9.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.
@@ -6,6 +6,15 @@ import { join, dirname, basename, resolve } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
8
8
  import { collectSpend } from './spend-collect.js';
9
+ import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
10
+ import {
11
+ earlierTimestamp,
12
+ formatKyivDisplay,
13
+ isoOrNull,
14
+ laterTimestamp,
15
+ nowUtcIso,
16
+ parseFlexibleIso,
17
+ } from './metrics-time.js';
9
18
 
10
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
20
  const KIT_ROOT = join(__dirname, '..');
@@ -484,7 +493,7 @@ function printSpendHealth(projectDir) {
484
493
  ? String(process.env.AMP_DATA_DIR).trim()
485
494
  : join(home, '.local', 'share', 'amp');
486
495
  const ampOk = existsSync(join(ampDir, 'threads'));
487
- console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}`);
496
+ console.log(` amp ${ampOk ? 'ok (threads found)' : 'no local Amp data'}; locked client + amp threads export`);
488
497
  console.log('');
489
498
  }
490
499
 
@@ -1122,12 +1131,22 @@ function firstNonNull(...values) {
1122
1131
  return null;
1123
1132
  }
1124
1133
 
1134
+ function isPlaceholderModel(value) {
1135
+ const normalized = String(value || '').trim().toLowerCase();
1136
+ return !normalized || ['unknown', 'none', 'n/a', '-', '—', 'null', 'amp-default'].includes(normalized);
1137
+ }
1138
+
1125
1139
  function resolveModel(opts, env, reported) {
1126
- const flag = opts && opts.model != null ? String(opts.model).trim() : '';
1140
+ const pick = (value) => {
1141
+ const text = value == null ? '' : String(value).trim();
1142
+ if (!text || isPlaceholderModel(text)) return '';
1143
+ return text;
1144
+ };
1145
+ const flag = pick(opts && opts.model);
1127
1146
  if (flag) return flag;
1128
- const fromReport = reported && reported.model != null ? String(reported.model).trim() : '';
1147
+ const fromReport = pick(reported && reported.model);
1129
1148
  if (fromReport) return fromReport;
1130
- const fromEnv = env && env.AOK_MODEL != null ? String(env.AOK_MODEL).trim() : '';
1149
+ const fromEnv = pick(env && env.AOK_MODEL);
1131
1150
  if (fromEnv) return fromEnv;
1132
1151
  return null;
1133
1152
  }
@@ -1151,7 +1170,7 @@ function inferPlatformFromHost(env) {
1151
1170
  return null;
1152
1171
  }
1153
1172
 
1154
- function resolvePlatform(opts, env, reported) {
1173
+ function resolvePlatform(opts, env, reported, pending) {
1155
1174
  const flag = opts && opts.platform != null ? String(opts.platform).trim() : '';
1156
1175
  if (flag) {
1157
1176
  const lower = flag.toLowerCase();
@@ -1172,6 +1191,10 @@ function resolvePlatform(opts, env, reported) {
1172
1191
  if (VALID_PLATFORMS.has(lower)) return { value: lower };
1173
1192
  return { value: null, warn: 'invalid AOK_PLATFORM (use cursor, claude, or amp)' };
1174
1193
  }
1194
+ const pendingPlatform = pending && pending.platform != null ? String(pending.platform).trim() : '';
1195
+ if (pendingPlatform && VALID_PLATFORMS.has(pendingPlatform)) {
1196
+ return { value: pendingPlatform };
1197
+ }
1175
1198
  return { value: inferPlatformFromHost(env) };
1176
1199
  }
1177
1200
 
@@ -1589,14 +1612,14 @@ function readHandoffFields(projectDir, changeName) {
1589
1612
  }
1590
1613
 
1591
1614
  const METRICS_VERSION = 1;
1592
- const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'];
1615
+ const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
1593
1616
 
1594
1617
  function metricsFilePath(projectDir, changeName) {
1595
1618
  return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
1596
1619
  }
1597
1620
 
1598
1621
  function emptySpendTotals() {
1599
- return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
1622
+ return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null, costUsdEstimated: null };
1600
1623
  }
1601
1624
 
1602
1625
  function emptyPlatformSpend(source = 'none') {
@@ -1606,6 +1629,7 @@ function emptyPlatformSpend(source = 'none') {
1606
1629
  totalTokens: null,
1607
1630
  costUsd: null,
1608
1631
  ampCredits: null,
1632
+ costUsdEstimated: null,
1609
1633
  source,
1610
1634
  };
1611
1635
  }
@@ -1673,7 +1697,9 @@ function loadMetricsFile(filePath, changeName, nowIso) {
1673
1697
 
1674
1698
  function saveMetricsFile(filePath, metrics) {
1675
1699
  mkdirSync(dirname(filePath), { recursive: true });
1676
- writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
1700
+ const out = { ...metrics };
1701
+ delete out.timezone;
1702
+ writeFileSync(filePath, `${JSON.stringify(out, null, 2)}\n`);
1677
1703
  }
1678
1704
 
1679
1705
  function numOrNull(value) {
@@ -1698,12 +1724,6 @@ function phaseForRole(role) {
1698
1724
  return 'other';
1699
1725
  }
1700
1726
 
1701
- function isoOrNull(value) {
1702
- if (!value) return null;
1703
- const ms = Date.parse(String(value));
1704
- return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
1705
- }
1706
-
1707
1727
  function sessionFieldOrSources(session, key) {
1708
1728
  if (session[key] != null && session[key] !== '') return numOrNull(session[key]);
1709
1729
  let sum = null;
@@ -1716,7 +1736,7 @@ function sessionFieldOrSources(session, key) {
1716
1736
  function lastSessionEndedAt(metrics) {
1717
1737
  let last = null;
1718
1738
  for (const session of metrics.sessions || []) {
1719
- if (session.endedAt && (last == null || session.endedAt > last)) last = session.endedAt;
1739
+ if (session.endedAt) last = last == null ? session.endedAt : laterTimestamp(last, session.endedAt);
1720
1740
  }
1721
1741
  return last;
1722
1742
  }
@@ -1735,6 +1755,33 @@ function existingSourceIdSet(metrics) {
1735
1755
  return ids;
1736
1756
  }
1737
1757
 
1758
+ function lastNonArchiverSession(sessions) {
1759
+ const list = Array.isArray(sessions) ? sessions : [];
1760
+ for (let i = list.length - 1; i >= 0; i -= 1) {
1761
+ if (list[i] && list[i].role !== 'Archiver') return list[i];
1762
+ }
1763
+ return null;
1764
+ }
1765
+
1766
+ function dropStaleArchiveSelfReport(reported, sessions) {
1767
+ const base = reported && typeof reported === 'object' ? { ...reported } : emptyMetricsFields();
1768
+ const last = lastNonArchiverSession(sessions);
1769
+ if (!last) return base;
1770
+ const sameInput = numOrNull(base.inputTokens) != null && numOrNull(base.inputTokens) === numOrNull(last.inputTokens);
1771
+ const sameOutput = numOrNull(base.outputTokens) === numOrNull(last.outputTokens);
1772
+ if (sameInput && sameOutput) {
1773
+ base.inputTokens = null;
1774
+ base.outputTokens = null;
1775
+ base.totalTokens = null;
1776
+ base.costUsd = null;
1777
+ base.ampCredits = null;
1778
+ if (!base.spendSource || base.spendSource === 'self-report') base.spendSource = null;
1779
+ }
1780
+ if (base.model && last.model && base.model === last.model) base.model = null;
1781
+ if (base.platform && last.platform && base.platform === last.platform) base.platform = null;
1782
+ return base;
1783
+ }
1784
+
1738
1785
  function uniqueSourceModels(sources) {
1739
1786
  const seen = [];
1740
1787
  for (const src of sources || []) {
@@ -1793,11 +1840,35 @@ function sourceAmpCredits(sources) {
1793
1840
  return sum;
1794
1841
  }
1795
1842
 
1796
- function resolveSessionSpend(opts, reported, sources) {
1843
+ function sourceEstimatedUsd(sources) {
1844
+ let sum = null;
1845
+ for (const src of sources || []) {
1846
+ sum = addNullable(sum, numOrNull(src.costUsdEstimated));
1847
+ }
1848
+ return sum;
1849
+ }
1850
+
1851
+ function ampThreadTotals(threads) {
1852
+ const list = Array.isArray(threads) ? threads : [];
1853
+ let costUsd = null;
1854
+ let agentMode = null;
1855
+ const models = [];
1856
+ for (const thread of list) {
1857
+ costUsd = addNullable(costUsd, numOrNull(thread && thread.costUsd));
1858
+ if (!agentMode && thread && thread.agentMode) agentMode = String(thread.agentMode);
1859
+ for (const row of (thread && thread.models) || []) {
1860
+ if (row && row.model && !models.includes(row.model)) models.push(row.model);
1861
+ }
1862
+ }
1863
+ return { costUsd, agentMode, models };
1864
+ }
1865
+
1866
+ function resolveSessionSpend(opts, reported, sources, extra = {}) {
1797
1867
  const flags = opts || {};
1798
1868
  const self = reported || emptyMetricsFields();
1799
1869
  const fromSources = sessionTotalsFromSources(sources || []);
1800
1870
  const sourceCredits = sourceAmpCredits(sources || []);
1871
+ const fromAmp = ampThreadTotals(extra.ampThreads);
1801
1872
  const flagInput = numOrNull(flags.inputTokens);
1802
1873
  const flagOutput = numOrNull(flags.outputTokens);
1803
1874
  const flagTotal = numOrNull(flags.totalTokens);
@@ -1809,12 +1880,14 @@ function resolveSessionSpend(opts, reported, sources) {
1809
1880
  if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
1810
1881
  totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
1811
1882
  }
1812
- const costUsd = firstNonNull(flagCost, self.costUsd, fromSources.costUsd);
1883
+ const costUsd = firstNonNull(flagCost, self.costUsd, fromAmp.costUsd, fromSources.costUsd);
1813
1884
  const ampCredits = firstNonNull(flagCredits, self.ampCredits, sourceCredits);
1885
+ const costUsdEstimated = sourceEstimatedUsd(sources || []);
1814
1886
  let spendSource = 'unreported';
1815
1887
  if (self.spendSource) spendSource = String(self.spendSource);
1816
1888
  else if (hasSpendOverride(flags)) spendSource = 'flag';
1817
1889
  else if (reportedHasSpendNumbers(self)) spendSource = 'self-report';
1890
+ else if (fromAmp.costUsd != null) spendSource = 'amp-usage';
1818
1891
  else if (
1819
1892
  fromSources.inputTokens != null
1820
1893
  || fromSources.outputTokens != null
@@ -1824,7 +1897,7 @@ function resolveSessionSpend(opts, reported, sources) {
1824
1897
  ) {
1825
1898
  spendSource = 'adapter';
1826
1899
  }
1827
- return { inputTokens, outputTokens, totalTokens, costUsd, ampCredits, spendSource };
1900
+ return { inputTokens, outputTokens, totalTokens, costUsd, ampCredits, costUsdEstimated, spendSource, agentMode: fromAmp.agentMode };
1828
1901
  }
1829
1902
 
1830
1903
  function sessionTotalsFromFlags(opts) {
@@ -1856,7 +1929,7 @@ function sessionTotalsFromSources(sources) {
1856
1929
  return { inputTokens, outputTokens, totalTokens, costUsd };
1857
1930
  }
1858
1931
 
1859
- function runCollectSpend(metrics, windowStart, windowEnd) {
1932
+ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
1860
1933
  try {
1861
1934
  return collectSpend({
1862
1935
  cwd: process.cwd(),
@@ -1865,25 +1938,48 @@ function runCollectSpend(metrics, windowStart, windowEnd) {
1865
1938
  existingSourceIds: existingSourceIdSet(metrics),
1866
1939
  env: process.env,
1867
1940
  homedir: process.env.HOME,
1941
+ platforms: extra.platforms,
1942
+ ampThreadId: extra.ampThreadId,
1943
+ ampCli: extra.ampCli === true,
1944
+ exportAmpThread: extra.exportAmpThread,
1945
+ usageAmpThread: extra.usageAmpThread,
1868
1946
  });
1869
1947
  } catch {
1870
1948
  return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
1871
1949
  }
1872
1950
  }
1873
1951
 
1874
- function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported) {
1952
+ function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported, extra = {}) {
1875
1953
  session.sources = sources || [];
1876
1954
  const uniqueModels = uniqueSourceModels(session.sources);
1877
- if (!session.model) session.model = resolvedModel || primaryModelFromSources(session.sources) || null;
1955
+ const fromAmp = ampThreadTotals(extra.ampThreads);
1956
+ for (const model of fromAmp.models) {
1957
+ if (model && !uniqueModels.includes(model)) uniqueModels.push(model);
1958
+ }
1959
+ if (!session.model || isPlaceholderModel(session.model)) {
1960
+ session.model = (resolvedModel && !isPlaceholderModel(resolvedModel) ? resolvedModel : null)
1961
+ || primaryModelFromSources(session.sources)
1962
+ || fromAmp.models[0]
1963
+ || null;
1964
+ }
1878
1965
  if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
1879
1966
  if (uniqueModels.length > 1) session.models = uniqueModels;
1880
- const spend = resolveSessionSpend(opts, reported, session.sources);
1967
+ const spend = resolveSessionSpend(opts, reported, session.sources, extra);
1881
1968
  session.inputTokens = spend.inputTokens;
1882
1969
  session.outputTokens = spend.outputTokens;
1883
1970
  session.totalTokens = spend.totalTokens;
1884
1971
  session.costUsd = spend.costUsd;
1885
1972
  session.ampCredits = spend.ampCredits;
1973
+ session.costUsdEstimated = spend.costUsdEstimated;
1886
1974
  session.spendSource = spend.spendSource;
1975
+ if (spend.agentMode) session.agentMode = spend.agentMode;
1976
+ const usageModels = [];
1977
+ for (const thread of extra.ampThreads || []) {
1978
+ for (const row of thread.models || []) {
1979
+ if (row && row.model) usageModels.push(row);
1980
+ }
1981
+ }
1982
+ if (usageModels.length) session.usageModels = usageModels;
1887
1983
  }
1888
1984
 
1889
1985
  function sessionTotalsLookOverridden(session) {
@@ -1904,13 +2000,17 @@ function metricsBackfillLastSession(projectDir, changeName) {
1904
2000
  }
1905
2001
 
1906
2002
  function metricsBackfillFile(filePath, changeName) {
1907
- const nowIso = new Date().toISOString();
2003
+ const nowIso = nowUtcIso();
1908
2004
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
1909
2005
  const sessions = metrics.sessions || [];
1910
2006
  if (!sessions.length) return { filePath, added: 0, empty: true };
1911
2007
  const last = sessions[sessions.length - 1];
1912
2008
  const windowStart = last.startedAt || collectWindowStart(metrics);
1913
- const collected = runCollectSpend(metrics, windowStart, nowIso);
2009
+ const lastPlatform = last && last.platform;
2010
+ const collected = runCollectSpend(metrics, windowStart, nowIso, {
2011
+ ampThreadId: last && last.threadId || ampThreadIdFromEnv(process.env) || null,
2012
+ ampCli: lastPlatform === 'amp',
2013
+ });
1914
2014
  const incoming = collected.sources || [];
1915
2015
  if (!incoming.length) return { filePath, added: 0 };
1916
2016
  const overridden = sessionTotalsLookOverridden(last);
@@ -1927,7 +2027,7 @@ function metricsBackfillFile(filePath, changeName) {
1927
2027
  applyCollectedSessionFields(last, merged, last.model, {}, {
1928
2028
  model: last.model,
1929
2029
  platform: last.platform,
1930
- });
2030
+ }, { ampThreads: collected.ampThreads });
1931
2031
  }
1932
2032
  metrics.updatedAt = nowIso;
1933
2033
  recomputeMetricsAggregates(metrics);
@@ -1935,9 +2035,9 @@ function metricsBackfillFile(filePath, changeName) {
1935
2035
  return { filePath, added: incoming.length };
1936
2036
  }
1937
2037
 
1938
- function adapterSourceName(platform) {
2038
+ function adapterSourceName(platform, via) {
1939
2039
  if (platform === 'claude') return 'claude-jsonl';
1940
- if (platform === 'amp') return 'amp-thread';
2040
+ if (platform === 'amp') return via === 'amp-cli' ? 'amp-cli' : 'amp-thread';
1941
2041
  if (platform === 'cursor') return 'cursor-hook';
1942
2042
  return null;
1943
2043
  }
@@ -1949,11 +2049,12 @@ function spendTuple(obj) {
1949
2049
  totalTokens: numOrNull(obj && obj.totalTokens),
1950
2050
  costUsd: numOrNull(obj && obj.costUsd),
1951
2051
  ampCredits: numOrNull(obj && obj.ampCredits),
2052
+ costUsdEstimated: numOrNull(obj && obj.costUsdEstimated),
1952
2053
  };
1953
2054
  }
1954
2055
 
1955
2056
  function spendTuplesMatch(a, b) {
1956
- return ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'].every((key) => {
2057
+ return ['inputTokens', 'outputTokens', 'totalTokens'].every((key) => {
1957
2058
  const left = a[key];
1958
2059
  const right = b[key];
1959
2060
  if (left == null && right == null) return true;
@@ -1967,6 +2068,7 @@ function addSpendNums(target, nums) {
1967
2068
  target.totalTokens = addNullable(target.totalTokens, nums.totalTokens);
1968
2069
  target.costUsd = addNullable(target.costUsd, nums.costUsd);
1969
2070
  target.ampCredits = addNullable(target.ampCredits, nums.ampCredits);
2071
+ target.costUsdEstimated = addNullable(target.costUsdEstimated, nums.costUsdEstimated);
1970
2072
  }
1971
2073
 
1972
2074
  function recomputeSpendMaps(metrics) {
@@ -1984,6 +2086,7 @@ function recomputeSpendMaps(metrics) {
1984
2086
  totalTokens: null,
1985
2087
  costUsd: null,
1986
2088
  ampCredits: null,
2089
+ costUsdEstimated: null,
1987
2090
  };
1988
2091
  addSpendNums(row, nums);
1989
2092
  byModel.set(key, row);
@@ -1993,7 +2096,7 @@ function recomputeSpendMaps(metrics) {
1993
2096
  const nums = spendTuple(src);
1994
2097
  if (src.platform && byPlatform[src.platform]) {
1995
2098
  addSpendNums(byPlatform[src.platform], nums);
1996
- const label = adapterSourceName(src.platform);
2099
+ const label = adapterSourceName(src.platform, src.via);
1997
2100
  if (label) byPlatform[src.platform].source = label;
1998
2101
  }
1999
2102
  addModelRow(src.model, src.platform, nums);
@@ -2008,6 +2111,19 @@ function recomputeSpendMaps(metrics) {
2008
2111
 
2009
2112
  if (sourcesMatchSession) {
2010
2113
  for (const src of sources) addSourceRow(src);
2114
+ if (session.costUsd != null && sourceTotals.costUsd == null && session.platform && byPlatform[session.platform]) {
2115
+ byPlatform[session.platform].costUsd = addNullable(byPlatform[session.platform].costUsd, session.costUsd);
2116
+ }
2117
+ for (const row of session.usageModels || []) {
2118
+ addModelRow(row.model, session.platform || 'amp', {
2119
+ inputTokens: null,
2120
+ outputTokens: null,
2121
+ totalTokens: null,
2122
+ costUsd: numOrNull(row.costUsd),
2123
+ ampCredits: null,
2124
+ costUsdEstimated: null,
2125
+ });
2126
+ }
2011
2127
  continue;
2012
2128
  }
2013
2129
 
@@ -2031,8 +2147,8 @@ function recomputeMetricsAggregates(metrics) {
2031
2147
  totals.sessions += 1;
2032
2148
  if (session.runtime === 'cloud') totals.cloudSessions += 1;
2033
2149
  totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
2034
- if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
2035
- if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
2150
+ if (session.startedAt) firstStart = firstStart == null ? session.startedAt : earlierTimestamp(firstStart, session.startedAt);
2151
+ if (session.endedAt) lastEnd = lastEnd == null ? session.endedAt : laterTimestamp(lastEnd, session.endedAt);
2036
2152
  const key = session.phase || 'other';
2037
2153
  const phase = phases[key] || { sessions: 0, durationMs: null, ...emptySpendTotals(), agents: [], models: [] };
2038
2154
  phase.sessions += 1;
@@ -2052,7 +2168,7 @@ function recomputeMetricsAggregates(metrics) {
2052
2168
  phases[key] = phase;
2053
2169
  }
2054
2170
  if (firstStart && lastEnd) {
2055
- totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
2171
+ totals.leadTimeMs = Math.max(0, parseFlexibleIso(lastEnd) - parseFlexibleIso(firstStart));
2056
2172
  }
2057
2173
  metrics.phases = phases;
2058
2174
  metrics.totals = totals;
@@ -2060,11 +2176,17 @@ function recomputeMetricsAggregates(metrics) {
2060
2176
  recomputeSpendMaps(metrics);
2061
2177
  }
2062
2178
 
2063
- function metricsRecordSessionStart(projectDir, changeName, role) {
2179
+ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
2064
2180
  const filePath = metricsFilePath(projectDir, changeName);
2065
- const nowIso = new Date().toISOString();
2181
+ const nowIso = nowUtcIso();
2066
2182
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2067
- metrics.pending = { startedAt: nowIso, role: role || '' };
2183
+ metrics.pending = {
2184
+ startedAt: nowIso,
2185
+ role: role || '',
2186
+ platform: client.platform || null,
2187
+ threadId: client.threadId || null,
2188
+ clientSource: client.source || null,
2189
+ };
2068
2190
  metrics.updatedAt = nowIso;
2069
2191
  saveMetricsFile(filePath, metrics);
2070
2192
  return filePath;
@@ -2072,15 +2194,28 @@ function metricsRecordSessionStart(projectDir, changeName, role) {
2072
2194
 
2073
2195
  function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2074
2196
  const filePath = metricsFilePath(projectDir, fields.changeName);
2075
- const nowIso = new Date().toISOString();
2197
+ const nowIso = nowUtcIso();
2076
2198
  const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
2077
- const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
2078
- const durationMs = startedAt ? Math.max(0, Date.parse(nowIso) - Date.parse(startedAt)) : null;
2199
+ const startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
2200
+ const startedMs = parseFlexibleIso(startedAt);
2201
+ const endedMs = parseFlexibleIso(nowIso);
2202
+ const durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
2203
+ ? Math.max(0, endedMs - startedMs)
2204
+ : null;
2079
2205
  const reported = opts.reported || fields.metrics || emptyMetricsFields();
2080
2206
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2081
2207
  const windowStart = collectWindowStart(metrics);
2082
- const collected = opts.collect === true
2083
- ? runCollectSpend(metrics, windowStart, nowIso)
2208
+ const pending = metrics.pending || {};
2209
+ const platform = opts.platform || pending.platform || null;
2210
+ const collectAll = opts.collect === true;
2211
+ const platforms = collectAll ? undefined : (platform ? [platform] : []);
2212
+ const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2213
+ const collected = shouldCollect
2214
+ ? runCollectSpend(metrics, windowStart, nowIso, {
2215
+ platforms,
2216
+ ampThreadId: opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null,
2217
+ ampCli: collectAll || platform === 'amp',
2218
+ })
2084
2219
  : { sources: [] };
2085
2220
  const session = {
2086
2221
  startedAt,
@@ -2091,7 +2226,8 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2091
2226
  runtime: fields.runtime || 'local',
2092
2227
  agentId: fields.agentId || 'none',
2093
2228
  model: resolvedModel || null,
2094
- platform: opts.platform || null,
2229
+ platform: opts.platform || pending.platform || null,
2230
+ threadId: opts.ampThreadId || pending.threadId || null,
2095
2231
  tasks: fields.tasks || null,
2096
2232
  sources: [],
2097
2233
  inputTokens: null,
@@ -2099,9 +2235,10 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2099
2235
  totalTokens: null,
2100
2236
  costUsd: null,
2101
2237
  ampCredits: null,
2238
+ costUsdEstimated: null,
2102
2239
  spendSource: 'unreported',
2103
2240
  };
2104
- applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
2241
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
2105
2242
  metrics.sessions.push(session);
2106
2243
  metrics.pending = null;
2107
2244
  metrics.updatedAt = nowIso;
@@ -2117,13 +2254,22 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2117
2254
 
2118
2255
  function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2119
2256
  const filePath = join(targetDir, 'metrics.json');
2120
- const nowIso = new Date().toISOString();
2257
+ const nowIso = nowUtcIso();
2121
2258
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2122
2259
  const windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
2123
- const collected = opts.collect === true
2124
- ? runCollectSpend(metrics, windowStart, nowIso)
2260
+ const collectAll = opts.collect === true;
2261
+ const platform = opts.platform || null;
2262
+ const platforms = collectAll ? undefined : (platform ? [platform] : []);
2263
+ const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
2264
+ const ampThreadId = opts.ampThreadId || ampThreadIdFromEnv(process.env) || null;
2265
+ const collected = shouldCollect
2266
+ ? runCollectSpend(metrics, windowStart, nowIso, {
2267
+ platforms,
2268
+ ampThreadId,
2269
+ ampCli: collectAll || platform === 'amp',
2270
+ })
2125
2271
  : { sources: [] };
2126
- const reported = opts.reported || emptyMetricsFields();
2272
+ const reported = dropStaleArchiveSelfReport(opts.reported, metrics.sessions);
2127
2273
  const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
2128
2274
  const session = {
2129
2275
  startedAt: nowIso,
@@ -2134,7 +2280,8 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2134
2280
  runtime: opts.runtime || 'local',
2135
2281
  agentId: opts.agentId || 'none',
2136
2282
  model: resolvedModel || null,
2137
- platform: opts.platform || null,
2283
+ platform,
2284
+ threadId: ampThreadId || null,
2138
2285
  tasks: opts.tasks || null,
2139
2286
  sources: [],
2140
2287
  inputTokens: null,
@@ -2142,9 +2289,10 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2142
2289
  totalTokens: null,
2143
2290
  costUsd: null,
2144
2291
  ampCredits: null,
2292
+ costUsdEstimated: null,
2145
2293
  spendSource: 'unreported',
2146
2294
  };
2147
- applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported);
2295
+ applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
2148
2296
  metrics.sessions.push(session);
2149
2297
  metrics.archivedAt = nowIso;
2150
2298
  metrics.pending = null;
@@ -2179,6 +2327,17 @@ function formatMetricsCost(value) {
2179
2327
  return value == null ? '—' : `$${Number(value).toFixed(2)}`;
2180
2328
  }
2181
2329
 
2330
+ function formatMetricsCostLine(spend) {
2331
+ const billed = spend && spend.costUsd;
2332
+ const estimated = spend && spend.costUsdEstimated;
2333
+ if (billed == null && estimated == null) return '—';
2334
+ if (billed != null && estimated != null) {
2335
+ return `${formatMetricsCost(billed)} billed + ~${formatMetricsCost(estimated)} est.`;
2336
+ }
2337
+ if (billed != null) return formatMetricsCost(billed);
2338
+ return `~${formatMetricsCost(estimated)} est.`;
2339
+ }
2340
+
2182
2341
  function sessionSpendSourceLabel(session) {
2183
2342
  const raw = session && session.spendSource;
2184
2343
  if (raw == null || String(raw).trim() === '') return 'unreported';
@@ -2193,11 +2352,14 @@ function renderMetricsSummary(metrics) {
2193
2352
  lines.push(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
2194
2353
  lines.push(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
2195
2354
  lines.push(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
2196
- lines.push(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
2355
+ lines.push(`cost: ${formatMetricsCostLine(metrics.spend)}`);
2197
2356
  lines.push(`unreported: ${unreported}`);
2198
- if (metrics.archivedAt) lines.push(`archived: ${metrics.archivedAt}`);
2357
+ if (metrics.archivedAt) lines.push(`archived: ${formatKyivDisplay(metrics.archivedAt)}`);
2199
2358
  if (metrics.pending) {
2200
- lines.push(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
2359
+ const pendingClient = metrics.pending.platform
2360
+ ? ` ${metrics.pending.platform}${metrics.pending.threadId ? ` ${metrics.pending.threadId}` : ''}`
2361
+ : '';
2362
+ lines.push(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'}${pendingClient})`);
2201
2363
  }
2202
2364
 
2203
2365
  const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
@@ -2212,7 +2374,7 @@ function renderMetricsSummary(metrics) {
2212
2374
  String(phase.sessions).padEnd(9),
2213
2375
  formatMetricsDuration(phase.durationMs).padEnd(9),
2214
2376
  formatMetricsNumber(phase.totalTokens).padEnd(9),
2215
- formatMetricsCost(phase.costUsd).padEnd(9),
2377
+ formatMetricsCostLine(phase).padEnd(9),
2216
2378
  (phase.agents.join(', ') || '—').padEnd(20),
2217
2379
  phase.models.join(', ') || '—',
2218
2380
  ].join(' '));
@@ -2228,7 +2390,7 @@ function renderMetricsSummary(metrics) {
2228
2390
  lines.push([
2229
2391
  key.padEnd(10),
2230
2392
  formatMetricsNumber(row.totalTokens).padEnd(9),
2231
- formatMetricsCost(row.costUsd).padEnd(9),
2393
+ formatMetricsCostLine(row).padEnd(9),
2232
2394
  formatMetricsNumber(row.ampCredits).padEnd(9),
2233
2395
  row.source || 'none',
2234
2396
  ].join(' '));
@@ -2246,7 +2408,7 @@ function renderMetricsSummary(metrics) {
2246
2408
  String(row.model || '—').padEnd(20),
2247
2409
  String(row.platform || '—').padEnd(10),
2248
2410
  formatMetricsNumber(row.totalTokens).padEnd(9),
2249
- formatMetricsCost(row.costUsd).padEnd(9),
2411
+ formatMetricsCostLine(row).padEnd(9),
2250
2412
  formatMetricsNumber(row.ampCredits),
2251
2413
  ].join(' '));
2252
2414
  }
@@ -2256,10 +2418,11 @@ function renderMetricsSummary(metrics) {
2256
2418
  lines.push('');
2257
2419
  lines.push('recent sessions:');
2258
2420
  for (const session of sessions.slice(-5)) {
2259
- const spendLabel = session.totalTokens != null || session.costUsd != null
2260
- ? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
2421
+ const spendLabel = session.totalTokens != null || session.costUsd != null || session.costUsdEstimated != null
2422
+ ? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCostLine(session)}`
2261
2423
  : '';
2262
- lines.push(`- ${session.endedAt} ${String(session.phase || '').padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''} (${sessionSpendSourceLabel(session)})${spendLabel}`);
2424
+ const modeLabel = session.agentMode ? ` mode:${session.agentMode}` : '';
2425
+ lines.push(`- ${formatKyivDisplay(session.endedAt)} ${String(session.phase || '').padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''}${modeLabel} (${sessionSpendSourceLabel(session)})${spendLabel}`);
2263
2426
  }
2264
2427
  }
2265
2428
  return lines;
@@ -3438,7 +3601,7 @@ program
3438
3601
  .option('--output-tokens <n>', 'Output tokens spent in the Archiver session')
3439
3602
  .option('--total-tokens <n>', 'Total tokens spent in the Archiver session (default: input + output)')
3440
3603
  .option('--cost-usd <usd>', 'Cost of the Archiver session in USD')
3441
- .option('--collect', 'Additionally collect local spend adapters', false)
3604
+ .option('--collect', 'Collect all spend adapters (default: locked Cursor/Amp/Claude client only)', false)
3442
3605
  .action((name, opts) => {
3443
3606
  const projectDir = process.cwd();
3444
3607
  const fail = (msg) => {
@@ -3558,11 +3721,25 @@ program
3558
3721
  if (existsSync(archivedHandoffPath)) {
3559
3722
  priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
3560
3723
  }
3561
- const reported = priorFields.metrics || emptyMetricsFields();
3562
- const archivePlatform = resolvePlatform(opts, process.env, reported);
3724
+ const metricsPreview = loadMetricsFile(join(targetDir, 'metrics.json'), name, nowUtcIso());
3725
+ const reported = dropStaleArchiveSelfReport(priorFields.metrics, metricsPreview.sessions);
3726
+ const client = resolveRestoreClient({
3727
+ env: process.env,
3728
+ cwd: projectDir,
3729
+ homedir: process.env.HOME,
3730
+ platform: opts.platform,
3731
+ });
3732
+ const archivePlatform = resolvePlatform(opts, process.env, reported, {
3733
+ platform: client.platform,
3734
+ threadId: client.threadId,
3735
+ });
3563
3736
  const archiveModel = resolveModel(opts, process.env, reported);
3564
3737
  if (archivePlatform.warn) console.error(archivePlatform.warn);
3565
3738
  printMetricsSectionWarnings(reported, Boolean(archivePlatform.warn));
3739
+ const clientLabel = archivePlatform.value || client.platform
3740
+ ? `${archivePlatform.value || client.platform}${client.threadId ? ` ${client.threadId}` : ''}${client.source ? ` (${client.source})` : ''}`
3741
+ : 'unknown — pass --platform or run archive from Cursor/Amp/Claude';
3742
+ console.error(`metrics: archive client ${clientLabel}`);
3566
3743
  const runtimeResult = resolveRuntime({}, process.env, priorFields);
3567
3744
  const progress = parseTasksProgress(targetDir);
3568
3745
  const fields = {
@@ -3589,7 +3766,8 @@ program
3589
3766
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
3590
3767
  const metricsPath = metricsFinalizeArchive(targetDir, name, {
3591
3768
  model: archiveModel,
3592
- platform: archivePlatform.value || null,
3769
+ platform: archivePlatform.value || client.platform || null,
3770
+ ampThreadId: client.threadId || ampThreadIdFromEnv(process.env) || null,
3593
3771
  runtime: fields.runtime,
3594
3772
  agentId: fields.agentId,
3595
3773
  tasks: fields.tasks,
@@ -3609,7 +3787,7 @@ program
3609
3787
  console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
3610
3788
  console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
3611
3789
  try {
3612
- const archivedMetrics = loadMetricsFile(metricsPath, name, new Date().toISOString());
3790
+ const archivedMetrics = loadMetricsFile(metricsPath, name, nowUtcIso());
3613
3791
  for (const line of renderMetricsSummary(archivedMetrics)) console.log(line);
3614
3792
  } catch {}
3615
3793
  log.ok(`archived ${name}`);
@@ -3877,8 +4055,18 @@ program
3877
4055
  log.warn(`Memory JSON empty or missing at ${memoryPath}`);
3878
4056
  }
3879
4057
  if (opts.metrics !== false && existsSync(changeDir)) {
3880
- const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
4058
+ const client = resolveRestoreClient({
4059
+ env: process.env,
4060
+ cwd: projectDir,
4061
+ homedir: process.env.HOME,
4062
+ platform: opts.platform,
4063
+ });
4064
+ const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '', client);
4065
+ const clientLabel = client.platform
4066
+ ? `${client.platform}${client.threadId ? ` ${client.threadId}` : ''} (${client.source})`
4067
+ : 'unknown — pass --platform or fill ## Metrics';
3881
4068
  log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
4069
+ log.info(`metrics: client ${clientLabel}`);
3882
4070
  }
3883
4071
  return;
3884
4072
  }
@@ -3948,7 +4136,8 @@ program
3948
4136
  }
3949
4137
 
3950
4138
  const reported = fields.metrics || emptyMetricsFields();
3951
- const platformResult = resolvePlatform(opts, process.env, reported);
4139
+ const metricsPreview = loadMetricsFile(metricsFilePath(projectDir, name), name, nowUtcIso());
4140
+ const platformResult = resolvePlatform(opts, process.env, reported, metricsPreview.pending);
3952
4141
  if (platformResult.error) {
3953
4142
  log.err(platformResult.error);
3954
4143
  process.exitCode = 1;
@@ -3977,6 +4166,7 @@ program
3977
4166
  totalTokens: opts.totalTokens,
3978
4167
  costUsd: opts.costUsd,
3979
4168
  collect: opts.collect === true,
4169
+ ampThreadId: (metricsPreview.pending && metricsPreview.pending.threadId) || ampThreadIdFromEnv(process.env) || null,
3980
4170
  reported,
3981
4171
  });
3982
4172
  console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
@@ -4047,7 +4237,7 @@ program
4047
4237
  return;
4048
4238
  }
4049
4239
 
4050
- const metrics = loadMetricsFile(filePath, name, new Date().toISOString());
4240
+ const metrics = loadMetricsFile(filePath, name, nowUtcIso());
4051
4241
  if (opts.json) {
4052
4242
  process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
4053
4243
  return;