agent-orchestrator-kit 0.11.0 β†’ 0.12.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,11 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.12.0] - 2026-09-02
8
+
9
+ ### Fixed
10
+ - **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`.
11
+
7
12
  ## [0.11.0] - 2026-09-02
8
13
 
9
14
  ### Changed
@@ -266,6 +271,7 @@ All notable changes to this project will be documented in this file.
266
271
  ### Added
267
272
  - Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
268
273
 
274
+ [0.12.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.11.0...v0.12.0
269
275
  [0.11.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.10.0...v0.11.0
270
276
  [0.10.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.9.0...v0.10.0
271
277
  [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 (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
@@ -806,14 +806,14 @@ Every change accumulates git-tracked `openspec/changes/<name>/metrics.json` β€”
806
806
  - **Session start** β€” `handoff --restore` writes a `pending` marker (`startedAt`, expected role).
807
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`.
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.
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
+ - **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).
811
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 `β€”`.
812
812
  - **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
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
+ - **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
815
 
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.
816
+ 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
817
 
818
818
  Fill `## Metrics` in `handoff.md` **before** persist (unknown is fine; do not invent `0`):
819
819
 
@@ -1006,6 +1006,11 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
1006
1006
 
1007
1007
  ## Changelog
1008
1008
 
1009
+ ### 0.12.0
1010
+ - Cursor leftover after `stop` / `afterAgentResponse` (fail-open, idempotent `sessionEnd`); leftover filters `last.threadId` when it is set
1011
+ - Multi-root hook/collect resolve the consumer window; leftover walks every candidate with `openspec/changes`
1012
+ - `costUsdEstimated` aggregates write 4 decimals; `phases.<phase>` stores `startedAt` / `endedAt` / `leadTimeMs` (work-time `durationMs` is not `totals.leadTimeMs`)
1013
+
1009
1014
  ### 0.11.0
1010
1015
  - 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
1016
  - 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
  }
@@ -1902,7 +1907,7 @@ function sourceEstimatedUsd(sources) {
1902
1907
  for (const src of sources || []) {
1903
1908
  sum = addNullable(sum, numOrNull(src.costUsdEstimated));
1904
1909
  }
1905
- return sum;
1910
+ return roundUsd4(sum);
1906
1911
  }
1907
1912
 
1908
1913
  function ampThreadTotals(threads) {
@@ -2202,6 +2207,12 @@ function recomputeSpendMaps(metrics) {
2202
2207
  }
2203
2208
  addModelRow(session.model, session.platform, sessionNums);
2204
2209
  }
2210
+ for (const key of Object.keys(byPlatform)) {
2211
+ byPlatform[key].costUsdEstimated = roundUsd4(byPlatform[key].costUsdEstimated);
2212
+ }
2213
+ for (const row of byModel.values()) {
2214
+ row.costUsdEstimated = roundUsd4(row.costUsdEstimated);
2215
+ }
2205
2216
  metrics.spendByPlatform = byPlatform;
2206
2217
  metrics.spendByModel = [...byModel.values()];
2207
2218
  }
@@ -2214,15 +2225,39 @@ function recomputeMetricsAggregates(metrics) {
2214
2225
  let firstStart = null;
2215
2226
  let lastEnd = null;
2216
2227
  for (const session of metrics.sessions) {
2228
+ session.costUsdEstimated = roundUsd4(session.costUsdEstimated);
2217
2229
  totals.sessions += 1;
2218
2230
  if (session.runtime === 'cloud') totals.cloudSessions += 1;
2219
2231
  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);
2232
+ if (Number.isFinite(parseFlexibleIso(session.startedAt))) {
2233
+ firstStart = firstStart == null ? session.startedAt : earlierTimestamp(firstStart, session.startedAt);
2234
+ }
2235
+ if (Number.isFinite(parseFlexibleIso(session.endedAt))) {
2236
+ lastEnd = lastEnd == null ? session.endedAt : laterTimestamp(lastEnd, session.endedAt);
2237
+ }
2222
2238
  const key = session.phase || 'other';
2223
- const phase = phases[key] || { sessions: 0, durationMs: null, ...emptySpendTotals(), agents: [], models: [] };
2239
+ const phase = phases[key] || {
2240
+ sessions: 0,
2241
+ durationMs: null,
2242
+ startedAt: null,
2243
+ endedAt: null,
2244
+ leadTimeMs: null,
2245
+ ...emptySpendTotals(),
2246
+ agents: [],
2247
+ models: [],
2248
+ };
2224
2249
  phase.sessions += 1;
2225
2250
  phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
2251
+ if (Number.isFinite(parseFlexibleIso(session.startedAt))) {
2252
+ phase.startedAt = phase.startedAt == null
2253
+ ? session.startedAt
2254
+ : earlierTimestamp(phase.startedAt, session.startedAt);
2255
+ }
2256
+ if (Number.isFinite(parseFlexibleIso(session.endedAt))) {
2257
+ phase.endedAt = phase.endedAt == null
2258
+ ? session.endedAt
2259
+ : laterTimestamp(phase.endedAt, session.endedAt);
2260
+ }
2226
2261
  for (const spendKey of METRICS_SPEND_KEYS) {
2227
2262
  let value = null;
2228
2263
  if ((session.sources || []).length > 0) {
@@ -2247,6 +2282,15 @@ function recomputeMetricsAggregates(metrics) {
2247
2282
  if (firstStart && lastEnd) {
2248
2283
  totals.leadTimeMs = Math.max(0, parseFlexibleIso(lastEnd) - parseFlexibleIso(firstStart));
2249
2284
  }
2285
+ for (const phase of Object.values(phases)) {
2286
+ const startMs = parseFlexibleIso(phase.startedAt);
2287
+ const endMs = parseFlexibleIso(phase.endedAt);
2288
+ phase.leadTimeMs = Number.isFinite(startMs) && Number.isFinite(endMs)
2289
+ ? Math.max(0, endMs - startMs)
2290
+ : null;
2291
+ phase.costUsdEstimated = roundUsd4(phase.costUsdEstimated);
2292
+ }
2293
+ spend.costUsdEstimated = roundUsd4(spend.costUsdEstimated);
2250
2294
  metrics.phases = phases;
2251
2295
  metrics.totals = totals;
2252
2296
  metrics.spend = spend;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-orchestrator-kit",
3
- "version": "0.11.0",
3
+ "version": "0.12.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;
@@ -375,6 +548,21 @@ function recompute(metrics) {
375
548
  if (firstStart && lastEnd) {
376
549
  totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
377
550
  }
551
+ for (const phase of Object.values(phases)) {
552
+ const startMs = timestampMs(phase.startedAt);
553
+ const endMs = timestampMs(phase.endedAt);
554
+ phase.leadTimeMs = Number.isFinite(startMs) && Number.isFinite(endMs)
555
+ ? Math.max(0, endMs - startMs)
556
+ : null;
557
+ phase.costUsdEstimated = roundUsd4(phase.costUsdEstimated);
558
+ }
559
+ spend.costUsdEstimated = roundUsd4(spend.costUsdEstimated);
560
+ for (const bucket of Object.values(byPlatform)) {
561
+ bucket.costUsdEstimated = roundUsd4(bucket.costUsdEstimated);
562
+ }
563
+ for (const row of byModel.values()) {
564
+ row.costUsdEstimated = roundUsd4(row.costUsdEstimated);
565
+ }
378
566
  metrics.phases = phases;
379
567
  metrics.totals = totals;
380
568
  metrics.spend = spend;
@@ -382,12 +570,19 @@ function recompute(metrics) {
382
570
  metrics.spendByModel = [...byModel.values()];
383
571
  }
384
572
 
385
- function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowEnd, byId, exclusiveEnd) {
573
+ function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowEnd, byId, exclusiveEnd, filterConversationId) {
386
574
  const startMs = windowStart ? Date.parse(windowStart) : NaN;
387
575
  const endMs = windowEnd ? Date.parse(windowEnd) : NaN;
576
+ const filterId = String(filterConversationId || '').trim();
388
577
  const bestById = new Map();
389
578
  for (const [id, row] of byId) {
390
579
  if (existing.has(id)) continue;
580
+ if (filterId) {
581
+ const rowConversationId = row.conversationId == null || row.conversationId === ''
582
+ ? ''
583
+ : String(row.conversationId).trim();
584
+ if (rowConversationId !== filterId) continue;
585
+ }
391
586
  const atMs = Date.parse(row.at);
392
587
  if (Number.isFinite(startMs) && Number.isFinite(atMs) && atMs < startMs) continue;
393
588
  if (Number.isFinite(endMs) && Number.isFinite(atMs)) {
@@ -527,6 +722,7 @@ function backfillMetricsFile(cwd, filePath) {
527
722
  leftoverEnd,
528
723
  byId,
529
724
  leftoverEndExclusive(metrics),
725
+ last.threadId,
530
726
  )
531
727
  : [];
532
728
  if (incoming.length) {
@@ -602,14 +798,7 @@ function newestArchiveDirForName(archiveDir, changeName) {
602
798
  return best;
603
799
  }
604
800
 
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 : {});
801
+ function backfillRoot(cwd) {
613
802
  const changesDir = join(cwd, 'openspec', 'changes');
614
803
  if (!existsSync(changesDir)) return;
615
804
  const activeNames = new Set();
@@ -645,21 +834,48 @@ function main(raw) {
645
834
  }
646
835
  }
647
836
 
648
- if (process.stdin.isTTY) {
837
+ function parsePayload(raw) {
838
+ if (raw && typeof raw === 'object') return raw;
649
839
  try {
650
- main('');
651
- } catch {}
652
- process.exit(0);
840
+ return raw ? JSON.parse(raw) : {};
841
+ } catch {
842
+ return {};
843
+ }
653
844
  }
654
845
 
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));
846
+ function main(raw) {
847
+ const payload = parsePayload(raw);
848
+ const candidates = leftoverCandidateRoots(payload && typeof payload === 'object' ? payload : {});
849
+ for (const cwd of candidates) {
850
+ try {
851
+ backfillRoot(cwd);
852
+ } catch {}
853
+ }
854
+ }
855
+
856
+ if (require.main === module) {
857
+ if (process.stdin.isTTY) {
858
+ try {
859
+ main('');
860
+ } catch {}
861
+ process.exit(0);
862
+ }
863
+
864
+ let input = '';
865
+ process.stdin.on('data', (chunk) => {
866
+ input += chunk;
867
+ });
868
+ process.stdin.on('end', () => {
869
+ try {
870
+ main(input);
871
+ } catch {}
872
+ process.exit(0);
873
+ });
874
+ process.stdin.on('error', () => process.exit(0));
875
+ }
876
+
877
+ module.exports = {
878
+ main,
879
+ backfillLeftover: main,
880
+ resolveBaseDir,
881
+ };
@@ -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 = '';