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 +6 -0
- package/README.md +10 -5
- package/bin/agent-orchestrator.js +48 -4
- package/package.json +1 -1
- package/templates/scripts/cursor-spend-collect.cjs +250 -34
- package/templates/scripts/cursor-spend-hook.cjs +23 -8
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
|
|
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 (
|
|
2221
|
-
|
|
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] || {
|
|
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.
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
302
|
-
if (
|
|
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
|
|
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
|
-
|
|
837
|
+
function parsePayload(raw) {
|
|
838
|
+
if (raw && typeof raw === 'object') return raw;
|
|
649
839
|
try {
|
|
650
|
-
|
|
651
|
-
} catch {
|
|
652
|
-
|
|
840
|
+
return raw ? JSON.parse(raw) : {};
|
|
841
|
+
} catch {
|
|
842
|
+
return {};
|
|
843
|
+
}
|
|
653
844
|
}
|
|
654
845
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 = '';
|