sidekick-shared 0.25.0 → 0.26.1

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 (119) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +50 -3
  3. package/dist/accountChanges.js +22 -3
  4. package/dist/accountRegistry.d.ts +22 -1
  5. package/dist/accountRegistry.js +37 -14
  6. package/dist/accountStatus.d.ts +9 -1
  7. package/dist/accountStatus.js +3 -3
  8. package/dist/accounts.d.ts +3 -1
  9. package/dist/accounts.js +84 -64
  10. package/dist/aggregation/EventAggregator.d.ts +15 -2
  11. package/dist/aggregation/EventAggregator.js +54 -9
  12. package/dist/aggregation/costProvenance.d.ts +14 -0
  13. package/dist/aggregation/costProvenance.js +35 -0
  14. package/dist/aggregation/snapshot.d.ts +13 -2
  15. package/dist/aggregation/snapshot.js +57 -6
  16. package/dist/aggregation/types.d.ts +30 -1
  17. package/dist/analytics/compactionLedger.js +15 -5
  18. package/dist/analytics/qualityScore.js +2 -1
  19. package/dist/browser.d.ts +11 -1
  20. package/dist/browser.js +29 -1
  21. package/dist/codexProfiles.d.ts +3 -1
  22. package/dist/codexProfiles.js +4 -2
  23. package/dist/codexQuota.d.ts +2 -0
  24. package/dist/codexQuota.js +11 -66
  25. package/dist/failingTools.d.ts +37 -0
  26. package/dist/failingTools.js +70 -0
  27. package/dist/formatters/sessionDump.js +13 -7
  28. package/dist/formatting.d.ts +13 -0
  29. package/dist/formatting.js +35 -0
  30. package/dist/historicalStore.d.ts +47 -0
  31. package/dist/historicalStore.js +312 -0
  32. package/dist/index.d.ts +46 -12
  33. package/dist/index.js +112 -24
  34. package/dist/modelContext.js +8 -3
  35. package/dist/modelInfo.js +45 -27
  36. package/dist/node.d.ts +19 -0
  37. package/dist/node.js +47 -1
  38. package/dist/observedContextWindows.d.ts +7 -0
  39. package/dist/observedContextWindows.js +17 -2
  40. package/dist/observedSessionCollector.d.ts +3 -8
  41. package/dist/observedSessionCollector.js +10 -27
  42. package/dist/parsers/sessionActivityDetector.d.ts +24 -2
  43. package/dist/parsers/sessionActivityDetector.js +95 -17
  44. package/dist/parsers/sessionPathResolver.d.ts +11 -0
  45. package/dist/parsers/sessionPathResolver.js +13 -10
  46. package/dist/peakHours.d.ts +34 -3
  47. package/dist/peakHours.js +92 -7
  48. package/dist/pricingCatalog.d.ts +7 -0
  49. package/dist/pricingCatalog.js +8 -5
  50. package/dist/providers/claudeCode.js +11 -96
  51. package/dist/providers/codex.d.ts +2 -0
  52. package/dist/providers/codex.js +94 -259
  53. package/dist/providers/detect.d.ts +2 -0
  54. package/dist/providers/detect.js +29 -24
  55. package/dist/providers/factory.d.ts +6 -0
  56. package/dist/providers/openCode.d.ts +2 -0
  57. package/dist/providers/openCode.js +105 -121
  58. package/dist/providers/rolloutWalker.d.ts +53 -0
  59. package/dist/providers/rolloutWalker.js +209 -0
  60. package/dist/providers/types.d.ts +30 -0
  61. package/dist/quota.d.ts +30 -3
  62. package/dist/quota.js +35 -3
  63. package/dist/quotaHistory.d.ts +1 -1
  64. package/dist/quotaHistory.js +36 -35
  65. package/dist/quotaPresentation.js +17 -5
  66. package/dist/quotaResolver.d.ts +83 -0
  67. package/dist/quotaResolver.js +234 -0
  68. package/dist/quotaSnapshots.d.ts +15 -2
  69. package/dist/quotaSnapshots.js +25 -3
  70. package/dist/quotaThresholds.d.ts +60 -0
  71. package/dist/quotaThresholds.js +90 -0
  72. package/dist/report/htmlReportGenerator.js +10 -4
  73. package/dist/report/sessionReportInputs.d.ts +23 -0
  74. package/dist/report/sessionReportInputs.js +34 -0
  75. package/dist/schemas/index.d.ts +2 -0
  76. package/dist/schemas/index.js +10 -1
  77. package/dist/schemas/quota.d.ts +139 -0
  78. package/dist/schemas/quota.js +17 -2
  79. package/dist/schemas/quotaHistory.d.ts +1 -0
  80. package/dist/schemas/quotaHistory.js +1 -1
  81. package/dist/schemas/sessionEvent.d.ts +6 -0
  82. package/dist/schemas/sessionEvent.js +1 -0
  83. package/dist/schemas/stateFile.d.ts +198 -0
  84. package/dist/schemas/stateFile.js +77 -0
  85. package/dist/schemas/transcript.js +2 -0
  86. package/dist/sessionFingerprint.d.ts +25 -0
  87. package/dist/sessionFingerprint.js +46 -0
  88. package/dist/sessionPreviews.d.ts +7 -1
  89. package/dist/sessionPreviews.js +15 -3
  90. package/dist/sessionStats.d.ts +51 -0
  91. package/dist/sessionStats.js +166 -0
  92. package/dist/stateFile.d.ts +113 -0
  93. package/dist/stateFile.js +160 -0
  94. package/dist/statusline/claudeStatuslinePayload.d.ts +89 -0
  95. package/dist/statusline/claudeStatuslinePayload.js +152 -0
  96. package/dist/statusline/formatter.d.ts +13 -0
  97. package/dist/statusline/formatter.js +48 -6
  98. package/dist/statusline/index.d.ts +10 -2
  99. package/dist/statusline/index.js +19 -1
  100. package/dist/tokenSummary.d.ts +62 -0
  101. package/dist/tokenSummary.js +72 -0
  102. package/dist/transcript.d.ts +5 -0
  103. package/dist/transcript.js +2 -0
  104. package/dist/types/observedSessionV1.d.ts +2 -0
  105. package/dist/types/observedSessionV1.js +75 -18
  106. package/dist/types/sessionEvent.d.ts +2 -0
  107. package/dist/usage/billingBlocks.d.ts +85 -0
  108. package/dist/usage/billingBlocks.js +153 -0
  109. package/dist/usage/importSessionHistory.d.ts +53 -0
  110. package/dist/usage/importSessionHistory.js +88 -0
  111. package/dist/usage/usageEvents.d.ts +92 -0
  112. package/dist/usage/usageEvents.js +322 -0
  113. package/dist/usage/usageReports.d.ts +76 -0
  114. package/dist/usage/usageReports.js +208 -0
  115. package/dist/watchers/factory.d.ts +6 -0
  116. package/dist/watchers/factory.js +30 -25
  117. package/dist/zaiQuotaApi.d.ts +2 -0
  118. package/dist/zaiQuotaApi.js +2 -0
  119. package/package.json +1 -1
package/dist/quota.d.ts CHANGED
@@ -46,12 +46,26 @@ export interface QuotaState {
46
46
  projectedSevenDay?: number;
47
47
  /** Provider that produced this quota sample */
48
48
  providerId?: QuotaProviderId;
49
- /** Source of the sample */
50
- source?: 'api' | 'session' | 'cache';
49
+ /**
50
+ * Source of the sample. `statusline` is the official rate-limit block Claude
51
+ * Code hands to its status-line command; `session` is inferred from session
52
+ * logs; `api` is a live usage-endpoint fetch; `cache` is a persisted snapshot.
53
+ */
54
+ source?: 'api' | 'session' | 'cache' | 'statusline';
55
+ /**
56
+ * Where a cached sample originally came from. `readQuotaSnapshot()` forces
57
+ * `source` to `cache`; this keeps the origin (`statusline`, `session`, `api`)
58
+ * so a reader can rank an official status-line sample above a session guess.
59
+ */
60
+ capturedSource?: 'api' | 'session' | 'statusline';
51
61
  /** ISO timestamp when the sample was captured */
52
62
  capturedAt?: string;
53
63
  /** Whether the sample is stale cached data */
54
64
  stale?: boolean;
65
+ /** Milliseconds between `capturedAt` and the read, when read from a snapshot. */
66
+ ageMs?: number;
67
+ /** Freshness tier derived from `ageMs`; see `classifyQuotaFreshness`. */
68
+ freshness?: QuotaFreshness;
55
69
  /** Provider-specific display label for the first window */
56
70
  fiveHourLabel?: string;
57
71
  /** Provider-specific display label for the second window */
@@ -71,6 +85,16 @@ export interface QuotaState {
71
85
  }
72
86
  export declare const FIVE_HOUR_WINDOW_MS: number;
73
87
  export declare const SEVEN_DAY_WINDOW_MS: number;
88
+ /** How much to trust a cached quota sample, by age. */
89
+ export type QuotaFreshness = 'fresh' | 'aging' | 'stale';
90
+ /** A snapshot younger than this is as good as live. */
91
+ export declare const QUOTA_FRESH_MAX_AGE_MS: number;
92
+ /** A snapshot older than this should be labelled as stale wherever it is shown. */
93
+ export declare const QUOTA_AGING_MAX_AGE_MS: number;
94
+ /** Classify a cached sample's age. Non-finite or negative ages count as stale. */
95
+ export declare function classifyQuotaFreshness(ageMs: number | undefined): QuotaFreshness;
96
+ /** Compact age label for status lines and table footers: `just now`, `3m ago`, `2h ago`, `3d ago`. */
97
+ export declare function formatQuotaAge(ageMs: number | undefined): string;
74
98
  export interface QuotaProjectionInput {
75
99
  utilization: number;
76
100
  resetsAt: string;
@@ -95,6 +119,9 @@ export declare function withQuotaProjections<T extends QuotaState>(state: T, opt
95
119
  * it in a polling loop, VS Code EventEmitter, or CLI interval as needed.
96
120
  *
97
121
  * @param accessToken - OAuth access token from `readClaudeMaxCredentials()`
122
+ * @param options.fetchImpl - Fetch implementation (defaults to the global `fetch`; tests inject a stub)
98
123
  * @returns QuotaState with utilization data and elapsed-time projections
99
124
  */
100
- export declare function fetchQuota(accessToken: string): Promise<QuotaState>;
125
+ export declare function fetchQuota(accessToken: string, options?: {
126
+ fetchImpl?: typeof fetch;
127
+ }): Promise<QuotaState>;
package/dist/quota.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SEVEN_DAY_WINDOW_MS = exports.FIVE_HOUR_WINDOW_MS = exports.QUOTA_PROVIDER_IDS = void 0;
3
+ exports.QUOTA_AGING_MAX_AGE_MS = exports.QUOTA_FRESH_MAX_AGE_MS = exports.SEVEN_DAY_WINDOW_MS = exports.FIVE_HOUR_WINDOW_MS = exports.QUOTA_PROVIDER_IDS = void 0;
4
+ exports.classifyQuotaFreshness = classifyQuotaFreshness;
5
+ exports.formatQuotaAge = formatQuotaAge;
4
6
  exports.projectQuotaWindow = projectQuotaWindow;
5
7
  exports.withQuotaProjections = withQuotaProjections;
6
8
  exports.fetchQuota = fetchQuota;
@@ -11,6 +13,34 @@ const BETA_HEADER = 'oauth-2025-04-20';
11
13
  const FETCH_TIMEOUT_MS = 10_000;
12
14
  exports.FIVE_HOUR_WINDOW_MS = 5 * 3_600_000;
13
15
  exports.SEVEN_DAY_WINDOW_MS = 7 * 86_400_000;
16
+ /** A snapshot younger than this is as good as live. */
17
+ exports.QUOTA_FRESH_MAX_AGE_MS = 5 * 60_000;
18
+ /** A snapshot older than this should be labelled as stale wherever it is shown. */
19
+ exports.QUOTA_AGING_MAX_AGE_MS = 60 * 60_000;
20
+ /** Classify a cached sample's age. Non-finite or negative ages count as stale. */
21
+ function classifyQuotaFreshness(ageMs) {
22
+ if (typeof ageMs !== 'number' || !Number.isFinite(ageMs) || ageMs < 0)
23
+ return 'stale';
24
+ if (ageMs < exports.QUOTA_FRESH_MAX_AGE_MS)
25
+ return 'fresh';
26
+ if (ageMs < exports.QUOTA_AGING_MAX_AGE_MS)
27
+ return 'aging';
28
+ return 'stale';
29
+ }
30
+ /** Compact age label for status lines and table footers: `just now`, `3m ago`, `2h ago`, `3d ago`. */
31
+ function formatQuotaAge(ageMs) {
32
+ if (typeof ageMs !== 'number' || !Number.isFinite(ageMs) || ageMs < 0)
33
+ return 'age unknown';
34
+ const minutes = Math.floor(ageMs / 60_000);
35
+ if (minutes < 1)
36
+ return 'just now';
37
+ if (minutes < 60)
38
+ return `${minutes}m ago`;
39
+ const hours = Math.floor(minutes / 60);
40
+ if (hours < 48)
41
+ return `${hours}h ago`;
42
+ return `${Math.floor(hours / 24)}d ago`;
43
+ }
14
44
  /**
15
45
  * Projects utilization at end of window based on elapsed time.
16
46
  *
@@ -88,14 +118,16 @@ function parseRetryAfterMs(retryAfter) {
88
118
  * it in a polling loop, VS Code EventEmitter, or CLI interval as needed.
89
119
  *
90
120
  * @param accessToken - OAuth access token from `readClaudeMaxCredentials()`
121
+ * @param options.fetchImpl - Fetch implementation (defaults to the global `fetch`; tests inject a stub)
91
122
  * @returns QuotaState with utilization data and elapsed-time projections
92
123
  */
93
- async function fetchQuota(accessToken) {
124
+ async function fetchQuota(accessToken, options = {}) {
94
125
  const controller = new AbortController();
95
126
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
96
127
  timeout.unref();
97
128
  try {
98
- const res = await fetch(USAGE_URL, {
129
+ const fetchImpl = options.fetchImpl ?? fetch;
130
+ const res = await fetchImpl(USAGE_URL, {
99
131
  method: 'GET',
100
132
  signal: controller.signal,
101
133
  headers: {
@@ -23,7 +23,7 @@ export interface QuotaHistorySample {
23
23
  };
24
24
  available: boolean;
25
25
  error?: string;
26
- source?: 'session' | 'cache' | 'api';
26
+ source?: 'session' | 'cache' | 'api' | 'statusline';
27
27
  stale?: boolean;
28
28
  }
29
29
  export interface QuotaHistoryAppendOptions {
@@ -50,8 +50,10 @@ exports._resetQuotaHistoryInMemoryStateForTests = _resetQuotaHistoryInMemoryStat
50
50
  const crypto = __importStar(require("crypto"));
51
51
  const fs = __importStar(require("fs"));
52
52
  const path = __importStar(require("path"));
53
+ const formatting_1 = require("./formatting");
53
54
  const paths_1 = require("./paths");
54
55
  const quotaSnapshots_1 = require("./quotaSnapshots");
56
+ const atomic_1 = require("./writers/atomic");
55
57
  const DEFAULT_MIN_INTERVAL_MS = 60_000;
56
58
  const DEFAULT_RETENTION_DAYS = 91;
57
59
  const PRUNE_FILESIZE_THRESHOLD = 16 * 1024;
@@ -150,21 +152,16 @@ function readLastSampleTimestampMs(filePath) {
150
152
  }
151
153
  }
152
154
  }
155
+ /**
156
+ * Append-and-prune under the shared cross-process lock. Without it a prune
157
+ * rewrite (rename over the file) could race another process's append, and
158
+ * the appended line would land on the old, unlinked inode.
159
+ */
160
+ function withHistoryFileLock(filePath, operation) {
161
+ return (0, atomic_1.withFileLockSync)(`${filePath}.lock`, operation);
162
+ }
153
163
  function atomicRewriteFile(filePath, contents) {
154
- const tmp = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomBytes(8).toString('hex')}.tmp`;
155
- try {
156
- fs.writeFileSync(tmp, contents, { encoding: 'utf8', mode: 0o600 });
157
- fs.renameSync(tmp, filePath);
158
- }
159
- catch (error) {
160
- try {
161
- fs.rmSync(tmp, { force: true });
162
- }
163
- catch {
164
- // Best effort cleanup only.
165
- }
166
- throw error;
167
- }
164
+ (0, atomic_1.atomicWriteFileSync)(filePath, contents, 0o600);
168
165
  }
169
166
  function pruneFileSync(filePath, retentionDays) {
170
167
  let stat;
@@ -245,18 +242,19 @@ async function runAppend(sample, filePath, options) {
245
242
  }
246
243
  ensureHistoryDir(sample.workspaceId);
247
244
  const line = JSON.stringify(sample) + '\n';
248
- await fs.promises.appendFile(filePath, line, { encoding: 'utf8', mode: 0o600 });
249
- lastWriteCache.set(filePath, Number.isFinite(sampleTs) ? sampleTs : Date.now());
250
- // Opportunistic prune. Skip when the file is small to avoid pointless rewrite churn.
251
- try {
252
- const stat = await fs.promises.stat(filePath);
253
- if (stat.size >= PRUNE_FILESIZE_THRESHOLD) {
254
- pruneFileSync(filePath, options.retentionDays);
245
+ withHistoryFileLock(filePath, () => {
246
+ fs.appendFileSync(filePath, line, { encoding: 'utf8', mode: 0o600 });
247
+ // Opportunistic prune. Skip when the file is small to avoid pointless rewrite churn.
248
+ try {
249
+ if (fs.statSync(filePath).size >= PRUNE_FILESIZE_THRESHOLD) {
250
+ pruneFileSync(filePath, options.retentionDays);
251
+ }
255
252
  }
256
- }
257
- catch {
258
- // ignore
259
- }
253
+ catch {
254
+ // ignore
255
+ }
256
+ });
257
+ lastWriteCache.set(filePath, Number.isFinite(sampleTs) ? sampleTs : Date.now());
260
258
  // Backwards-compat: keep the latest-snapshot store hot so existing callers (DashboardViewProvider,
261
259
  // codex session provider, external consumers) don't have to query history for "latest".
262
260
  try {
@@ -323,23 +321,26 @@ async function readQuotaHistoryRange(options) {
323
321
  samples.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
324
322
  return samples;
325
323
  }
326
- function utcDateString(ms) {
327
- return new Date(ms).toISOString().slice(0, 10);
328
- }
329
- function addDaysUtc(dateString, days) {
330
- const ms = Date.parse(`${dateString}T00:00:00Z`) + days * MS_PER_DAY;
331
- return utcDateString(ms);
324
+ /**
325
+ * Daily buckets are keyed by the machine's local calendar day, matching the
326
+ * `today` brief, the historical-data store, and the dashboard date filters.
327
+ */
328
+ function localDateString(ms) {
329
+ return (0, formatting_1.formatLocalDateKey)(ms);
332
330
  }
333
331
  async function readQuotaHistoryDailyBuckets(options) {
334
332
  const samples = await readQuotaHistoryRange(options);
335
333
  const { fromMs: defaultFromMs, toMs: defaultToMs } = defaultRangeMs();
336
334
  const fromMs = options.from ? Date.parse(options.from) : defaultFromMs;
337
335
  const toMs = options.to ? Date.parse(options.to) : defaultToMs;
338
- const startDate = utcDateString(fromMs);
339
- const endDate = utcDateString(toMs);
336
+ const startDate = localDateString(fromMs);
337
+ const endDate = localDateString(toMs);
340
338
  const grouped = new Map();
341
339
  for (const sample of samples) {
342
- const day = sample.timestamp.slice(0, 10);
340
+ const sampleMs = Date.parse(sample.timestamp);
341
+ if (!Number.isFinite(sampleMs))
342
+ continue;
343
+ const day = localDateString(sampleMs);
343
344
  const bucket = grouped.get(day);
344
345
  if (bucket) {
345
346
  bucket.push(sample);
@@ -389,7 +390,7 @@ async function readQuotaHistoryDailyBuckets(options) {
389
390
  anyUnavailable,
390
391
  });
391
392
  }
392
- cursor = addDaysUtc(cursor, 1);
393
+ cursor = (0, formatting_1.addLocalDays)(cursor, 1);
393
394
  }
394
395
  return buckets;
395
396
  }
@@ -23,8 +23,20 @@ function formatRetryAfter(ms) {
23
23
  function describeQuotaFailure(quota) {
24
24
  if (!quota || quota.available || !quota.failureKind)
25
25
  return null;
26
+ const isCodex = quota.providerId === 'codex';
27
+ const apiName = isCodex ? 'Codex' : 'Anthropic';
26
28
  switch (quota.failureKind) {
27
29
  case 'auth':
30
+ if (isCodex) {
31
+ return {
32
+ severity: 'error',
33
+ title: quota.httpStatus ? 'Codex sign-in expired' : 'Codex sign-in required',
34
+ message: quota.error ?? 'The current Codex login could not be used.',
35
+ detail: 'Run `codex login` to sign in, then retry quota refresh.',
36
+ alertKey: `auth:${quota.httpStatus ?? 'no-credentials'}`,
37
+ isRetryable: false,
38
+ };
39
+ }
28
40
  if (quota.error === 'No OAuth token available') {
29
41
  return {
30
42
  severity: 'error',
@@ -47,7 +59,7 @@ function describeQuotaFailure(quota) {
47
59
  return {
48
60
  severity: 'warning',
49
61
  title: 'Quota API unreachable',
50
- message: 'Could not reach Anthropic from the current environment.',
62
+ message: `Could not reach ${apiName} from the current environment.`,
51
63
  detail: 'Check connectivity, proxy, or firewall settings, then retry.',
52
64
  alertKey: 'network',
53
65
  isRetryable: true,
@@ -59,7 +71,7 @@ function describeQuotaFailure(quota) {
59
71
  message: quota.retryAfterMs != null
60
72
  ? `Retry in ${formatRetryAfter(quota.retryAfterMs)}.`
61
73
  : 'Retry shortly.',
62
- detail: quota.httpStatus != null ? `Anthropic returned HTTP ${quota.httpStatus}.` : undefined,
74
+ detail: quota.httpStatus != null ? `${apiName} returned HTTP ${quota.httpStatus}.` : undefined,
63
75
  alertKey: `rate_limit:${quota.httpStatus ?? 429}`,
64
76
  isRetryable: true,
65
77
  };
@@ -68,8 +80,8 @@ function describeQuotaFailure(quota) {
68
80
  severity: 'warning',
69
81
  title: 'Quota API unavailable',
70
82
  message: quota.httpStatus != null
71
- ? `Anthropic returned HTTP ${quota.httpStatus}. Try again shortly.`
72
- : 'Anthropic quota data is temporarily unavailable.',
83
+ ? `${apiName} returned HTTP ${quota.httpStatus}. Try again shortly.`
84
+ : `${apiName} quota data is temporarily unavailable.`,
73
85
  alertKey: `server:${quota.httpStatus ?? 'unknown'}`,
74
86
  isRetryable: true,
75
87
  };
@@ -78,7 +90,7 @@ function describeQuotaFailure(quota) {
78
90
  severity: 'error',
79
91
  title: 'Unexpected quota response',
80
92
  message: quota.httpStatus != null
81
- ? `Anthropic returned HTTP ${quota.httpStatus}.`
93
+ ? `${apiName} returned HTTP ${quota.httpStatus}.`
82
94
  : (quota.error ?? 'Quota data could not be retrieved.'),
83
95
  detail: 'This failure is not classified as retryable.',
84
96
  alertKey: `unknown:${quota.httpStatus ?? 'none'}`,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * One quota resolution path for every Sidekick surface.
3
+ *
4
+ * `sidekick quota`, `quota --all`, the MCP facts server, and both dashboards
5
+ * used to pick between the live API, session logs, and the persisted snapshot
6
+ * in four different orders. `resolveQuota()` defaults to this precedence:
7
+ *
8
+ * 1. a fresh persisted snapshot (younger than `QUOTA_FRESH_MAX_AGE_MS`, whatever
9
+ * its origin: the official status-line sample, a session log, or an earlier
10
+ * API call) — zero network;
11
+ * 2. a session-derived sample (Codex rollouts carry rate limits; Claude and
12
+ * z.ai have no local equivalent);
13
+ * 3. the provider API, when `allowApi` is not disabled;
14
+ * 4. an aging or stale snapshot, labelled as such, with the API failure attached;
15
+ * 5. an unavailable state describing why nothing could be resolved.
16
+ *
17
+ * `preferFresh: false` asks the API first when allowed (the `--refresh` flag
18
+ * and one-shot Codex queries). On failure, the newer session or persisted
19
+ * sample wins, with ties going to the persisted sample. Every result carries
20
+ * `source`, `capturedSource`, `freshness`, `ageMs`, and a `resolution` tag.
21
+ */
22
+ import type { ResolvedActiveAccount, SavedAccountProfile } from './accountRegistry';
23
+ import type { ProviderQuotaState } from './providerQuota';
24
+ import type { CodexProvider } from './providers/codex';
25
+ import type { QuotaState } from './quota';
26
+ import type { QuotaSnapshotProviderId } from './quotaSnapshots';
27
+ import type { ZaiQuotaApiOptions } from './zaiQuotaApi';
28
+ /** Providers the resolver knows how to read. `opencode` callers map to `zai`. */
29
+ export type QuotaResolveProviderId = 'claude-code' | 'codex' | 'zai';
30
+ /** Which step of the precedence produced the answer. */
31
+ export type QuotaResolution = 'snapshot-fresh' | 'session' | 'api' | 'snapshot-aging' | 'snapshot-stale' | 'unavailable';
32
+ type RuntimeProviderFor<T extends QuotaResolveProviderId> = T extends 'claude-code' ? 'claude' : T extends 'codex' ? 'codex' : 'zai';
33
+ export interface ResolvedQuota<T extends QuotaResolveProviderId = QuotaResolveProviderId> extends ProviderQuotaState<RuntimeProviderFor<T>> {
34
+ /** Precedence step that produced this sample. */
35
+ resolution: QuotaResolution;
36
+ }
37
+ export interface ResolveQuotaOptions<T extends QuotaResolveProviderId = QuotaResolveProviderId> {
38
+ providerId: T;
39
+ /** Snapshot key; derived from the active account when omitted. */
40
+ accountId?: string;
41
+ /** Workspace whose Codex rollouts are searched first for session-derived rate limits. */
42
+ workspacePath?: string;
43
+ /** Prefer local samples (default true). False asks the API before any local sample when allowed. */
44
+ preferFresh?: boolean;
45
+ /** Allow provider API calls (default true), including when preferFresh is false. */
46
+ allowApi?: boolean;
47
+ /** Re-point a stale saved-account pointer while resolving the account (default true). */
48
+ selfHeal?: boolean;
49
+ /** Clock for freshness classification; defaults to `new Date()`. */
50
+ now?: Date;
51
+ /** Fetch implementation forwarded to every API call (tests inject a stub). */
52
+ fetchImpl?: typeof fetch;
53
+ readSnapshot?: (providerId: QuotaSnapshotProviderId, accountId: string, now?: Date) => QuotaState | null;
54
+ writeSnapshot?: (providerId: QuotaSnapshotProviderId, accountId: string, quota: QuotaState) => void;
55
+ /** Claude access token source; defaults to `readClaudeMaxCredentials()`. */
56
+ getClaudeAccessToken?: () => Promise<string | null>;
57
+ /** Claude account resolution; defaults to `resolveActiveClaudeAccount()`. */
58
+ resolveClaudeAccount?: (options: {
59
+ selfHeal?: boolean;
60
+ }) => ResolvedActiveAccount;
61
+ /** Codex account resolution; defaults to the self-healing registry lookup. */
62
+ resolveCodexAccount?: (options: {
63
+ selfHeal?: boolean;
64
+ }) => SavedAccountProfile | null;
65
+ /** Reuse a caller-owned Codex provider for the session scan. */
66
+ codexProvider?: CodexProvider;
67
+ /** Codex home whose `auth.json` and rollouts are read. */
68
+ codexHome?: string;
69
+ /** Explicit Codex access token for the API step. */
70
+ codexAccessToken?: string;
71
+ /** z.ai credential discovery and API options. */
72
+ zai?: ZaiQuotaApiOptions;
73
+ }
74
+ /**
75
+ * Resolve the current quota for one provider with the shared precedence
76
+ * described at the top of this file.
77
+ *
78
+ * The result always has `runtimeProvider`, `resolution`, `freshness`, and
79
+ * (when a capture time is known) `ageMs`; `failure` describes an API failure
80
+ * even when older numbers were returned from a snapshot.
81
+ */
82
+ export declare function resolveQuota<T extends QuotaResolveProviderId>(options: ResolveQuotaOptions<T>): Promise<ResolvedQuota<T>>;
83
+ export {};
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ /**
3
+ * One quota resolution path for every Sidekick surface.
4
+ *
5
+ * `sidekick quota`, `quota --all`, the MCP facts server, and both dashboards
6
+ * used to pick between the live API, session logs, and the persisted snapshot
7
+ * in four different orders. `resolveQuota()` defaults to this precedence:
8
+ *
9
+ * 1. a fresh persisted snapshot (younger than `QUOTA_FRESH_MAX_AGE_MS`, whatever
10
+ * its origin: the official status-line sample, a session log, or an earlier
11
+ * API call) — zero network;
12
+ * 2. a session-derived sample (Codex rollouts carry rate limits; Claude and
13
+ * z.ai have no local equivalent);
14
+ * 3. the provider API, when `allowApi` is not disabled;
15
+ * 4. an aging or stale snapshot, labelled as such, with the API failure attached;
16
+ * 5. an unavailable state describing why nothing could be resolved.
17
+ *
18
+ * `preferFresh: false` asks the API first when allowed (the `--refresh` flag
19
+ * and one-shot Codex queries). On failure, the newer session or persisted
20
+ * sample wins, with ties going to the persisted sample. Every result carries
21
+ * `source`, `capturedSource`, `freshness`, `ageMs`, and a `resolution` tag.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.resolveQuota = resolveQuota;
25
+ const accounts_1 = require("./accounts");
26
+ const codexProfiles_1 = require("./codexProfiles");
27
+ const codexQuota_1 = require("./codexQuota");
28
+ const credentials_1 = require("./credentials");
29
+ const quota_1 = require("./quota");
30
+ const quotaPresentation_1 = require("./quotaPresentation");
31
+ const quotaSnapshots_1 = require("./quotaSnapshots");
32
+ const zaiQuotaApi_1 = require("./zaiQuotaApi");
33
+ const ZAI_DEFAULT_ACCOUNT_ID = 'default';
34
+ const CLAUDE_NO_CREDENTIALS_ERROR = 'No OAuth token available';
35
+ function snapshotProviderId(providerId) {
36
+ return providerId;
37
+ }
38
+ function defaultResolveCodexAccount(options) {
39
+ // Self-heal the saved pointer to the live login first so the snapshot key and
40
+ // the display identity both track reality after a native `codex login`.
41
+ (0, codexProfiles_1.resolveActiveCodexAccount)(options);
42
+ return (0, codexProfiles_1.getActiveCodexAccount)();
43
+ }
44
+ function resolveIdentity(options) {
45
+ const selfHeal = options.selfHeal ?? true;
46
+ switch (options.providerId) {
47
+ case 'claude-code': {
48
+ const account = (options.resolveClaudeAccount ?? accounts_1.resolveActiveClaudeAccount)({ selfHeal });
49
+ return {
50
+ accountId: options.accountId ?? account.registryAccountId,
51
+ codexAccount: null,
52
+ accountLabel: account.label ?? account.email,
53
+ accountDetail: account.email,
54
+ };
55
+ }
56
+ case 'codex': {
57
+ const profile = (options.resolveCodexAccount ?? defaultResolveCodexAccount)({ selfHeal });
58
+ return {
59
+ accountId: options.accountId ?? profile?.id,
60
+ codexAccount: profile,
61
+ accountLabel: profile?.label,
62
+ accountDetail: profile?.email,
63
+ };
64
+ }
65
+ case 'zai':
66
+ return { accountId: options.accountId ?? ZAI_DEFAULT_ACCOUNT_ID, codexAccount: null };
67
+ }
68
+ }
69
+ async function defaultClaudeAccessToken() {
70
+ const credentials = await (0, credentials_1.readClaudeMaxCredentials)();
71
+ return credentials?.accessToken ?? null;
72
+ }
73
+ function zeroWindows() {
74
+ return { fiveHour: { utilization: 0, resetsAt: '' }, sevenDay: { utilization: 0, resetsAt: '' } };
75
+ }
76
+ function unavailableState(providerId, error, capturedAt) {
77
+ return {
78
+ ...zeroWindows(),
79
+ available: false,
80
+ error,
81
+ failureKind: 'unknown',
82
+ providerId,
83
+ capturedAt,
84
+ ...(providerId === 'codex' ? { fiveHourLabel: 'Primary', sevenDayLabel: 'Secondary' } : {}),
85
+ };
86
+ }
87
+ async function fetchFromApi(options, identity, capturedAt) {
88
+ switch (options.providerId) {
89
+ case 'claude-code': {
90
+ const token = await (options.getClaudeAccessToken ?? defaultClaudeAccessToken)();
91
+ if (!token) {
92
+ return {
93
+ ...zeroWindows(),
94
+ available: false,
95
+ error: CLAUDE_NO_CREDENTIALS_ERROR,
96
+ failureKind: 'auth',
97
+ providerId: 'claude-code',
98
+ source: 'api',
99
+ capturedAt,
100
+ };
101
+ }
102
+ const state = await (0, quota_1.fetchQuota)(token, { fetchImpl: options.fetchImpl });
103
+ return { ...state, providerId: 'claude-code', source: 'api', capturedAt };
104
+ }
105
+ case 'codex':
106
+ return (0, codexQuota_1.fetchCodexQuotaFromApi)({
107
+ codexHome: options.codexHome,
108
+ fetchImpl: options.fetchImpl,
109
+ accessToken: options.codexAccessToken,
110
+ accountId: identity.codexAccount?.providerAccountId,
111
+ capturedAt,
112
+ });
113
+ case 'zai':
114
+ return (0, zaiQuotaApi_1.fetchZaiQuotaFromApi)({ ...options.zai, fetchImpl: options.fetchImpl, capturedAt });
115
+ }
116
+ }
117
+ function withAge(state, now) {
118
+ if (state.ageMs !== undefined && state.freshness !== undefined)
119
+ return state;
120
+ const capturedMs = state.capturedAt ? Date.parse(state.capturedAt) : NaN;
121
+ const ageMs = Number.isFinite(capturedMs) ? Math.max(0, now.getTime() - capturedMs) : undefined;
122
+ return {
123
+ ...state,
124
+ ...(ageMs !== undefined ? { ageMs } : {}),
125
+ freshness: (0, quota_1.classifyQuotaFreshness)(ageMs),
126
+ };
127
+ }
128
+ function enrich(providerId, state, identity) {
129
+ switch (providerId) {
130
+ case 'claude-code':
131
+ return {
132
+ ...(0, quota_1.withQuotaProjections)(state),
133
+ runtimeProvider: 'claude',
134
+ providerId: 'claude-code',
135
+ accountLabel: identity.accountLabel,
136
+ accountDetail: identity.accountDetail,
137
+ };
138
+ case 'codex':
139
+ return (0, codexQuota_1.enrichCodexQuota)(state, identity.codexAccount);
140
+ default:
141
+ return (0, zaiQuotaApi_1.enrichZaiQuota)(state);
142
+ }
143
+ }
144
+ function snapshotResolution(state) {
145
+ if (state.freshness === 'fresh')
146
+ return 'snapshot-fresh';
147
+ if (state.freshness === 'aging')
148
+ return 'snapshot-aging';
149
+ return 'snapshot-stale';
150
+ }
151
+ function captureTimeMs(state) {
152
+ const capturedMs = state.capturedAt ? Date.parse(state.capturedAt) : NaN;
153
+ return Number.isFinite(capturedMs) ? capturedMs : Number.NEGATIVE_INFINITY;
154
+ }
155
+ /**
156
+ * Resolve the current quota for one provider with the shared precedence
157
+ * described at the top of this file.
158
+ *
159
+ * The result always has `runtimeProvider`, `resolution`, `freshness`, and
160
+ * (when a capture time is known) `ageMs`; `failure` describes an API failure
161
+ * even when older numbers were returned from a snapshot.
162
+ */
163
+ async function resolveQuota(options) {
164
+ const providerId = options.providerId;
165
+ const now = options.now ?? new Date();
166
+ const capturedAt = now.toISOString();
167
+ const preferFresh = options.preferFresh ?? true;
168
+ const allowApi = options.allowApi ?? true;
169
+ const apiFirst = !preferFresh && allowApi;
170
+ const readSnapshot = options.readSnapshot ?? quotaSnapshots_1.readQuotaSnapshot;
171
+ const writeSnapshot = options.writeSnapshot ?? quotaSnapshots_1.writeQuotaSnapshot;
172
+ const identity = resolveIdentity(options);
173
+ const finish = (state, resolution) => {
174
+ const enriched = withAge(enrich(providerId, state, identity), now);
175
+ return {
176
+ ...enriched,
177
+ failure: enriched.failure ?? (0, quotaPresentation_1.describeQuotaFailure)(enriched),
178
+ resolution,
179
+ };
180
+ };
181
+ const stored = identity.accountId
182
+ ? readSnapshot(snapshotProviderId(providerId), identity.accountId, now)
183
+ : null;
184
+ const snapshot = stored?.available ? stored : null;
185
+ if (preferFresh && snapshot && snapshot.freshness === 'fresh') {
186
+ return finish(snapshot, 'snapshot-fresh');
187
+ }
188
+ const readLocalCodex = () => (0, codexQuota_1.resolveCodexQuotaFromLocalSources)({
189
+ workspacePath: options.workspacePath,
190
+ provider: options.codexProvider,
191
+ activeAccount: identity.codexAccount,
192
+ codexHome: options.codexHome,
193
+ // Only rollout hits count as "session-derived"; the snapshot fallback is
194
+ // handled below so it can be labelled by age.
195
+ readSnapshot: () => null,
196
+ // Inspect candidates without overwriting the persisted fallback. Only
197
+ // the selected session sample is written, using the resolved account key.
198
+ writeSnapshot: () => { },
199
+ });
200
+ const finishLocal = (local) => {
201
+ if (identity.accountId)
202
+ writeSnapshot('codex', identity.accountId, local);
203
+ return finish(local, 'session');
204
+ };
205
+ if (providerId === 'codex' && !apiFirst) {
206
+ const local = readLocalCodex();
207
+ if (local?.available)
208
+ return finishLocal(local);
209
+ }
210
+ let apiState = null;
211
+ if (allowApi) {
212
+ apiState = await fetchFromApi(options, identity, capturedAt);
213
+ if (apiState.available) {
214
+ if (identity.accountId) {
215
+ writeSnapshot(snapshotProviderId(providerId), identity.accountId, apiState);
216
+ }
217
+ return finish(apiState, 'api');
218
+ }
219
+ }
220
+ if (providerId === 'codex' && apiFirst) {
221
+ const local = readLocalCodex();
222
+ if (local?.available && (!snapshot || captureTimeMs(local) > captureTimeMs(snapshot))) {
223
+ return { ...finishLocal(local), failure: (0, quotaPresentation_1.describeQuotaFailure)(apiState) };
224
+ }
225
+ }
226
+ if (snapshot) {
227
+ const fallback = finish(snapshot, snapshotResolution(snapshot));
228
+ return apiState ? { ...fallback, failure: (0, quotaPresentation_1.describeQuotaFailure)(apiState) } : fallback;
229
+ }
230
+ return finish(apiState ??
231
+ unavailableState(providerId, stored
232
+ ? 'The last persisted quota sample was unavailable.'
233
+ : 'No quota data is available yet; run the provider once so a sample can be recorded.', capturedAt), 'unavailable');
234
+ }
@@ -1,4 +1,4 @@
1
- import type { QuotaState } from './quota';
1
+ import { type QuotaState } from './quota';
2
2
  import type { AccountProviderId } from './accountRegistry';
3
3
  /**
4
4
  * Storage key for a quota snapshot. Extends `AccountProviderId` with `'zai'`
@@ -6,5 +6,18 @@ import type { AccountProviderId } from './accountRegistry';
6
6
  * store still needs a stable key for the z.ai quota cache.
7
7
  */
8
8
  export type QuotaSnapshotProviderId = AccountProviderId | 'zai';
9
+ export interface QuotaSnapshotRecord {
10
+ providerId: QuotaSnapshotProviderId;
11
+ accountId: string;
12
+ quota: QuotaState;
13
+ }
9
14
  export declare function writeQuotaSnapshot(providerId: QuotaSnapshotProviderId, accountId: string, quota: QuotaState): void;
10
- export declare function readQuotaSnapshot(providerId: QuotaSnapshotProviderId, accountId: string): QuotaState | null;
15
+ /**
16
+ * Read the persisted snapshot for one provider/account.
17
+ *
18
+ * `stale: true` and `source: 'cache'` mean "not a live fetch"; the age of the
19
+ * sample is reported separately as `ageMs` and `freshness` so a reader can
20
+ * tell a ten-second-old snapshot from a two-day-old one, and the original
21
+ * origin is kept as `capturedSource` (`statusline`, `session`, or `api`).
22
+ */
23
+ export declare function readQuotaSnapshot(providerId: QuotaSnapshotProviderId, accountId: string, now?: Date): QuotaState | null;