agent-orchestrator-kit 0.11.0 → 0.13.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.13.0] - 2026-09-04
8
+
9
+ ### Fixed
10
+ - **Amp Cost-once rollup, restore `amp-session-last`, leftover thread-scope.** After persist/recompute, Amp `Cost: $N` is added once **per qualifying session** into `spend.costUsd`, `spendByPlatform.amp.costUsd`, and `phases.*.costUsd` when that session’s `sources[].costUsd` are all null — never copied onto each source (not ×N); several Amp sessions accumulate. Restore without Amp parent locks a fresh `session.json` `lastThreadId` as `amp-session-last` (Amp parent without tty still uses `amp threads list`). Leftover without `--collect` scopes to `last.platform` and the Amp thread id / `T-…` prefix; an explicit `ampThreadId` does not add the env thread. Leftover after `amp-usage` resyncs tokens from sources and keeps billed Cost; `usageModels` stay unique to that thread; `pending.role` is the canonical token.
11
+
12
+ ## [0.12.0] - 2026-09-02
13
+
14
+ ### Fixed
15
+ - **Cursor leftover race, multi-root, 4-decimal estimates, and per-phase clock.** `stop` / `afterAgentResponse` run leftover immediately after a successful jsonl append (fail-open, no stdout); `sessionEnd` leftover stays and stays idempotent. Leftover filters `last.threadId` when it is non-empty and stays time-only when it is `null` or `''`. Hook and collect resolve the consumer in a multi-root window (not the first cwd with `.agents` or `openspec`); leftover walks every candidate that has `openspec/changes`. Written `costUsdEstimated` aggregates are rounded to 4 decimals after sums (`2.3911 + 2.8153 + 1.355 === 6.5614`). Each `phases.<phase>` now stores `startedAt` / `endedAt` / `leadTimeMs` from that phase’s sessions (epoch ms); `durationMs` remains the work-time sum and does not clone `totals.leadTimeMs`.
16
+
7
17
  ## [0.11.0] - 2026-09-02
8
18
 
9
19
  ### Changed
@@ -266,6 +276,8 @@ All notable changes to this project will be documented in this file.
266
276
  ### Added
267
277
  - Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
268
278
 
279
+ [0.13.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.12.0...v0.13.0
280
+ [0.12.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.11.0...v0.12.0
269
281
  [0.11.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.10.0...v0.11.0
270
282
  [0.10.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.9.0...v0.10.0
271
283
  [0.9.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.8.0...v0.9.0
package/README.md CHANGED
@@ -74,7 +74,7 @@ npx agent-orchestrator-kit@latest init --profile generic --ci gitlab --spec-veri
74
74
 
75
75
  See [Installation](#installation) for profile/CI options.
76
76
 
77
- **🔄 Already have the kit installed? Upgrade to latest (session attribution + Cursor estimate/dedup in v0.11.0; Cursor fallback estimate + first-class `costUsdEstimated` in v0.10.0; UTC timestamps + Amp stamp parse, Amp billed `$`, Cursor API estimate, archive auto-collect in v0.9.0; locked Amp/Cursor client in v0.8.0; `## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; change metrics in v0.5.0+, factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
77
+ **🔄 Already have the kit installed? Upgrade to latest (Amp Cost-once rollup + `amp-session-last` + leftover thread-scope in v0.13.0; Cursor leftover race + multi-root + per-phase clock in v0.12.0; session attribution + Cursor estimate/dedup in v0.11.0; Cursor fallback estimate + first-class `costUsdEstimated` in v0.10.0; UTC timestamps + Amp stamp parse, Amp billed `$`, Cursor API estimate, archive auto-collect in v0.9.0; locked Amp/Cursor client in v0.8.0; `## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; change metrics in v0.5.0+, factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
78
78
 
79
79
  ```bash
80
80
  npx agent-orchestrator-kit@latest update
@@ -804,16 +804,17 @@ Every change accumulates git-tracked `openspec/changes/<name>/metrics.json` —
804
804
  - **`## Metrics` self-report** — Session Exit fills `handoff.md` with `platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source` (`unknown` when missing). Persist reads that section; `metrics.json` is the source of truth for what landed. CLI flags do not rewrite the section.
805
805
  - **`session.model`** — source product id wins when any collected source has a model; `--model` / `## Metrics: model` / `AOK_MODEL` apply only when sources have no model. Never a Closed role.
806
806
  - **Session start** — `handoff --restore` writes a `pending` marker (`startedAt`, expected role).
807
- - **Session end** — `handoff <name>` closes the pending session: duration, closed role, mapped phase (`explore` / `design` / `spec` / `review` / `apply` / `archive`), runtime (local/cloud), tasks snapshot (`n/m`), and spend from flags → self-report → adapters. Persist collect window is `[pending.startedAt, endedAt]`; a late hook after persist goes to leftover of that session, not the next persist. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session totals only and do not wipe `spendByPlatform` / `spendByModel`. Amp billed USD comes from `amp threads usage --details` (`Cost: $N` only; missing line → `costUsd: null`), not from converting credits. Cursor always writes a labeled `costUsdEstimated` when tokens exist: grok-4.6/4.5 use xAI API rates (`costSource: api-estimate`, including `-fast` and the 200k cliff); other models use a versioned fallback of $3/1M input + $15/1M output (or $3.50/1M when only `totalTokens` is present, `costSource: api-estimate-fallback`). That estimate is **not** the Cursor invoice and is never added into billed `costUsd`. Timestamps are ISO-8601 UTC (`…Z`). No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
807
+ - **Session end** — `handoff <name>` closes the pending session: duration, closed role, mapped phase (`explore` / `design` / `spec` / `review` / `apply` / `archive`), runtime (local/cloud), tasks snapshot (`n/m`), and spend from flags → self-report → adapters. Persist collect window is `[pending.startedAt, endedAt]`; a late hook after persist goes to leftover of that session, not the next persist. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session totals only and do not wipe `spendByPlatform` / `spendByModel`. Amp billed USD comes from `amp threads usage --details` (`Cost: $N` only; missing line → `costUsd: null`), not from converting credits. After persist/recompute, that Cost is a **Cost-once rollup**: once **per qualifying session** into `spend.costUsd` / `spendByPlatform.amp.costUsd` / `phases.*.costUsd` when that session’s `sources[].costUsd` are all null — never copied onto each source (not ×N); several Amp sessions accumulate. Cursor always writes a labeled `costUsdEstimated` when tokens exist: grok-4.6/4.5 use xAI API rates (`costSource: api-estimate`, including `-fast` and the 200k cliff); other models use a versioned fallback of $3/1M input + $15/1M output (or $3.50/1M when only `totalTokens` is present, `costSource: api-estimate-fallback`). That estimate is **not** the Cursor invoice and is never added into billed `costUsd`. Timestamps are ISO-8601 UTC (`…Z`). No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
808
808
  - **Archive** — successful `archive <name>` always creates or finalizes `metrics.json`, sets `archivedAt`, appends an Archiver session, collects the locked client (Cursor hook / Amp export+usage / Claude JSONL) in `[pending.startedAt, now]` plus leftover of the previous session, and prints the same human summary as `metrics <name>`. `--collect` still runs all three adapters. Leftover apply `## Metrics` that repeats the previous session is ignored so those tokens are not counted twice.
809
- - **`sessionEnd` leftover** — `scripts/cursor-spend-collect.cjs` also reads the newest `openspec/changes/archive/*-<name>/metrics.json` when the active change folder is gone, so a late hook after archive still attaches.
810
- - **Cursor `conversationId`** — restore writes `CURSOR_CONVERSATION_ID` into `pending.threadId`; Cursor collect skips rows whose `conversationId` does not match when a filter id is present.
811
- - **Canonical role** — `session.role` stores the first known token (`Explorer`, `Architect`, `Spec Reviewer`, `Implementer`, `Archiver`, `Design Intake`); Closed role in `handoff.md` MAY keep a sentence after `—`.
809
+ - **`sessionEnd` leftover** — `scripts/cursor-spend-collect.cjs` also reads the newest `openspec/changes/archive/*-<name>/metrics.json` when the active change folder is gone, so a late hook after archive still attaches. `stop` / `afterAgentResponse` run the same leftover after a successful jsonl append. When `last.threadId` is non-empty, leftover keeps only rows whose `conversationId` matches that id (empty/`null` threadId stays time-only). In a multi-root window leftover walks every candidate that has `openspec/changes`, reading that root’s jsonl. Aggregates write `costUsdEstimated` with 4 decimal places. Each `phases.<phase>` stores `startedAt` / `endedAt` / `leadTimeMs` from that phase’s sessions; `durationMs` stays the work-time sum and does not clone `totals.leadTimeMs`.
810
+ - **Leftover without `--collect`** — Scopes to `last.platform` and, for Amp, leftover **thread-scope**: the session thread id or the `T-…` prefix from `sources[].id` (`T-apply:8` `T-apply`). An explicit `ampThreadId` does not also add the env thread. After `amp-usage`, leftover **token resync** sums tokens from all sources and keeps billed Cost (`spendSource: amp-usage`). `usageModels` stay unique to that thread.
811
+ - **Cursor `conversationId`**restore writes `CURSOR_CONVERSATION_ID` into `pending.threadId`; Cursor collect and sessionEnd leftover skip rows whose `conversationId` does not match when a filter id is present (`last.threadId` on leftover).
812
+ - **Canonical role** — `session.role` and `pending.role` store the first known token (`Explorer`, `Architect`, `Spec Reviewer`, `Implementer`, `Archiver`, `Design Intake`); Closed role in `handoff.md` MAY keep a sentence after `—`.
812
813
  - **Platform** — `--platform` → `## Metrics: platform` → `AOK_PLATFORM` → **pending client from `--restore`** → host env (Amp / Cursor / Claude Code) → collected sources (`cursor|claude|amp` only). Invalid `--platform` fails before persist/move.
813
- - **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` plus `amp threads usage --details` (`AOK_AMP_BIN`) and local `threads/*.json`. Export supplies `model` / tokens / `agentMode`; usage supplies billed `$`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
814
- - **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL remains a fallback. Amp web/CLI spend is taken from `amp threads export` (tokens, model, `agentMode`) and `amp threads usage` (billed USD).
814
+ - **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` plus `amp threads usage --details` (`AOK_AMP_BIN`) and local `threads/*.json`. Export supplies `model` / tokens / `agentMode`; usage supplies billed `$`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. When env and Amp parent do not win, restore locks a fresh `session.json` `lastThreadId` as `amp-session-last`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
815
+ - **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. After a successful `stop` / `afterAgentResponse` append the hook runs leftover (fail-open, no stdout). Hook and collect resolve the consumer in a multi-root window (not the first cwd with `.agents` or `openspec`). Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL remains a fallback. Amp web/CLI spend is taken from `amp threads export` (tokens, model, `agentMode`) and `amp threads usage` (billed USD).
815
816
 
816
- Aggregates are recomputed on every write: per-phase totals (`durationMs`, tokens, `costUsd`, `sessions`, `roles`, `models`) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end), `spend` (USD only), and separate **by platform** / **by model** tables. Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`. No single total that adds Amp credits to USD.
817
+ Aggregates are recomputed on every write: per-phase totals (`startedAt`, `endedAt`, `leadTimeMs` from that phase’s sessions, `durationMs` = sum of session work time — not `totals.leadTimeMs` and not `endedAt − startedAt`, tokens, `costUsd`, `costUsdEstimated` to 4 decimals, `sessions`, `roles`, `models`) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end), `spend` (USD only), and separate **by platform** / **by model** tables. Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`. No single total that adds Amp credits to USD.
817
818
 
818
819
  Fill `## Metrics` in `handoff.md` **before** persist (unknown is fine; do not invent `0`):
819
820
 
@@ -1006,6 +1007,16 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
1006
1007
 
1007
1008
  ## Changelog
1008
1009
 
1010
+ ### 0.13.0
1011
+ - Amp Cost-once rollup: `Cost: $N` once per qualifying session into `spend` / `spendByPlatform.amp` / `phases` (not ×N sources; several sessions accumulate)
1012
+ - Restore without Amp parent locks a fresh `session.json` `lastThreadId` as `amp-session-last`
1013
+ - Leftover without `--collect` scopes to `last.platform` and Amp thread / `T-…` prefix; leftover after `amp-usage` resyncs tokens and keeps billed Cost
1014
+
1015
+ ### 0.12.0
1016
+ - Cursor leftover after `stop` / `afterAgentResponse` (fail-open, idempotent `sessionEnd`); leftover filters `last.threadId` when it is set
1017
+ - Multi-root hook/collect resolve the consumer window; leftover walks every candidate with `openspec/changes`
1018
+ - `costUsdEstimated` aggregates write 4 decimals; `phases.<phase>` stores `startedAt` / `endedAt` / `leadTimeMs` (work-time `durationMs` is not `totals.leadTimeMs`)
1019
+
1009
1020
  ### 0.11.0
1010
1021
  - Session metrics attribution: persist collect is `[pending.startedAt, endedAt]`; a late hook stays leftover of that session; archive is `[pending.startedAt, now]` plus leftover of the previous session
1011
1022
  - Source product id wins for `session.model`; `--model` / `## Metrics` / `AOK_MODEL` apply only when sources have none
@@ -1615,6 +1615,11 @@ const METRICS_VERSION = 1;
1615
1615
  const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
1616
1616
  const LEFTOVER_GRACE_MS = 120000;
1617
1617
 
1618
+ function roundUsd4(x) {
1619
+ if (x == null) return null;
1620
+ return Math.round(Number(x) * 10000) / 10000;
1621
+ }
1622
+
1618
1623
  function metricsFilePath(projectDir, changeName) {
1619
1624
  return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
1620
1625
  }
@@ -1783,12 +1788,64 @@ function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
1783
1788
  return true;
1784
1789
  }
1785
1790
 
1791
+ function uniqueAmpSourceThreadPrefix(sources) {
1792
+ const prefixes = [];
1793
+ for (const src of sources || []) {
1794
+ const id = src && src.id != null ? String(src.id) : '';
1795
+ if (!id.startsWith('T-')) continue;
1796
+ const prefix = id.split(':')[0];
1797
+ if (!prefix) continue;
1798
+ if (!prefixes.includes(prefix)) prefixes.push(prefix);
1799
+ }
1800
+ return prefixes.length === 1 ? prefixes[0] : null;
1801
+ }
1802
+
1803
+ function leftoverAmpThreadId(session) {
1804
+ if (!session) return null;
1805
+ if (session.threadId) return String(session.threadId);
1806
+ return uniqueAmpSourceThreadPrefix(session.sources);
1807
+ }
1808
+
1809
+ function sessionAmpThreadKeys(session) {
1810
+ const keys = new Set();
1811
+ 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) : '';
1814
+ if (!id.startsWith('T-')) continue;
1815
+ const prefix = id.split(':')[0];
1816
+ if (prefix) keys.add(prefix);
1817
+ }
1818
+ return keys;
1819
+ }
1820
+
1821
+ function usageModelTokenCount(row) {
1822
+ const total = numOrNull(row && row.totalTokens);
1823
+ if (total != null) return total;
1824
+ return (numOrNull(row && row.inputTokens) ?? 0) + (numOrNull(row && row.outputTokens) ?? 0);
1825
+ }
1826
+
1827
+ function usageModelsForSession(session, ampThreads) {
1828
+ const allowed = sessionAmpThreadKeys(session);
1829
+ const byModel = new Map();
1830
+ for (const thread of ampThreads || []) {
1831
+ const id = thread && thread.id != null ? String(thread.id) : '';
1832
+ if (!allowed.has(id)) continue;
1833
+ for (const row of (thread && thread.models) || []) {
1834
+ if (!row || !row.model) continue;
1835
+ const key = String(row.model);
1836
+ const prev = byModel.get(key);
1837
+ if (!prev || usageModelTokenCount(row) >= usageModelTokenCount(prev)) {
1838
+ byModel.set(key, row);
1839
+ }
1840
+ }
1841
+ }
1842
+ return [...byModel.values()];
1843
+ }
1844
+
1786
1845
  function sessionSpendIsFrozen(session) {
1787
1846
  if (!session) return false;
1788
1847
  if (session.spendSource === 'flag') return true;
1789
- if (!reportedHasSpendNumbers(session)) return false;
1790
- if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
1791
- return true;
1848
+ return session.spendSource === 'self-report' && reportedHasSpendNumbers(session);
1792
1849
  }
1793
1850
 
1794
1851
  function existingSourceRecords(metrics) {
@@ -1902,7 +1959,7 @@ function sourceEstimatedUsd(sources) {
1902
1959
  for (const src of sources || []) {
1903
1960
  sum = addNullable(sum, numOrNull(src.costUsdEstimated));
1904
1961
  }
1905
- return sum;
1962
+ return roundUsd4(sum);
1906
1963
  }
1907
1964
 
1908
1965
  function ampThreadTotals(threads) {
@@ -1989,41 +2046,90 @@ function sessionTotalsFromSources(sources) {
1989
2046
  function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
1990
2047
  try {
1991
2048
  return collectSpend({
1992
- cwd: process.cwd(),
2049
+ cwd: extra.cwd || process.cwd(),
1993
2050
  windowStart,
1994
2051
  windowEnd,
1995
2052
  existingSourceIds: existingSourceIdSet(metrics),
1996
2053
  existingSources: existingSourceRecords(metrics),
1997
- env: process.env,
1998
- homedir: process.env.HOME,
2054
+ env: extra.env || process.env,
2055
+ homedir: extra.homedir || process.env.HOME,
1999
2056
  platforms: extra.platforms,
2000
2057
  ampThreadId: extra.ampThreadId,
2001
2058
  ampCli: extra.ampCli === true,
2002
2059
  cursorConversationId: extra.cursorConversationId,
2003
2060
  exportAmpThread: extra.exportAmpThread,
2004
2061
  usageAmpThread: extra.usageAmpThread,
2062
+ listRecentAmpThreads: extra.listRecentAmpThreads,
2063
+ listAmpThreads: extra.listAmpThreads,
2005
2064
  });
2006
2065
  } catch {
2007
2066
  return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
2008
2067
  }
2009
2068
  }
2010
2069
 
2070
+ function leftoverCollectPlatforms(session, collectAll, ampThreadId) {
2071
+ if (collectAll) return undefined;
2072
+ if (session.platform === 'amp' || session.platform === 'cursor' || session.platform === 'claude') {
2073
+ if (session.platform === 'amp' && !ampThreadId) return [];
2074
+ return [session.platform];
2075
+ }
2076
+ if (session.platform == null && ampThreadId) return ['amp'];
2077
+ return [];
2078
+ }
2079
+
2080
+ function resyncLeftoverSessionSpend(session, priorCost, priorSource) {
2081
+ const fromSources = sessionTotalsFromSources(session.sources || []);
2082
+ session.inputTokens = fromSources.inputTokens;
2083
+ session.outputTokens = fromSources.outputTokens;
2084
+ let totalTokens = fromSources.totalTokens;
2085
+ if (totalTokens == null && (fromSources.inputTokens != null || fromSources.outputTokens != null)) {
2086
+ totalTokens = (fromSources.inputTokens ?? 0) + (fromSources.outputTokens ?? 0);
2087
+ }
2088
+ session.totalTokens = totalTokens;
2089
+ if (fromSources.costUsd == null && priorCost != null) {
2090
+ session.costUsd = priorCost;
2091
+ }
2092
+ if (priorSource === 'amp-usage' && numOrNull(session.costUsd) != null) {
2093
+ session.spendSource = 'amp-usage';
2094
+ }
2095
+ }
2096
+
2011
2097
  function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2012
2098
  if (!session || !session.endedAt || !leftoverEnd) return 0;
2013
2099
  const exclusiveEnd = extra.exclusiveEnd === true;
2100
+ const collectAll = extra.collect === true;
2101
+ const ampThreadId = leftoverAmpThreadId(session);
2102
+ const platforms = leftoverCollectPlatforms(session, collectAll, ampThreadId);
2103
+ if (!collectAll && (!Array.isArray(platforms) || platforms.length === 0)) return 0;
2104
+ const collectAmp = collectAll || (Array.isArray(platforms) && platforms.includes('amp'));
2105
+ const collectCursor = collectAll || (Array.isArray(platforms) && platforms.includes('cursor'));
2014
2106
  const collected = runCollectSpend(metrics, session.endedAt, leftoverEnd, {
2015
- platforms: extra.platforms,
2016
- ampThreadId: session.threadId || extra.ampThreadId || ampThreadIdFromEnv(process.env) || null,
2017
- ampCli: extra.ampCli === true || session.platform === 'amp',
2107
+ platforms,
2108
+ ...(ampThreadId ? { ampThreadId } : {}),
2109
+ listRecentAmpThreads: false,
2110
+ ampCli: extra.ampCli === true || collectAmp,
2018
2111
  cursorConversationId: extra.cursorConversationId
2019
- || (session.platform === 'cursor' ? session.threadId : undefined),
2112
+ || (collectCursor && session.platform === 'cursor' ? session.threadId : undefined),
2113
+ exportAmpThread: extra.exportAmpThread,
2114
+ usageAmpThread: extra.usageAmpThread,
2115
+ listAmpThreads: extra.listAmpThreads,
2116
+ env: extra.env,
2117
+ cwd: extra.cwd,
2118
+ homedir: extra.homedir,
2020
2119
  });
2021
- const incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
2120
+ let incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
2022
2121
  src.at,
2023
2122
  session.endedAt,
2024
2123
  leftoverEnd,
2025
2124
  exclusiveEnd,
2026
2125
  ));
2126
+ if (ampThreadId) {
2127
+ const prefix = `${ampThreadId}:`;
2128
+ incoming = incoming.filter((src) => {
2129
+ if (src && src.platform === 'amp') return String(src.id || '').startsWith(prefix);
2130
+ return true;
2131
+ });
2132
+ }
2027
2133
  if (!incoming.length) return 0;
2028
2134
  const merged = [...(session.sources || []), ...incoming];
2029
2135
  if (sessionSpendIsFrozen(session)) {
@@ -2031,10 +2137,13 @@ function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
2031
2137
  const uniqueModels = uniqueSourceModels(merged);
2032
2138
  if (uniqueModels.length > 1) session.models = uniqueModels;
2033
2139
  } else {
2140
+ const priorCost = numOrNull(session.costUsd);
2141
+ const priorSource = session.spendSource;
2034
2142
  applyCollectedSessionFields(session, merged, session.model, {}, {
2035
2143
  model: session.model,
2036
2144
  platform: session.platform,
2037
- }, { ampThreads: collected.ampThreads });
2145
+ }, { ampThreads: Array.isArray(extra.ampThreads) ? extra.ampThreads : collected.ampThreads });
2146
+ resyncLeftoverSessionSpend(session, priorCost, priorSource);
2038
2147
  }
2039
2148
  return incoming.length;
2040
2149
  }
@@ -2065,13 +2174,10 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
2065
2174
  session.costUsdEstimated = spend.costUsdEstimated;
2066
2175
  session.spendSource = spend.spendSource;
2067
2176
  if (spend.agentMode) session.agentMode = spend.agentMode;
2068
- const usageModels = [];
2069
- for (const thread of extra.ampThreads || []) {
2070
- for (const row of thread.models || []) {
2071
- if (row && row.model) usageModels.push(row);
2072
- }
2177
+ const usageModels = usageModelsForSession(session, extra.ampThreads);
2178
+ if (usageModels.length || Array.isArray(session.usageModels)) {
2179
+ session.usageModels = usageModels;
2073
2180
  }
2074
- if (usageModels.length) session.usageModels = usageModels;
2075
2181
  }
2076
2182
 
2077
2183
  function sessionTotalsLookOverridden(session) {
@@ -2194,6 +2300,14 @@ function recomputeSpendMaps(metrics) {
2194
2300
  const sources = session.sources || [];
2195
2301
  if (sources.length > 0) {
2196
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
+ }
2197
2311
  continue;
2198
2312
  }
2199
2313
  const sessionNums = spendTuple(session);
@@ -2202,6 +2316,12 @@ function recomputeSpendMaps(metrics) {
2202
2316
  }
2203
2317
  addModelRow(session.model, session.platform, sessionNums);
2204
2318
  }
2319
+ for (const key of Object.keys(byPlatform)) {
2320
+ byPlatform[key].costUsdEstimated = roundUsd4(byPlatform[key].costUsdEstimated);
2321
+ }
2322
+ for (const row of byModel.values()) {
2323
+ row.costUsdEstimated = roundUsd4(row.costUsdEstimated);
2324
+ }
2205
2325
  metrics.spendByPlatform = byPlatform;
2206
2326
  metrics.spendByModel = [...byModel.values()];
2207
2327
  }
@@ -2214,20 +2334,51 @@ function recomputeMetricsAggregates(metrics) {
2214
2334
  let firstStart = null;
2215
2335
  let lastEnd = null;
2216
2336
  for (const session of metrics.sessions) {
2337
+ session.costUsdEstimated = roundUsd4(session.costUsdEstimated);
2217
2338
  totals.sessions += 1;
2218
2339
  if (session.runtime === 'cloud') totals.cloudSessions += 1;
2219
2340
  totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
2220
- if (session.startedAt) firstStart = firstStart == null ? session.startedAt : earlierTimestamp(firstStart, session.startedAt);
2221
- if (session.endedAt) lastEnd = lastEnd == null ? session.endedAt : laterTimestamp(lastEnd, session.endedAt);
2341
+ if (Number.isFinite(parseFlexibleIso(session.startedAt))) {
2342
+ firstStart = firstStart == null ? session.startedAt : earlierTimestamp(firstStart, session.startedAt);
2343
+ }
2344
+ if (Number.isFinite(parseFlexibleIso(session.endedAt))) {
2345
+ lastEnd = lastEnd == null ? session.endedAt : laterTimestamp(lastEnd, session.endedAt);
2346
+ }
2222
2347
  const key = session.phase || 'other';
2223
- const phase = phases[key] || { sessions: 0, durationMs: null, ...emptySpendTotals(), agents: [], models: [] };
2348
+ const phase = phases[key] || {
2349
+ sessions: 0,
2350
+ durationMs: null,
2351
+ startedAt: null,
2352
+ endedAt: null,
2353
+ leadTimeMs: null,
2354
+ ...emptySpendTotals(),
2355
+ agents: [],
2356
+ models: [],
2357
+ };
2224
2358
  phase.sessions += 1;
2225
2359
  phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
2360
+ if (Number.isFinite(parseFlexibleIso(session.startedAt))) {
2361
+ phase.startedAt = phase.startedAt == null
2362
+ ? session.startedAt
2363
+ : earlierTimestamp(phase.startedAt, session.startedAt);
2364
+ }
2365
+ if (Number.isFinite(parseFlexibleIso(session.endedAt))) {
2366
+ phase.endedAt = phase.endedAt == null
2367
+ ? session.endedAt
2368
+ : laterTimestamp(phase.endedAt, session.endedAt);
2369
+ }
2370
+ const sources = session.sources || [];
2371
+ let sourceCost = null;
2372
+ for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
2226
2373
  for (const spendKey of METRICS_SPEND_KEYS) {
2227
2374
  let value = null;
2228
- if ((session.sources || []).length > 0) {
2229
- for (const src of session.sources) {
2230
- value = addNullable(value, numOrNull(src[spendKey]));
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
+ }
2231
2382
  }
2232
2383
  } else {
2233
2384
  value = sessionFieldOrSources(session, spendKey);
@@ -2247,6 +2398,15 @@ function recomputeMetricsAggregates(metrics) {
2247
2398
  if (firstStart && lastEnd) {
2248
2399
  totals.leadTimeMs = Math.max(0, parseFlexibleIso(lastEnd) - parseFlexibleIso(firstStart));
2249
2400
  }
2401
+ for (const phase of Object.values(phases)) {
2402
+ const startMs = parseFlexibleIso(phase.startedAt);
2403
+ const endMs = parseFlexibleIso(phase.endedAt);
2404
+ phase.leadTimeMs = Number.isFinite(startMs) && Number.isFinite(endMs)
2405
+ ? Math.max(0, endMs - startMs)
2406
+ : null;
2407
+ phase.costUsdEstimated = roundUsd4(phase.costUsdEstimated);
2408
+ }
2409
+ spend.costUsdEstimated = roundUsd4(spend.costUsdEstimated);
2250
2410
  metrics.phases = phases;
2251
2411
  metrics.totals = totals;
2252
2412
  metrics.spend = spend;
@@ -2259,7 +2419,7 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
2259
2419
  const metrics = loadMetricsFile(filePath, changeName, nowIso);
2260
2420
  metrics.pending = {
2261
2421
  startedAt: nowIso,
2262
- role: role || '',
2422
+ role: canonicalRole(role) || role || '',
2263
2423
  platform: client.platform || null,
2264
2424
  threadId: client.threadId || null,
2265
2425
  clientSource: client.source || null,
@@ -2280,6 +2440,7 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
2280
2440
  : leftoverGraceEnd(lastClosed && lastClosed.endedAt);
2281
2441
  attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
2282
2442
  exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
2443
+ collect: opts.collect === true,
2283
2444
  });
2284
2445
  let startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
2285
2446
  const reported = opts.reported || fields.metrics || emptyMetricsFields();
@@ -2420,7 +2581,7 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
2420
2581
  return filePath;
2421
2582
  }
2422
2583
 
2423
- function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
2584
+ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}, extra = {}) {
2424
2585
  const filePath = join(changeRoot, 'metrics.json');
2425
2586
  if (!existsSync(filePath)) return;
2426
2587
  const nowIso = nowUtcIso();
@@ -2437,6 +2598,7 @@ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
2437
2598
  const leftoverEnd = metrics.pending && metrics.pending.startedAt;
2438
2599
  attachLeftoverSources(metrics, lastNonArchiverSession(metrics.sessions), leftoverEnd, {
2439
2600
  exclusiveEnd: true,
2601
+ collect: extra.collect === true,
2440
2602
  });
2441
2603
  metrics.updatedAt = nowIso;
2442
2604
  recomputeMetricsAggregates(metrics);
@@ -3832,7 +3994,7 @@ program
3832
3994
  homedir: process.env.HOME,
3833
3995
  platform: opts.platform,
3834
3996
  });
3835
- metricsPrepareArchiveStart(changeRoot, name, archiveClient);
3997
+ metricsPrepareArchiveStart(changeRoot, name, archiveClient, { collect: opts.collect === true });
3836
3998
  mkdirSync(dirname(targetDir), { recursive: true });
3837
3999
  renameSync(changeRoot, targetDir);
3838
4000
 
@@ -4403,4 +4565,13 @@ if (isDirectCliRun()) {
4403
4565
  program.parse();
4404
4566
  }
4405
4567
 
4406
- export { formatMetricsCostLine, resolveSessionSpend, canonicalRole, phaseForRole };
4568
+ export {
4569
+ formatMetricsCostLine,
4570
+ resolveSessionSpend,
4571
+ canonicalRole,
4572
+ phaseForRole,
4573
+ recomputeMetricsAggregates,
4574
+ sessionSpendIsFrozen,
4575
+ attachLeftoverSources,
4576
+ runCollectSpend,
4577
+ };
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readlinkSync } from 'fs';
1
+ import { existsSync, readFileSync, readlinkSync, statSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir as osHomedir } from 'os';
4
4
  import { execFileSync } from 'child_process';
@@ -114,28 +114,68 @@ function isFreshTimestamp(value, now, maxAgeMs) {
114
114
  return now - n >= 0 && now - n <= maxAgeMs;
115
115
  }
116
116
 
117
+ function isSessionFileFresh(updatedAt, filePath, now, maxAgeMs) {
118
+ if (updatedAt != null && updatedAt !== '') {
119
+ return isFreshTimestamp(updatedAt, now, maxAgeMs);
120
+ }
121
+ try {
122
+ return isFreshTimestamp(statSync(filePath).mtimeMs, now, maxAgeMs);
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ function emptyAmpSessionHint(filePath, fileFresh, updatedAt) {
129
+ return {
130
+ threadId: '',
131
+ source: '',
132
+ lastThreadId: '',
133
+ filePath: filePath || '',
134
+ fileFresh: Boolean(fileFresh),
135
+ updatedAt: updatedAt == null ? '' : updatedAt,
136
+ };
137
+ }
138
+
117
139
  export function readAmpSessionHint(options = {}) {
118
140
  const env = options.env || {};
119
141
  const homedir = options.homedir || env.HOME;
120
142
  const now = options.now != null ? Number(options.now) : Date.now();
121
143
  const maxAgeMs = options.maxAgeMs != null ? Number(options.maxAgeMs) : AMP_TTY_MAX_AGE_MS;
122
144
  const filePath = join(ampDataRoot(env, homedir), 'session.json');
123
- if (!existsSync(filePath)) return { threadId: '', source: '' };
145
+ if (!existsSync(filePath)) return emptyAmpSessionHint(filePath, false, '');
124
146
  let data;
125
147
  try {
126
148
  data = JSON.parse(readFileSync(filePath, 'utf-8'));
127
149
  } catch {
128
- return { threadId: '', source: '' };
150
+ return emptyAmpSessionHint(filePath, isSessionFileFresh('', filePath, now, maxAgeMs), '');
151
+ }
152
+ if (!data || typeof data !== 'object') {
153
+ return emptyAmpSessionHint(filePath, isSessionFileFresh('', filePath, now, maxAgeMs), '');
129
154
  }
130
- if (!data || typeof data !== 'object') return { threadId: '', source: '', lastThreadId: '' };
131
155
  const lastThreadId = trim(data.lastThreadId);
156
+ const updatedAt = data.updatedAt;
157
+ const fileFresh = isSessionFileFresh(updatedAt, filePath, now, maxAgeMs);
132
158
  const rawTty = options.ttyKey != null ? options.ttyKey : currentTtyKey(env, options.readlink);
133
159
  const ttyKey = isUsableTtyPath(rawTty) ? (String(rawTty).startsWith('tty:') ? rawTty : `tty:${rawTty}`) : '';
134
160
  const byTty = data.lastThreadByTerminal && ttyKey ? data.lastThreadByTerminal[ttyKey] : null;
135
161
  if (byTty && trim(byTty.lastThreadId) && isFreshTimestamp(byTty.updatedAt, now, maxAgeMs)) {
136
- return { threadId: trim(byTty.lastThreadId), source: 'amp-session-tty', lastThreadId };
162
+ return {
163
+ threadId: trim(byTty.lastThreadId),
164
+ source: 'amp-session-tty',
165
+ lastThreadId,
166
+ filePath,
167
+ fileFresh,
168
+ updatedAt,
169
+ };
137
170
  }
138
- return { threadId: '', source: '', lastThreadId };
171
+ return {
172
+ threadId: '',
173
+ source: '',
174
+ lastThreadId,
175
+ filePath,
176
+ fileFresh,
177
+ updatedAt,
178
+ };
139
179
  }
140
180
 
141
181
  export function detectSessionClient(options = {}) {
@@ -168,6 +208,15 @@ export function detectSessionClient(options = {}) {
168
208
  if (hint.threadId) {
169
209
  return { platform: 'amp', threadId: hint.threadId, source: hint.source };
170
210
  }
211
+ if (hint.lastThreadId && hint.fileFresh) {
212
+ return { platform: 'amp', threadId: hint.lastThreadId, source: 'amp-session-last' };
213
+ }
214
+ if (hint.fileFresh) {
215
+ const listed = listRecentAmpThreadIds(options);
216
+ if (listed[0]) {
217
+ return { platform: 'amp', threadId: listed[0], source: 'amp-session-list' };
218
+ }
219
+ }
171
220
 
172
221
  return { platform: null, threadId: null, source: 'none' };
173
222
  }
@@ -504,8 +504,10 @@ function collectAmpCli(ctx) {
504
504
  if (id && !ids.includes(id)) ids.push(id);
505
505
  };
506
506
  push(ampThreadId);
507
- push(ampCurrentThreadId(env));
508
- if (!ids.length) {
507
+ if (ctx.listRecentAmpThreads !== false) {
508
+ push(ampCurrentThreadId(env));
509
+ }
510
+ if (!ids.length && ctx.listRecentAmpThreads !== false) {
509
511
  for (const id of listRecentAmpThreadIds(ctx)) push(id);
510
512
  }
511
513
  const sources = [];
@@ -825,6 +827,7 @@ export function collectSpend(options = {}) {
825
827
  homedir,
826
828
  notes,
827
829
  ampThreadId: options.ampThreadId,
830
+ listRecentAmpThreads: options.listRecentAmpThreads,
828
831
  cursorConversationId: options.cursorConversationId,
829
832
  exportAmpThread: options.exportAmpThread,
830
833
  listAmpThreads: options.listAmpThreads,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-orchestrator-kit",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Universal AI agent orchestration kit for Cursor, Claude Code, and Amp Code — spec-driven OpenSpec pipeline, conductor subagents, durable session handoff, factory gates and MCP setup, cloud-agent handoff, and optional local Figma PAT setup",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -5,7 +5,7 @@
5
5
  'use strict';
6
6
 
7
7
  const { existsSync, readdirSync, readFileSync, writeFileSync, statSync } = require('fs');
8
- const { join } = require('path');
8
+ const { join, resolve } = require('path');
9
9
 
10
10
  function numOrNull(value) {
11
11
  if (value == null || value === '') return null;
@@ -18,6 +18,17 @@ function addNullable(a, b) {
18
18
  return (a ?? 0) + (b ?? 0);
19
19
  }
20
20
 
21
+ function roundUsd4(x) {
22
+ if (x == null) return null;
23
+ return Math.round(Number(x) * 10000) / 10000;
24
+ }
25
+
26
+ function timestampMs(value) {
27
+ if (value == null || value === '') return NaN;
28
+ const ms = Date.parse(value);
29
+ return Number.isFinite(ms) ? ms : NaN;
30
+ }
31
+
21
32
  const GROK_46 = {
22
33
  inputPerM: 2,
23
34
  cachedPerM: 0.5,
@@ -85,14 +96,161 @@ function describeCursorCostEstimate(args) {
85
96
  };
86
97
  }
87
98
 
99
+ function uniqueExistingAbsPaths(payload) {
100
+ const seen = new Set();
101
+ const out = [];
102
+ const add = (value) => {
103
+ if (value == null || value === '') return;
104
+ let abs;
105
+ try {
106
+ abs = resolve(String(value));
107
+ } catch {
108
+ return;
109
+ }
110
+ if (seen.has(abs)) return;
111
+ try {
112
+ if (!existsSync(abs)) return;
113
+ } catch {
114
+ return;
115
+ }
116
+ seen.add(abs);
117
+ out.push(abs);
118
+ };
119
+ add(process.cwd());
120
+ const roots = payload && Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
121
+ for (const root of roots) add(root);
122
+ return out;
123
+ }
124
+
125
+ function leftoverCandidateRoots(payload) {
126
+ return uniqueExistingAbsPaths(payload).filter((root) => existsSync(join(root, 'openspec', 'changes')));
127
+ }
128
+
129
+ function readJsonSafe(filePath) {
130
+ try {
131
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function metricsHasConversationThread(metrics, conversationId) {
138
+ if (!metrics || typeof metrics !== 'object' || !conversationId) return false;
139
+ const pendingId = metrics.pending && metrics.pending.threadId != null && metrics.pending.threadId !== ''
140
+ ? String(metrics.pending.threadId).trim()
141
+ : '';
142
+ if (pendingId && pendingId === conversationId) return true;
143
+ const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
144
+ const last = sessions[sessions.length - 1];
145
+ const lastId = last && last.threadId != null && last.threadId !== ''
146
+ ? String(last.threadId).trim()
147
+ : '';
148
+ return Boolean(lastId && lastId === conversationId);
149
+ }
150
+
151
+ function activeChangeNames(changesDir) {
152
+ const names = [];
153
+ let entries;
154
+ try {
155
+ entries = readdirSync(changesDir);
156
+ } catch {
157
+ return names;
158
+ }
159
+ for (const name of entries) {
160
+ if (name === 'archive') continue;
161
+ const full = join(changesDir, name);
162
+ try {
163
+ if (statSync(full).isDirectory()) names.push(name);
164
+ } catch {
165
+ continue;
166
+ }
167
+ }
168
+ return names;
169
+ }
170
+
171
+ function rootHasMatchingThread(root, conversationId) {
172
+ const changesDir = join(root, 'openspec', 'changes');
173
+ if (!existsSync(changesDir)) return false;
174
+ for (const name of activeChangeNames(changesDir)) {
175
+ if (metricsHasConversationThread(readJsonSafe(join(changesDir, name, 'metrics.json')), conversationId)) {
176
+ return true;
177
+ }
178
+ }
179
+ const archiveDir = join(changesDir, 'archive');
180
+ if (!existsSync(archiveDir)) return false;
181
+ const seen = new Set();
182
+ let entries;
183
+ try {
184
+ entries = readdirSync(archiveDir);
185
+ } catch {
186
+ return false;
187
+ }
188
+ for (const entry of entries) {
189
+ const changeName = archivedChangeName(entry);
190
+ if (!changeName || seen.has(changeName)) continue;
191
+ seen.add(changeName);
192
+ const dir = newestArchiveDirForName(archiveDir, changeName);
193
+ if (!dir) continue;
194
+ if (metricsHasConversationThread(readJsonSafe(join(dir, 'metrics.json')), conversationId)) return true;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ function rootHasActiveChange(root) {
200
+ return activeChangeNames(join(root, 'openspec', 'changes')).length > 0;
201
+ }
202
+
203
+ function jsonlHasConversationId(root, conversationId) {
204
+ if (!conversationId) return false;
205
+ const filePath = join(root, '.agents', 'spend', 'cursor-usage.jsonl');
206
+ if (!existsSync(filePath)) return false;
207
+ let text;
208
+ try {
209
+ text = readFileSync(filePath, 'utf-8');
210
+ } catch {
211
+ return false;
212
+ }
213
+ for (const line of text.split('\n')) {
214
+ if (!line.trim()) continue;
215
+ let row;
216
+ try {
217
+ row = JSON.parse(line);
218
+ } catch {
219
+ continue;
220
+ }
221
+ const id = row && row.conversationId != null && row.conversationId !== ''
222
+ ? String(row.conversationId).trim()
223
+ : '';
224
+ if (id && id === conversationId) return true;
225
+ }
226
+ return false;
227
+ }
228
+
88
229
  function resolveBaseDir(payload) {
89
- const cwd = process.cwd();
90
- if (existsSync(join(cwd, 'openspec', 'changes'))) return cwd;
91
- const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
92
- for (const root of roots) {
93
- if (root && existsSync(join(String(root), 'openspec', 'changes'))) return String(root);
230
+ const candidates = uniqueExistingAbsPaths(payload && typeof payload === 'object' ? payload : {});
231
+ const conversationId = String((payload && payload.conversation_id) || '').trim();
232
+ if (conversationId) {
233
+ for (const root of candidates) {
234
+ if (rootHasMatchingThread(root, conversationId)) return root;
235
+ }
236
+ }
237
+ for (const root of candidates) {
238
+ if (rootHasActiveChange(root)) return root;
239
+ }
240
+ if (conversationId) {
241
+ for (const root of candidates) {
242
+ if (jsonlHasConversationId(root, conversationId)) return root;
243
+ }
244
+ }
245
+ const cwd = resolve(process.cwd());
246
+ if (
247
+ candidates.includes(cwd)
248
+ && (existsSync(join(cwd, 'openspec', 'changes')) || existsSync(join(cwd, '.agents')))
249
+ ) {
250
+ return cwd;
94
251
  }
95
- return cwd;
252
+ if (!candidates.length) return cwd;
253
+ return [...candidates].sort()[0];
96
254
  }
97
255
 
98
256
  const CURSOR_LEFTOVER_GRACE_MS = 120000;
@@ -255,7 +413,7 @@ function sourceTotals(sources) {
255
413
  if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
256
414
  costUsdEstimated = addNullable(costUsdEstimated, numOrNull(src.costUsdEstimated));
257
415
  }
258
- return { inputTokens, outputTokens, totalTokens, costUsd, costUsdEstimated };
416
+ return { inputTokens, outputTokens, totalTokens, costUsd, costUsdEstimated: roundUsd4(costUsdEstimated) };
259
417
  }
260
418
 
261
419
  function looksOverridden(session) {
@@ -298,13 +456,22 @@ function recompute(metrics) {
298
456
  totals.sessions += 1;
299
457
  if (session.runtime === 'cloud') totals.cloudSessions += 1;
300
458
  totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
301
- if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
302
- if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
459
+ const sessionStartMs = timestampMs(session.startedAt);
460
+ if (Number.isFinite(sessionStartMs) && (firstStart == null || sessionStartMs < timestampMs(firstStart))) {
461
+ firstStart = session.startedAt;
462
+ }
463
+ const sessionEndMs = timestampMs(session.endedAt);
464
+ if (Number.isFinite(sessionEndMs) && (lastEnd == null || sessionEndMs > timestampMs(lastEnd))) {
465
+ lastEnd = session.endedAt;
466
+ }
303
467
 
304
468
  const key = session.phase || 'other';
305
469
  const phase = phases[key] || {
306
470
  sessions: 0,
307
471
  durationMs: null,
472
+ startedAt: null,
473
+ endedAt: null,
474
+ leadTimeMs: null,
308
475
  inputTokens: null,
309
476
  outputTokens: null,
310
477
  totalTokens: null,
@@ -315,6 +482,12 @@ function recompute(metrics) {
315
482
  };
316
483
  phase.sessions += 1;
317
484
  phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
485
+ if (Number.isFinite(sessionStartMs) && (phase.startedAt == null || sessionStartMs < timestampMs(phase.startedAt))) {
486
+ phase.startedAt = session.startedAt;
487
+ }
488
+ if (Number.isFinite(sessionEndMs) && (phase.endedAt == null || sessionEndMs > timestampMs(phase.endedAt))) {
489
+ phase.endedAt = session.endedAt;
490
+ }
318
491
  for (const spendKey of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
319
492
  const fromSession = numOrNull(session[spendKey]);
320
493
  let value = fromSession;
@@ -335,7 +508,9 @@ function recompute(metrics) {
335
508
  }
336
509
  phases[key] = phase;
337
510
 
511
+ let sessionSourceCostUsd = null;
338
512
  for (const src of session.sources || []) {
513
+ sessionSourceCostUsd = addNullable(sessionSourceCostUsd, numOrNull(src.costUsd));
339
514
  const platform = src.platform;
340
515
  if (platform && byPlatform[platform]) {
341
516
  const bucket = byPlatform[platform];
@@ -370,11 +545,31 @@ function recompute(metrics) {
370
545
  byModel.set(modelKey, row);
371
546
  }
372
547
  }
548
+ const sessionCostUsd = numOrNull(session.costUsd);
549
+ if (sessionSourceCostUsd == null && sessionCostUsd != null) {
550
+ const billedBucket = session.platform && byPlatform[session.platform];
551
+ if (billedBucket) billedBucket.costUsd = addNullable(billedBucket.costUsd, sessionCostUsd);
552
+ }
373
553
  }
374
554
 
375
555
  if (firstStart && lastEnd) {
376
556
  totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
377
557
  }
558
+ for (const phase of Object.values(phases)) {
559
+ const startMs = timestampMs(phase.startedAt);
560
+ const endMs = timestampMs(phase.endedAt);
561
+ phase.leadTimeMs = Number.isFinite(startMs) && Number.isFinite(endMs)
562
+ ? Math.max(0, endMs - startMs)
563
+ : null;
564
+ phase.costUsdEstimated = roundUsd4(phase.costUsdEstimated);
565
+ }
566
+ spend.costUsdEstimated = roundUsd4(spend.costUsdEstimated);
567
+ for (const bucket of Object.values(byPlatform)) {
568
+ bucket.costUsdEstimated = roundUsd4(bucket.costUsdEstimated);
569
+ }
570
+ for (const row of byModel.values()) {
571
+ row.costUsdEstimated = roundUsd4(row.costUsdEstimated);
572
+ }
378
573
  metrics.phases = phases;
379
574
  metrics.totals = totals;
380
575
  metrics.spend = spend;
@@ -382,12 +577,19 @@ function recompute(metrics) {
382
577
  metrics.spendByModel = [...byModel.values()];
383
578
  }
384
579
 
385
- function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowEnd, byId, exclusiveEnd) {
580
+ function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowEnd, byId, exclusiveEnd, filterConversationId) {
386
581
  const startMs = windowStart ? Date.parse(windowStart) : NaN;
387
582
  const endMs = windowEnd ? Date.parse(windowEnd) : NaN;
583
+ const filterId = String(filterConversationId || '').trim();
388
584
  const bestById = new Map();
389
585
  for (const [id, row] of byId) {
390
586
  if (existing.has(id)) continue;
587
+ if (filterId) {
588
+ const rowConversationId = row.conversationId == null || row.conversationId === ''
589
+ ? ''
590
+ : String(row.conversationId).trim();
591
+ if (rowConversationId !== filterId) continue;
592
+ }
391
593
  const atMs = Date.parse(row.at);
392
594
  if (Number.isFinite(startMs) && Number.isFinite(atMs) && atMs < startMs) continue;
393
595
  if (Number.isFinite(endMs) && Number.isFinite(atMs)) {
@@ -527,6 +729,7 @@ function backfillMetricsFile(cwd, filePath) {
527
729
  leftoverEnd,
528
730
  byId,
529
731
  leftoverEndExclusive(metrics),
732
+ last.threadId,
530
733
  )
531
734
  : [];
532
735
  if (incoming.length) {
@@ -602,14 +805,7 @@ function newestArchiveDirForName(archiveDir, changeName) {
602
805
  return best;
603
806
  }
604
807
 
605
- function main(raw) {
606
- let payload = {};
607
- try {
608
- payload = raw ? JSON.parse(raw) : {};
609
- } catch {
610
- payload = {};
611
- }
612
- const cwd = resolveBaseDir(payload && typeof payload === 'object' ? payload : {});
808
+ function backfillRoot(cwd) {
613
809
  const changesDir = join(cwd, 'openspec', 'changes');
614
810
  if (!existsSync(changesDir)) return;
615
811
  const activeNames = new Set();
@@ -645,21 +841,48 @@ function main(raw) {
645
841
  }
646
842
  }
647
843
 
648
- if (process.stdin.isTTY) {
844
+ function parsePayload(raw) {
845
+ if (raw && typeof raw === 'object') return raw;
649
846
  try {
650
- main('');
651
- } catch {}
652
- process.exit(0);
847
+ return raw ? JSON.parse(raw) : {};
848
+ } catch {
849
+ return {};
850
+ }
653
851
  }
654
852
 
655
- let input = '';
656
- process.stdin.on('data', (chunk) => {
657
- input += chunk;
658
- });
659
- process.stdin.on('end', () => {
660
- try {
661
- main(input);
662
- } catch {}
663
- process.exit(0);
664
- });
665
- process.stdin.on('error', () => process.exit(0));
853
+ function main(raw) {
854
+ const payload = parsePayload(raw);
855
+ const candidates = leftoverCandidateRoots(payload && typeof payload === 'object' ? payload : {});
856
+ for (const cwd of candidates) {
857
+ try {
858
+ backfillRoot(cwd);
859
+ } catch {}
860
+ }
861
+ }
862
+
863
+ if (require.main === module) {
864
+ if (process.stdin.isTTY) {
865
+ try {
866
+ main('');
867
+ } catch {}
868
+ process.exit(0);
869
+ }
870
+
871
+ let input = '';
872
+ process.stdin.on('data', (chunk) => {
873
+ input += chunk;
874
+ });
875
+ process.stdin.on('end', () => {
876
+ try {
877
+ main(input);
878
+ } catch {}
879
+ process.exit(0);
880
+ });
881
+ process.stdin.on('error', () => process.exit(0));
882
+ }
883
+
884
+ module.exports = {
885
+ main,
886
+ backfillLeftover: main,
887
+ resolveBaseDir,
888
+ };
@@ -5,7 +5,7 @@
5
5
  // must never block the agent loop, so every failure path exits 0 with no output.
6
6
  'use strict';
7
7
 
8
- const { existsSync, mkdirSync, appendFileSync } = require('fs');
8
+ const { mkdirSync, appendFileSync } = require('fs');
9
9
  const { join } = require('path');
10
10
 
11
11
  function numOrNull(value) {
@@ -14,14 +14,24 @@ function numOrNull(value) {
14
14
  return Number.isFinite(n) ? n : null;
15
15
  }
16
16
 
17
+ function loadCollect() {
18
+ return require('./cursor-spend-collect.cjs');
19
+ }
20
+
17
21
  function resolveBaseDir(payload) {
18
- const cwd = process.cwd();
19
- if (existsSync(join(cwd, '.agents'))) return cwd;
20
- const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
21
- for (const root of roots) {
22
- if (root && existsSync(join(String(root), '.agents'))) return String(root);
23
- }
24
- return cwd;
22
+ try {
23
+ const collect = loadCollect();
24
+ if (typeof collect.resolveBaseDir === 'function') return collect.resolveBaseDir(payload);
25
+ } catch {}
26
+ return process.cwd();
27
+ }
28
+
29
+ function runLeftover(payload) {
30
+ try {
31
+ const collect = loadCollect();
32
+ const fn = collect.backfillLeftover || collect.main;
33
+ if (typeof fn === 'function') fn(payload);
34
+ } catch {}
25
35
  }
26
36
 
27
37
  function main(raw) {
@@ -65,6 +75,11 @@ function main(raw) {
65
75
  const spendDir = join(resolveBaseDir(payload), '.agents', 'spend');
66
76
  mkdirSync(spendDir, { recursive: true });
67
77
  appendFileSync(join(spendDir, 'cursor-usage.jsonl'), `${JSON.stringify(record)}\n`);
78
+
79
+ const event = payload.hook_event_name ? String(payload.hook_event_name) : '';
80
+ if (event === 'stop' || event === 'afterAgentResponse') {
81
+ runLeftover(payload);
82
+ }
68
83
  }
69
84
 
70
85
  let input = '';