agent-orchestrator-kit 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +10 -4
- package/bin/agent-orchestrator.js +152 -25
- package/bin/session-client.js +55 -6
- package/bin/spend-collect.js +5 -2
- package/package.json +1 -1
- package/templates/scripts/cursor-spend-collect.cjs +7 -0
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.13.0] - 2026-09-04
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Amp Cost-once rollup, restore `amp-session-last`, leftover thread-scope.** After persist/recompute, Amp `Cost: $N` is added once **per qualifying session** into `spend.costUsd`, `spendByPlatform.amp.costUsd`, and `phases.*.costUsd` when that session’s `sources[].costUsd` are all null — never copied onto each source (not ×N); several Amp sessions accumulate. Restore without Amp parent locks a fresh `session.json` `lastThreadId` as `amp-session-last` (Amp parent without tty still uses `amp threads list`). Leftover without `--collect` scopes to `last.platform` and the Amp thread id / `T-…` prefix; an explicit `ampThreadId` does not add the env thread. Leftover after `amp-usage` resyncs tokens from sources and keeps billed Cost; `usageModels` stay unique to that thread; `pending.role` is the canonical token.
|
|
11
|
+
|
|
7
12
|
## [0.12.0] - 2026-09-02
|
|
8
13
|
|
|
9
14
|
### Fixed
|
|
@@ -271,6 +276,7 @@ All notable changes to this project will be documented in this file.
|
|
|
271
276
|
### Added
|
|
272
277
|
- Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
|
|
273
278
|
|
|
279
|
+
[0.13.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.12.0...v0.13.0
|
|
274
280
|
[0.12.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.11.0...v0.12.0
|
|
275
281
|
[0.11.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.10.0...v0.11.0
|
|
276
282
|
[0.10.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.9.0...v0.10.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 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+):**
|
|
77
|
+
**🔄 Already have the kit installed? Upgrade to latest (Amp Cost-once rollup + `amp-session-last` + leftover thread-scope in v0.13.0; Cursor leftover race + multi-root + per-phase clock in v0.12.0; session attribution + Cursor estimate/dedup in v0.11.0; Cursor fallback estimate + first-class `costUsdEstimated` in v0.10.0; UTC timestamps + Amp stamp parse, Amp billed `$`, Cursor API estimate, archive auto-collect in v0.9.0; locked Amp/Cursor client in v0.8.0; `## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; change metrics in v0.5.0+, factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
80
|
npx agent-orchestrator-kit@latest update
|
|
@@ -804,13 +804,14 @@ Every change accumulates git-tracked `openspec/changes/<name>/metrics.json` —
|
|
|
804
804
|
- **`## Metrics` self-report** — Session Exit fills `handoff.md` with `platform`, `model`, `input_tokens`, `output_tokens`, `cost_usd`, `amp_credits`, `spend_source` (`unknown` when missing). Persist reads that section; `metrics.json` is the source of truth for what landed. CLI flags do not rewrite the section.
|
|
805
805
|
- **`session.model`** — source product id wins when any collected source has a model; `--model` / `## Metrics: model` / `AOK_MODEL` apply only when sources have no model. Never a Closed role.
|
|
806
806
|
- **Session start** — `handoff --restore` writes a `pending` marker (`startedAt`, expected role).
|
|
807
|
-
- **Session end** — `handoff <name>` closes the pending session: duration, closed role, mapped phase (`explore` / `design` / `spec` / `review` / `apply` / `archive`), runtime (local/cloud), tasks snapshot (`n/m`), and spend from flags → self-report → adapters. Persist collect window is `[pending.startedAt, endedAt]`; a late hook after persist goes to leftover of that session, not the next persist. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session totals only and do not wipe `spendByPlatform` / `spendByModel`. Amp billed USD comes from `amp threads usage --details` (`Cost: $N` only; missing line → `costUsd: null`), not from converting credits. Cursor always writes a labeled `costUsdEstimated` when tokens exist: grok-4.6/4.5 use xAI API rates (`costSource: api-estimate`, including `-fast` and the 200k cliff); other models use a versioned fallback of $3/1M input + $15/1M output (or $3.50/1M when only `totalTokens` is present, `costSource: api-estimate-fallback`). That estimate is **not** the Cursor invoice and is never added into billed `costUsd`. Timestamps are ISO-8601 UTC (`…Z`). No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
|
|
807
|
+
- **Session end** — `handoff <name>` closes the pending session: duration, closed role, mapped phase (`explore` / `design` / `spec` / `review` / `apply` / `archive`), runtime (local/cloud), tasks snapshot (`n/m`), and spend from flags → self-report → adapters. Persist collect window is `[pending.startedAt, endedAt]`; a late hook after persist goes to leftover of that session, not the next persist. `--input-tokens` / `--output-tokens` / `--total-tokens` / `--cost-usd` override session totals only and do not wipe `spendByPlatform` / `spendByModel`. Amp billed USD comes from `amp threads usage --details` (`Cost: $N` only; missing line → `costUsd: null`), not from converting credits. After persist/recompute, that Cost is a **Cost-once rollup**: once **per qualifying session** into `spend.costUsd` / `spendByPlatform.amp.costUsd` / `phases.*.costUsd` when that session’s `sources[].costUsd` are all null — never copied onto each source (not ×N); several Amp sessions accumulate. Cursor always writes a labeled `costUsdEstimated` when tokens exist: grok-4.6/4.5 use xAI API rates (`costSource: api-estimate`, including `-fast` and the 200k cliff); other models use a versioned fallback of $3/1M input + $15/1M output (or $3.50/1M when only `totalTokens` is present, `costSource: api-estimate-fallback`). That estimate is **not** the Cursor invoice and is never added into billed `costUsd`. Timestamps are ISO-8601 UTC (`…Z`). No restore marker? Pass `--started-at <iso>` or the duration stays honestly `null`.
|
|
808
808
|
- **Archive** — successful `archive <name>` always creates or finalizes `metrics.json`, sets `archivedAt`, appends an Archiver session, collects the locked client (Cursor hook / Amp export+usage / Claude JSONL) in `[pending.startedAt, now]` plus leftover of the previous session, and prints the same human summary as `metrics <name>`. `--collect` still runs all three adapters. Leftover apply `## Metrics` that repeats the previous session is ignored so those tokens are not counted twice.
|
|
809
809
|
- **`sessionEnd` leftover** — `scripts/cursor-spend-collect.cjs` also reads the newest `openspec/changes/archive/*-<name>/metrics.json` when the active change folder is gone, so a late hook after archive still attaches. `stop` / `afterAgentResponse` run the same leftover after a successful jsonl append. When `last.threadId` is non-empty, leftover keeps only rows whose `conversationId` matches that id (empty/`null` threadId stays time-only). In a multi-root window leftover walks every candidate that has `openspec/changes`, reading that root’s jsonl. Aggregates write `costUsdEstimated` with 4 decimal places. Each `phases.<phase>` stores `startedAt` / `endedAt` / `leadTimeMs` from that phase’s sessions; `durationMs` stays the work-time sum and does not clone `totals.leadTimeMs`.
|
|
810
|
+
- **Leftover without `--collect`** — Scopes to `last.platform` and, for Amp, leftover **thread-scope**: the session thread id or the `T-…` prefix from `sources[].id` (`T-apply:8` → `T-apply`). An explicit `ampThreadId` does not also add the env thread. After `amp-usage`, leftover **token resync** sums tokens from all sources and keeps billed Cost (`spendSource: amp-usage`). `usageModels` stay unique to that thread.
|
|
810
811
|
- **Cursor `conversationId`** — restore writes `CURSOR_CONVERSATION_ID` into `pending.threadId`; Cursor collect and sessionEnd leftover skip rows whose `conversationId` does not match when a filter id is present (`last.threadId` on leftover).
|
|
811
|
-
- **Canonical role** — `session.role`
|
|
812
|
+
- **Canonical role** — `session.role` and `pending.role` store the first known token (`Explorer`, `Architect`, `Spec Reviewer`, `Implementer`, `Archiver`, `Design Intake`); Closed role in `handoff.md` MAY keep a sentence after `—`.
|
|
812
813
|
- **Platform** — `--platform` → `## Metrics: platform` → `AOK_PLATFORM` → **pending client from `--restore`** → host env (Amp / Cursor / Claude Code) → collected sources (`cursor|claude|amp` only). Invalid `--platform` fails before persist/move.
|
|
813
|
-
- **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` plus `amp threads usage --details` (`AOK_AMP_BIN`) and local `threads/*.json`. Export supplies `model` / tokens / `agentMode`; usage supplies billed `$`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
|
|
814
|
+
- **Locked client** — `--restore` records `pending.platform` and Amp `pending.threadId` before phase work. Persist follows that client’s flow even if persist runs in another shell (no `AMP_*` / `CURSOR_*`). Amp: `amp threads export` plus `amp threads usage --details` (`AOK_AMP_BIN`) and local `threads/*.json`. Export supplies `model` / tokens / `agentMode`; usage supplies billed `$`. If Amp runs tools over a pipe (`/dev/null`), thread id comes from `amp threads list`, not stale `session.json` `lastThreadId`. When env and Amp parent do not win, restore locks a fresh `session.json` `lastThreadId` as `amp-session-last`. Cursor: spend hook file. Claude: `~/.claude/projects`. `--collect` still runs all three adapters.
|
|
814
815
|
- **Cursor spend hook (optional)** — Cursor never writes token usage to disk, so the kit can install `scripts/cursor-spend-hook.cjs` plus `.cursor/hooks.json` entries (`stop` / `subagentStop` / `afterAgentResponse`) in `init` / `update` / `sync` / `mcp-setup`: the hook appends each turn's tokens to gitignored `.agents/spend/cursor-usage.jsonl`. After a successful `stop` / `afterAgentResponse` append the hook runs leftover (fail-open, no stdout). Hook and collect resolve the consumer in a multi-root window (not the first cwd with `.agents` or `openspec`). Persist auto-reads that file when the locked client is Cursor. Persist and restore do not self-heal the hook. `sessionEnd` still runs `scripts/cursor-spend-collect.cjs`. Restart Cursor once after the first install. `status` shows a `Spend capture` section. Claude JSONL remains a fallback. Amp web/CLI spend is taken from `amp threads export` (tokens, model, `agentMode`) and `amp threads usage` (billed USD).
|
|
815
816
|
|
|
816
817
|
Aggregates are recomputed on every write: per-phase totals (`startedAt`, `endedAt`, `leadTimeMs` from that phase’s sessions, `durationMs` = sum of session work time — not `totals.leadTimeMs` and not `endedAt − startedAt`, tokens, `costUsd`, `costUsdEstimated` to 4 decimals, `sessions`, `roles`, `models`) plus overall `totals` (`sessions`, `cloudSessions`, `durationMs` = sum of session work time, `leadTimeMs` = wall clock from first session start to last session end), `spend` (USD only), and separate **by platform** / **by model** tables. Numbers are null-honest: a metric nobody reported stays `null`, never a fake `0`. No single total that adds Amp credits to USD.
|
|
@@ -1006,6 +1007,11 @@ Phase bounds and non-goals: [`openspec/specs/agentic-factory-roadmap/spec.md`](o
|
|
|
1006
1007
|
|
|
1007
1008
|
## Changelog
|
|
1008
1009
|
|
|
1010
|
+
### 0.13.0
|
|
1011
|
+
- Amp Cost-once rollup: `Cost: $N` once per qualifying session into `spend` / `spendByPlatform.amp` / `phases` (not ×N sources; several sessions accumulate)
|
|
1012
|
+
- Restore without Amp parent locks a fresh `session.json` `lastThreadId` as `amp-session-last`
|
|
1013
|
+
- Leftover without `--collect` scopes to `last.platform` and Amp thread / `T-…` prefix; leftover after `amp-usage` resyncs tokens and keeps billed Cost
|
|
1014
|
+
|
|
1009
1015
|
### 0.12.0
|
|
1010
1016
|
- Cursor leftover after `stop` / `afterAgentResponse` (fail-open, idempotent `sessionEnd`); leftover filters `last.threadId` when it is set
|
|
1011
1017
|
- Multi-root hook/collect resolve the consumer window; leftover walks every candidate with `openspec/changes`
|
|
@@ -1788,12 +1788,64 @@ function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
|
|
|
1788
1788
|
return true;
|
|
1789
1789
|
}
|
|
1790
1790
|
|
|
1791
|
+
function uniqueAmpSourceThreadPrefix(sources) {
|
|
1792
|
+
const prefixes = [];
|
|
1793
|
+
for (const src of sources || []) {
|
|
1794
|
+
const id = src && src.id != null ? String(src.id) : '';
|
|
1795
|
+
if (!id.startsWith('T-')) continue;
|
|
1796
|
+
const prefix = id.split(':')[0];
|
|
1797
|
+
if (!prefix) continue;
|
|
1798
|
+
if (!prefixes.includes(prefix)) prefixes.push(prefix);
|
|
1799
|
+
}
|
|
1800
|
+
return prefixes.length === 1 ? prefixes[0] : null;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function leftoverAmpThreadId(session) {
|
|
1804
|
+
if (!session) return null;
|
|
1805
|
+
if (session.threadId) return String(session.threadId);
|
|
1806
|
+
return uniqueAmpSourceThreadPrefix(session.sources);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function sessionAmpThreadKeys(session) {
|
|
1810
|
+
const keys = new Set();
|
|
1811
|
+
if (session && session.threadId) keys.add(String(session.threadId));
|
|
1812
|
+
for (const src of (session && session.sources) || []) {
|
|
1813
|
+
const id = src && src.id != null ? String(src.id) : '';
|
|
1814
|
+
if (!id.startsWith('T-')) continue;
|
|
1815
|
+
const prefix = id.split(':')[0];
|
|
1816
|
+
if (prefix) keys.add(prefix);
|
|
1817
|
+
}
|
|
1818
|
+
return keys;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function usageModelTokenCount(row) {
|
|
1822
|
+
const total = numOrNull(row && row.totalTokens);
|
|
1823
|
+
if (total != null) return total;
|
|
1824
|
+
return (numOrNull(row && row.inputTokens) ?? 0) + (numOrNull(row && row.outputTokens) ?? 0);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function usageModelsForSession(session, ampThreads) {
|
|
1828
|
+
const allowed = sessionAmpThreadKeys(session);
|
|
1829
|
+
const byModel = new Map();
|
|
1830
|
+
for (const thread of ampThreads || []) {
|
|
1831
|
+
const id = thread && thread.id != null ? String(thread.id) : '';
|
|
1832
|
+
if (!allowed.has(id)) continue;
|
|
1833
|
+
for (const row of (thread && thread.models) || []) {
|
|
1834
|
+
if (!row || !row.model) continue;
|
|
1835
|
+
const key = String(row.model);
|
|
1836
|
+
const prev = byModel.get(key);
|
|
1837
|
+
if (!prev || usageModelTokenCount(row) >= usageModelTokenCount(prev)) {
|
|
1838
|
+
byModel.set(key, row);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
return [...byModel.values()];
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1791
1845
|
function sessionSpendIsFrozen(session) {
|
|
1792
1846
|
if (!session) return false;
|
|
1793
1847
|
if (session.spendSource === 'flag') return true;
|
|
1794
|
-
|
|
1795
|
-
if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
|
|
1796
|
-
return true;
|
|
1848
|
+
return session.spendSource === 'self-report' && reportedHasSpendNumbers(session);
|
|
1797
1849
|
}
|
|
1798
1850
|
|
|
1799
1851
|
function existingSourceRecords(metrics) {
|
|
@@ -1994,41 +2046,90 @@ function sessionTotalsFromSources(sources) {
|
|
|
1994
2046
|
function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
|
|
1995
2047
|
try {
|
|
1996
2048
|
return collectSpend({
|
|
1997
|
-
cwd: process.cwd(),
|
|
2049
|
+
cwd: extra.cwd || process.cwd(),
|
|
1998
2050
|
windowStart,
|
|
1999
2051
|
windowEnd,
|
|
2000
2052
|
existingSourceIds: existingSourceIdSet(metrics),
|
|
2001
2053
|
existingSources: existingSourceRecords(metrics),
|
|
2002
|
-
env: process.env,
|
|
2003
|
-
homedir: process.env.HOME,
|
|
2054
|
+
env: extra.env || process.env,
|
|
2055
|
+
homedir: extra.homedir || process.env.HOME,
|
|
2004
2056
|
platforms: extra.platforms,
|
|
2005
2057
|
ampThreadId: extra.ampThreadId,
|
|
2006
2058
|
ampCli: extra.ampCli === true,
|
|
2007
2059
|
cursorConversationId: extra.cursorConversationId,
|
|
2008
2060
|
exportAmpThread: extra.exportAmpThread,
|
|
2009
2061
|
usageAmpThread: extra.usageAmpThread,
|
|
2062
|
+
listRecentAmpThreads: extra.listRecentAmpThreads,
|
|
2063
|
+
listAmpThreads: extra.listAmpThreads,
|
|
2010
2064
|
});
|
|
2011
2065
|
} catch {
|
|
2012
2066
|
return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
|
|
2013
2067
|
}
|
|
2014
2068
|
}
|
|
2015
2069
|
|
|
2070
|
+
function leftoverCollectPlatforms(session, collectAll, ampThreadId) {
|
|
2071
|
+
if (collectAll) return undefined;
|
|
2072
|
+
if (session.platform === 'amp' || session.platform === 'cursor' || session.platform === 'claude') {
|
|
2073
|
+
if (session.platform === 'amp' && !ampThreadId) return [];
|
|
2074
|
+
return [session.platform];
|
|
2075
|
+
}
|
|
2076
|
+
if (session.platform == null && ampThreadId) return ['amp'];
|
|
2077
|
+
return [];
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function resyncLeftoverSessionSpend(session, priorCost, priorSource) {
|
|
2081
|
+
const fromSources = sessionTotalsFromSources(session.sources || []);
|
|
2082
|
+
session.inputTokens = fromSources.inputTokens;
|
|
2083
|
+
session.outputTokens = fromSources.outputTokens;
|
|
2084
|
+
let totalTokens = fromSources.totalTokens;
|
|
2085
|
+
if (totalTokens == null && (fromSources.inputTokens != null || fromSources.outputTokens != null)) {
|
|
2086
|
+
totalTokens = (fromSources.inputTokens ?? 0) + (fromSources.outputTokens ?? 0);
|
|
2087
|
+
}
|
|
2088
|
+
session.totalTokens = totalTokens;
|
|
2089
|
+
if (fromSources.costUsd == null && priorCost != null) {
|
|
2090
|
+
session.costUsd = priorCost;
|
|
2091
|
+
}
|
|
2092
|
+
if (priorSource === 'amp-usage' && numOrNull(session.costUsd) != null) {
|
|
2093
|
+
session.spendSource = 'amp-usage';
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2016
2097
|
function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
|
|
2017
2098
|
if (!session || !session.endedAt || !leftoverEnd) return 0;
|
|
2018
2099
|
const exclusiveEnd = extra.exclusiveEnd === true;
|
|
2100
|
+
const collectAll = extra.collect === true;
|
|
2101
|
+
const ampThreadId = leftoverAmpThreadId(session);
|
|
2102
|
+
const platforms = leftoverCollectPlatforms(session, collectAll, ampThreadId);
|
|
2103
|
+
if (!collectAll && (!Array.isArray(platforms) || platforms.length === 0)) return 0;
|
|
2104
|
+
const collectAmp = collectAll || (Array.isArray(platforms) && platforms.includes('amp'));
|
|
2105
|
+
const collectCursor = collectAll || (Array.isArray(platforms) && platforms.includes('cursor'));
|
|
2019
2106
|
const collected = runCollectSpend(metrics, session.endedAt, leftoverEnd, {
|
|
2020
|
-
platforms
|
|
2021
|
-
ampThreadId
|
|
2022
|
-
|
|
2107
|
+
platforms,
|
|
2108
|
+
...(ampThreadId ? { ampThreadId } : {}),
|
|
2109
|
+
listRecentAmpThreads: false,
|
|
2110
|
+
ampCli: extra.ampCli === true || collectAmp,
|
|
2023
2111
|
cursorConversationId: extra.cursorConversationId
|
|
2024
|
-
|| (session.platform === 'cursor' ? session.threadId : undefined),
|
|
2112
|
+
|| (collectCursor && session.platform === 'cursor' ? session.threadId : undefined),
|
|
2113
|
+
exportAmpThread: extra.exportAmpThread,
|
|
2114
|
+
usageAmpThread: extra.usageAmpThread,
|
|
2115
|
+
listAmpThreads: extra.listAmpThreads,
|
|
2116
|
+
env: extra.env,
|
|
2117
|
+
cwd: extra.cwd,
|
|
2118
|
+
homedir: extra.homedir,
|
|
2025
2119
|
});
|
|
2026
|
-
|
|
2120
|
+
let incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
|
|
2027
2121
|
src.at,
|
|
2028
2122
|
session.endedAt,
|
|
2029
2123
|
leftoverEnd,
|
|
2030
2124
|
exclusiveEnd,
|
|
2031
2125
|
));
|
|
2126
|
+
if (ampThreadId) {
|
|
2127
|
+
const prefix = `${ampThreadId}:`;
|
|
2128
|
+
incoming = incoming.filter((src) => {
|
|
2129
|
+
if (src && src.platform === 'amp') return String(src.id || '').startsWith(prefix);
|
|
2130
|
+
return true;
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2032
2133
|
if (!incoming.length) return 0;
|
|
2033
2134
|
const merged = [...(session.sources || []), ...incoming];
|
|
2034
2135
|
if (sessionSpendIsFrozen(session)) {
|
|
@@ -2036,10 +2137,13 @@ function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
|
|
|
2036
2137
|
const uniqueModels = uniqueSourceModels(merged);
|
|
2037
2138
|
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
2038
2139
|
} else {
|
|
2140
|
+
const priorCost = numOrNull(session.costUsd);
|
|
2141
|
+
const priorSource = session.spendSource;
|
|
2039
2142
|
applyCollectedSessionFields(session, merged, session.model, {}, {
|
|
2040
2143
|
model: session.model,
|
|
2041
2144
|
platform: session.platform,
|
|
2042
|
-
}, { ampThreads: collected.ampThreads });
|
|
2145
|
+
}, { ampThreads: Array.isArray(extra.ampThreads) ? extra.ampThreads : collected.ampThreads });
|
|
2146
|
+
resyncLeftoverSessionSpend(session, priorCost, priorSource);
|
|
2043
2147
|
}
|
|
2044
2148
|
return incoming.length;
|
|
2045
2149
|
}
|
|
@@ -2070,13 +2174,10 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
|
|
|
2070
2174
|
session.costUsdEstimated = spend.costUsdEstimated;
|
|
2071
2175
|
session.spendSource = spend.spendSource;
|
|
2072
2176
|
if (spend.agentMode) session.agentMode = spend.agentMode;
|
|
2073
|
-
const usageModels =
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
if (row && row.model) usageModels.push(row);
|
|
2077
|
-
}
|
|
2177
|
+
const usageModels = usageModelsForSession(session, extra.ampThreads);
|
|
2178
|
+
if (usageModels.length || Array.isArray(session.usageModels)) {
|
|
2179
|
+
session.usageModels = usageModels;
|
|
2078
2180
|
}
|
|
2079
|
-
if (usageModels.length) session.usageModels = usageModels;
|
|
2080
2181
|
}
|
|
2081
2182
|
|
|
2082
2183
|
function sessionTotalsLookOverridden(session) {
|
|
@@ -2199,6 +2300,14 @@ function recomputeSpendMaps(metrics) {
|
|
|
2199
2300
|
const sources = session.sources || [];
|
|
2200
2301
|
if (sources.length > 0) {
|
|
2201
2302
|
for (const src of sources) addSourceRow(src);
|
|
2303
|
+
let sourceCost = null;
|
|
2304
|
+
for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
|
|
2305
|
+
if (sourceCost == null && session.platform && byPlatform[session.platform]) {
|
|
2306
|
+
byPlatform[session.platform].costUsd = addNullable(
|
|
2307
|
+
byPlatform[session.platform].costUsd,
|
|
2308
|
+
numOrNull(session.costUsd),
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2202
2311
|
continue;
|
|
2203
2312
|
}
|
|
2204
2313
|
const sessionNums = spendTuple(session);
|
|
@@ -2258,11 +2367,18 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
2258
2367
|
? session.endedAt
|
|
2259
2368
|
: laterTimestamp(phase.endedAt, session.endedAt);
|
|
2260
2369
|
}
|
|
2370
|
+
const sources = session.sources || [];
|
|
2371
|
+
let sourceCost = null;
|
|
2372
|
+
for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
|
|
2261
2373
|
for (const spendKey of METRICS_SPEND_KEYS) {
|
|
2262
2374
|
let value = null;
|
|
2263
|
-
if (
|
|
2264
|
-
|
|
2265
|
-
value =
|
|
2375
|
+
if (sources.length > 0) {
|
|
2376
|
+
if (spendKey === 'costUsd') {
|
|
2377
|
+
value = sourceCost == null ? numOrNull(session.costUsd) : sourceCost;
|
|
2378
|
+
} else {
|
|
2379
|
+
for (const src of sources) {
|
|
2380
|
+
value = addNullable(value, numOrNull(src[spendKey]));
|
|
2381
|
+
}
|
|
2266
2382
|
}
|
|
2267
2383
|
} else {
|
|
2268
2384
|
value = sessionFieldOrSources(session, spendKey);
|
|
@@ -2303,7 +2419,7 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
|
|
|
2303
2419
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2304
2420
|
metrics.pending = {
|
|
2305
2421
|
startedAt: nowIso,
|
|
2306
|
-
role: role || '',
|
|
2422
|
+
role: canonicalRole(role) || role || '',
|
|
2307
2423
|
platform: client.platform || null,
|
|
2308
2424
|
threadId: client.threadId || null,
|
|
2309
2425
|
clientSource: client.source || null,
|
|
@@ -2324,6 +2440,7 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2324
2440
|
: leftoverGraceEnd(lastClosed && lastClosed.endedAt);
|
|
2325
2441
|
attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
|
|
2326
2442
|
exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
|
|
2443
|
+
collect: opts.collect === true,
|
|
2327
2444
|
});
|
|
2328
2445
|
let startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
|
|
2329
2446
|
const reported = opts.reported || fields.metrics || emptyMetricsFields();
|
|
@@ -2464,7 +2581,7 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2464
2581
|
return filePath;
|
|
2465
2582
|
}
|
|
2466
2583
|
|
|
2467
|
-
function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
|
|
2584
|
+
function metricsPrepareArchiveStart(changeRoot, changeName, client = {}, extra = {}) {
|
|
2468
2585
|
const filePath = join(changeRoot, 'metrics.json');
|
|
2469
2586
|
if (!existsSync(filePath)) return;
|
|
2470
2587
|
const nowIso = nowUtcIso();
|
|
@@ -2481,6 +2598,7 @@ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
|
|
|
2481
2598
|
const leftoverEnd = metrics.pending && metrics.pending.startedAt;
|
|
2482
2599
|
attachLeftoverSources(metrics, lastNonArchiverSession(metrics.sessions), leftoverEnd, {
|
|
2483
2600
|
exclusiveEnd: true,
|
|
2601
|
+
collect: extra.collect === true,
|
|
2484
2602
|
});
|
|
2485
2603
|
metrics.updatedAt = nowIso;
|
|
2486
2604
|
recomputeMetricsAggregates(metrics);
|
|
@@ -3876,7 +3994,7 @@ program
|
|
|
3876
3994
|
homedir: process.env.HOME,
|
|
3877
3995
|
platform: opts.platform,
|
|
3878
3996
|
});
|
|
3879
|
-
metricsPrepareArchiveStart(changeRoot, name, archiveClient);
|
|
3997
|
+
metricsPrepareArchiveStart(changeRoot, name, archiveClient, { collect: opts.collect === true });
|
|
3880
3998
|
mkdirSync(dirname(targetDir), { recursive: true });
|
|
3881
3999
|
renameSync(changeRoot, targetDir);
|
|
3882
4000
|
|
|
@@ -4447,4 +4565,13 @@ if (isDirectCliRun()) {
|
|
|
4447
4565
|
program.parse();
|
|
4448
4566
|
}
|
|
4449
4567
|
|
|
4450
|
-
export {
|
|
4568
|
+
export {
|
|
4569
|
+
formatMetricsCostLine,
|
|
4570
|
+
resolveSessionSpend,
|
|
4571
|
+
canonicalRole,
|
|
4572
|
+
phaseForRole,
|
|
4573
|
+
recomputeMetricsAggregates,
|
|
4574
|
+
sessionSpendIsFrozen,
|
|
4575
|
+
attachLeftoverSources,
|
|
4576
|
+
runCollectSpend,
|
|
4577
|
+
};
|
package/bin/session-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readlinkSync } from 'fs';
|
|
1
|
+
import { existsSync, readFileSync, readlinkSync, statSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { homedir as osHomedir } from 'os';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
@@ -114,28 +114,68 @@ function isFreshTimestamp(value, now, maxAgeMs) {
|
|
|
114
114
|
return now - n >= 0 && now - n <= maxAgeMs;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
function isSessionFileFresh(updatedAt, filePath, now, maxAgeMs) {
|
|
118
|
+
if (updatedAt != null && updatedAt !== '') {
|
|
119
|
+
return isFreshTimestamp(updatedAt, now, maxAgeMs);
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return isFreshTimestamp(statSync(filePath).mtimeMs, now, maxAgeMs);
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function emptyAmpSessionHint(filePath, fileFresh, updatedAt) {
|
|
129
|
+
return {
|
|
130
|
+
threadId: '',
|
|
131
|
+
source: '',
|
|
132
|
+
lastThreadId: '',
|
|
133
|
+
filePath: filePath || '',
|
|
134
|
+
fileFresh: Boolean(fileFresh),
|
|
135
|
+
updatedAt: updatedAt == null ? '' : updatedAt,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
117
139
|
export function readAmpSessionHint(options = {}) {
|
|
118
140
|
const env = options.env || {};
|
|
119
141
|
const homedir = options.homedir || env.HOME;
|
|
120
142
|
const now = options.now != null ? Number(options.now) : Date.now();
|
|
121
143
|
const maxAgeMs = options.maxAgeMs != null ? Number(options.maxAgeMs) : AMP_TTY_MAX_AGE_MS;
|
|
122
144
|
const filePath = join(ampDataRoot(env, homedir), 'session.json');
|
|
123
|
-
if (!existsSync(filePath)) return
|
|
145
|
+
if (!existsSync(filePath)) return emptyAmpSessionHint(filePath, false, '');
|
|
124
146
|
let data;
|
|
125
147
|
try {
|
|
126
148
|
data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
127
149
|
} catch {
|
|
128
|
-
return
|
|
150
|
+
return emptyAmpSessionHint(filePath, isSessionFileFresh('', filePath, now, maxAgeMs), '');
|
|
151
|
+
}
|
|
152
|
+
if (!data || typeof data !== 'object') {
|
|
153
|
+
return emptyAmpSessionHint(filePath, isSessionFileFresh('', filePath, now, maxAgeMs), '');
|
|
129
154
|
}
|
|
130
|
-
if (!data || typeof data !== 'object') return { threadId: '', source: '', lastThreadId: '' };
|
|
131
155
|
const lastThreadId = trim(data.lastThreadId);
|
|
156
|
+
const updatedAt = data.updatedAt;
|
|
157
|
+
const fileFresh = isSessionFileFresh(updatedAt, filePath, now, maxAgeMs);
|
|
132
158
|
const rawTty = options.ttyKey != null ? options.ttyKey : currentTtyKey(env, options.readlink);
|
|
133
159
|
const ttyKey = isUsableTtyPath(rawTty) ? (String(rawTty).startsWith('tty:') ? rawTty : `tty:${rawTty}`) : '';
|
|
134
160
|
const byTty = data.lastThreadByTerminal && ttyKey ? data.lastThreadByTerminal[ttyKey] : null;
|
|
135
161
|
if (byTty && trim(byTty.lastThreadId) && isFreshTimestamp(byTty.updatedAt, now, maxAgeMs)) {
|
|
136
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
threadId: trim(byTty.lastThreadId),
|
|
164
|
+
source: 'amp-session-tty',
|
|
165
|
+
lastThreadId,
|
|
166
|
+
filePath,
|
|
167
|
+
fileFresh,
|
|
168
|
+
updatedAt,
|
|
169
|
+
};
|
|
137
170
|
}
|
|
138
|
-
return {
|
|
171
|
+
return {
|
|
172
|
+
threadId: '',
|
|
173
|
+
source: '',
|
|
174
|
+
lastThreadId,
|
|
175
|
+
filePath,
|
|
176
|
+
fileFresh,
|
|
177
|
+
updatedAt,
|
|
178
|
+
};
|
|
139
179
|
}
|
|
140
180
|
|
|
141
181
|
export function detectSessionClient(options = {}) {
|
|
@@ -168,6 +208,15 @@ export function detectSessionClient(options = {}) {
|
|
|
168
208
|
if (hint.threadId) {
|
|
169
209
|
return { platform: 'amp', threadId: hint.threadId, source: hint.source };
|
|
170
210
|
}
|
|
211
|
+
if (hint.lastThreadId && hint.fileFresh) {
|
|
212
|
+
return { platform: 'amp', threadId: hint.lastThreadId, source: 'amp-session-last' };
|
|
213
|
+
}
|
|
214
|
+
if (hint.fileFresh) {
|
|
215
|
+
const listed = listRecentAmpThreadIds(options);
|
|
216
|
+
if (listed[0]) {
|
|
217
|
+
return { platform: 'amp', threadId: listed[0], source: 'amp-session-list' };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
171
220
|
|
|
172
221
|
return { platform: null, threadId: null, source: 'none' };
|
|
173
222
|
}
|
package/bin/spend-collect.js
CHANGED
|
@@ -504,8 +504,10 @@ function collectAmpCli(ctx) {
|
|
|
504
504
|
if (id && !ids.includes(id)) ids.push(id);
|
|
505
505
|
};
|
|
506
506
|
push(ampThreadId);
|
|
507
|
-
|
|
508
|
-
|
|
507
|
+
if (ctx.listRecentAmpThreads !== false) {
|
|
508
|
+
push(ampCurrentThreadId(env));
|
|
509
|
+
}
|
|
510
|
+
if (!ids.length && ctx.listRecentAmpThreads !== false) {
|
|
509
511
|
for (const id of listRecentAmpThreadIds(ctx)) push(id);
|
|
510
512
|
}
|
|
511
513
|
const sources = [];
|
|
@@ -825,6 +827,7 @@ export function collectSpend(options = {}) {
|
|
|
825
827
|
homedir,
|
|
826
828
|
notes,
|
|
827
829
|
ampThreadId: options.ampThreadId,
|
|
830
|
+
listRecentAmpThreads: options.listRecentAmpThreads,
|
|
828
831
|
cursorConversationId: options.cursorConversationId,
|
|
829
832
|
exportAmpThread: options.exportAmpThread,
|
|
830
833
|
listAmpThreads: options.listAmpThreads,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Universal AI agent orchestration kit for Cursor, Claude Code, and Amp Code — spec-driven OpenSpec pipeline, conductor subagents, durable session handoff, factory gates and MCP setup, cloud-agent handoff, and optional local Figma PAT setup",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -508,7 +508,9 @@ function recompute(metrics) {
|
|
|
508
508
|
}
|
|
509
509
|
phases[key] = phase;
|
|
510
510
|
|
|
511
|
+
let sessionSourceCostUsd = null;
|
|
511
512
|
for (const src of session.sources || []) {
|
|
513
|
+
sessionSourceCostUsd = addNullable(sessionSourceCostUsd, numOrNull(src.costUsd));
|
|
512
514
|
const platform = src.platform;
|
|
513
515
|
if (platform && byPlatform[platform]) {
|
|
514
516
|
const bucket = byPlatform[platform];
|
|
@@ -543,6 +545,11 @@ function recompute(metrics) {
|
|
|
543
545
|
byModel.set(modelKey, row);
|
|
544
546
|
}
|
|
545
547
|
}
|
|
548
|
+
const sessionCostUsd = numOrNull(session.costUsd);
|
|
549
|
+
if (sessionSourceCostUsd == null && sessionCostUsd != null) {
|
|
550
|
+
const billedBucket = session.platform && byPlatform[session.platform];
|
|
551
|
+
if (billedBucket) billedBucket.costUsd = addNullable(billedBucket.costUsd, sessionCostUsd);
|
|
552
|
+
}
|
|
546
553
|
}
|
|
547
554
|
|
|
548
555
|
if (firstStart && lastEnd) {
|