@agent-finops/core 0.8.0 → 0.9.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.
Files changed (49) hide show
  1. package/README.md +5 -3
  2. package/dist/actionPlanner.d.ts +140 -0
  3. package/dist/actionPlanner.js +938 -0
  4. package/dist/actionVerification.d.ts +1240 -0
  5. package/dist/actionVerification.js +1028 -0
  6. package/dist/activitySnapshot.d.ts +101 -9
  7. package/dist/activitySnapshot.js +145 -6
  8. package/dist/activitySnapshotCache.d.ts +8 -1
  9. package/dist/activitySnapshotCache.js +103 -7
  10. package/dist/agentEconomicsReceipt.d.ts +58 -58
  11. package/dist/analyze.js +3 -1
  12. package/dist/cutList.js +1 -1
  13. package/dist/glance.d.ts +30 -2
  14. package/dist/glance.js +265 -84
  15. package/dist/index.d.ts +11 -2
  16. package/dist/index.js +10 -1
  17. package/dist/insights.js +3 -1
  18. package/dist/localAgentFormats/gemini.js +2 -2
  19. package/dist/localAgentFormats/registry.js +6 -2
  20. package/dist/localAgentFormats/runtimeRegistry.js +5 -2
  21. package/dist/localAgentFormats/types.d.ts +2 -1
  22. package/dist/localAgentLogs.d.ts +362 -3
  23. package/dist/localAgentLogs.js +1964 -165
  24. package/dist/modelPricing.d.ts +1 -1
  25. package/dist/modelPricing.js +4 -1
  26. package/dist/planMath.js +12 -7
  27. package/dist/projectEconomics.d.ts +617 -0
  28. package/dist/projectEconomics.js +620 -0
  29. package/dist/projectEconomicsBuilder.d.ts +89 -0
  30. package/dist/projectEconomicsBuilder.js +473 -0
  31. package/dist/projectIndexStore.d.ts +545 -0
  32. package/dist/projectIndexStore.js +606 -0
  33. package/dist/providerConnectors.d.ts +59 -1
  34. package/dist/providerConnectors.js +192 -12
  35. package/dist/qualitativeIndexCache.d.ts +494 -0
  36. package/dist/qualitativeIndexCache.js +930 -0
  37. package/dist/resultCard.d.ts +350 -0
  38. package/dist/resultCard.js +604 -0
  39. package/dist/runtimeCommands.d.ts +21 -0
  40. package/dist/runtimeCommands.js +27 -0
  41. package/dist/scanGuard.d.ts +3 -1
  42. package/dist/scanGuard.js +164 -4
  43. package/dist/schema.d.ts +31 -31
  44. package/dist/sessionVitals.d.ts +145 -0
  45. package/dist/sessionVitals.js +521 -0
  46. package/dist/sourceRegistry.js +90 -52
  47. package/dist/toolInvocations.d.ts +40 -1
  48. package/dist/toolInvocations.js +101 -20
  49. package/package.json +1 -1
@@ -41,6 +41,18 @@ export type ActivitySnapshotBuildInput = {
41
41
  /** Explicit provider-billed overage records; must also be trusted. */
42
42
  billedOverageRecordIds?: readonly string[];
43
43
  providerCoverage?: readonly ActivitySnapshotProviderCoverageInput[];
44
+ /**
45
+ * C-lane §2.1: provider-billed subscriptions with no local agent (cursor).
46
+ * The builder includes a billed30d amount only when the supplied window is
47
+ * "verified"; anything else is degraded to a missing window (writer-side
48
+ * lock — the renderer re-checks independently).
49
+ */
50
+ providerSubscriptions?: readonly {
51
+ provider: ActivitySnapshotProvider;
52
+ planLabel: string | null;
53
+ committedUsdPerMonth: number | null;
54
+ billed30d?: ActivitySnapshotBilledWindow;
55
+ }[];
44
56
  pricingAsOf?: string;
45
57
  /** Deliberately accepts only false; a runtime true is rejected as defense in depth. */
46
58
  sampleData?: false;
@@ -187,6 +199,7 @@ export declare const activitySnapshotSubscriptionAgentSchema: z.ZodObject<{
187
199
  "chatgpt-plus": "chatgpt-plus";
188
200
  "chatgpt-pro": "chatgpt-pro";
189
201
  }>>;
202
+ committedUsdPerMonth: z.ZodNullable<z.ZodNumber>;
190
203
  apiEquivalent: z.ZodObject<{
191
204
  oneDay: z.ZodObject<{
192
205
  amountUsd: z.ZodNullable<z.ZodNumber>;
@@ -247,6 +260,47 @@ export declare const activitySnapshotSubscriptionAgentSchema: z.ZodObject<{
247
260
  }>>;
248
261
  }, z.core.$strict>;
249
262
  export type ActivitySnapshotSubscriptionAgent = z.infer<typeof activitySnapshotSubscriptionAgentSchema>;
263
+ /**
264
+ * C-lane §2.1: a provider-billed subscription with no local agent (cursor
265
+ * today). The writer includes a billed30d amount ONLY when its financial
266
+ * evidence is "verified"; the renderer independently drops anything else
267
+ * (double lock: estimated/unverified provider dollars can never reach a
268
+ * statusline segment).
269
+ */
270
+ export declare const activitySnapshotProviderSubscriptionSchema: z.ZodObject<{
271
+ provider: z.ZodEnum<{
272
+ anthropic: "anthropic";
273
+ openai: "openai";
274
+ cursor: "cursor";
275
+ other: "other";
276
+ "github-copilot": "github-copilot";
277
+ }>;
278
+ billing: z.ZodLiteral<"subscription">;
279
+ planLabel: z.ZodNullable<z.ZodString>;
280
+ committedUsdPerMonth: z.ZodNullable<z.ZodNumber>;
281
+ billed30d: z.ZodObject<{
282
+ amountUsd: z.ZodNullable<z.ZodNumber>;
283
+ recordCount: z.ZodNumber;
284
+ basis: z.ZodLiteral<"provider_billed">;
285
+ financialEvidence: z.ZodEnum<{
286
+ verified: "verified";
287
+ missing: "missing";
288
+ }>;
289
+ coverage: z.ZodEnum<{
290
+ missing: "missing";
291
+ complete: "complete";
292
+ partial: "partial";
293
+ }>;
294
+ }, z.core.$strict>;
295
+ }, z.core.$strict>;
296
+ export type ActivitySnapshotProviderSubscription = z.infer<typeof activitySnapshotProviderSubscriptionSchema>;
297
+ /** C-lane §2.1: the committed $/mo total across every detected subscription. */
298
+ export declare const activitySnapshotCommittedTotalSchema: z.ZodObject<{
299
+ amountUsd: z.ZodNullable<z.ZodNumber>;
300
+ pricedSubs: z.ZodNumber;
301
+ totalSubs: z.ZodNumber;
302
+ }, z.core.$strict>;
303
+ export type ActivitySnapshotCommittedTotal = z.infer<typeof activitySnapshotCommittedTotalSchema>;
250
304
  export declare const activitySnapshotOverageSchema: z.ZodObject<{
251
305
  amountUsd: z.ZodNumber;
252
306
  currency: z.ZodLiteral<"USD">;
@@ -276,8 +330,8 @@ export declare const activitySnapshotCoverageSchema: z.ZodObject<{
276
330
  nonFinancialBytesPrefiltered: z.ZodNumber;
277
331
  jsonlValidationCoverage: z.ZodEnum<{
278
332
  complete: "complete";
279
- financial_events_only: "financial_events_only";
280
333
  not_reported: "not_reported";
334
+ financial_events_only: "financial_events_only";
281
335
  }>;
282
336
  }, z.core.$strict>>;
283
337
  providers: z.ZodArray<z.ZodObject<{
@@ -309,10 +363,10 @@ export declare const activitySnapshotCoverageSchema: z.ZodObject<{
309
363
  recordsPriced: z.ZodNumber;
310
364
  recordsUnpriced: z.ZodNumber;
311
365
  validationStatus: z.ZodEnum<{
312
- failed: "failed";
313
366
  complete: "complete";
314
- not_checked: "not_checked";
315
367
  partial: "partial";
368
+ failed: "failed";
369
+ not_checked: "not_checked";
316
370
  }>;
317
371
  pricingAsOf: z.ZodString;
318
372
  networkUploaded: z.ZodLiteral<false>;
@@ -320,7 +374,7 @@ export declare const activitySnapshotCoverageSchema: z.ZodObject<{
320
374
  export type ActivitySnapshotCoverage = z.infer<typeof activitySnapshotCoverageSchema>;
321
375
  export declare const activitySnapshotSchema: z.ZodObject<{
322
376
  kind: z.ZodLiteral<"aibill.activity_snapshot">;
323
- schemaVersion: z.ZodLiteral<1>;
377
+ schemaVersion: z.ZodLiteral<2>;
324
378
  currency: z.ZodLiteral<"USD">;
325
379
  asOf: z.ZodString;
326
380
  generatedAt: z.ZodString;
@@ -332,20 +386,20 @@ export declare const activitySnapshotSchema: z.ZodObject<{
332
386
  status: z.ZodLiteral<"error">;
333
387
  errorCode: z.ZodEnum<{
334
388
  unknown: "unknown";
389
+ timeout: "timeout";
335
390
  scan_failed: "scan_failed";
336
391
  source_unreadable: "source_unreadable";
337
392
  invalid_evidence: "invalid_evidence";
338
- timeout: "timeout";
339
393
  cache_write_failed: "cache_write_failed";
340
394
  }>;
341
395
  }, z.core.$strict>], "status">;
342
396
  mode: z.ZodEnum<{
343
397
  error: "error";
398
+ empty: "empty";
344
399
  subscription: "subscription";
345
400
  metered: "metered";
346
401
  mixed: "mixed";
347
402
  unresolved: "unresolved";
348
- empty: "empty";
349
403
  }>;
350
404
  subscription: z.ZodNullable<z.ZodObject<{
351
405
  agents: z.ZodArray<z.ZodObject<{
@@ -358,6 +412,7 @@ export declare const activitySnapshotSchema: z.ZodObject<{
358
412
  "chatgpt-plus": "chatgpt-plus";
359
413
  "chatgpt-pro": "chatgpt-pro";
360
414
  }>>;
415
+ committedUsdPerMonth: z.ZodNullable<z.ZodNumber>;
361
416
  apiEquivalent: z.ZodObject<{
362
417
  oneDay: z.ZodObject<{
363
418
  amountUsd: z.ZodNullable<z.ZodNumber>;
@@ -576,6 +631,37 @@ export declare const activitySnapshotSchema: z.ZodObject<{
576
631
  }, z.core.$strict>;
577
632
  }, z.core.$strict>;
578
633
  }, z.core.$strict>>;
634
+ providers: z.ZodNullable<z.ZodArray<z.ZodObject<{
635
+ provider: z.ZodEnum<{
636
+ anthropic: "anthropic";
637
+ openai: "openai";
638
+ cursor: "cursor";
639
+ other: "other";
640
+ "github-copilot": "github-copilot";
641
+ }>;
642
+ billing: z.ZodLiteral<"subscription">;
643
+ planLabel: z.ZodNullable<z.ZodString>;
644
+ committedUsdPerMonth: z.ZodNullable<z.ZodNumber>;
645
+ billed30d: z.ZodObject<{
646
+ amountUsd: z.ZodNullable<z.ZodNumber>;
647
+ recordCount: z.ZodNumber;
648
+ basis: z.ZodLiteral<"provider_billed">;
649
+ financialEvidence: z.ZodEnum<{
650
+ verified: "verified";
651
+ missing: "missing";
652
+ }>;
653
+ coverage: z.ZodEnum<{
654
+ missing: "missing";
655
+ complete: "complete";
656
+ partial: "partial";
657
+ }>;
658
+ }, z.core.$strict>;
659
+ }, z.core.$strict>>>;
660
+ committedTotal: z.ZodObject<{
661
+ amountUsd: z.ZodNullable<z.ZodNumber>;
662
+ pricedSubs: z.ZodNumber;
663
+ totalSubs: z.ZodNumber;
664
+ }, z.core.$strict>;
579
665
  overage: z.ZodNullable<z.ZodObject<{
580
666
  amountUsd: z.ZodNumber;
581
667
  currency: z.ZodLiteral<"USD">;
@@ -604,8 +690,8 @@ export declare const activitySnapshotSchema: z.ZodObject<{
604
690
  nonFinancialBytesPrefiltered: z.ZodNumber;
605
691
  jsonlValidationCoverage: z.ZodEnum<{
606
692
  complete: "complete";
607
- financial_events_only: "financial_events_only";
608
693
  not_reported: "not_reported";
694
+ financial_events_only: "financial_events_only";
609
695
  }>;
610
696
  }, z.core.$strict>>;
611
697
  providers: z.ZodArray<z.ZodObject<{
@@ -637,10 +723,10 @@ export declare const activitySnapshotSchema: z.ZodObject<{
637
723
  recordsPriced: z.ZodNumber;
638
724
  recordsUnpriced: z.ZodNumber;
639
725
  validationStatus: z.ZodEnum<{
640
- failed: "failed";
641
726
  complete: "complete";
642
- not_checked: "not_checked";
643
727
  partial: "partial";
728
+ failed: "failed";
729
+ not_checked: "not_checked";
644
730
  }>;
645
731
  pricingAsOf: z.ZodString;
646
732
  networkUploaded: z.ZodLiteral<false>;
@@ -654,6 +740,12 @@ export type ActivitySnapshot = z.infer<typeof activitySnapshotSchema>;
654
740
  * IDs already validated by the external connected-state trust receipt.
655
741
  */
656
742
  export declare function buildActivitySnapshot(input: ActivitySnapshotBuildInput): ActivitySnapshot;
743
+ /**
744
+ * The v1 dual-write payload (C-lane §2.1 fleet back-compat): today's fields
745
+ * only, so an already-installed v1 runner keeps rendering fresh data instead
746
+ * of decaying into permanent staleness. Exact v1 key set; no v2 fields.
747
+ */
748
+ export declare function activitySnapshotV1Payload(snapshot: ActivitySnapshot): Record<string, unknown>;
657
749
  /** A bounded no-evidence state for an initial failed refresh. */
658
750
  export declare function createActivitySnapshotError(attemptedAt: string, errorCode: ActivitySnapshotRefreshErrorCode): ActivitySnapshot;
659
751
  //# sourceMappingURL=activitySnapshot.d.ts.map
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { aggregateCalls, dedupeCumulativeSessionCalls } from "./localAgentLogs.js";
3
3
  import { localAgentFormatDescriptors } from "./localAgentFormats/registry.js";
4
4
  import { canPriceTokenUsageAtScope, estimateTokenCostUsd, PRICING_TABLE_AS_OF } from "./modelPricing.js";
5
+ import { subscriptionPlans } from "./planMath.js";
5
6
  import { isBundledSampleUsage } from "./schema.js";
6
7
  import { sourceValidationCoverageValues } from "./sourceStatus.js";
7
8
  const DAY_MS = 24 * 60 * 60 * 1_000;
@@ -163,6 +164,8 @@ export const activitySnapshotSubscriptionAgentSchema = z.object({
163
164
  agent: agentSchema,
164
165
  billing: z.literal("subscription"),
165
166
  planId: planIdSchema,
167
+ /** C-lane §2.1: detected-plan list price; null when the plan is unpriced. */
168
+ committedUsdPerMonth: usdSchema.nullable(),
166
169
  apiEquivalent: activitySnapshotApiEquivalentWindowsSchema,
167
170
  limits: z.array(activitySnapshotLimitSchema).max(2),
168
171
  pressure: z.enum(["extra_usage_credits_exhausted"]).nullable()
@@ -215,6 +218,41 @@ const activitySnapshotUnresolvedSchema = z.object({
215
218
  });
216
219
  }
217
220
  });
221
+ /**
222
+ * C-lane §2.1: a provider-billed subscription with no local agent (cursor
223
+ * today). The writer includes a billed30d amount ONLY when its financial
224
+ * evidence is "verified"; the renderer independently drops anything else
225
+ * (double lock: estimated/unverified provider dollars can never reach a
226
+ * statusline segment).
227
+ */
228
+ export const activitySnapshotProviderSubscriptionSchema = z.object({
229
+ provider: z.enum(activitySnapshotProviderValues),
230
+ billing: z.literal("subscription"),
231
+ planLabel: z.string().min(1).max(64).nullable(),
232
+ committedUsdPerMonth: usdSchema.nullable(),
233
+ billed30d: activitySnapshotBilledWindowSchema
234
+ }).strict();
235
+ /** C-lane §2.1: the committed $/mo total across every detected subscription. */
236
+ export const activitySnapshotCommittedTotalSchema = z.object({
237
+ amountUsd: usdSchema.nullable(),
238
+ pricedSubs: countSchema,
239
+ totalSubs: countSchema
240
+ }).strict().superRefine((total, context) => {
241
+ if (total.pricedSubs > total.totalSubs) {
242
+ context.addIssue({
243
+ code: "custom",
244
+ path: ["pricedSubs"],
245
+ message: "Priced subscriptions cannot exceed total subscriptions."
246
+ });
247
+ }
248
+ if ((total.amountUsd === null) !== (total.pricedSubs === 0)) {
249
+ context.addIssue({
250
+ code: "custom",
251
+ path: ["amountUsd"],
252
+ message: "A committed total exists exactly when at least one subscription is priced."
253
+ });
254
+ }
255
+ });
218
256
  export const activitySnapshotOverageSchema = z.object({
219
257
  amountUsd: z.number().finite().positive(),
220
258
  currency: z.literal("USD"),
@@ -330,7 +368,10 @@ export const activitySnapshotCoverageSchema = z.object({
330
368
  });
331
369
  export const activitySnapshotSchema = z.object({
332
370
  kind: z.literal("aibill.activity_snapshot"),
333
- schemaVersion: z.literal(1),
371
+ // v2 (C-lane §2.1): adds per-agent committedUsdPerMonth, provider-billed
372
+ // subscriptions, and the committed total. The writer dual-writes a v1
373
+ // payload for already-installed v1 runners during the deprecation window.
374
+ schemaVersion: z.literal(2),
334
375
  currency: z.literal("USD"),
335
376
  asOf: isoTimestampSchema,
336
377
  generatedAt: isoTimestampSchema,
@@ -347,6 +388,8 @@ export const activitySnapshotSchema = z.object({
347
388
  subscription: activitySnapshotSubscriptionSchema.nullable(),
348
389
  metered: activitySnapshotMeteredSchema.nullable(),
349
390
  unresolved: activitySnapshotUnresolvedSchema.nullable(),
391
+ providers: z.array(activitySnapshotProviderSubscriptionSchema).max(5).nullable(),
392
+ committedTotal: activitySnapshotCommittedTotalSchema,
350
393
  overage: activitySnapshotOverageSchema.nullable(),
351
394
  coverage: activitySnapshotCoverageSchema,
352
395
  networkUploaded: z.literal(false)
@@ -372,6 +415,20 @@ export const activitySnapshotSchema = z.object({
372
415
  (snapshot.subscription || snapshot.metered || snapshot.unresolved || snapshot.overage)) {
373
416
  invalid("Empty and error snapshots cannot carry financial cohorts.", ["mode"]);
374
417
  }
418
+ if ((snapshot.mode === "empty" || snapshot.mode === "error") &&
419
+ (snapshot.providers || snapshot.committedTotal.amountUsd !== null ||
420
+ snapshot.committedTotal.totalSubs !== 0)) {
421
+ invalid("Empty and error snapshots cannot carry subscription pricing.", ["committedTotal"]);
422
+ }
423
+ if (snapshot.providers &&
424
+ new Set(snapshot.providers.map((provider) => provider.provider)).size !== snapshot.providers.length) {
425
+ invalid("A provider subscription may appear only once.", ["providers"]);
426
+ }
427
+ const expectedTotalSubs = (snapshot.subscription?.agents.length ?? 0) +
428
+ (snapshot.providers?.length ?? 0);
429
+ if (snapshot.committedTotal.totalSubs !== expectedTotalSubs) {
430
+ invalid("The committed total must count every subscription row exactly once.", ["committedTotal"]);
431
+ }
375
432
  if (snapshot.mode === "error" && snapshot.refresh.status !== "error") {
376
433
  invalid("Error mode requires an error refresh state.", ["refresh"]);
377
434
  }
@@ -559,9 +616,32 @@ export function buildActivitySnapshot(input) {
559
616
  } : null;
560
617
  const overage = buildOverage(meteredBilledRecords, overageIds, asOfMs);
561
618
  const coverage = buildCoverage(classified.map((entry) => entry.record), scans, providers, input.pricingAsOf ?? PRICING_TABLE_AS_OF, deduplicated.conflictingIds);
619
+ const finalSubscription = mode === "metered" || mode === "unresolved" || mode === "empty"
620
+ ? null
621
+ : subscription;
622
+ // Writer-side lock (C-lane §2.1): a provider-billed amount survives only
623
+ // when its supplied window is verified; anything else degrades to missing.
624
+ const providerSubscriptionRows = mode === "empty"
625
+ ? []
626
+ : (input.providerSubscriptions ?? []).map((row) => ({
627
+ provider: row.provider,
628
+ billing: "subscription",
629
+ planLabel: row.planLabel,
630
+ committedUsdPerMonth: row.committedUsdPerMonth,
631
+ billed30d: row.billed30d &&
632
+ row.billed30d.financialEvidence === "verified" &&
633
+ row.billed30d.amountUsd !== null
634
+ ? row.billed30d
635
+ : missingBilledWindow()
636
+ }));
637
+ const committedRows = [
638
+ ...(finalSubscription?.agents ?? []).map((agent) => agent.committedUsdPerMonth),
639
+ ...providerSubscriptionRows.map((row) => row.committedUsdPerMonth)
640
+ ];
641
+ const pricedRows = committedRows.filter((amount) => amount !== null);
562
642
  return activitySnapshotSchema.parse({
563
643
  kind: "aibill.activity_snapshot",
564
- schemaVersion: 1,
644
+ schemaVersion: 2,
565
645
  currency: "USD",
566
646
  asOf: new Date(asOfMs).toISOString(),
567
647
  generatedAt,
@@ -569,24 +649,80 @@ export function buildActivitySnapshot(input) {
569
649
  lastSuccessAt: generatedAt,
570
650
  refresh: { status: "ok" },
571
651
  mode,
572
- subscription: mode === "metered" || mode === "unresolved" || mode === "empty"
573
- ? null
574
- : subscription,
652
+ subscription: finalSubscription,
575
653
  metered: mode === "subscription" || mode === "unresolved" || mode === "empty"
576
654
  ? null
577
655
  : metered,
578
656
  unresolved,
657
+ providers: providerSubscriptionRows.length > 0 ? providerSubscriptionRows : null,
658
+ committedTotal: {
659
+ amountUsd: pricedRows.length > 0
660
+ ? roundUsd(pricedRows.reduce((total, amount) => total + amount, 0))
661
+ : null,
662
+ pricedSubs: pricedRows.length,
663
+ totalSubs: committedRows.length
664
+ },
579
665
  overage: mode === "metered" || mode === "mixed" ? overage : null,
580
666
  coverage,
581
667
  networkUploaded: false
582
668
  });
583
669
  }
670
+ /** Detected-plan list price (subscriptionPlans); null when unpriced. */
671
+ function committedPriceForPlanId(planId) {
672
+ if (!planId)
673
+ return null;
674
+ return subscriptionPlans.find((plan) => plan.id === planId)?.monthlyUsd ?? null;
675
+ }
676
+ function missingBilledWindow() {
677
+ return {
678
+ amountUsd: null,
679
+ recordCount: 0,
680
+ basis: "provider_billed",
681
+ financialEvidence: "missing",
682
+ coverage: "missing"
683
+ };
684
+ }
685
+ /**
686
+ * The v1 dual-write payload (C-lane §2.1 fleet back-compat): today's fields
687
+ * only, so an already-installed v1 runner keeps rendering fresh data instead
688
+ * of decaying into permanent staleness. Exact v1 key set; no v2 fields.
689
+ */
690
+ export function activitySnapshotV1Payload(snapshot) {
691
+ return {
692
+ kind: snapshot.kind,
693
+ schemaVersion: 1,
694
+ currency: snapshot.currency,
695
+ asOf: snapshot.asOf,
696
+ generatedAt: snapshot.generatedAt,
697
+ lastAttemptAt: snapshot.lastAttemptAt,
698
+ lastSuccessAt: snapshot.lastSuccessAt,
699
+ refresh: snapshot.refresh,
700
+ mode: snapshot.mode,
701
+ subscription: snapshot.subscription
702
+ ? {
703
+ agents: snapshot.subscription.agents.map((agent) => ({
704
+ agent: agent.agent,
705
+ billing: agent.billing,
706
+ planId: agent.planId,
707
+ apiEquivalent: agent.apiEquivalent,
708
+ limits: agent.limits,
709
+ pressure: agent.pressure
710
+ }))
711
+ }
712
+ : null,
713
+ metered: snapshot.metered,
714
+ unresolved: snapshot.unresolved,
715
+ overage: snapshot.overage,
716
+ coverage: snapshot.coverage,
717
+ networkUploaded: snapshot.networkUploaded
718
+ };
719
+ }
584
720
  /** A bounded no-evidence state for an initial failed refresh. */
585
721
  export function createActivitySnapshotError(attemptedAt, errorCode) {
586
722
  const timestamp = new Date(parseTimestamp(attemptedAt, "attemptedAt")).toISOString();
587
723
  return activitySnapshotSchema.parse({
588
724
  kind: "aibill.activity_snapshot",
589
- schemaVersion: 1,
725
+ schemaVersion: 2,
590
726
  currency: "USD",
591
727
  asOf: timestamp,
592
728
  generatedAt: timestamp,
@@ -597,6 +733,8 @@ export function createActivitySnapshotError(attemptedAt, errorCode) {
597
733
  subscription: null,
598
734
  metered: null,
599
735
  unresolved: null,
736
+ providers: null,
737
+ committedTotal: { amountUsd: null, pricedSubs: 0, totalSubs: 0 },
600
738
  overage: null,
601
739
  coverage: {
602
740
  agents: [],
@@ -650,6 +788,7 @@ function activitySubscriptionAgents(records, calls, allCalls, plans, scans, trus
650
788
  agent,
651
789
  billing: "subscription",
652
790
  planId: isKnownPlanId(plan.planId) ? plan.planId : null,
791
+ committedUsdPerMonth: committedPriceForPlanId(plan.planId),
653
792
  apiEquivalent: buildApiWindows(agentRecords, allCalls.filter((call) => call.agent === agent), trustedProviderIds, asOfMs, localCoverageForAgent(agent, scans)),
654
793
  limits: latestReportedLimits(agentCalls, asOfMs),
655
794
  pressure: plan.limitSignal === "extra-usage credits exhausted"
@@ -1,6 +1,13 @@
1
1
  import { type ActivitySnapshot, type ActivitySnapshotRefreshErrorCode } from "./activitySnapshot.js";
2
2
  export declare const activitySnapshotCacheEnvironmentVariable = "AIBILL_CACHE_DIR";
3
- export declare const activitySnapshotCacheFileName = "statusline-v1.json";
3
+ /** The v2 snapshot cache (C-lane §2.1). */
4
+ export declare const activitySnapshotCacheFileName = "statusline-v2.json";
5
+ /**
6
+ * Deprecation-window dual-write target: installed v1 runners are frozen
7
+ * copies that read only this file, so every v2 write also refreshes a v1
8
+ * payload here (today's fields only) instead of stranding them stale.
9
+ */
10
+ export declare const activitySnapshotLegacyCacheFileName = "statusline-v1.json";
4
11
  export declare const activitySnapshotCacheMaxBytes: number;
5
12
  export type ActivitySnapshotCacheOptions = {
6
13
  /** Test/embedding override. Production defaults to ~/.aibill/cache. */
@@ -1,18 +1,30 @@
1
1
  import { constants } from "node:fs";
2
+ import { execFile as execFileCallback } from "node:child_process";
2
3
  import { open, lstat, mkdir, chmod, realpath, rename, unlink } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
- import { join, resolve } from "node:path";
5
+ import { dirname, join, relative, resolve } from "node:path";
5
6
  import { randomUUID } from "node:crypto";
6
7
  import { setTimeout as delay } from "node:timers/promises";
7
- import { activitySnapshotSchema, createActivitySnapshotError } from "./activitySnapshot.js";
8
+ import { promisify } from "node:util";
9
+ import { activitySnapshotSchema, activitySnapshotV1Payload, createActivitySnapshotError } from "./activitySnapshot.js";
8
10
  export const activitySnapshotCacheEnvironmentVariable = "AIBILL_CACHE_DIR";
9
- export const activitySnapshotCacheFileName = "statusline-v1.json";
11
+ /** The v2 snapshot cache (C-lane §2.1). */
12
+ export const activitySnapshotCacheFileName = "statusline-v2.json";
13
+ /**
14
+ * Deprecation-window dual-write target: installed v1 runners are frozen
15
+ * copies that read only this file, so every v2 write also refreshes a v1
16
+ * payload here (today's fields only) instead of stranding them stale.
17
+ */
18
+ export const activitySnapshotLegacyCacheFileName = "statusline-v1.json";
10
19
  export const activitySnapshotCacheMaxBytes = 64 * 1_024;
20
+ // The lock name is shared with pre-v2 writers on purpose: during the fleet
21
+ // deprecation window both CLI generations serialize through one lock.
11
22
  const lockFileName = ".statusline-v1.lock";
12
23
  const defaultLockTimeoutMs = 2_000;
13
24
  const staleLockMs = 15_000;
14
25
  const lockPollMs = 20;
15
26
  const lockMetadataMaxBytes = 512;
27
+ const execFile = promisify(execFileCallback);
16
28
  export class ActivitySnapshotCacheError extends Error {
17
29
  code;
18
30
  constructor(code, message) {
@@ -207,7 +219,7 @@ async function readSnapshotFile(directory) {
207
219
  catch {
208
220
  return { status: "error", code: "malformed" };
209
221
  }
210
- if (isRecord(value) && value.schemaVersion !== undefined && value.schemaVersion !== 1) {
222
+ if (isRecord(value) && value.schemaVersion !== undefined && value.schemaVersion !== 2) {
211
223
  return { status: "error", code: "unsupported_version" };
212
224
  }
213
225
  const parsed = activitySnapshotSchema.safeParse(value);
@@ -225,11 +237,17 @@ async function readSnapshotFile(directory) {
225
237
  }
226
238
  }
227
239
  async function atomicWriteSnapshot(directory, snapshot) {
228
- const contents = `${JSON.stringify(snapshot)}\n`;
240
+ await atomicWriteCacheFile(directory, activitySnapshotCacheFileName, `${JSON.stringify(snapshot)}\n`);
241
+ // C-lane §2.1 fleet back-compat: dual-write the v1 payload so an
242
+ // already-installed v1 runner keeps rendering fresh data during the
243
+ // deprecation window instead of decaying into permanent staleness.
244
+ await atomicWriteCacheFile(directory, activitySnapshotLegacyCacheFileName, `${JSON.stringify(activitySnapshotV1Payload(snapshot))}\n`);
245
+ }
246
+ async function atomicWriteCacheFile(directory, fileName, contents) {
229
247
  if (Buffer.byteLength(contents, "utf8") > activitySnapshotCacheMaxBytes) {
230
248
  throw new ActivitySnapshotCacheError("invalid_snapshot", "Activity snapshot exceeds the 64 KiB cache limit.");
231
249
  }
232
- const filePath = join(directory, activitySnapshotCacheFileName);
250
+ const filePath = join(directory, fileName);
233
251
  const existing = await lstat(filePath).catch((error) => {
234
252
  if (isNodeError(error, "ENOENT"))
235
253
  return undefined;
@@ -238,7 +256,7 @@ async function atomicWriteSnapshot(directory, snapshot) {
238
256
  if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
239
257
  throw new ActivitySnapshotCacheError("unsafe_file", "Activity snapshot cache path is not a regular file.");
240
258
  }
241
- const temporaryPath = join(directory, `.${activitySnapshotCacheFileName}.${process.pid}.${randomUUID()}.tmp`);
259
+ const temporaryPath = join(directory, `.${fileName}.${process.pid}.${randomUUID()}.tmp`);
242
260
  let handle;
243
261
  try {
244
262
  handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
@@ -329,6 +347,84 @@ async function ensureDefaultParent(homeDirectory, create) {
329
347
  }
330
348
  if (create)
331
349
  await chmod(parent, 0o700);
350
+ await ensureDefaultCacheGitPrivacy(parent, create);
351
+ }
352
+ /**
353
+ * When a synthetic or real HOME is itself inside a Git worktree, protect the
354
+ * complete top-level private state directory before any cache child is
355
+ * created. Explicit AIBILL_CACHE_DIR/cacheDirectory overrides intentionally
356
+ * remain caller-owned and never receive repository files from this helper.
357
+ */
358
+ async function ensureDefaultCacheGitPrivacy(aibillDirectory, create) {
359
+ const gitRoot = await findEnclosingGitRoot(aibillDirectory);
360
+ if (!gitRoot)
361
+ return;
362
+ const marker = join(aibillDirectory, ".gitignore");
363
+ let handle;
364
+ try {
365
+ handle = await open(marker, constants.O_RDONLY | noFollowFlag());
366
+ }
367
+ catch (error) {
368
+ if (!isNodeError(error, "ENOENT"))
369
+ throw error;
370
+ if (!create) {
371
+ const missing = new Error("Private aibill Git privacy marker does not exist.");
372
+ missing.code = "ENOENT";
373
+ throw missing;
374
+ }
375
+ handle = await open(marker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
376
+ await handle.writeFile("*\n", "utf8");
377
+ await handle.sync();
378
+ await handle.close();
379
+ handle = await open(marker, constants.O_RDONLY | noFollowFlag());
380
+ }
381
+ try {
382
+ const info = await handle.stat();
383
+ if (!info.isFile() || !hasPrivatePermissions(info.mode) || info.size !== 2) {
384
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill Git privacy marker is unsafe.");
385
+ }
386
+ const buffer = Buffer.alloc(2);
387
+ const { bytesRead } = await handle.read(buffer, 0, 2, 0);
388
+ if (bytesRead !== 2 || buffer.toString("utf8") !== "*\n") {
389
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill Git privacy marker is invalid.");
390
+ }
391
+ }
392
+ finally {
393
+ await handle.close().catch(() => undefined);
394
+ }
395
+ const relativeDirectory = relative(gitRoot, aibillDirectory);
396
+ const tracked = await execFile("git", ["-C", gitRoot, "ls-files", "--", relativeDirectory], {
397
+ encoding: "utf8",
398
+ maxBuffer: 64 * 1024
399
+ }).then(({ stdout }) => stdout.trim()).catch(() => {
400
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill cache tracking status could not be verified.");
401
+ });
402
+ if (tracked) {
403
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill cache is already tracked by Git.");
404
+ }
405
+ const ignored = await execFile("git", [
406
+ "-C", gitRoot, "check-ignore", "--quiet", "--no-index", "--",
407
+ join(relativeDirectory, "cache", "privacy-probe.json")
408
+ ]).then(() => true).catch(() => false);
409
+ if (!ignored) {
410
+ throw new ActivitySnapshotCacheError("unsafe_directory", "Private aibill cache is not proven ignored by Git.");
411
+ }
412
+ }
413
+ async function findEnclosingGitRoot(path) {
414
+ let current = resolve(path);
415
+ while (true) {
416
+ const gitEntry = await lstat(join(current, ".git")).catch((error) => {
417
+ if (isNodeError(error, "ENOENT") || isNodeError(error, "ENOTDIR"))
418
+ return undefined;
419
+ throw error;
420
+ });
421
+ if (gitEntry)
422
+ return current;
423
+ const parent = dirname(current);
424
+ if (parent === current)
425
+ return undefined;
426
+ current = parent;
427
+ }
332
428
  }
333
429
  function configuredCacheDirectory(options) {
334
430
  const configured = options.cacheDirectory?.trim() ||