agent-orchestrator-kit 0.10.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 +16 -0
- package/README.md +20 -7
- package/bin/agent-orchestrator.js +248 -63
- package/bin/session-client.js +1 -1
- package/bin/spend-collect.js +197 -2
- package/package.json +1 -1
- package/templates/.agents/rules/session-handoff.mdc +2 -2
- package/templates/.agents/skills/agent-orchestration/SKILL.md +3 -3
- package/templates/.agents/subagents/session-handoff.md +2 -2
- package/templates/.agents/subagents/spec-archiver.md +1 -1
- package/templates/scripts/cursor-spend-collect.cjs +660 -64
- package/templates/scripts/cursor-spend-hook.cjs +31 -10
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ 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
|
+
|
|
12
|
+
## [0.11.0] - 2026-09-02
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **Session metrics attribution.** Persist collect is `[pending.startedAt, endedAt]`; a late hook goes to leftover of that session, not the next persist. Archive collect is `[pending.startedAt, now]` plus leftover of the previous session; `sessionEnd` reads the newest `openspec/changes/archive/*-<name>/metrics.json`. Source product id wins when sources have a model; `--model` / `## Metrics` / `AOK_MODEL` apply only when sources have none. Cursor collect filters by `conversationId` when a filter id is present. Canonical Closed role token is stored in `session.role`. Placeholder `unknown` plus `spend_source: self-report` does not freeze leftover totals.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Cursor `sessionEnd` collect writes `costUsdEstimated`.** `scripts/cursor-spend-collect.cjs` no longer copies tokens without an estimate: leftover hook rows keep `cacheReadTokens`, grok uses the xAI table (`costSource: api-estimate`, including `-fast` and the 200k cliff), other models use the `$3/$15` fallback, and aggregates (`spend`, platforms, models, phases) keep `costUsdEstimated`. Leftover rows after `pending.startedAt` stay off the last closed session. Persist / restore / `metrics --collect` also backfill estimates onto legacy cursor sources that already had tokens but `costUsdEstimated: null`. Amp credits stay self-report only; Amp billed USD still comes only from `amp threads usage` `Cost: $N`.
|
|
19
|
+
- **Cursor `stop` + `afterAgentResponse` no longer double-count a turn.** When Cursor omits `generation_id`, the spend hook used `Date.now()` and wrote two ids for the same tokens. The hook now uses a stable fallback id; persist and `sessionEnd` collect also collapse rows that share model + input/output/cache, prefer `stop`, skip leftover after `endedAt + 120s` when no session is pending, and resync adapter session totals from the remaining sources.
|
|
20
|
+
|
|
7
21
|
## [0.10.0] - 2026-08-31
|
|
8
22
|
|
|
9
23
|
### Added
|
|
@@ -257,6 +271,8 @@ All notable changes to this project will be documented in this file.
|
|
|
257
271
|
### Added
|
|
258
272
|
- Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
|
|
259
273
|
|
|
274
|
+
[0.12.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.11.0...v0.12.0
|
|
275
|
+
[0.11.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.10.0...v0.11.0
|
|
260
276
|
[0.10.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.9.0...v0.10.0
|
|
261
277
|
[0.9.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.8.0...v0.9.0
|
|
262
278
|
[0.8.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.7.0...v0.8.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 (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
|
|
@@ -600,7 +600,7 @@ Archive is a **deterministic CLI**, not an agent workflow:
|
|
|
600
600
|
npx agent-orchestrator-kit archive <name> [--sync | --no-sync --force] [--collect]
|
|
601
601
|
```
|
|
602
602
|
|
|
603
|
-
It checks the gates (APPROVE in `review.md` when required, all tasks `[x]`, target folder free), merges delta specs into `openspec/specs/` (`--sync`: ADDED append, MODIFIED replace, REMOVED delete), moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>`, and runs `npx openspec validate --all --strict` with a full rollback on failure (main specs restored, new spec files deleted, move reverted). With delta specs present you must decide: `--sync` merges, `--no-sync --force` archives without merging, and no flag refuses with exit 1. It finishes by writing the final `handoff.md` (`next_command: none`), updating memory, appending an Archiver session, and printing the same human metrics summary as `metrics <name>`. Archive collects the locked client (Cursor hook / Amp threads / Claude JSONL) in
|
|
603
|
+
It checks the gates (APPROVE in `review.md` when required, all tasks `[x]`, target folder free), merges delta specs into `openspec/specs/` (`--sync`: ADDED append, MODIFIED replace, REMOVED delete), moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>`, and runs `npx openspec validate --all --strict` with a full rollback on failure (main specs restored, new spec files deleted, move reverted). With delta specs present you must decide: `--sync` merges, `--no-sync --force` archives without merging, and no flag refuses with exit 1. It finishes by writing the final `handoff.md` (`next_command: none`), updating memory, appending an Archiver session, and printing the same human metrics summary as `metrics <name>`. Archive collects the locked client (Cursor hook / Amp threads / Claude JSONL) in `[pending.startedAt, now]` plus leftover of the previous session β the same leftover-then-collect flow as persist. `--collect` still runs every adapter. Unique `## Metrics` in the change `handoff.md` still counts as Archiver self-report; leftover apply numbers that match the previous session are ignored. The `/opsx:archive` command is a thin wrapper that calls this CLI; the `spec-archiver` subagent remains only as a fallback when the CLI is unavailable.
|
|
604
604
|
|
|
605
605
|
## Configuration
|
|
606
606
|
|
|
@@ -802,15 +802,18 @@ Persist with `runtime: cloud` prints those four steps on stderr; stdout stays th
|
|
|
802
802
|
Every change accumulates git-tracked `openspec/changes/<name>/metrics.json` β the data source for planning the next feature: how long each phase took, how many sessions it needed, what it cost.
|
|
803
803
|
|
|
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
|
-
- **`session.model`** β `--model`
|
|
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. `--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
|
-
- **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 `[
|
|
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
|
+
- **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. `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
|
+
- **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
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.
|
|
810
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.
|
|
811
|
-
- **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).
|
|
812
815
|
|
|
813
|
-
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.
|
|
814
817
|
|
|
815
818
|
Fill `## Metrics` in `handoff.md` **before** persist (unknown is fine; do not invent `0`):
|
|
816
819
|
|
|
@@ -1003,6 +1006,16 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
|
|
|
1003
1006
|
|
|
1004
1007
|
## Changelog
|
|
1005
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
|
+
|
|
1014
|
+
### 0.11.0
|
|
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
|
|
1016
|
+
- Source product id wins for `session.model`; `--model` / `## Metrics` / `AOK_MODEL` apply only when sources have none
|
|
1017
|
+
- Cursor `sessionEnd` writes `costUsdEstimated`; `stop` + `afterAgentResponse` no longer double-count a turn
|
|
1018
|
+
|
|
1006
1019
|
### 0.10.0
|
|
1007
1020
|
- `costUsdEstimated` is first-class on `metrics.json`; Cursor writes a labeled estimate whenever tokens exist β grok via xAI API (`costSource: api-estimate`), other models via versioned fallback $3/1M in + $15/1M out (or $3.50/1M when only `totalTokens`, `costSource: api-estimate-fallback`). The estimate is **not** an invoice and is never mixed into billed `costUsd`. Amp without a `Cost:` line leaves `costUsd: null`; self-report `cost_usd` stays billed.
|
|
1008
1021
|
|
|
@@ -5,7 +5,7 @@ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSyn
|
|
|
5
5
|
import { join, dirname, basename, resolve } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
|
-
import { collectSpend } from './spend-collect.js';
|
|
8
|
+
import { collectSpend, enrichMetricsCursorEstimates } from './spend-collect.js';
|
|
9
9
|
import { resolveRestoreClient, ampThreadIdFromEnv } from './session-client.js';
|
|
10
10
|
import {
|
|
11
11
|
earlierTimestamp,
|
|
@@ -1613,6 +1613,12 @@ function readHandoffFields(projectDir, changeName) {
|
|
|
1613
1613
|
|
|
1614
1614
|
const METRICS_VERSION = 1;
|
|
1615
1615
|
const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
|
|
1616
|
+
const LEFTOVER_GRACE_MS = 120000;
|
|
1617
|
+
|
|
1618
|
+
function roundUsd4(x) {
|
|
1619
|
+
if (x == null) return null;
|
|
1620
|
+
return Math.round(Number(x) * 10000) / 10000;
|
|
1621
|
+
}
|
|
1616
1622
|
|
|
1617
1623
|
function metricsFilePath(projectDir, changeName) {
|
|
1618
1624
|
return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
|
|
@@ -1713,12 +1719,29 @@ function addNullable(a, b) {
|
|
|
1713
1719
|
return (a ?? 0) + (b ?? 0);
|
|
1714
1720
|
}
|
|
1715
1721
|
|
|
1722
|
+
function canonicalRole(role) {
|
|
1723
|
+
const raw = String(role || '').trim();
|
|
1724
|
+
if (!raw) return '';
|
|
1725
|
+
const segment = raw.split(/[β,]/)[0].trim();
|
|
1726
|
+
const known = (text) => {
|
|
1727
|
+
const value = String(text || '').trim();
|
|
1728
|
+
if (/^spec\s+reviewer\b/i.test(value)) return 'Spec Reviewer';
|
|
1729
|
+
if (/^design\s+intake\b/i.test(value)) return 'Design Intake';
|
|
1730
|
+
if (/^explorer\b/i.test(value)) return 'Explorer';
|
|
1731
|
+
if (/^architect\b/i.test(value)) return 'Architect';
|
|
1732
|
+
if (/^implementer\b/i.test(value)) return 'Implementer';
|
|
1733
|
+
if (/^archiver\b/i.test(value)) return 'Archiver';
|
|
1734
|
+
return '';
|
|
1735
|
+
};
|
|
1736
|
+
return known(segment) || known(raw) || segment;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1716
1739
|
function phaseForRole(role) {
|
|
1717
1740
|
const value = String(role || '').toLowerCase();
|
|
1718
1741
|
if (/explor/.test(value)) return 'explore';
|
|
1742
|
+
if (/architect|propose/.test(value)) return 'spec';
|
|
1719
1743
|
if (/review/.test(value)) return 'review';
|
|
1720
1744
|
if (/implement|apply|code-writer|test-writer/.test(value)) return 'apply';
|
|
1721
|
-
if (/architect|propose/.test(value)) return 'spec';
|
|
1722
1745
|
if (/design/.test(value)) return 'design';
|
|
1723
1746
|
if (/archiv/.test(value)) return 'archive';
|
|
1724
1747
|
return 'other';
|
|
@@ -1741,8 +1764,46 @@ function lastSessionEndedAt(metrics) {
|
|
|
1741
1764
|
return last;
|
|
1742
1765
|
}
|
|
1743
1766
|
|
|
1744
|
-
function collectWindowStart(metrics) {
|
|
1745
|
-
return
|
|
1767
|
+
function collectWindowStart(metrics, extra) {
|
|
1768
|
+
return (extra && extra.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function leftoverGraceEnd(endedAt) {
|
|
1772
|
+
const ms = parseFlexibleIso(endedAt);
|
|
1773
|
+
if (!Number.isFinite(ms)) return null;
|
|
1774
|
+
return new Date(ms + LEFTOVER_GRACE_MS).toISOString();
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
|
|
1778
|
+
const t = parseFlexibleIso(at);
|
|
1779
|
+
if (!Number.isFinite(t)) return false;
|
|
1780
|
+
const start = parseFlexibleIso(windowStart);
|
|
1781
|
+
if (Number.isFinite(start) && t < start) return false;
|
|
1782
|
+
const end = parseFlexibleIso(leftoverEnd);
|
|
1783
|
+
if (Number.isFinite(end)) {
|
|
1784
|
+
if (exclusiveEnd) {
|
|
1785
|
+
if (t >= end) return false;
|
|
1786
|
+
} else if (t > end) return false;
|
|
1787
|
+
}
|
|
1788
|
+
return true;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function sessionSpendIsFrozen(session) {
|
|
1792
|
+
if (!session) return false;
|
|
1793
|
+
if (session.spendSource === 'flag') return true;
|
|
1794
|
+
if (!reportedHasSpendNumbers(session)) return false;
|
|
1795
|
+
if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
|
|
1796
|
+
return true;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function existingSourceRecords(metrics) {
|
|
1800
|
+
const out = [];
|
|
1801
|
+
for (const session of metrics.sessions || []) {
|
|
1802
|
+
for (const src of session.sources || []) {
|
|
1803
|
+
if (src) out.push(src);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
return out;
|
|
1746
1807
|
}
|
|
1747
1808
|
|
|
1748
1809
|
function existingSourceIdSet(metrics) {
|
|
@@ -1802,8 +1863,9 @@ function rankSources(sources) {
|
|
|
1802
1863
|
}
|
|
1803
1864
|
|
|
1804
1865
|
function primaryModelFromSources(sources) {
|
|
1805
|
-
|
|
1806
|
-
|
|
1866
|
+
const withModel = (sources || []).filter((src) => src && src.model);
|
|
1867
|
+
if (!withModel.length) return null;
|
|
1868
|
+
const top = rankSources(withModel)[0];
|
|
1807
1869
|
const model = top && top.model;
|
|
1808
1870
|
return model == null || model === '' ? null : String(model);
|
|
1809
1871
|
}
|
|
@@ -1845,7 +1907,7 @@ function sourceEstimatedUsd(sources) {
|
|
|
1845
1907
|
for (const src of sources || []) {
|
|
1846
1908
|
sum = addNullable(sum, numOrNull(src.costUsdEstimated));
|
|
1847
1909
|
}
|
|
1848
|
-
return sum;
|
|
1910
|
+
return roundUsd4(sum);
|
|
1849
1911
|
}
|
|
1850
1912
|
|
|
1851
1913
|
function ampThreadTotals(threads) {
|
|
@@ -1884,7 +1946,7 @@ function resolveSessionSpend(opts, reported, sources, extra = {}) {
|
|
|
1884
1946
|
const ampCredits = firstNonNull(flagCredits, self.ampCredits, sourceCredits);
|
|
1885
1947
|
const costUsdEstimated = sourceEstimatedUsd(sources || []);
|
|
1886
1948
|
let spendSource = 'unreported';
|
|
1887
|
-
if (self.spendSource) spendSource = String(self.spendSource);
|
|
1949
|
+
if (self.spendSource && reportedHasSpendNumbers(self)) spendSource = String(self.spendSource);
|
|
1888
1950
|
else if (hasSpendOverride(flags)) spendSource = 'flag';
|
|
1889
1951
|
else if (reportedHasSpendNumbers(self)) spendSource = 'self-report';
|
|
1890
1952
|
else if (fromAmp.costUsd != null) spendSource = 'amp-usage';
|
|
@@ -1936,11 +1998,13 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
|
|
|
1936
1998
|
windowStart,
|
|
1937
1999
|
windowEnd,
|
|
1938
2000
|
existingSourceIds: existingSourceIdSet(metrics),
|
|
2001
|
+
existingSources: existingSourceRecords(metrics),
|
|
1939
2002
|
env: process.env,
|
|
1940
2003
|
homedir: process.env.HOME,
|
|
1941
2004
|
platforms: extra.platforms,
|
|
1942
2005
|
ampThreadId: extra.ampThreadId,
|
|
1943
2006
|
ampCli: extra.ampCli === true,
|
|
2007
|
+
cursorConversationId: extra.cursorConversationId,
|
|
1944
2008
|
exportAmpThread: extra.exportAmpThread,
|
|
1945
2009
|
usageAmpThread: extra.usageAmpThread,
|
|
1946
2010
|
});
|
|
@@ -1949,6 +2013,37 @@ function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
|
|
|
1949
2013
|
}
|
|
1950
2014
|
}
|
|
1951
2015
|
|
|
2016
|
+
function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
|
|
2017
|
+
if (!session || !session.endedAt || !leftoverEnd) return 0;
|
|
2018
|
+
const exclusiveEnd = extra.exclusiveEnd === true;
|
|
2019
|
+
const collected = runCollectSpend(metrics, session.endedAt, leftoverEnd, {
|
|
2020
|
+
platforms: extra.platforms,
|
|
2021
|
+
ampThreadId: session.threadId || extra.ampThreadId || ampThreadIdFromEnv(process.env) || null,
|
|
2022
|
+
ampCli: extra.ampCli === true || session.platform === 'amp',
|
|
2023
|
+
cursorConversationId: extra.cursorConversationId
|
|
2024
|
+
|| (session.platform === 'cursor' ? session.threadId : undefined),
|
|
2025
|
+
});
|
|
2026
|
+
const incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
|
|
2027
|
+
src.at,
|
|
2028
|
+
session.endedAt,
|
|
2029
|
+
leftoverEnd,
|
|
2030
|
+
exclusiveEnd,
|
|
2031
|
+
));
|
|
2032
|
+
if (!incoming.length) return 0;
|
|
2033
|
+
const merged = [...(session.sources || []), ...incoming];
|
|
2034
|
+
if (sessionSpendIsFrozen(session)) {
|
|
2035
|
+
session.sources = merged;
|
|
2036
|
+
const uniqueModels = uniqueSourceModels(merged);
|
|
2037
|
+
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
2038
|
+
} else {
|
|
2039
|
+
applyCollectedSessionFields(session, merged, session.model, {}, {
|
|
2040
|
+
model: session.model,
|
|
2041
|
+
platform: session.platform,
|
|
2042
|
+
}, { ampThreads: collected.ampThreads });
|
|
2043
|
+
}
|
|
2044
|
+
return incoming.length;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
1952
2047
|
function applyCollectedSessionFields(session, sources, resolvedModel, opts, reported, extra = {}) {
|
|
1953
2048
|
session.sources = sources || [];
|
|
1954
2049
|
const uniqueModels = uniqueSourceModels(session.sources);
|
|
@@ -1956,14 +2051,16 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
|
|
|
1956
2051
|
for (const model of fromAmp.models) {
|
|
1957
2052
|
if (model && !uniqueModels.includes(model)) uniqueModels.push(model);
|
|
1958
2053
|
}
|
|
1959
|
-
if (
|
|
2054
|
+
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
2055
|
+
const sourceModel = primaryModelFromSources(session.sources);
|
|
2056
|
+
if (sourceModel) {
|
|
2057
|
+
session.model = sourceModel;
|
|
2058
|
+
} else {
|
|
1960
2059
|
session.model = (resolvedModel && !isPlaceholderModel(resolvedModel) ? resolvedModel : null)
|
|
1961
|
-
|| primaryModelFromSources(session.sources)
|
|
1962
2060
|
|| fromAmp.models[0]
|
|
1963
2061
|
|| null;
|
|
1964
2062
|
}
|
|
1965
2063
|
if (!session.platform) session.platform = primaryPlatformFromSources(session.sources) || null;
|
|
1966
|
-
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
1967
2064
|
const spend = resolveSessionSpend(opts, reported, session.sources, extra);
|
|
1968
2065
|
session.inputTokens = spend.inputTokens;
|
|
1969
2066
|
session.outputTokens = spend.outputTokens;
|
|
@@ -2005,21 +2102,17 @@ function metricsBackfillFile(filePath, changeName) {
|
|
|
2005
2102
|
const sessions = metrics.sessions || [];
|
|
2006
2103
|
if (!sessions.length) return { filePath, added: 0, empty: true };
|
|
2007
2104
|
const last = sessions[sessions.length - 1];
|
|
2008
|
-
const windowStart = last.startedAt ||
|
|
2105
|
+
const windowStart = last.startedAt || last.endedAt || metrics.createdAt;
|
|
2009
2106
|
const lastPlatform = last && last.platform;
|
|
2010
2107
|
const collected = runCollectSpend(metrics, windowStart, nowIso, {
|
|
2011
2108
|
ampThreadId: last && last.threadId || ampThreadIdFromEnv(process.env) || null,
|
|
2012
2109
|
ampCli: lastPlatform === 'amp',
|
|
2110
|
+
cursorConversationId: lastPlatform === 'cursor' ? last.threadId : undefined,
|
|
2013
2111
|
});
|
|
2014
2112
|
const incoming = collected.sources || [];
|
|
2015
2113
|
if (!incoming.length) return { filePath, added: 0 };
|
|
2016
|
-
const overridden = sessionTotalsLookOverridden(last);
|
|
2017
2114
|
const merged = [...(last.sources || []), ...incoming];
|
|
2018
|
-
|
|
2019
|
-
|| last.spendSource === 'flag'
|
|
2020
|
-
|| last.spendSource === 'self-report'
|
|
2021
|
-
|| (last.spendSource && last.spendSource !== 'adapter' && last.spendSource !== 'unreported');
|
|
2022
|
-
if (keepReportedTotals) {
|
|
2115
|
+
if (sessionSpendIsFrozen(last)) {
|
|
2023
2116
|
last.sources = merged;
|
|
2024
2117
|
const uniqueModels = uniqueSourceModels(merged);
|
|
2025
2118
|
if (uniqueModels.length > 1) last.models = uniqueModels;
|
|
@@ -2103,58 +2196,77 @@ function recomputeSpendMaps(metrics) {
|
|
|
2103
2196
|
}
|
|
2104
2197
|
|
|
2105
2198
|
for (const session of metrics.sessions || []) {
|
|
2106
|
-
const sessionNums = spendTuple(session);
|
|
2107
|
-
const sourceTotals = sessionTotalsFromSources(session.sources || []);
|
|
2108
|
-
sourceTotals.ampCredits = sourceAmpCredits(session.sources || []);
|
|
2109
2199
|
const sources = session.sources || [];
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
if (sourcesMatchSession) {
|
|
2200
|
+
if (sources.length > 0) {
|
|
2113
2201
|
for (const src of sources) addSourceRow(src);
|
|
2114
|
-
if (session.costUsd != null && sourceTotals.costUsd == null && session.platform && byPlatform[session.platform]) {
|
|
2115
|
-
byPlatform[session.platform].costUsd = addNullable(byPlatform[session.platform].costUsd, session.costUsd);
|
|
2116
|
-
}
|
|
2117
|
-
for (const row of session.usageModels || []) {
|
|
2118
|
-
addModelRow(row.model, session.platform || 'amp', {
|
|
2119
|
-
inputTokens: null,
|
|
2120
|
-
outputTokens: null,
|
|
2121
|
-
totalTokens: null,
|
|
2122
|
-
costUsd: numOrNull(row.costUsd),
|
|
2123
|
-
ampCredits: null,
|
|
2124
|
-
costUsdEstimated: null,
|
|
2125
|
-
});
|
|
2126
|
-
}
|
|
2127
2202
|
continue;
|
|
2128
2203
|
}
|
|
2129
|
-
|
|
2204
|
+
const sessionNums = spendTuple(session);
|
|
2130
2205
|
if (session.platform && byPlatform[session.platform]) {
|
|
2131
2206
|
addSpendNums(byPlatform[session.platform], sessionNums);
|
|
2132
2207
|
}
|
|
2133
2208
|
addModelRow(session.model, session.platform, sessionNums);
|
|
2134
|
-
|
|
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);
|
|
2135
2215
|
}
|
|
2136
2216
|
metrics.spendByPlatform = byPlatform;
|
|
2137
2217
|
metrics.spendByModel = [...byModel.values()];
|
|
2138
2218
|
}
|
|
2139
2219
|
|
|
2140
2220
|
function recomputeMetricsAggregates(metrics) {
|
|
2221
|
+
enrichMetricsCursorEstimates(metrics, process.cwd());
|
|
2141
2222
|
const phases = {};
|
|
2142
2223
|
const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
|
|
2143
2224
|
const spend = emptySpendTotals();
|
|
2144
2225
|
let firstStart = null;
|
|
2145
2226
|
let lastEnd = null;
|
|
2146
2227
|
for (const session of metrics.sessions) {
|
|
2228
|
+
session.costUsdEstimated = roundUsd4(session.costUsdEstimated);
|
|
2147
2229
|
totals.sessions += 1;
|
|
2148
2230
|
if (session.runtime === 'cloud') totals.cloudSessions += 1;
|
|
2149
2231
|
totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
|
|
2150
|
-
if (
|
|
2151
|
-
|
|
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
|
+
}
|
|
2152
2238
|
const key = session.phase || 'other';
|
|
2153
|
-
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
|
+
};
|
|
2154
2249
|
phase.sessions += 1;
|
|
2155
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
|
+
}
|
|
2156
2261
|
for (const spendKey of METRICS_SPEND_KEYS) {
|
|
2157
|
-
|
|
2262
|
+
let value = null;
|
|
2263
|
+
if ((session.sources || []).length > 0) {
|
|
2264
|
+
for (const src of session.sources) {
|
|
2265
|
+
value = addNullable(value, numOrNull(src[spendKey]));
|
|
2266
|
+
}
|
|
2267
|
+
} else {
|
|
2268
|
+
value = sessionFieldOrSources(session, spendKey);
|
|
2269
|
+
}
|
|
2158
2270
|
phase[spendKey] = addNullable(phase[spendKey], value);
|
|
2159
2271
|
spend[spendKey] = addNullable(spend[spendKey], value);
|
|
2160
2272
|
}
|
|
@@ -2170,6 +2282,15 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
2170
2282
|
if (firstStart && lastEnd) {
|
|
2171
2283
|
totals.leadTimeMs = Math.max(0, parseFlexibleIso(lastEnd) - parseFlexibleIso(firstStart));
|
|
2172
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);
|
|
2173
2294
|
metrics.phases = phases;
|
|
2174
2295
|
metrics.totals = totals;
|
|
2175
2296
|
metrics.spend = spend;
|
|
@@ -2188,6 +2309,7 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
|
|
|
2188
2309
|
clientSource: client.source || null,
|
|
2189
2310
|
};
|
|
2190
2311
|
metrics.updatedAt = nowIso;
|
|
2312
|
+
recomputeMetricsAggregates(metrics);
|
|
2191
2313
|
saveMetricsFile(filePath, metrics);
|
|
2192
2314
|
return filePath;
|
|
2193
2315
|
}
|
|
@@ -2196,33 +2318,43 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2196
2318
|
const filePath = metricsFilePath(projectDir, fields.changeName);
|
|
2197
2319
|
const nowIso = nowUtcIso();
|
|
2198
2320
|
const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
|
|
2199
|
-
const
|
|
2200
|
-
const
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
:
|
|
2321
|
+
const lastClosed = (metrics.sessions || [])[(metrics.sessions || []).length - 1];
|
|
2322
|
+
const leftoverEnd = (metrics.pending && metrics.pending.startedAt)
|
|
2323
|
+
? metrics.pending.startedAt
|
|
2324
|
+
: leftoverGraceEnd(lastClosed && lastClosed.endedAt);
|
|
2325
|
+
attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
|
|
2326
|
+
exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
|
|
2327
|
+
});
|
|
2328
|
+
let startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
|
|
2205
2329
|
const reported = opts.reported || fields.metrics || emptyMetricsFields();
|
|
2206
2330
|
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
|
|
2207
|
-
const windowStart = collectWindowStart(metrics);
|
|
2331
|
+
const windowStart = collectWindowStart(metrics, { startedAt });
|
|
2208
2332
|
const pending = metrics.pending || {};
|
|
2209
2333
|
const platform = opts.platform || pending.platform || null;
|
|
2210
2334
|
const collectAll = opts.collect === true;
|
|
2211
2335
|
const platforms = collectAll ? undefined : (platform ? [platform] : []);
|
|
2212
2336
|
const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
|
|
2337
|
+
const endedAt = nowIso;
|
|
2213
2338
|
const collected = shouldCollect
|
|
2214
|
-
? runCollectSpend(metrics, windowStart,
|
|
2339
|
+
? runCollectSpend(metrics, windowStart, endedAt, {
|
|
2215
2340
|
platforms,
|
|
2216
2341
|
ampThreadId: opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null,
|
|
2217
2342
|
ampCli: collectAll || platform === 'amp',
|
|
2343
|
+
cursorConversationId: platform === 'cursor' ? pending.threadId : undefined,
|
|
2218
2344
|
})
|
|
2219
2345
|
: { sources: [] };
|
|
2346
|
+
const startedMs = parseFlexibleIso(startedAt);
|
|
2347
|
+
const endedMs = parseFlexibleIso(endedAt);
|
|
2348
|
+
let durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
|
|
2349
|
+
? Math.max(0, endedMs - startedMs)
|
|
2350
|
+
: null;
|
|
2351
|
+
const closedRole = canonicalRole(fields.closedRole) || fields.closedRole || '';
|
|
2220
2352
|
const session = {
|
|
2221
2353
|
startedAt,
|
|
2222
|
-
endedAt
|
|
2354
|
+
endedAt,
|
|
2223
2355
|
durationMs,
|
|
2224
|
-
role:
|
|
2225
|
-
phase: phaseForRole(
|
|
2356
|
+
role: closedRole,
|
|
2357
|
+
phase: phaseForRole(closedRole),
|
|
2226
2358
|
runtime: fields.runtime || 'local',
|
|
2227
2359
|
agentId: fields.agentId || 'none',
|
|
2228
2360
|
model: resolvedModel || null,
|
|
@@ -2239,6 +2371,22 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2239
2371
|
spendSource: 'unreported',
|
|
2240
2372
|
};
|
|
2241
2373
|
applyCollectedSessionFields(session, collected.sources || [], resolvedModel, opts, reported, collected);
|
|
2374
|
+
if (!session.startedAt) {
|
|
2375
|
+
let earliest = null;
|
|
2376
|
+
for (const src of session.sources || []) {
|
|
2377
|
+
const at = isoOrNull(src.at);
|
|
2378
|
+
if (!at) continue;
|
|
2379
|
+
earliest = earliest == null ? at : earlierTimestamp(earliest, at);
|
|
2380
|
+
}
|
|
2381
|
+
if (earliest) {
|
|
2382
|
+
session.startedAt = earliest;
|
|
2383
|
+
const fromMs = parseFlexibleIso(session.startedAt);
|
|
2384
|
+
const toMs = parseFlexibleIso(session.endedAt);
|
|
2385
|
+
session.durationMs = Number.isFinite(fromMs) && Number.isFinite(toMs)
|
|
2386
|
+
? Math.max(0, toMs - fromMs)
|
|
2387
|
+
: null;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2242
2390
|
metrics.sessions.push(session);
|
|
2243
2391
|
metrics.pending = null;
|
|
2244
2392
|
metrics.updatedAt = nowIso;
|
|
@@ -2256,25 +2404,33 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2256
2404
|
const filePath = join(targetDir, 'metrics.json');
|
|
2257
2405
|
const nowIso = nowUtcIso();
|
|
2258
2406
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2259
|
-
const
|
|
2407
|
+
const pending = metrics.pending || {};
|
|
2408
|
+
const startedAt = isoOrNull(pending.startedAt) || nowIso;
|
|
2409
|
+
const endedAt = nowIso;
|
|
2410
|
+
const startedMs = parseFlexibleIso(startedAt);
|
|
2411
|
+
const endedMs = parseFlexibleIso(endedAt);
|
|
2412
|
+
const durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
|
|
2413
|
+
? Math.max(0, endedMs - startedMs)
|
|
2414
|
+
: 0;
|
|
2260
2415
|
const collectAll = opts.collect === true;
|
|
2261
|
-
const platform = opts.platform || null;
|
|
2416
|
+
const platform = opts.platform || pending.platform || null;
|
|
2262
2417
|
const platforms = collectAll ? undefined : (platform ? [platform] : []);
|
|
2263
2418
|
const shouldCollect = collectAll || (Array.isArray(platforms) && platforms.length > 0);
|
|
2264
|
-
const ampThreadId = opts.ampThreadId || ampThreadIdFromEnv(process.env) || null;
|
|
2419
|
+
const ampThreadId = opts.ampThreadId || pending.threadId || ampThreadIdFromEnv(process.env) || null;
|
|
2265
2420
|
const collected = shouldCollect
|
|
2266
|
-
? runCollectSpend(metrics,
|
|
2421
|
+
? runCollectSpend(metrics, startedAt, endedAt, {
|
|
2267
2422
|
platforms,
|
|
2268
2423
|
ampThreadId,
|
|
2269
2424
|
ampCli: collectAll || platform === 'amp',
|
|
2425
|
+
cursorConversationId: platform === 'cursor' ? (pending.threadId || ampThreadId) : undefined,
|
|
2270
2426
|
})
|
|
2271
2427
|
: { sources: [] };
|
|
2272
2428
|
const reported = dropStaleArchiveSelfReport(opts.reported, metrics.sessions);
|
|
2273
2429
|
const resolvedModel = opts.model === undefined ? resolveModel(opts, process.env, reported) : opts.model;
|
|
2274
2430
|
const session = {
|
|
2275
|
-
startedAt
|
|
2276
|
-
endedAt
|
|
2277
|
-
durationMs
|
|
2431
|
+
startedAt,
|
|
2432
|
+
endedAt,
|
|
2433
|
+
durationMs,
|
|
2278
2434
|
role: 'Archiver',
|
|
2279
2435
|
phase: phaseForRole('Archiver'),
|
|
2280
2436
|
runtime: opts.runtime || 'local',
|
|
@@ -2308,6 +2464,29 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2308
2464
|
return filePath;
|
|
2309
2465
|
}
|
|
2310
2466
|
|
|
2467
|
+
function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
|
|
2468
|
+
const filePath = join(changeRoot, 'metrics.json');
|
|
2469
|
+
if (!existsSync(filePath)) return;
|
|
2470
|
+
const nowIso = nowUtcIso();
|
|
2471
|
+
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2472
|
+
if (metrics.pending == null) {
|
|
2473
|
+
metrics.pending = {
|
|
2474
|
+
startedAt: nowIso,
|
|
2475
|
+
role: 'Archiver',
|
|
2476
|
+
platform: client.platform || null,
|
|
2477
|
+
threadId: client.threadId || null,
|
|
2478
|
+
clientSource: client.source || null,
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
const leftoverEnd = metrics.pending && metrics.pending.startedAt;
|
|
2482
|
+
attachLeftoverSources(metrics, lastNonArchiverSession(metrics.sessions), leftoverEnd, {
|
|
2483
|
+
exclusiveEnd: true,
|
|
2484
|
+
});
|
|
2485
|
+
metrics.updatedAt = nowIso;
|
|
2486
|
+
recomputeMetricsAggregates(metrics);
|
|
2487
|
+
saveMetricsFile(filePath, metrics);
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2311
2490
|
function formatMetricsDuration(durationMs) {
|
|
2312
2491
|
if (durationMs == null || !Number.isFinite(durationMs)) return 'β';
|
|
2313
2492
|
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
|
|
@@ -3691,7 +3870,13 @@ program
|
|
|
3691
3870
|
}
|
|
3692
3871
|
}
|
|
3693
3872
|
|
|
3694
|
-
|
|
3873
|
+
const archiveClient = resolveRestoreClient({
|
|
3874
|
+
env: process.env,
|
|
3875
|
+
cwd: projectDir,
|
|
3876
|
+
homedir: process.env.HOME,
|
|
3877
|
+
platform: opts.platform,
|
|
3878
|
+
});
|
|
3879
|
+
metricsPrepareArchiveStart(changeRoot, name, archiveClient);
|
|
3695
3880
|
mkdirSync(dirname(targetDir), { recursive: true });
|
|
3696
3881
|
renameSync(changeRoot, targetDir);
|
|
3697
3882
|
|
|
@@ -4262,4 +4447,4 @@ if (isDirectCliRun()) {
|
|
|
4262
4447
|
program.parse();
|
|
4263
4448
|
}
|
|
4264
4449
|
|
|
4265
|
-
export { formatMetricsCostLine, resolveSessionSpend };
|
|
4450
|
+
export { formatMetricsCostLine, resolveSessionSpend, canonicalRole, phaseForRole };
|
package/bin/session-client.js
CHANGED
|
@@ -146,7 +146,7 @@ export function detectSessionClient(options = {}) {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
if (envFlagOn(env.CURSOR_AGENT) || trim(env.CURSOR_CONVERSATION_ID)) {
|
|
149
|
-
return { platform: 'cursor', threadId: null, source: 'cursor-env' };
|
|
149
|
+
return { platform: 'cursor', threadId: trim(env.CURSOR_CONVERSATION_ID) || null, source: 'cursor-env' };
|
|
150
150
|
}
|
|
151
151
|
if (envFlagOn(env.CLAUDECODE) || envFlagOn(env.CLAUDE_CODE) || trim(env.CLAUDE_CODE_ENTRYPOINT)) {
|
|
152
152
|
return { platform: 'claude', threadId: null, source: 'claude-env' };
|