agent-dealer 1.2.1 → 1.2.2

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.
Files changed (43) hide show
  1. package/bundle/server/dist/adapters/linear-inbox.js +9 -5
  2. package/bundle/server/dist/capacity/adapter.js +2 -0
  3. package/bundle/server/dist/capacity/claude-events.js +18 -0
  4. package/bundle/server/dist/capacity/claude-events.test.js +7 -0
  5. package/bundle/server/dist/capacity/codex-app-server.js +100 -10
  6. package/bundle/server/dist/capacity/codex-app-server.test.js +195 -2
  7. package/bundle/server/dist/capacity/cursor-individual-credentials.js +238 -0
  8. package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +185 -0
  9. package/bundle/server/dist/capacity/cursor-individual.js +970 -0
  10. package/bundle/server/dist/capacity/cursor-individual.test.js +815 -0
  11. package/bundle/server/dist/capacity/muse.js +94 -43
  12. package/bundle/server/dist/capacity/muse.test.js +95 -13
  13. package/bundle/server/dist/capacity/service.js +1 -0
  14. package/bundle/server/dist/db/index.js +30 -0
  15. package/bundle/server/dist/db/schema.sql +27 -0
  16. package/bundle/server/dist/index.js +2 -0
  17. package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
  18. package/bundle/server/dist/repository/repository-mappings.js +46 -0
  19. package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
  20. package/bundle/server/dist/repository/runtime-capacity.js +8 -3
  21. package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
  22. package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
  23. package/bundle/server/dist/routes/index.js +32 -0
  24. package/bundle/server/dist/routes/intake-linear.test.js +55 -1
  25. package/bundle/server/dist/routes/repository-mappings.js +22 -0
  26. package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
  27. package/bundle/server/dist/routes/runtime-capacity.test.js +72 -2
  28. package/bundle/server/package.json +2 -2
  29. package/bundle/server/static-ui/assets/index-0GDYfbaM.js +60 -0
  30. package/bundle/server/static-ui/assets/index-DUYzboWh.css +1 -0
  31. package/bundle/server/static-ui/index.html +2 -2
  32. package/bundle/shared/dist/index.d.ts +6 -6
  33. package/bundle/shared/dist/linear-intake.d.ts +71 -0
  34. package/bundle/shared/dist/linear-intake.js +90 -0
  35. package/bundle/shared/dist/linear-intake.test.js +80 -1
  36. package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
  37. package/bundle/shared/dist/runtime-capacity.js +60 -0
  38. package/bundle/shared/dist/runtime-capacity.test.js +1 -0
  39. package/bundle/shared/package.json +1 -1
  40. package/dist/doctor.js +6 -0
  41. package/package.json +1 -1
  42. package/bundle/server/static-ui/assets/index-ChnI0F02.css +0 -1
  43. package/bundle/server/static-ui/assets/index-CyB1573Q.js +0 -60
@@ -0,0 +1,970 @@
1
+ // packages/server/src/capacity/cursor-individual.ts
2
+ //
3
+ // NOT-250: Cursor Individual capacity over an opt-in EXPERIMENTAL dashboard
4
+ // adapter.
5
+ //
6
+ // Cursor documents monthly individual usage and exposes it in the Spending
7
+ // dashboard, but the supported CLI exposes no usage surface. Community tools
8
+ // commonly call authenticated dashboard endpoints (usage-summary /
9
+ // current-period routes) using the existing local Cursor login. Those
10
+ // endpoints and credential formats are NOT a supported public contract and
11
+ // may change without notice — so this adapter is:
12
+ //
13
+ // - DISABLED BY DEFAULT behind the explicit opt-in
14
+ // `AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY=experimental`. Any other value
15
+ // (including unset) short-circuits BEFORE any credential file is read and
16
+ // before any HTTP is attempted, and serves `enabled: false` N/A.
17
+ // - Labeled `experimental_api` everywhere it surfaces (API source,
18
+ // diagnostics, UI badge, docs).
19
+ // - Minimal-credential: the raw token lives only in one in-memory auth
20
+ // header for the single dashboard call. It is never persisted (see
21
+ // repository/cursor-individual-billing.ts), never returned to the browser,
22
+ // and never logged — tests pin all three.
23
+ // - Bounded: strict origin/path allowlisting, manual redirect handling that
24
+ // rejects unknown origins, per-request timeout, response-size cap, and
25
+ // response-schema validation. A broken endpoint degrades to N/A with an
26
+ // explicit reason; it never throws out of read(), never touches
27
+ // `runtime_availability` (NOT-111 health), and never breaks job execution.
28
+ // - Forward-compatible: `supportedReader` is checked FIRST — if Cursor ships
29
+ // a supported Individual usage surface, wiring its reader as the default
30
+ // below makes the dashboard path the fallback automatically, with no
31
+ // operator action and no opt-in change.
32
+ //
33
+ // Billing-cycle values only (label/start/end, reported usage, remaining
34
+ // percent): no 5H/1W-style windows are invented from monthly data, and money
35
+ // is never rendered as a token percentage.
36
+ import { remainingPercentFromFraction, remainingPercentFromUsedPercent } from "@agent-dealer/shared";
37
+ import { DEFAULT_EXPIRES_AFTER_MS, DEFAULT_STALE_AFTER_MS } from "./adapter.js";
38
+ import { cursorIndividualCredentialStatus, loadCursorIndividualCredential, } from "./cursor-individual-credentials.js";
39
+ import { normalizeCursorApiDate } from "./cursor-team.js";
40
+ export const CURSOR_INDIVIDUAL_RUNTIME = "cursor_local";
41
+ /** Explicit opt-in env: the ONLY value that enables the dashboard read. */
42
+ export const CURSOR_INDIVIDUAL_OPT_IN_ENV = "AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY";
43
+ /** The opt-in value. Anything else (including unset) means disabled. */
44
+ export const CURSOR_INDIVIDUAL_OPT_IN_VALUE = "experimental";
45
+ /** Base URL override for tests (mock HTTP servers). Never set in production. */
46
+ export const CURSOR_INDIVIDUAL_BASE_URL_ENV = "CURSOR_INDIVIDUAL_API_BASE_URL";
47
+ /** Per-request timeout override (mirrors the Team adapter). */
48
+ export const CURSOR_INDIVIDUAL_TIMEOUT_ENV = "AGENT_DEALER_CURSOR_INDIVIDUAL_TIMEOUT_MS";
49
+ /** Opt-out for the on-demand stale refresh (mirrors the Team/Codex adapters). */
50
+ export const CURSOR_INDIVIDUAL_REFRESH_ENV = "AGENT_DEALER_CURSOR_INDIVIDUAL_REFRESH";
51
+ /**
52
+ * Undocumented dashboard origins the adapter may talk to. `www.cursor.com`
53
+ * canonicalizes to `cursor.com` (a same-site redirect), so the bare domain
54
+ * must be allowlisted too or that redirect itself reads `unsafe-redirect`.
55
+ * Community tools target the Cursor web dashboard for these routes; the
56
+ * allowlist is exactly these origins — redirects or config pointing
57
+ * anywhere else are rejected before any credential is sent.
58
+ */
59
+ export const CURSOR_INDIVIDUAL_ALLOWED_ORIGINS = [
60
+ "https://cursor.com",
61
+ "https://www.cursor.com",
62
+ "https://api.cursor.com",
63
+ ];
64
+ /** Default dashboard origin (community-observed; undocumented, may drift). */
65
+ export const CURSOR_INDIVIDUAL_DEFAULT_ORIGIN = "https://cursor.com";
66
+ /**
67
+ * Candidate dashboard usage routes, tried in order. The first 2xx carrying
68
+ * a usable billing value wins; a 404 moves to the next candidate (endpoint
69
+ * drift); auth/transport failures dominate immediately. Both entries name
70
+ * the community-observed usage-summary/current-period shape from the brief —
71
+ * neither is a supported contract.
72
+ */
73
+ export const CURSOR_INDIVIDUAL_USAGE_PATHS = [
74
+ "/api/usage-summary/current-period",
75
+ "/api/usage-summary",
76
+ ];
77
+ /** Cap on a dashboard response body (larger bodies read `malformed`). */
78
+ export const CURSOR_INDIVIDUAL_MAX_BODY_BYTES = 512 * 1024;
79
+ /** Redirect hops followed per read before giving up (allowlisted origins only). */
80
+ export const CURSOR_INDIVIDUAL_MAX_REDIRECTS = 3;
81
+ /**
82
+ * Cooldown after a failed poll before `refreshCursorIndividualBillingIfStale()`
83
+ * polls again (mirrors the Team adapter's 429/5xx backoff).
84
+ */
85
+ export const CURSOR_INDIVIDUAL_FAILURE_BACKOFF_MS = 60_000;
86
+ /** Server-side evidence pointers only — never credentials or raw payloads. */
87
+ export const CURSOR_INDIVIDUAL_EVIDENCE = "cursor-dashboard:usage-summary/current-period";
88
+ /** The billing-cycle window identity in `runtime_capacity_snapshots`. */
89
+ export const CURSOR_INDIVIDUAL_WINDOW_KEY = "billing_cycle";
90
+ export const CURSOR_INDIVIDUAL_PROVIDER_BUCKET = "individual";
91
+ export const CURSOR_INDIVIDUAL_PROVIDER_LABEL = "billing cycle";
92
+ /** True only with the explicit experimental opt-in. No silent opt-in, ever. */
93
+ export function isCursorIndividualExperimentalEnabled() {
94
+ return process.env[CURSOR_INDIVIDUAL_OPT_IN_ENV] === CURSOR_INDIVIDUAL_OPT_IN_VALUE;
95
+ }
96
+ export function cursorIndividualBaseUrl() {
97
+ const raw = process.env[CURSOR_INDIVIDUAL_BASE_URL_ENV]?.trim();
98
+ const base = raw ? raw : CURSOR_INDIVIDUAL_DEFAULT_ORIGIN;
99
+ return base.replace(/\/+$/, "");
100
+ }
101
+ /** Per-request timeout (each dashboard fetch gets this budget). */
102
+ export function cursorIndividualTimeoutMs() {
103
+ const raw = process.env[CURSOR_INDIVIDUAL_TIMEOUT_ENV];
104
+ if (raw !== undefined && raw !== "") {
105
+ const n = Number(raw);
106
+ if (Number.isFinite(n) && n > 0)
107
+ return n;
108
+ }
109
+ return 10_000;
110
+ }
111
+ /** Operator-safe failure log: the static kind only, never tokens or payloads. */
112
+ function logFailure(kind) {
113
+ console.error(`[cursor-individual-capacity] dashboard read failed: ${kind}`);
114
+ }
115
+ /** Default reader: no supported surface exists yet — always null. */
116
+ export function defaultSupportedUsageReader() {
117
+ return null;
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // Undocumented-shape parsing: only the fields below are recognized —
121
+ // billing-cycle label/start/end, a reported usage value/unit, and a usable
122
+ // remaining/used scale. Anything else yields nulls (the caller maps "no
123
+ // usable value" to `unparsable`), never guesses. In particular: no durations
124
+ // are invented (monthly data never becomes a 5H/1W window) and no cycle
125
+ // label is synthesized when the dashboard reports none.
126
+ // ---------------------------------------------------------------------------
127
+ function asFiniteNumber(value) {
128
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
129
+ }
130
+ function pickNumber(obj, keys) {
131
+ for (const k of keys) {
132
+ const n = asFiniteNumber(obj[k]);
133
+ if (n !== null)
134
+ return n;
135
+ }
136
+ return null;
137
+ }
138
+ function pickString(obj, keys) {
139
+ for (const k of keys) {
140
+ const v = obj[k];
141
+ if (typeof v === "string" && v.trim())
142
+ return v.trim();
143
+ }
144
+ return null;
145
+ }
146
+ function pickRaw(obj, keys) {
147
+ for (const k of keys) {
148
+ if (obj[k] !== undefined && obj[k] !== null)
149
+ return obj[k];
150
+ }
151
+ return null;
152
+ }
153
+ const CYCLE_LABEL_KEYS = [
154
+ "cycleLabel",
155
+ "cycle_label",
156
+ "billingPeriod",
157
+ "billing_period",
158
+ "period",
159
+ "periodLabel",
160
+ "period_label",
161
+ "month",
162
+ ];
163
+ const CYCLE_START_KEYS = [
164
+ "cycleStart",
165
+ "cycle_start",
166
+ "billingCycleStart",
167
+ "billing_cycle_start",
168
+ "periodStart",
169
+ "period_start",
170
+ "startDate",
171
+ "start_date",
172
+ "currentPeriodStart",
173
+ "current_period_start",
174
+ ];
175
+ const CYCLE_END_KEYS = [
176
+ "cycleEnd",
177
+ "cycle_end",
178
+ "billingCycleEnd",
179
+ "billing_cycle_end",
180
+ "periodEnd",
181
+ "period_end",
182
+ "endDate",
183
+ "end_date",
184
+ "resetAt",
185
+ "reset_at",
186
+ "resetDate",
187
+ "reset_date",
188
+ "currentPeriodEnd",
189
+ "current_period_end",
190
+ ];
191
+ const USAGE_VALUE_KEYS = [
192
+ "usageValue",
193
+ "usage_value",
194
+ "usage",
195
+ "used",
196
+ "spent",
197
+ "totalUsage",
198
+ "total_usage",
199
+ "spendValue",
200
+ "spend_value",
201
+ "costInCents",
202
+ "cost_in_cents",
203
+ ];
204
+ const USAGE_UNIT_KEYS = ["usageUnit", "usage_unit", "unit", "currency"];
205
+ const USED_PERCENT_KEYS = [
206
+ "usedPercent",
207
+ "used_percent",
208
+ "usagePercent",
209
+ "usage_percent",
210
+ "totalPercentUsed",
211
+ "total_percent_used",
212
+ ];
213
+ const USED_FRACTION_KEYS = ["usedFraction", "used_fraction", "usageFraction", "usage_fraction"];
214
+ const REMAINING_PERCENT_KEYS = [
215
+ "remainingPercent",
216
+ "remaining_percent",
217
+ "percentRemaining",
218
+ "percent_remaining",
219
+ ];
220
+ const REMAINING_FRACTION_KEYS = ["remainingFraction", "remaining_fraction"];
221
+ function clampPercent(n) {
222
+ return Math.min(100, Math.max(0, n));
223
+ }
224
+ /**
225
+ * Normalize one dashboard usage-summary payload. Returns null when the
226
+ * payload is not an object at all, or when it carries no usable billing
227
+ * value (no cycle label/start/end AND no usage/remaining scale) — the caller
228
+ * maps that to `unparsable`.
229
+ */
230
+ function individualUsagePlan(p) {
231
+ const usage = p.individualUsage;
232
+ if (!usage || typeof usage !== "object" || Array.isArray(usage))
233
+ return null;
234
+ const plan = usage.plan;
235
+ return plan && typeof plan === "object" && !Array.isArray(plan) ? plan : null;
236
+ }
237
+ export function cursorIndividualPayloadToReadings(payload) {
238
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
239
+ return null;
240
+ const top = payload;
241
+ // The live payload nests its usage scale under `individualUsage.plan`
242
+ // (e.g. `totalPercentUsed`) while the cycle dates stay top-level — merge
243
+ // the plan in (top-level wins on a name clash) so both shapes read the
244
+ // same fields below.
245
+ const plan = individualUsagePlan(top);
246
+ const p = plan ? { ...plan, ...top } : top;
247
+ const cycleLabel = pickString(p, CYCLE_LABEL_KEYS);
248
+ const cycleStart = normalizeCursorApiDate(pickRaw(p, CYCLE_START_KEYS));
249
+ const cycleEnd = normalizeCursorApiDate(pickRaw(p, CYCLE_END_KEYS));
250
+ const usageValue = pickNumber(p, USAGE_VALUE_KEYS);
251
+ const usageUnit = pickString(p, USAGE_UNIT_KEYS);
252
+ let remainingPercent = null;
253
+ const usedPercent = pickNumber(p, USED_PERCENT_KEYS);
254
+ const usedFraction = pickNumber(p, USED_FRACTION_KEYS);
255
+ const remainingDirect = pickNumber(p, REMAINING_PERCENT_KEYS);
256
+ const remainingFraction = pickNumber(p, REMAINING_FRACTION_KEYS);
257
+ if (usedPercent !== null) {
258
+ remainingPercent = remainingPercentFromUsedPercent(usedPercent);
259
+ }
260
+ else if (usedFraction !== null) {
261
+ remainingPercent = remainingPercentFromFraction(usedFraction);
262
+ }
263
+ else if (remainingDirect !== null) {
264
+ remainingPercent = clampPercent(remainingDirect);
265
+ }
266
+ else if (remainingFraction !== null) {
267
+ remainingPercent = clampPercent(remainingFraction * 100);
268
+ }
269
+ const hasCycle = cycleLabel !== null || cycleStart !== null || cycleEnd !== null;
270
+ const hasUsage = usageValue !== null || remainingPercent !== null;
271
+ if (!hasCycle && !hasUsage)
272
+ return null;
273
+ return { cycleLabel, cycleStart, cycleEnd, usageValue, usageUnit, remainingPercent };
274
+ }
275
+ // ---------------------------------------------------------------------------
276
+ // Bounded HTTP read (injectable fetch so tests never touch the live dashboard)
277
+ // ---------------------------------------------------------------------------
278
+ function isAllowedOrigin(url) {
279
+ let origin;
280
+ try {
281
+ origin = new URL(url).origin;
282
+ }
283
+ catch {
284
+ return false;
285
+ }
286
+ return CURSOR_INDIVIDUAL_ALLOWED_ORIGINS.includes(origin);
287
+ }
288
+ /**
289
+ * Read a response body up to `CURSOR_INDIVIDUAL_MAX_BODY_BYTES`, aborting
290
+ * the read (never buffering the full body first) the moment that cap is
291
+ * crossed — `res.text()` has no such bound, so a large/streaming response
292
+ * would otherwise sit fully in memory before the size check ever ran.
293
+ * Shares the caller's `signal`, so a hop's timeout aborts a stalled body
294
+ * read the same way it aborts a stalled header response.
295
+ */
296
+ export async function readBoundedText(res, signal) {
297
+ if (!res.body)
298
+ return res.text();
299
+ const reader = res.body.getReader();
300
+ const chunks = [];
301
+ let total = 0;
302
+ try {
303
+ for (;;) {
304
+ if (signal.aborted)
305
+ throw new Error("aborted");
306
+ const { done, value } = await reader.read();
307
+ if (done)
308
+ break;
309
+ if (!value)
310
+ continue;
311
+ total += value.byteLength;
312
+ if (total > CURSOR_INDIVIDUAL_MAX_BODY_BYTES) {
313
+ await reader.cancel().catch(() => undefined);
314
+ throw new Error("response exceeds the size cap");
315
+ }
316
+ chunks.push(value);
317
+ }
318
+ }
319
+ finally {
320
+ reader.releaseLock();
321
+ }
322
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
323
+ }
324
+ function defaultFetch() {
325
+ const impl = globalThis.fetch;
326
+ return (url, init) => impl(url, { ...init, redirect: "manual" }).then((res) => {
327
+ const headers = {};
328
+ res.headers.forEach((value, key) => {
329
+ headers[key.toLowerCase()] = value;
330
+ });
331
+ return { status: res.status, headers, text: () => readBoundedText(res, init.signal) };
332
+ });
333
+ }
334
+ function isRedirect(status) {
335
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
336
+ }
337
+ /**
338
+ * One bounded GET with manual redirect handling: redirects are followed only
339
+ * to allowlisted https origins (up to CURSOR_INDIVIDUAL_MAX_REDIRECTS hops).
340
+ * A redirect to an unknown origin — or a Location that does not parse —
341
+ * fails as `unsafe-redirect` BEFORE any further request, so the credential
342
+ * never travels off the allowlist.
343
+ */
344
+ async function fetchJson(fetchImpl, url, authHeader, timeoutMs) {
345
+ if (!isAllowedOrigin(url))
346
+ return { failure: "unsafe-redirect" };
347
+ let current = url;
348
+ for (let hop = 0; hop <= CURSOR_INDIVIDUAL_MAX_REDIRECTS; hop += 1) {
349
+ const controller = new AbortController();
350
+ // A manual, ref'd setTimeout (not AbortSignal.timeout(), whose internal
351
+ // timer is unref'd) — otherwise, once nothing else in the process holds
352
+ // the event loop open, Node can conclude the run before this timer ever
353
+ // fires, surfacing as "Promise resolution is still pending but the event
354
+ // loop has already resolved" instead of an actual abort. The timer stays
355
+ // live for the WHOLE hop (headers + body read below), cleared only once
356
+ // this hop is fully settled — a server that sends headers and then
357
+ // stalls the body must abort too, not just a slow header response.
358
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
359
+ try {
360
+ let res;
361
+ try {
362
+ res = await fetchImpl(current, {
363
+ method: "GET",
364
+ headers: { Cookie: authHeader, Accept: "application/json" },
365
+ signal: controller.signal,
366
+ });
367
+ }
368
+ catch {
369
+ return { failure: "unavailable" };
370
+ }
371
+ if (isRedirect(res.status)) {
372
+ const location = res.headers["location"];
373
+ if (!location)
374
+ return { failure: "unavailable" };
375
+ let next;
376
+ try {
377
+ next = new URL(location, current).toString();
378
+ }
379
+ catch {
380
+ return { failure: "unsafe-redirect" };
381
+ }
382
+ if (!next.toLowerCase().startsWith("https://") || !isAllowedOrigin(next)) {
383
+ return { failure: "unsafe-redirect" };
384
+ }
385
+ current = next;
386
+ continue;
387
+ }
388
+ if (res.status === 401 || res.status === 403)
389
+ return { failure: "forbidden" };
390
+ if (res.status === 429)
391
+ return { failure: "rate-limited" };
392
+ // 404 on an undocumented path means the endpoint moved or never existed
393
+ // for this account — the caller tries the next candidate path.
394
+ if (res.status === 404 || res.status === 405)
395
+ return { absent: true };
396
+ if (res.status < 200 || res.status >= 300)
397
+ return { failure: "unavailable" };
398
+ let text;
399
+ try {
400
+ text = await res.text();
401
+ }
402
+ catch {
403
+ // An abort mid-body-read is a timeout (`unavailable`), never a parse
404
+ // verdict on data that never fully arrived.
405
+ return { failure: controller.signal.aborted ? "unavailable" : "malformed" };
406
+ }
407
+ if (text.length > CURSOR_INDIVIDUAL_MAX_BODY_BYTES)
408
+ return { failure: "malformed" };
409
+ try {
410
+ return { status: res.status, payload: JSON.parse(text) };
411
+ }
412
+ catch {
413
+ return { failure: "malformed" };
414
+ }
415
+ }
416
+ finally {
417
+ clearTimeout(timer);
418
+ }
419
+ }
420
+ return { failure: "unavailable" };
421
+ }
422
+ function emptyBilling(observedAt) {
423
+ return {
424
+ cycleLabel: null,
425
+ cycleStart: null,
426
+ cycleEnd: null,
427
+ usageValue: null,
428
+ usageUnit: null,
429
+ remainingPercent: null,
430
+ source: "unavailable",
431
+ unavailableReason: "missing",
432
+ observedAt,
433
+ };
434
+ }
435
+ function readingsToBilling(readings, observedAt, viaSupportedApi) {
436
+ return {
437
+ cycleLabel: readings.cycleLabel,
438
+ cycleStart: readings.cycleStart,
439
+ cycleEnd: readings.cycleEnd,
440
+ usageValue: readings.usageValue,
441
+ usageUnit: readings.usageUnit,
442
+ remainingPercent: readings.remainingPercent,
443
+ source: viaSupportedApi ? "supported_protocol" : "experimental_api",
444
+ unavailableReason: null,
445
+ observedAt,
446
+ };
447
+ }
448
+ function supportedToReadings(supported) {
449
+ const normalized = {
450
+ cycleLabel: supported.cycleLabel ?? null,
451
+ cycleStart: supported.cycleStart ?? null,
452
+ cycleEnd: supported.cycleEnd ?? null,
453
+ usageValue: supported.usageValue ?? null,
454
+ usageUnit: supported.usageUnit ?? null,
455
+ };
456
+ if (supported.usedPercent !== undefined && supported.usedPercent !== null) {
457
+ normalized.usedPercent = supported.usedPercent;
458
+ }
459
+ else if (supported.usedFraction !== undefined && supported.usedFraction !== null) {
460
+ normalized.usedFraction = supported.usedFraction;
461
+ }
462
+ else if (supported.remainingPercent !== undefined && supported.remainingPercent !== null) {
463
+ normalized.remainingPercent = supported.remainingPercent;
464
+ }
465
+ else if (supported.remainingFraction !== undefined && supported.remainingFraction !== null) {
466
+ normalized.remainingFraction = supported.remainingFraction;
467
+ }
468
+ return cursorIndividualPayloadToReadings(normalized);
469
+ }
470
+ /**
471
+ * One bounded experimental dashboard read. Never throws for provider-side
472
+ * outcomes — those come back as `failure.kind` — never touches
473
+ * `runtime_availability`, and never exposes the credential: the auth header
474
+ * value stays in this function's scope and never appears in the observation,
475
+ * logs, or evidence refs.
476
+ *
477
+ * Order: explicit opt-in → supported surface (preferred, skips the
478
+ * dashboard entirely) → local credential → allowlisted dashboard GETs.
479
+ */
480
+ export async function readCursorIndividualBilling(opts = {}) {
481
+ const nowMs = opts.nowMs ?? Date.now();
482
+ const observedAt = new Date(nowMs).toISOString();
483
+ if (!isCursorIndividualExperimentalEnabled()) {
484
+ return {
485
+ billing: emptyBilling(observedAt),
486
+ enabled: false,
487
+ configured: false,
488
+ viaSupportedApi: false,
489
+ failure: { kind: "disabled" },
490
+ evidenceRef: null,
491
+ };
492
+ }
493
+ const supportedReader = opts.supportedReader ?? defaultSupportedUsageReader;
494
+ let supported = null;
495
+ try {
496
+ supported = await supportedReader();
497
+ }
498
+ catch {
499
+ supported = null;
500
+ }
501
+ if (supported) {
502
+ const readings = supportedToReadings(supported);
503
+ if (readings) {
504
+ return {
505
+ billing: readingsToBilling(readings, observedAt, true),
506
+ enabled: true,
507
+ configured: true,
508
+ viaSupportedApi: true,
509
+ failure: null,
510
+ evidenceRef: null,
511
+ };
512
+ }
513
+ // A supported surface answered but carried nothing usable: fall through
514
+ // to the dashboard rather than discarding the read.
515
+ }
516
+ const loadCredential = opts.loadCredential ?? (() => loadCursorIndividualCredential(opts.readFile));
517
+ const credential = loadCredential();
518
+ if (credential.status === "absent") {
519
+ logFailure("absent-credential");
520
+ return {
521
+ billing: emptyBilling(observedAt),
522
+ enabled: true,
523
+ configured: false,
524
+ viaSupportedApi: false,
525
+ failure: { kind: "absent-credential" },
526
+ evidenceRef: null,
527
+ };
528
+ }
529
+ if (credential.status === "unparsable" || !credential.authHeader) {
530
+ logFailure("bad-credential");
531
+ return {
532
+ billing: { ...emptyBilling(observedAt), unavailableReason: "unparsable" },
533
+ enabled: true,
534
+ configured: false,
535
+ viaSupportedApi: false,
536
+ failure: { kind: "bad-credential" },
537
+ evidenceRef: null,
538
+ };
539
+ }
540
+ const baseUrl = (opts.baseUrl ?? cursorIndividualBaseUrl()).replace(/\/+$/, "");
541
+ const timeoutMs = opts.timeoutMs ?? cursorIndividualTimeoutMs();
542
+ const fetchImpl = opts.fetchImpl ?? defaultFetch();
543
+ const authHeader = credential.authHeader;
544
+ let fatal = null;
545
+ let readings = null;
546
+ for (const candidatePath of CURSOR_INDIVIDUAL_USAGE_PATHS) {
547
+ const outcome = await fetchJson(fetchImpl, `${baseUrl}${candidatePath}`, authHeader, timeoutMs);
548
+ if ("failure" in outcome) {
549
+ // Auth, rate-limit, transport, redirect, and oversize failures dominate
550
+ // over remaining candidates: retrying another undocumented path cannot
551
+ // fix a rejected credential or an unsafe redirect.
552
+ fatal = outcome.failure;
553
+ break;
554
+ }
555
+ if ("absent" in outcome)
556
+ continue;
557
+ const parsed = cursorIndividualPayloadToReadings(outcome.payload);
558
+ if (parsed === null) {
559
+ fatal = "malformed";
560
+ break;
561
+ }
562
+ readings = parsed;
563
+ break;
564
+ }
565
+ if (fatal) {
566
+ logFailure(fatal);
567
+ const reason = fatal === "malformed" ? "unparsable" : "missing";
568
+ return {
569
+ billing: { ...emptyBilling(observedAt), unavailableReason: reason },
570
+ enabled: true,
571
+ configured: true,
572
+ viaSupportedApi: false,
573
+ failure: { kind: fatal },
574
+ evidenceRef: null,
575
+ };
576
+ }
577
+ if (readings === null) {
578
+ // Every candidate path 404/405'd (or nothing answered): the undocumented
579
+ // endpoint drifted or never existed for this account — `missing`, never
580
+ // a parse verdict on data that never arrived.
581
+ logFailure("unavailable");
582
+ return {
583
+ billing: emptyBilling(observedAt),
584
+ enabled: true,
585
+ configured: true,
586
+ viaSupportedApi: false,
587
+ failure: { kind: "unavailable" },
588
+ evidenceRef: null,
589
+ };
590
+ }
591
+ return {
592
+ billing: readingsToBilling(readings, observedAt, false),
593
+ enabled: true,
594
+ configured: true,
595
+ viaSupportedApi: false,
596
+ failure: null,
597
+ evidenceRef: CURSOR_INDIVIDUAL_EVIDENCE,
598
+ };
599
+ }
600
+ /**
601
+ * Failure kind → stored N/A reason (shared enum only). Transport/auth/
602
+ * rate-limit/redirect failures record `missing` (supported-shaped N/A, no
603
+ * snapshot without stored data); malformed payloads and unreadable
604
+ * credentials record `unparsable`. Disabled and absent-credential never
605
+ * reach storage — they short-circuit at serve time.
606
+ */
607
+ export function cursorIndividualFailureReason(kind) {
608
+ return kind === "malformed" || kind === "bad-credential" ? "unparsable" : "missing";
609
+ }
610
+ /**
611
+ * Persist one observation as the normalized individual snapshot. Transient
612
+ * failures do NOT overwrite a stored snapshot — the last-known values keep
613
+ * serving under read-time freshness instead. Successful and malformed
614
+ * observations do overwrite (malformed is the current verdict on arrived
615
+ * data). Disabled/absent-credential observations persist nothing. Imported
616
+ * lazily to keep this module free of DB binds.
617
+ */
618
+ export async function ingestCursorIndividualObservation(observation, nowMs = Date.now()) {
619
+ if (!observation.enabled)
620
+ return;
621
+ if (observation.failure &&
622
+ observation.failure.kind !== "malformed" &&
623
+ observation.failure.kind !== "bad-credential") {
624
+ return;
625
+ }
626
+ const { writeCursorIndividualBillingRow } = await import("../repository/cursor-individual-billing.js");
627
+ const b = observation.billing;
628
+ const observedAt = b.observedAt ?? new Date(nowMs).toISOString();
629
+ const observedMs = Date.parse(observedAt);
630
+ const base = Number.isFinite(observedMs) ? observedMs : nowMs;
631
+ const unavailable = observation.failure !== null;
632
+ writeCursorIndividualBillingRow({
633
+ cycleLabel: b.cycleLabel,
634
+ cycleStart: b.cycleStart,
635
+ cycleEnd: b.cycleEnd,
636
+ usageValue: b.usageValue,
637
+ usageUnit: b.usageUnit,
638
+ remainingPercent: b.remainingPercent,
639
+ source: b.source,
640
+ unavailableReason: b.unavailableReason,
641
+ observedAt,
642
+ freshUntil: unavailable ? null : new Date(base + DEFAULT_STALE_AFTER_MS).toISOString(),
643
+ expiresAt: unavailable ? null : new Date(base + DEFAULT_EXPIRES_AFTER_MS).toISOString(),
644
+ evidenceRef: observation.evidenceRef,
645
+ });
646
+ }
647
+ function observationToUnavailable(observedAt, reason) {
648
+ return { ...emptyBilling(observedAt), unavailableReason: reason };
649
+ }
650
+ /**
651
+ * Read model for `GET /api/cursor-individual-billing`: the stored normalized
652
+ * snapshot with freshness applied, or an explicit N/A. Disabled reads
653
+ * `enabled: false` without touching credentials, HTTP, or the database. A
654
+ * `cycleEnd` in the past reads `expired` — a finished cycle is never
655
+ * presented as current capacity (mirrors "a past reset is never current").
656
+ */
657
+ export async function getCursorIndividualBillingSnapshot(nowMs = Date.now()) {
658
+ const generatedAt = new Date(nowMs).toISOString();
659
+ const disabled = {
660
+ enabled: false,
661
+ configured: false,
662
+ cycleLabel: null,
663
+ cycleStart: null,
664
+ cycleEnd: null,
665
+ usageValue: null,
666
+ usageUnit: null,
667
+ remainingPercent: null,
668
+ source: "unavailable",
669
+ unavailableReason: "missing",
670
+ observedAt: null,
671
+ generatedAt,
672
+ };
673
+ if (!isCursorIndividualExperimentalEnabled())
674
+ return disabled;
675
+ const status = cursorIndividualCredentialStatusSafe();
676
+ if (!status.present) {
677
+ return {
678
+ ...disabled,
679
+ enabled: true,
680
+ unavailableReason: status.format === null && status.path !== null ? "unparsable" : "missing",
681
+ };
682
+ }
683
+ const { readCursorIndividualBillingRow } = await import("../repository/cursor-individual-billing.js");
684
+ const row = readCursorIndividualBillingRow();
685
+ if (!row) {
686
+ return {
687
+ ...disabled,
688
+ enabled: true,
689
+ configured: true,
690
+ };
691
+ }
692
+ const hasValues = row.usageValue !== null || row.remainingPercent !== null;
693
+ let reason = row.unavailableReason;
694
+ let nulled = false;
695
+ if (hasValues && reason === null) {
696
+ const cycleMs = row.cycleEnd !== null ? Date.parse(row.cycleEnd) : NaN;
697
+ if (Number.isFinite(cycleMs) && cycleMs <= nowMs) {
698
+ reason = "expired";
699
+ nulled = true;
700
+ }
701
+ else if (row.expiresAt !== null && Date.parse(row.expiresAt) <= nowMs) {
702
+ reason = "expired";
703
+ nulled = true;
704
+ }
705
+ else if (row.freshUntil !== null && Date.parse(row.freshUntil) <= nowMs) {
706
+ reason = "stale";
707
+ nulled = true;
708
+ }
709
+ }
710
+ return {
711
+ enabled: true,
712
+ configured: true,
713
+ cycleLabel: nulled ? null : row.cycleLabel,
714
+ cycleStart: nulled ? null : row.cycleStart,
715
+ cycleEnd: nulled ? null : row.cycleEnd,
716
+ usageValue: nulled ? null : row.usageValue,
717
+ usageUnit: nulled ? null : row.usageUnit,
718
+ remainingPercent: nulled ? null : row.remainingPercent,
719
+ source: nulled || reason !== null ? "unavailable" : row.source,
720
+ unavailableReason: reason,
721
+ observedAt: row.observedAt,
722
+ generatedAt,
723
+ };
724
+ }
725
+ function cursorIndividualCredentialStatusSafe() {
726
+ try {
727
+ return cursorIndividualCredentialStatus();
728
+ }
729
+ catch {
730
+ return { present: false, path: null, format: null };
731
+ }
732
+ }
733
+ /**
734
+ * Bounded refresh: run one dashboard read and ingest it. Used by the stale
735
+ * path below; failures persist as N/A rows only for malformed/bad-credential
736
+ * observations — transient failures keep last-known values. Never throws,
737
+ * never touches runtime health, never logs the credential.
738
+ */
739
+ export async function refreshCursorIndividualBilling(opts = {}) {
740
+ const nowMs = opts.nowMs ?? Date.now();
741
+ let observation;
742
+ try {
743
+ observation = await readCursorIndividualBilling({ ...opts, nowMs });
744
+ }
745
+ catch {
746
+ logFailure("unavailable");
747
+ observation = {
748
+ billing: observationToUnavailable(new Date(nowMs).toISOString(), "missing"),
749
+ enabled: isCursorIndividualExperimentalEnabled(),
750
+ configured: false,
751
+ viaSupportedApi: false,
752
+ failure: { kind: "unavailable" },
753
+ evidenceRef: null,
754
+ };
755
+ }
756
+ try {
757
+ await ingestCursorIndividualObservation(observation, nowMs);
758
+ }
759
+ catch {
760
+ // Storage failure must not break the read below.
761
+ }
762
+ return getCursorIndividualBillingSnapshot(nowMs);
763
+ }
764
+ let cursorIndividualSharedPollInFlight = null;
765
+ let cursorIndividualLastFailedPollMs = null;
766
+ /** Test helper — clear the failure backoff (and any in-flight refresh). */
767
+ export function resetCursorIndividualPollStateForTests() {
768
+ cursorIndividualLastFailedPollMs = null;
769
+ cursorIndividualSharedPollInFlight = null;
770
+ }
771
+ /**
772
+ * One bounded dashboard poll, shared by BOTH on-demand refresh paths below.
773
+ * The billing card (`/api/cursor-individual-billing`) and the capacity strip
774
+ * (`/api/runtime-capacity`) each read a different stored table, so each
775
+ * deciding independently that its own table is stale — as when both mount
776
+ * concurrently against an empty database — would otherwise fire two
777
+ * separate dashboard polls. This single-flights across BOTH callers (not
778
+ * just concurrent calls to the same one) and ingests the one observation
779
+ * into both stores, so satisfying either caller's staleness also refreshes
780
+ * the other's. Failures back off for `CURSOR_INDIVIDUAL_FAILURE_BACKOFF_MS`
781
+ * (shared by both paths) before polling again. Never throws.
782
+ */
783
+ async function pollCursorIndividualShared(nowMs, opts) {
784
+ if (cursorIndividualLastFailedPollMs !== null &&
785
+ nowMs - cursorIndividualLastFailedPollMs < CURSOR_INDIVIDUAL_FAILURE_BACKOFF_MS) {
786
+ return;
787
+ }
788
+ if (cursorIndividualSharedPollInFlight) {
789
+ await cursorIndividualSharedPollInFlight;
790
+ return;
791
+ }
792
+ cursorIndividualSharedPollInFlight = (async () => {
793
+ try {
794
+ const observation = await readCursorIndividualBilling({ ...opts, nowMs });
795
+ // Transient (auth/transport/rate-limit/redirect) failures must not
796
+ // overwrite either store's last-known value — only a genuine verdict
797
+ // on data that arrived (success, or malformed/bad-credential) does.
798
+ // `ingestCursorIndividualObservation` already self-gates this for the
799
+ // billing table; `ingestAdapterResult` has no such guard, so the call
800
+ // site below gates it too — otherwise a transient failure would keep
801
+ // the billing card's last-known value while silently replacing the
802
+ // capacity strip's with `missing`, the two surfaces disagreeing after
803
+ // the same poll.
804
+ const transient = observation.failure !== null &&
805
+ observation.failure.kind !== "malformed" &&
806
+ observation.failure.kind !== "bad-credential";
807
+ if (transient) {
808
+ cursorIndividualLastFailedPollMs = nowMs;
809
+ }
810
+ try {
811
+ await ingestCursorIndividualObservation(observation, nowMs);
812
+ }
813
+ catch {
814
+ // Storage failure must not break the read below.
815
+ }
816
+ if (!transient) {
817
+ try {
818
+ const { ingestAdapterResult } = await import("./service.js");
819
+ await ingestAdapterResult(cursorIndividualObservationToAdapterResult(observation, nowMs));
820
+ }
821
+ catch {
822
+ // Storage failure must not break the read below.
823
+ }
824
+ }
825
+ }
826
+ catch {
827
+ cursorIndividualLastFailedPollMs = nowMs;
828
+ }
829
+ finally {
830
+ cursorIndividualSharedPollInFlight = null;
831
+ }
832
+ })();
833
+ await cursorIndividualSharedPollInFlight;
834
+ }
835
+ /**
836
+ * On-demand bounded refresh: when explicitly opted in and the stored
837
+ * snapshot is missing or older than the stale window, run one bounded poll
838
+ * (shared with the capacity-strip path below) and ingest it, then return.
839
+ * Disabled, fresh snapshots, and recent failed polls short-circuit with no
840
+ * credential access and no HTTP (single-flight across concurrent requests,
841
+ * including the other refresh path). This helper never throws, never
842
+ * touches runtime health, and never sends the credential off the allowlist.
843
+ * Set `AGENT_DEALER_CURSOR_INDIVIDUAL_REFRESH=off` to disable.
844
+ */
845
+ export async function refreshCursorIndividualBillingIfStale(nowMs = Date.now(), opts = {}) {
846
+ if (process.env[CURSOR_INDIVIDUAL_REFRESH_ENV] === "off")
847
+ return;
848
+ if (!isCursorIndividualExperimentalEnabled())
849
+ return;
850
+ const { readCursorIndividualBillingRow } = await import("../repository/cursor-individual-billing.js");
851
+ const row = readCursorIndividualBillingRow();
852
+ const newestObserved = row ? Date.parse(row.observedAt) : NaN;
853
+ if (Number.isFinite(newestObserved) && newestObserved + DEFAULT_STALE_AFTER_MS > nowMs) {
854
+ return;
855
+ }
856
+ await pollCursorIndividualShared(nowMs, opts);
857
+ }
858
+ // ---------------------------------------------------------------------------
859
+ // CapacityAdapter: billing-cycle window for `cursor_local` (NOT-243 strip)
860
+ // ---------------------------------------------------------------------------
861
+ /**
862
+ * Map one observation to an adapter read result: a single `billing_cycle`
863
+ * window (no duration — monthly data never becomes a 5H/1W window) carrying
864
+ * the reported cycle reset and remaining usage, or an explicit unavailable
865
+ * reading. The label/reset/remaining come verbatim from the dashboard; no
866
+ * unsupported windows are guessed.
867
+ */
868
+ export function cursorIndividualObservationToAdapterResult(observation, nowMs = Date.now()) {
869
+ const observedAt = observation.billing.observedAt ?? new Date(nowMs).toISOString();
870
+ if (observation.failure || observation.billing.unavailableReason !== null) {
871
+ const reason = observation.billing.unavailableReason ??
872
+ cursorIndividualFailureReason(observation.failure?.kind ?? "unavailable");
873
+ return {
874
+ runtime: CURSOR_INDIVIDUAL_RUNTIME,
875
+ windows: [],
876
+ unavailable: [
877
+ {
878
+ windowKey: CURSOR_INDIVIDUAL_WINDOW_KEY,
879
+ providerBucket: CURSOR_INDIVIDUAL_PROVIDER_BUCKET,
880
+ providerLabel: CURSOR_INDIVIDUAL_PROVIDER_LABEL,
881
+ reason,
882
+ observedAt,
883
+ },
884
+ ],
885
+ };
886
+ }
887
+ const b = observation.billing;
888
+ if (b.remainingPercent === null) {
889
+ return {
890
+ runtime: CURSOR_INDIVIDUAL_RUNTIME,
891
+ windows: [],
892
+ unavailable: [
893
+ {
894
+ windowKey: CURSOR_INDIVIDUAL_WINDOW_KEY,
895
+ providerBucket: CURSOR_INDIVIDUAL_PROVIDER_BUCKET,
896
+ providerLabel: CURSOR_INDIVIDUAL_PROVIDER_LABEL,
897
+ reason: "unparsable",
898
+ observedAt,
899
+ },
900
+ ],
901
+ };
902
+ }
903
+ return {
904
+ runtime: CURSOR_INDIVIDUAL_RUNTIME,
905
+ windows: [
906
+ {
907
+ windowKey: CURSOR_INDIVIDUAL_WINDOW_KEY,
908
+ providerBucket: CURSOR_INDIVIDUAL_PROVIDER_BUCKET,
909
+ durationMinutes: null,
910
+ providerLabel: CURSOR_INDIVIDUAL_PROVIDER_LABEL,
911
+ usedValue: b.usageValue,
912
+ usedUnit: b.usageUnit,
913
+ // Round-trip through the shared used scale: 100 - remaining is exact
914
+ // for the clamped 0–100 range, so normalization recovers `remaining`.
915
+ usedPercent: 100 - b.remainingPercent,
916
+ resetAt: b.cycleEnd,
917
+ observedAt,
918
+ source: b.source === "supported_protocol" ? "supported_protocol" : "experimental_api",
919
+ },
920
+ ],
921
+ unavailable: [],
922
+ };
923
+ }
924
+ /**
925
+ * Experimental CapacityAdapter for `cursor_local`: disabled reads N/A
926
+ * (`missing`) with no credential or endpoint access; opted-in reads run one
927
+ * bounded dashboard poll. Failures never throw — they normalize to
928
+ * unavailable windows downstream.
929
+ */
930
+ export function cursorIndividualCapacityAdapter(opts = {}) {
931
+ return {
932
+ runtime: CURSOR_INDIVIDUAL_RUNTIME,
933
+ source: "experimental_api",
934
+ read: async (nowMs = Date.now()) => {
935
+ const observation = await readCursorIndividualBilling({ ...opts, nowMs });
936
+ return cursorIndividualObservationToAdapterResult(observation, nowMs);
937
+ },
938
+ };
939
+ }
940
+ /**
941
+ * On-demand capacity-strip refresh: when the stored `cursor_local` window in
942
+ * `runtime_capacity_snapshots` is missing or stale, run the poll shared with
943
+ * the billing-card path above (see `pollCursorIndividualShared`) so the
944
+ * Agents-page strip shows the billing-cycle window (or its explicit N/A).
945
+ * Disabled is a strict no-op — no credential, no HTTP. Mirrors the
946
+ * Codex/Team stale-check pattern: a fresh stored window (younger than
947
+ * `DEFAULT_STALE_AFTER_MS`) short-circuits with no HTTP. `GET
948
+ * /api/runtime-capacity` calls this on every poll, so without the check (and
949
+ * the single-flight shared with the billing-card path) it would hit the
950
+ * dashboard on every request. Never throws, never touches runtime health.
951
+ * Imported lazily to keep this module free of DB binds.
952
+ */
953
+ export async function refreshCursorIndividualCapacityIfStale(nowMs = Date.now(), opts = {}) {
954
+ if (process.env[CURSOR_INDIVIDUAL_REFRESH_ENV] === "off")
955
+ return;
956
+ if (!isCursorIndividualExperimentalEnabled())
957
+ return;
958
+ const { listCapacitySnapshots } = await import("../repository/runtime-capacity.js");
959
+ const rows = listCapacitySnapshots(CURSOR_INDIVIDUAL_RUNTIME);
960
+ const newestObserved = rows.reduce((max, row) => {
961
+ const ms = Date.parse(row.observedAt);
962
+ if (!Number.isFinite(ms))
963
+ return max;
964
+ return max === null || ms > max ? ms : max;
965
+ }, null);
966
+ if (newestObserved !== null && newestObserved + DEFAULT_STALE_AFTER_MS > nowMs) {
967
+ return;
968
+ }
969
+ await pollCursorIndividualShared(nowMs, opts);
970
+ }