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 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 the window after the last session β€” the same 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.
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` β†’ `## Metrics: model` β†’ `AOK_MODEL` β†’ collected sources (only with `--collect`) β†’ `null` (stderr warning). Never a Closed role.
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 `[last session.endedAt, now]`, 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.
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 lastSessionEndedAt(metrics) || metrics.createdAt;
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
- if (!sources || !sources.length) return null;
1806
- const top = rankSources(sources)[0];
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 (!session.model || isPlaceholderModel(session.model)) {
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 || collectWindowStart(metrics);
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
- const keepReportedTotals = overridden
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
- const sourcesMatchSession = sources.length > 0 && spendTuplesMatch(sessionNums, sourceTotals);
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
- for (const src of sources) addSourceRow(src);
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 (session.startedAt) firstStart = firstStart == null ? session.startedAt : earlierTimestamp(firstStart, session.startedAt);
2151
- 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
+ }
2152
2238
  const key = session.phase || 'other';
2153
- 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
+ };
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
- const value = sessionFieldOrSources(session, spendKey);
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 startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
2200
- const startedMs = parseFlexibleIso(startedAt);
2201
- const endedMs = parseFlexibleIso(nowIso);
2202
- const durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
2203
- ? Math.max(0, endedMs - startedMs)
2204
- : null;
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, nowIso, {
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: nowIso,
2354
+ endedAt,
2223
2355
  durationMs,
2224
- role: fields.closedRole || '',
2225
- phase: phaseForRole(fields.closedRole),
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 windowStart = lastSessionEndedAt(metrics) || metrics.createdAt;
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, windowStart, nowIso, {
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: nowIso,
2276
- endedAt: nowIso,
2277
- durationMs: null,
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
- // Move change into the dated archive
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 };
@@ -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' };