agent-orchestrator-kit 0.11.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 +12 -0
- package/README.md +19 -8
- package/bin/agent-orchestrator.js +200 -29
- 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 +257 -34
- package/templates/scripts/cursor-spend-hook.cjs +23 -8
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ 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
|
+
|
|
12
|
+
## [0.12.0] - 2026-09-02
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **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`.
|
|
16
|
+
|
|
7
17
|
## [0.11.0] - 2026-09-02
|
|
8
18
|
|
|
9
19
|
### Changed
|
|
@@ -266,6 +276,8 @@ All notable changes to this project will be documented in this file.
|
|
|
266
276
|
### Added
|
|
267
277
|
- Initial release: 5-role orchestration pipeline, `/opsx:*` commands, IDE sync
|
|
268
278
|
|
|
279
|
+
[0.13.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.12.0...v0.13.0
|
|
280
|
+
[0.12.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.11.0...v0.12.0
|
|
269
281
|
[0.11.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.10.0...v0.11.0
|
|
270
282
|
[0.10.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.9.0...v0.10.0
|
|
271
283
|
[0.9.0]: https://github.com/makshc2/agent-orchestrator-kit/compare/v0.8.0...v0.9.0
|
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ npx agent-orchestrator-kit@latest init --profile generic --ci gitlab --spec-veri
|
|
|
74
74
|
|
|
75
75
|
See [Installation](#installation) for profile/CI options.
|
|
76
76
|
|
|
77
|
-
**🔄 Already have the kit installed? Upgrade to latest (session attribution + Cursor estimate/dedup in v0.11.0; Cursor fallback estimate + first-class `costUsdEstimated` in v0.10.0; UTC timestamps + Amp stamp parse, Amp billed `$`, Cursor API estimate, archive auto-collect in v0.9.0; locked Amp/Cursor client in v0.8.0; `## Metrics` self-report + opt-in `--collect` in v0.7.0 — **BREAKING:** `--no-collect` is gone; change metrics in v0.5.0+, factory phases 1–3 in v0.4.0+, lean pipeline / archive CLI in v0.3.0+, handoff CLI in v0.1.14+, Figma PAT in v0.1.11+):**
|
|
77
|
+
**🔄 Already have the kit installed? Upgrade to latest (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,16 +804,17 @@ 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
|
-
- **`sessionEnd` leftover** — `scripts/cursor-spend-collect.cjs` also reads the newest `openspec/changes/archive/*-<name>/metrics.json` when the active change folder is gone, so a late hook after archive still attaches.
|
|
810
|
-
- **
|
|
811
|
-
- **
|
|
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.
|
|
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).
|
|
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
|
-
- **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
|
+
- **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.
|
|
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
|
-
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.
|
|
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.
|
|
817
818
|
|
|
818
819
|
Fill `## Metrics` in `handoff.md` **before** persist (unknown is fine; do not invent `0`):
|
|
819
820
|
|
|
@@ -1006,6 +1007,16 @@ 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
|
+
|
|
1015
|
+
### 0.12.0
|
|
1016
|
+
- Cursor leftover after `stop` / `afterAgentResponse` (fail-open, idempotent `sessionEnd`); leftover filters `last.threadId` when it is set
|
|
1017
|
+
- Multi-root hook/collect resolve the consumer window; leftover walks every candidate with `openspec/changes`
|
|
1018
|
+
- `costUsdEstimated` aggregates write 4 decimals; `phases.<phase>` stores `startedAt` / `endedAt` / `leadTimeMs` (work-time `durationMs` is not `totals.leadTimeMs`)
|
|
1019
|
+
|
|
1009
1020
|
### 0.11.0
|
|
1010
1021
|
- Session metrics attribution: persist collect is `[pending.startedAt, endedAt]`; a late hook stays leftover of that session; archive is `[pending.startedAt, now]` plus leftover of the previous session
|
|
1011
1022
|
- Source product id wins for `session.model`; `--model` / `## Metrics` / `AOK_MODEL` apply only when sources have none
|
|
@@ -1615,6 +1615,11 @@ const METRICS_VERSION = 1;
|
|
|
1615
1615
|
const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated'];
|
|
1616
1616
|
const LEFTOVER_GRACE_MS = 120000;
|
|
1617
1617
|
|
|
1618
|
+
function roundUsd4(x) {
|
|
1619
|
+
if (x == null) return null;
|
|
1620
|
+
return Math.round(Number(x) * 10000) / 10000;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1618
1623
|
function metricsFilePath(projectDir, changeName) {
|
|
1619
1624
|
return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
|
|
1620
1625
|
}
|
|
@@ -1783,12 +1788,64 @@ function leftoverTimestampInWindow(at, windowStart, leftoverEnd, exclusiveEnd) {
|
|
|
1783
1788
|
return true;
|
|
1784
1789
|
}
|
|
1785
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
|
+
|
|
1786
1845
|
function sessionSpendIsFrozen(session) {
|
|
1787
1846
|
if (!session) return false;
|
|
1788
1847
|
if (session.spendSource === 'flag') return true;
|
|
1789
|
-
|
|
1790
|
-
if (!session.spendSource || session.spendSource === 'adapter' || session.spendSource === 'unreported') return false;
|
|
1791
|
-
return true;
|
|
1848
|
+
return session.spendSource === 'self-report' && reportedHasSpendNumbers(session);
|
|
1792
1849
|
}
|
|
1793
1850
|
|
|
1794
1851
|
function existingSourceRecords(metrics) {
|
|
@@ -1902,7 +1959,7 @@ function sourceEstimatedUsd(sources) {
|
|
|
1902
1959
|
for (const src of sources || []) {
|
|
1903
1960
|
sum = addNullable(sum, numOrNull(src.costUsdEstimated));
|
|
1904
1961
|
}
|
|
1905
|
-
return sum;
|
|
1962
|
+
return roundUsd4(sum);
|
|
1906
1963
|
}
|
|
1907
1964
|
|
|
1908
1965
|
function ampThreadTotals(threads) {
|
|
@@ -1989,41 +2046,90 @@ function sessionTotalsFromSources(sources) {
|
|
|
1989
2046
|
function runCollectSpend(metrics, windowStart, windowEnd, extra = {}) {
|
|
1990
2047
|
try {
|
|
1991
2048
|
return collectSpend({
|
|
1992
|
-
cwd: process.cwd(),
|
|
2049
|
+
cwd: extra.cwd || process.cwd(),
|
|
1993
2050
|
windowStart,
|
|
1994
2051
|
windowEnd,
|
|
1995
2052
|
existingSourceIds: existingSourceIdSet(metrics),
|
|
1996
2053
|
existingSources: existingSourceRecords(metrics),
|
|
1997
|
-
env: process.env,
|
|
1998
|
-
homedir: process.env.HOME,
|
|
2054
|
+
env: extra.env || process.env,
|
|
2055
|
+
homedir: extra.homedir || process.env.HOME,
|
|
1999
2056
|
platforms: extra.platforms,
|
|
2000
2057
|
ampThreadId: extra.ampThreadId,
|
|
2001
2058
|
ampCli: extra.ampCli === true,
|
|
2002
2059
|
cursorConversationId: extra.cursorConversationId,
|
|
2003
2060
|
exportAmpThread: extra.exportAmpThread,
|
|
2004
2061
|
usageAmpThread: extra.usageAmpThread,
|
|
2062
|
+
listRecentAmpThreads: extra.listRecentAmpThreads,
|
|
2063
|
+
listAmpThreads: extra.listAmpThreads,
|
|
2005
2064
|
});
|
|
2006
2065
|
} catch {
|
|
2007
2066
|
return { sources: [], byPlatform: defaultSpendByPlatform(), byModel: [], notes: [] };
|
|
2008
2067
|
}
|
|
2009
2068
|
}
|
|
2010
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
|
+
|
|
2011
2097
|
function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
|
|
2012
2098
|
if (!session || !session.endedAt || !leftoverEnd) return 0;
|
|
2013
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'));
|
|
2014
2106
|
const collected = runCollectSpend(metrics, session.endedAt, leftoverEnd, {
|
|
2015
|
-
platforms
|
|
2016
|
-
ampThreadId
|
|
2017
|
-
|
|
2107
|
+
platforms,
|
|
2108
|
+
...(ampThreadId ? { ampThreadId } : {}),
|
|
2109
|
+
listRecentAmpThreads: false,
|
|
2110
|
+
ampCli: extra.ampCli === true || collectAmp,
|
|
2018
2111
|
cursorConversationId: extra.cursorConversationId
|
|
2019
|
-
|| (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,
|
|
2020
2119
|
});
|
|
2021
|
-
|
|
2120
|
+
let incoming = (collected.sources || []).filter((src) => leftoverTimestampInWindow(
|
|
2022
2121
|
src.at,
|
|
2023
2122
|
session.endedAt,
|
|
2024
2123
|
leftoverEnd,
|
|
2025
2124
|
exclusiveEnd,
|
|
2026
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
|
+
}
|
|
2027
2133
|
if (!incoming.length) return 0;
|
|
2028
2134
|
const merged = [...(session.sources || []), ...incoming];
|
|
2029
2135
|
if (sessionSpendIsFrozen(session)) {
|
|
@@ -2031,10 +2137,13 @@ function attachLeftoverSources(metrics, session, leftoverEnd, extra = {}) {
|
|
|
2031
2137
|
const uniqueModels = uniqueSourceModels(merged);
|
|
2032
2138
|
if (uniqueModels.length > 1) session.models = uniqueModels;
|
|
2033
2139
|
} else {
|
|
2140
|
+
const priorCost = numOrNull(session.costUsd);
|
|
2141
|
+
const priorSource = session.spendSource;
|
|
2034
2142
|
applyCollectedSessionFields(session, merged, session.model, {}, {
|
|
2035
2143
|
model: session.model,
|
|
2036
2144
|
platform: session.platform,
|
|
2037
|
-
}, { ampThreads: collected.ampThreads });
|
|
2145
|
+
}, { ampThreads: Array.isArray(extra.ampThreads) ? extra.ampThreads : collected.ampThreads });
|
|
2146
|
+
resyncLeftoverSessionSpend(session, priorCost, priorSource);
|
|
2038
2147
|
}
|
|
2039
2148
|
return incoming.length;
|
|
2040
2149
|
}
|
|
@@ -2065,13 +2174,10 @@ function applyCollectedSessionFields(session, sources, resolvedModel, opts, repo
|
|
|
2065
2174
|
session.costUsdEstimated = spend.costUsdEstimated;
|
|
2066
2175
|
session.spendSource = spend.spendSource;
|
|
2067
2176
|
if (spend.agentMode) session.agentMode = spend.agentMode;
|
|
2068
|
-
const usageModels =
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
if (row && row.model) usageModels.push(row);
|
|
2072
|
-
}
|
|
2177
|
+
const usageModels = usageModelsForSession(session, extra.ampThreads);
|
|
2178
|
+
if (usageModels.length || Array.isArray(session.usageModels)) {
|
|
2179
|
+
session.usageModels = usageModels;
|
|
2073
2180
|
}
|
|
2074
|
-
if (usageModels.length) session.usageModels = usageModels;
|
|
2075
2181
|
}
|
|
2076
2182
|
|
|
2077
2183
|
function sessionTotalsLookOverridden(session) {
|
|
@@ -2194,6 +2300,14 @@ function recomputeSpendMaps(metrics) {
|
|
|
2194
2300
|
const sources = session.sources || [];
|
|
2195
2301
|
if (sources.length > 0) {
|
|
2196
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
|
+
}
|
|
2197
2311
|
continue;
|
|
2198
2312
|
}
|
|
2199
2313
|
const sessionNums = spendTuple(session);
|
|
@@ -2202,6 +2316,12 @@ function recomputeSpendMaps(metrics) {
|
|
|
2202
2316
|
}
|
|
2203
2317
|
addModelRow(session.model, session.platform, sessionNums);
|
|
2204
2318
|
}
|
|
2319
|
+
for (const key of Object.keys(byPlatform)) {
|
|
2320
|
+
byPlatform[key].costUsdEstimated = roundUsd4(byPlatform[key].costUsdEstimated);
|
|
2321
|
+
}
|
|
2322
|
+
for (const row of byModel.values()) {
|
|
2323
|
+
row.costUsdEstimated = roundUsd4(row.costUsdEstimated);
|
|
2324
|
+
}
|
|
2205
2325
|
metrics.spendByPlatform = byPlatform;
|
|
2206
2326
|
metrics.spendByModel = [...byModel.values()];
|
|
2207
2327
|
}
|
|
@@ -2214,20 +2334,51 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
2214
2334
|
let firstStart = null;
|
|
2215
2335
|
let lastEnd = null;
|
|
2216
2336
|
for (const session of metrics.sessions) {
|
|
2337
|
+
session.costUsdEstimated = roundUsd4(session.costUsdEstimated);
|
|
2217
2338
|
totals.sessions += 1;
|
|
2218
2339
|
if (session.runtime === 'cloud') totals.cloudSessions += 1;
|
|
2219
2340
|
totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
|
|
2220
|
-
if (
|
|
2221
|
-
|
|
2341
|
+
if (Number.isFinite(parseFlexibleIso(session.startedAt))) {
|
|
2342
|
+
firstStart = firstStart == null ? session.startedAt : earlierTimestamp(firstStart, session.startedAt);
|
|
2343
|
+
}
|
|
2344
|
+
if (Number.isFinite(parseFlexibleIso(session.endedAt))) {
|
|
2345
|
+
lastEnd = lastEnd == null ? session.endedAt : laterTimestamp(lastEnd, session.endedAt);
|
|
2346
|
+
}
|
|
2222
2347
|
const key = session.phase || 'other';
|
|
2223
|
-
const phase = phases[key] || {
|
|
2348
|
+
const phase = phases[key] || {
|
|
2349
|
+
sessions: 0,
|
|
2350
|
+
durationMs: null,
|
|
2351
|
+
startedAt: null,
|
|
2352
|
+
endedAt: null,
|
|
2353
|
+
leadTimeMs: null,
|
|
2354
|
+
...emptySpendTotals(),
|
|
2355
|
+
agents: [],
|
|
2356
|
+
models: [],
|
|
2357
|
+
};
|
|
2224
2358
|
phase.sessions += 1;
|
|
2225
2359
|
phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
|
|
2360
|
+
if (Number.isFinite(parseFlexibleIso(session.startedAt))) {
|
|
2361
|
+
phase.startedAt = phase.startedAt == null
|
|
2362
|
+
? session.startedAt
|
|
2363
|
+
: earlierTimestamp(phase.startedAt, session.startedAt);
|
|
2364
|
+
}
|
|
2365
|
+
if (Number.isFinite(parseFlexibleIso(session.endedAt))) {
|
|
2366
|
+
phase.endedAt = phase.endedAt == null
|
|
2367
|
+
? session.endedAt
|
|
2368
|
+
: laterTimestamp(phase.endedAt, session.endedAt);
|
|
2369
|
+
}
|
|
2370
|
+
const sources = session.sources || [];
|
|
2371
|
+
let sourceCost = null;
|
|
2372
|
+
for (const src of sources) sourceCost = addNullable(sourceCost, numOrNull(src.costUsd));
|
|
2226
2373
|
for (const spendKey of METRICS_SPEND_KEYS) {
|
|
2227
2374
|
let value = null;
|
|
2228
|
-
if (
|
|
2229
|
-
|
|
2230
|
-
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
|
+
}
|
|
2231
2382
|
}
|
|
2232
2383
|
} else {
|
|
2233
2384
|
value = sessionFieldOrSources(session, spendKey);
|
|
@@ -2247,6 +2398,15 @@ function recomputeMetricsAggregates(metrics) {
|
|
|
2247
2398
|
if (firstStart && lastEnd) {
|
|
2248
2399
|
totals.leadTimeMs = Math.max(0, parseFlexibleIso(lastEnd) - parseFlexibleIso(firstStart));
|
|
2249
2400
|
}
|
|
2401
|
+
for (const phase of Object.values(phases)) {
|
|
2402
|
+
const startMs = parseFlexibleIso(phase.startedAt);
|
|
2403
|
+
const endMs = parseFlexibleIso(phase.endedAt);
|
|
2404
|
+
phase.leadTimeMs = Number.isFinite(startMs) && Number.isFinite(endMs)
|
|
2405
|
+
? Math.max(0, endMs - startMs)
|
|
2406
|
+
: null;
|
|
2407
|
+
phase.costUsdEstimated = roundUsd4(phase.costUsdEstimated);
|
|
2408
|
+
}
|
|
2409
|
+
spend.costUsdEstimated = roundUsd4(spend.costUsdEstimated);
|
|
2250
2410
|
metrics.phases = phases;
|
|
2251
2411
|
metrics.totals = totals;
|
|
2252
2412
|
metrics.spend = spend;
|
|
@@ -2259,7 +2419,7 @@ function metricsRecordSessionStart(projectDir, changeName, role, client = {}) {
|
|
|
2259
2419
|
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
2260
2420
|
metrics.pending = {
|
|
2261
2421
|
startedAt: nowIso,
|
|
2262
|
-
role: role || '',
|
|
2422
|
+
role: canonicalRole(role) || role || '',
|
|
2263
2423
|
platform: client.platform || null,
|
|
2264
2424
|
threadId: client.threadId || null,
|
|
2265
2425
|
clientSource: client.source || null,
|
|
@@ -2280,6 +2440,7 @@ function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
|
2280
2440
|
: leftoverGraceEnd(lastClosed && lastClosed.endedAt);
|
|
2281
2441
|
attachLeftoverSources(metrics, lastClosed, leftoverEnd, {
|
|
2282
2442
|
exclusiveEnd: Boolean(metrics.pending && metrics.pending.startedAt),
|
|
2443
|
+
collect: opts.collect === true,
|
|
2283
2444
|
});
|
|
2284
2445
|
let startedAt = isoOrNull(opts.startedAt) || isoOrNull(metrics.pending && metrics.pending.startedAt) || null;
|
|
2285
2446
|
const reported = opts.reported || fields.metrics || emptyMetricsFields();
|
|
@@ -2420,7 +2581,7 @@ function metricsFinalizeArchive(targetDir, changeName, opts = {}) {
|
|
|
2420
2581
|
return filePath;
|
|
2421
2582
|
}
|
|
2422
2583
|
|
|
2423
|
-
function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
|
|
2584
|
+
function metricsPrepareArchiveStart(changeRoot, changeName, client = {}, extra = {}) {
|
|
2424
2585
|
const filePath = join(changeRoot, 'metrics.json');
|
|
2425
2586
|
if (!existsSync(filePath)) return;
|
|
2426
2587
|
const nowIso = nowUtcIso();
|
|
@@ -2437,6 +2598,7 @@ function metricsPrepareArchiveStart(changeRoot, changeName, client = {}) {
|
|
|
2437
2598
|
const leftoverEnd = metrics.pending && metrics.pending.startedAt;
|
|
2438
2599
|
attachLeftoverSources(metrics, lastNonArchiverSession(metrics.sessions), leftoverEnd, {
|
|
2439
2600
|
exclusiveEnd: true,
|
|
2601
|
+
collect: extra.collect === true,
|
|
2440
2602
|
});
|
|
2441
2603
|
metrics.updatedAt = nowIso;
|
|
2442
2604
|
recomputeMetricsAggregates(metrics);
|
|
@@ -3832,7 +3994,7 @@ program
|
|
|
3832
3994
|
homedir: process.env.HOME,
|
|
3833
3995
|
platform: opts.platform,
|
|
3834
3996
|
});
|
|
3835
|
-
metricsPrepareArchiveStart(changeRoot, name, archiveClient);
|
|
3997
|
+
metricsPrepareArchiveStart(changeRoot, name, archiveClient, { collect: opts.collect === true });
|
|
3836
3998
|
mkdirSync(dirname(targetDir), { recursive: true });
|
|
3837
3999
|
renameSync(changeRoot, targetDir);
|
|
3838
4000
|
|
|
@@ -4403,4 +4565,13 @@ if (isDirectCliRun()) {
|
|
|
4403
4565
|
program.parse();
|
|
4404
4566
|
}
|
|
4405
4567
|
|
|
4406
|
-
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",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
'use strict';
|
|
6
6
|
|
|
7
7
|
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync } = require('fs');
|
|
8
|
-
const { join } = require('path');
|
|
8
|
+
const { join, resolve } = require('path');
|
|
9
9
|
|
|
10
10
|
function numOrNull(value) {
|
|
11
11
|
if (value == null || value === '') return null;
|
|
@@ -18,6 +18,17 @@ function addNullable(a, b) {
|
|
|
18
18
|
return (a ?? 0) + (b ?? 0);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function roundUsd4(x) {
|
|
22
|
+
if (x == null) return null;
|
|
23
|
+
return Math.round(Number(x) * 10000) / 10000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function timestampMs(value) {
|
|
27
|
+
if (value == null || value === '') return NaN;
|
|
28
|
+
const ms = Date.parse(value);
|
|
29
|
+
return Number.isFinite(ms) ? ms : NaN;
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
const GROK_46 = {
|
|
22
33
|
inputPerM: 2,
|
|
23
34
|
cachedPerM: 0.5,
|
|
@@ -85,14 +96,161 @@ function describeCursorCostEstimate(args) {
|
|
|
85
96
|
};
|
|
86
97
|
}
|
|
87
98
|
|
|
99
|
+
function uniqueExistingAbsPaths(payload) {
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const out = [];
|
|
102
|
+
const add = (value) => {
|
|
103
|
+
if (value == null || value === '') return;
|
|
104
|
+
let abs;
|
|
105
|
+
try {
|
|
106
|
+
abs = resolve(String(value));
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (seen.has(abs)) return;
|
|
111
|
+
try {
|
|
112
|
+
if (!existsSync(abs)) return;
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
seen.add(abs);
|
|
117
|
+
out.push(abs);
|
|
118
|
+
};
|
|
119
|
+
add(process.cwd());
|
|
120
|
+
const roots = payload && Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
|
|
121
|
+
for (const root of roots) add(root);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function leftoverCandidateRoots(payload) {
|
|
126
|
+
return uniqueExistingAbsPaths(payload).filter((root) => existsSync(join(root, 'openspec', 'changes')));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readJsonSafe(filePath) {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function metricsHasConversationThread(metrics, conversationId) {
|
|
138
|
+
if (!metrics || typeof metrics !== 'object' || !conversationId) return false;
|
|
139
|
+
const pendingId = metrics.pending && metrics.pending.threadId != null && metrics.pending.threadId !== ''
|
|
140
|
+
? String(metrics.pending.threadId).trim()
|
|
141
|
+
: '';
|
|
142
|
+
if (pendingId && pendingId === conversationId) return true;
|
|
143
|
+
const sessions = Array.isArray(metrics.sessions) ? metrics.sessions : [];
|
|
144
|
+
const last = sessions[sessions.length - 1];
|
|
145
|
+
const lastId = last && last.threadId != null && last.threadId !== ''
|
|
146
|
+
? String(last.threadId).trim()
|
|
147
|
+
: '';
|
|
148
|
+
return Boolean(lastId && lastId === conversationId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function activeChangeNames(changesDir) {
|
|
152
|
+
const names = [];
|
|
153
|
+
let entries;
|
|
154
|
+
try {
|
|
155
|
+
entries = readdirSync(changesDir);
|
|
156
|
+
} catch {
|
|
157
|
+
return names;
|
|
158
|
+
}
|
|
159
|
+
for (const name of entries) {
|
|
160
|
+
if (name === 'archive') continue;
|
|
161
|
+
const full = join(changesDir, name);
|
|
162
|
+
try {
|
|
163
|
+
if (statSync(full).isDirectory()) names.push(name);
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return names;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function rootHasMatchingThread(root, conversationId) {
|
|
172
|
+
const changesDir = join(root, 'openspec', 'changes');
|
|
173
|
+
if (!existsSync(changesDir)) return false;
|
|
174
|
+
for (const name of activeChangeNames(changesDir)) {
|
|
175
|
+
if (metricsHasConversationThread(readJsonSafe(join(changesDir, name, 'metrics.json')), conversationId)) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const archiveDir = join(changesDir, 'archive');
|
|
180
|
+
if (!existsSync(archiveDir)) return false;
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
let entries;
|
|
183
|
+
try {
|
|
184
|
+
entries = readdirSync(archiveDir);
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
const changeName = archivedChangeName(entry);
|
|
190
|
+
if (!changeName || seen.has(changeName)) continue;
|
|
191
|
+
seen.add(changeName);
|
|
192
|
+
const dir = newestArchiveDirForName(archiveDir, changeName);
|
|
193
|
+
if (!dir) continue;
|
|
194
|
+
if (metricsHasConversationThread(readJsonSafe(join(dir, 'metrics.json')), conversationId)) return true;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function rootHasActiveChange(root) {
|
|
200
|
+
return activeChangeNames(join(root, 'openspec', 'changes')).length > 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function jsonlHasConversationId(root, conversationId) {
|
|
204
|
+
if (!conversationId) return false;
|
|
205
|
+
const filePath = join(root, '.agents', 'spend', 'cursor-usage.jsonl');
|
|
206
|
+
if (!existsSync(filePath)) return false;
|
|
207
|
+
let text;
|
|
208
|
+
try {
|
|
209
|
+
text = readFileSync(filePath, 'utf-8');
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
for (const line of text.split('\n')) {
|
|
214
|
+
if (!line.trim()) continue;
|
|
215
|
+
let row;
|
|
216
|
+
try {
|
|
217
|
+
row = JSON.parse(line);
|
|
218
|
+
} catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const id = row && row.conversationId != null && row.conversationId !== ''
|
|
222
|
+
? String(row.conversationId).trim()
|
|
223
|
+
: '';
|
|
224
|
+
if (id && id === conversationId) return true;
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
88
229
|
function resolveBaseDir(payload) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
230
|
+
const candidates = uniqueExistingAbsPaths(payload && typeof payload === 'object' ? payload : {});
|
|
231
|
+
const conversationId = String((payload && payload.conversation_id) || '').trim();
|
|
232
|
+
if (conversationId) {
|
|
233
|
+
for (const root of candidates) {
|
|
234
|
+
if (rootHasMatchingThread(root, conversationId)) return root;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const root of candidates) {
|
|
238
|
+
if (rootHasActiveChange(root)) return root;
|
|
239
|
+
}
|
|
240
|
+
if (conversationId) {
|
|
241
|
+
for (const root of candidates) {
|
|
242
|
+
if (jsonlHasConversationId(root, conversationId)) return root;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const cwd = resolve(process.cwd());
|
|
246
|
+
if (
|
|
247
|
+
candidates.includes(cwd)
|
|
248
|
+
&& (existsSync(join(cwd, 'openspec', 'changes')) || existsSync(join(cwd, '.agents')))
|
|
249
|
+
) {
|
|
250
|
+
return cwd;
|
|
94
251
|
}
|
|
95
|
-
return cwd;
|
|
252
|
+
if (!candidates.length) return cwd;
|
|
253
|
+
return [...candidates].sort()[0];
|
|
96
254
|
}
|
|
97
255
|
|
|
98
256
|
const CURSOR_LEFTOVER_GRACE_MS = 120000;
|
|
@@ -255,7 +413,7 @@ function sourceTotals(sources) {
|
|
|
255
413
|
if (src.costUsd != null) costUsd = addNullable(costUsd, numOrNull(src.costUsd));
|
|
256
414
|
costUsdEstimated = addNullable(costUsdEstimated, numOrNull(src.costUsdEstimated));
|
|
257
415
|
}
|
|
258
|
-
return { inputTokens, outputTokens, totalTokens, costUsd, costUsdEstimated };
|
|
416
|
+
return { inputTokens, outputTokens, totalTokens, costUsd, costUsdEstimated: roundUsd4(costUsdEstimated) };
|
|
259
417
|
}
|
|
260
418
|
|
|
261
419
|
function looksOverridden(session) {
|
|
@@ -298,13 +456,22 @@ function recompute(metrics) {
|
|
|
298
456
|
totals.sessions += 1;
|
|
299
457
|
if (session.runtime === 'cloud') totals.cloudSessions += 1;
|
|
300
458
|
totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
|
|
301
|
-
|
|
302
|
-
if (
|
|
459
|
+
const sessionStartMs = timestampMs(session.startedAt);
|
|
460
|
+
if (Number.isFinite(sessionStartMs) && (firstStart == null || sessionStartMs < timestampMs(firstStart))) {
|
|
461
|
+
firstStart = session.startedAt;
|
|
462
|
+
}
|
|
463
|
+
const sessionEndMs = timestampMs(session.endedAt);
|
|
464
|
+
if (Number.isFinite(sessionEndMs) && (lastEnd == null || sessionEndMs > timestampMs(lastEnd))) {
|
|
465
|
+
lastEnd = session.endedAt;
|
|
466
|
+
}
|
|
303
467
|
|
|
304
468
|
const key = session.phase || 'other';
|
|
305
469
|
const phase = phases[key] || {
|
|
306
470
|
sessions: 0,
|
|
307
471
|
durationMs: null,
|
|
472
|
+
startedAt: null,
|
|
473
|
+
endedAt: null,
|
|
474
|
+
leadTimeMs: null,
|
|
308
475
|
inputTokens: null,
|
|
309
476
|
outputTokens: null,
|
|
310
477
|
totalTokens: null,
|
|
@@ -315,6 +482,12 @@ function recompute(metrics) {
|
|
|
315
482
|
};
|
|
316
483
|
phase.sessions += 1;
|
|
317
484
|
phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
|
|
485
|
+
if (Number.isFinite(sessionStartMs) && (phase.startedAt == null || sessionStartMs < timestampMs(phase.startedAt))) {
|
|
486
|
+
phase.startedAt = session.startedAt;
|
|
487
|
+
}
|
|
488
|
+
if (Number.isFinite(sessionEndMs) && (phase.endedAt == null || sessionEndMs > timestampMs(phase.endedAt))) {
|
|
489
|
+
phase.endedAt = session.endedAt;
|
|
490
|
+
}
|
|
318
491
|
for (const spendKey of ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd', 'costUsdEstimated']) {
|
|
319
492
|
const fromSession = numOrNull(session[spendKey]);
|
|
320
493
|
let value = fromSession;
|
|
@@ -335,7 +508,9 @@ function recompute(metrics) {
|
|
|
335
508
|
}
|
|
336
509
|
phases[key] = phase;
|
|
337
510
|
|
|
511
|
+
let sessionSourceCostUsd = null;
|
|
338
512
|
for (const src of session.sources || []) {
|
|
513
|
+
sessionSourceCostUsd = addNullable(sessionSourceCostUsd, numOrNull(src.costUsd));
|
|
339
514
|
const platform = src.platform;
|
|
340
515
|
if (platform && byPlatform[platform]) {
|
|
341
516
|
const bucket = byPlatform[platform];
|
|
@@ -370,11 +545,31 @@ function recompute(metrics) {
|
|
|
370
545
|
byModel.set(modelKey, row);
|
|
371
546
|
}
|
|
372
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
|
+
}
|
|
373
553
|
}
|
|
374
554
|
|
|
375
555
|
if (firstStart && lastEnd) {
|
|
376
556
|
totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
|
|
377
557
|
}
|
|
558
|
+
for (const phase of Object.values(phases)) {
|
|
559
|
+
const startMs = timestampMs(phase.startedAt);
|
|
560
|
+
const endMs = timestampMs(phase.endedAt);
|
|
561
|
+
phase.leadTimeMs = Number.isFinite(startMs) && Number.isFinite(endMs)
|
|
562
|
+
? Math.max(0, endMs - startMs)
|
|
563
|
+
: null;
|
|
564
|
+
phase.costUsdEstimated = roundUsd4(phase.costUsdEstimated);
|
|
565
|
+
}
|
|
566
|
+
spend.costUsdEstimated = roundUsd4(spend.costUsdEstimated);
|
|
567
|
+
for (const bucket of Object.values(byPlatform)) {
|
|
568
|
+
bucket.costUsdEstimated = roundUsd4(bucket.costUsdEstimated);
|
|
569
|
+
}
|
|
570
|
+
for (const row of byModel.values()) {
|
|
571
|
+
row.costUsdEstimated = roundUsd4(row.costUsdEstimated);
|
|
572
|
+
}
|
|
378
573
|
metrics.phases = phases;
|
|
379
574
|
metrics.totals = totals;
|
|
380
575
|
metrics.spend = spend;
|
|
@@ -382,12 +577,19 @@ function recompute(metrics) {
|
|
|
382
577
|
metrics.spendByModel = [...byModel.values()];
|
|
383
578
|
}
|
|
384
579
|
|
|
385
|
-
function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowEnd, byId, exclusiveEnd) {
|
|
580
|
+
function incomingCursorSources(cwd, existing, fingerprints, windowStart, windowEnd, byId, exclusiveEnd, filterConversationId) {
|
|
386
581
|
const startMs = windowStart ? Date.parse(windowStart) : NaN;
|
|
387
582
|
const endMs = windowEnd ? Date.parse(windowEnd) : NaN;
|
|
583
|
+
const filterId = String(filterConversationId || '').trim();
|
|
388
584
|
const bestById = new Map();
|
|
389
585
|
for (const [id, row] of byId) {
|
|
390
586
|
if (existing.has(id)) continue;
|
|
587
|
+
if (filterId) {
|
|
588
|
+
const rowConversationId = row.conversationId == null || row.conversationId === ''
|
|
589
|
+
? ''
|
|
590
|
+
: String(row.conversationId).trim();
|
|
591
|
+
if (rowConversationId !== filterId) continue;
|
|
592
|
+
}
|
|
391
593
|
const atMs = Date.parse(row.at);
|
|
392
594
|
if (Number.isFinite(startMs) && Number.isFinite(atMs) && atMs < startMs) continue;
|
|
393
595
|
if (Number.isFinite(endMs) && Number.isFinite(atMs)) {
|
|
@@ -527,6 +729,7 @@ function backfillMetricsFile(cwd, filePath) {
|
|
|
527
729
|
leftoverEnd,
|
|
528
730
|
byId,
|
|
529
731
|
leftoverEndExclusive(metrics),
|
|
732
|
+
last.threadId,
|
|
530
733
|
)
|
|
531
734
|
: [];
|
|
532
735
|
if (incoming.length) {
|
|
@@ -602,14 +805,7 @@ function newestArchiveDirForName(archiveDir, changeName) {
|
|
|
602
805
|
return best;
|
|
603
806
|
}
|
|
604
807
|
|
|
605
|
-
function
|
|
606
|
-
let payload = {};
|
|
607
|
-
try {
|
|
608
|
-
payload = raw ? JSON.parse(raw) : {};
|
|
609
|
-
} catch {
|
|
610
|
-
payload = {};
|
|
611
|
-
}
|
|
612
|
-
const cwd = resolveBaseDir(payload && typeof payload === 'object' ? payload : {});
|
|
808
|
+
function backfillRoot(cwd) {
|
|
613
809
|
const changesDir = join(cwd, 'openspec', 'changes');
|
|
614
810
|
if (!existsSync(changesDir)) return;
|
|
615
811
|
const activeNames = new Set();
|
|
@@ -645,21 +841,48 @@ function main(raw) {
|
|
|
645
841
|
}
|
|
646
842
|
}
|
|
647
843
|
|
|
648
|
-
|
|
844
|
+
function parsePayload(raw) {
|
|
845
|
+
if (raw && typeof raw === 'object') return raw;
|
|
649
846
|
try {
|
|
650
|
-
|
|
651
|
-
} catch {
|
|
652
|
-
|
|
847
|
+
return raw ? JSON.parse(raw) : {};
|
|
848
|
+
} catch {
|
|
849
|
+
return {};
|
|
850
|
+
}
|
|
653
851
|
}
|
|
654
852
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
853
|
+
function main(raw) {
|
|
854
|
+
const payload = parsePayload(raw);
|
|
855
|
+
const candidates = leftoverCandidateRoots(payload && typeof payload === 'object' ? payload : {});
|
|
856
|
+
for (const cwd of candidates) {
|
|
857
|
+
try {
|
|
858
|
+
backfillRoot(cwd);
|
|
859
|
+
} catch {}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (require.main === module) {
|
|
864
|
+
if (process.stdin.isTTY) {
|
|
865
|
+
try {
|
|
866
|
+
main('');
|
|
867
|
+
} catch {}
|
|
868
|
+
process.exit(0);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
let input = '';
|
|
872
|
+
process.stdin.on('data', (chunk) => {
|
|
873
|
+
input += chunk;
|
|
874
|
+
});
|
|
875
|
+
process.stdin.on('end', () => {
|
|
876
|
+
try {
|
|
877
|
+
main(input);
|
|
878
|
+
} catch {}
|
|
879
|
+
process.exit(0);
|
|
880
|
+
});
|
|
881
|
+
process.stdin.on('error', () => process.exit(0));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
module.exports = {
|
|
885
|
+
main,
|
|
886
|
+
backfillLeftover: main,
|
|
887
|
+
resolveBaseDir,
|
|
888
|
+
};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// must never block the agent loop, so every failure path exits 0 with no output.
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
|
-
const {
|
|
8
|
+
const { mkdirSync, appendFileSync } = require('fs');
|
|
9
9
|
const { join } = require('path');
|
|
10
10
|
|
|
11
11
|
function numOrNull(value) {
|
|
@@ -14,14 +14,24 @@ function numOrNull(value) {
|
|
|
14
14
|
return Number.isFinite(n) ? n : null;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function loadCollect() {
|
|
18
|
+
return require('./cursor-spend-collect.cjs');
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
function resolveBaseDir(payload) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
try {
|
|
23
|
+
const collect = loadCollect();
|
|
24
|
+
if (typeof collect.resolveBaseDir === 'function') return collect.resolveBaseDir(payload);
|
|
25
|
+
} catch {}
|
|
26
|
+
return process.cwd();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runLeftover(payload) {
|
|
30
|
+
try {
|
|
31
|
+
const collect = loadCollect();
|
|
32
|
+
const fn = collect.backfillLeftover || collect.main;
|
|
33
|
+
if (typeof fn === 'function') fn(payload);
|
|
34
|
+
} catch {}
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
function main(raw) {
|
|
@@ -65,6 +75,11 @@ function main(raw) {
|
|
|
65
75
|
const spendDir = join(resolveBaseDir(payload), '.agents', 'spend');
|
|
66
76
|
mkdirSync(spendDir, { recursive: true });
|
|
67
77
|
appendFileSync(join(spendDir, 'cursor-usage.jsonl'), `${JSON.stringify(record)}\n`);
|
|
78
|
+
|
|
79
|
+
const event = payload.hook_event_name ? String(payload.hook_event_name) : '';
|
|
80
|
+
if (event === 'stop' || event === 'afterAgentResponse') {
|
|
81
|
+
runLeftover(payload);
|
|
82
|
+
}
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
let input = '';
|