sidekick-shared 0.27.6 → 0.28.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 (269) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +58 -18
  3. package/dist/browser.d.ts +13 -2
  4. package/dist/browser.js +30 -2
  5. package/dist/claudePaths.d.ts +11 -0
  6. package/dist/claudePaths.js +61 -0
  7. package/dist/claudeProfiles.d.ts +2 -9
  8. package/dist/claudeProfiles.js +10 -25
  9. package/dist/codexPaths.d.ts +22 -0
  10. package/dist/codexPaths.js +67 -0
  11. package/dist/codexProfiles.d.ts +1 -4
  12. package/dist/codexProfiles.js +5 -43
  13. package/dist/doctor.js +2 -2
  14. package/dist/esm/accountChangeSignal.js +15 -0
  15. package/dist/esm/accountChanges.js +152 -0
  16. package/dist/esm/accountHealth.js +288 -0
  17. package/dist/esm/accountLaunch.js +145 -0
  18. package/dist/esm/accountLease.js +60 -0
  19. package/dist/esm/accountManager.js +606 -0
  20. package/dist/esm/accountRegistry.js +214 -0
  21. package/dist/esm/accountStatus.js +51 -0
  22. package/dist/esm/accountSwitch.js +74 -0
  23. package/dist/esm/accountSync.js +156 -0
  24. package/dist/esm/accountSyncTypes.js +4 -0
  25. package/dist/esm/accounts.js +1291 -0
  26. package/dist/esm/aggregation/EventAggregator.js +2034 -0
  27. package/dist/esm/aggregation/FrequencyTracker.js +69 -0
  28. package/dist/esm/aggregation/HeatmapTracker.js +89 -0
  29. package/dist/esm/aggregation/PatternExtractor.js +168 -0
  30. package/dist/esm/aggregation/costProvenance.js +31 -0
  31. package/dist/esm/aggregation/snapshot.js +161 -0
  32. package/dist/esm/aggregation/types.js +5 -0
  33. package/dist/esm/analytics/codeImpact.js +18 -0
  34. package/dist/esm/analytics/compactionLedger.js +37 -0
  35. package/dist/esm/analytics/qualityScore.js +112 -0
  36. package/dist/esm/autoSwitch.js +185 -0
  37. package/dist/esm/browser.js +34 -0
  38. package/dist/esm/claudeCredentialDoctor.js +239 -0
  39. package/dist/esm/claudeCredentialLedger.js +91 -0
  40. package/dist/esm/claudeCredentials.js +111 -0
  41. package/dist/esm/claudeLiveGuard.js +43 -0
  42. package/dist/esm/claudePaths.js +22 -0
  43. package/dist/esm/claudeProfiles.js +142 -0
  44. package/dist/esm/claudeRefreshLock.js +106 -0
  45. package/dist/esm/codexAuth.js +101 -0
  46. package/dist/esm/codexCredentialDoctor.js +265 -0
  47. package/dist/esm/codexCredentialStore.js +164 -0
  48. package/dist/esm/codexKeepAlive.js +112 -0
  49. package/dist/esm/codexPaths.js +172 -0
  50. package/dist/esm/codexProfiles.js +1251 -0
  51. package/dist/esm/codexQuota.js +564 -0
  52. package/dist/esm/codexQuotaWatcher.js +363 -0
  53. package/dist/esm/context/composer.js +71 -0
  54. package/dist/esm/context/sessionContext.js +546 -0
  55. package/dist/esm/credentialIO.js +283 -0
  56. package/dist/esm/credentials.js +49 -0
  57. package/dist/esm/doctor.js +305 -0
  58. package/dist/esm/ensureDefaultAccounts.js +62 -0
  59. package/dist/esm/errorHistory.js +56 -0
  60. package/dist/esm/extractors/errorTaxonomy.js +43 -0
  61. package/dist/esm/extractors/gatherAssets.js +32 -0
  62. package/dist/esm/extractors/sessionAssets.js +176 -0
  63. package/dist/esm/extractors/sources/claudeAssets.js +158 -0
  64. package/dist/esm/extractors/sources/codexAssets.js +234 -0
  65. package/dist/esm/extractors/tokenUsage.js +41 -0
  66. package/dist/esm/extractors/toolCall.js +142 -0
  67. package/dist/esm/failingTools.js +65 -0
  68. package/dist/esm/fileHead.js +27 -0
  69. package/dist/esm/formatters/eventHighlighter.js +332 -0
  70. package/dist/esm/formatters/noiseClassifier.js +209 -0
  71. package/dist/esm/formatters/sessionDump.js +328 -0
  72. package/dist/esm/formatters/toolSummary.js +226 -0
  73. package/dist/esm/formatting.js +82 -0
  74. package/dist/esm/handoffUrl.js +23 -0
  75. package/dist/esm/historicalStore.js +305 -0
  76. package/dist/esm/humanPrompt.js +88 -0
  77. package/dist/esm/index.js +213 -0
  78. package/dist/esm/keepAlive.js +189 -0
  79. package/dist/esm/liveCredentialGuard.js +33 -0
  80. package/dist/esm/modelAliases.js +51 -0
  81. package/dist/esm/modelCatalog.js +139 -0
  82. package/dist/esm/modelContext.js +280 -0
  83. package/dist/esm/modelInfo.js +832 -0
  84. package/dist/esm/multiProviderQuotaService.js +450 -0
  85. package/dist/esm/node.js +46 -0
  86. package/dist/esm/observedContextWindows.js +165 -0
  87. package/dist/esm/observedSessionCollector.js +939 -0
  88. package/dist/esm/parsers/changelogParser.js +71 -0
  89. package/dist/esm/parsers/codexHistory.js +89 -0
  90. package/dist/esm/parsers/codexParser.js +906 -0
  91. package/dist/esm/parsers/debugLogParser.js +126 -0
  92. package/dist/esm/parsers/jsonl.js +66 -0
  93. package/dist/esm/parsers/mcpToolName.js +28 -0
  94. package/dist/esm/parsers/openCodeParser.js +625 -0
  95. package/dist/esm/parsers/planExtractor.js +335 -0
  96. package/dist/esm/parsers/sessionActivityDetector.js +246 -0
  97. package/dist/esm/parsers/sessionPathResolver.js +713 -0
  98. package/dist/esm/parsers/subagentScanner.js +360 -0
  99. package/dist/esm/parsers/subagentStatsCache.js +45 -0
  100. package/dist/esm/parsers/subagentTraceParser.js +325 -0
  101. package/dist/esm/paths.js +134 -0
  102. package/dist/esm/peakHours.js +194 -0
  103. package/dist/esm/phrases.js +1330 -0
  104. package/dist/esm/pricingCatalog.js +247 -0
  105. package/dist/esm/processDetection.js +200 -0
  106. package/dist/esm/projectMigration.js +59 -0
  107. package/dist/esm/promptHistory.js +1221 -0
  108. package/dist/esm/providerFailure.js +362 -0
  109. package/dist/esm/providerIds.js +6 -0
  110. package/dist/esm/providerQuota.js +1 -0
  111. package/dist/esm/providerServiceStatus.js +145 -0
  112. package/dist/esm/providerServiceStatusTypes.js +1 -0
  113. package/dist/esm/providerStatus.js +117 -0
  114. package/dist/esm/providers/claudeCode.js +575 -0
  115. package/dist/esm/providers/claudeListing.js +121 -0
  116. package/dist/esm/providers/codex.js +1034 -0
  117. package/dist/esm/providers/codexDatabase.js +297 -0
  118. package/dist/esm/providers/codexRollouts.js +215 -0
  119. package/dist/esm/providers/detect.js +185 -0
  120. package/dist/esm/providers/diagnostics.js +83 -0
  121. package/dist/esm/providers/directoryListingCache.js +206 -0
  122. package/dist/esm/providers/factory.js +54 -0
  123. package/dist/esm/providers/openCode.js +2008 -0
  124. package/dist/esm/providers/openCodeDatabase.js +419 -0
  125. package/dist/esm/providers/rolloutWalker.js +252 -0
  126. package/dist/esm/providers/types.js +12 -0
  127. package/dist/esm/quota.js +182 -0
  128. package/dist/esm/quotaHistory.js +364 -0
  129. package/dist/esm/quotaPoller.js +230 -0
  130. package/dist/esm/quotaPresentation.js +97 -0
  131. package/dist/esm/quotaResolver.js +231 -0
  132. package/dist/esm/quotaSnapshots.js +130 -0
  133. package/dist/esm/quotaThresholds.js +85 -0
  134. package/dist/esm/readers/decisions.js +24 -0
  135. package/dist/esm/readers/handoff.js +20 -0
  136. package/dist/esm/readers/helpers.js +27 -0
  137. package/dist/esm/readers/history.js +9 -0
  138. package/dist/esm/readers/notes.js +48 -0
  139. package/dist/esm/readers/plans.js +225 -0
  140. package/dist/esm/readers/tasks.js +19 -0
  141. package/dist/esm/report/htmlHelpers.js +280 -0
  142. package/dist/esm/report/htmlReportGenerator.js +704 -0
  143. package/dist/esm/report/index.js +7 -0
  144. package/dist/esm/report/logo.js +2 -0
  145. package/dist/esm/report/openBrowser.js +19 -0
  146. package/dist/esm/report/sessionReportInputs.js +33 -0
  147. package/dist/esm/report/transcriptParser.js +83 -0
  148. package/dist/esm/report/types.js +4 -0
  149. package/dist/esm/schemas/accountManager.js +204 -0
  150. package/dist/esm/schemas/accountStatus.js +25 -0
  151. package/dist/esm/schemas/assistantTurn.js +74 -0
  152. package/dist/esm/schemas/index.js +25 -0
  153. package/dist/esm/schemas/observedSessionV1.js +77 -0
  154. package/dist/esm/schemas/providerFailure.js +37 -0
  155. package/dist/esm/schemas/quota.js +132 -0
  156. package/dist/esm/schemas/quotaHistory.js +40 -0
  157. package/dist/esm/schemas/sessionEvent.js +216 -0
  158. package/dist/esm/schemas/stateFile.js +75 -0
  159. package/dist/esm/schemas/tokenEstimation.js +7 -0
  160. package/dist/esm/schemas/transcript.js +86 -0
  161. package/dist/esm/schemas/usageNormalization.js +38 -0
  162. package/dist/esm/search/advancedFilter.js +197 -0
  163. package/dist/esm/search/sessionSearch.js +66 -0
  164. package/dist/esm/sessionFingerprint.js +40 -0
  165. package/dist/esm/sessionMonitor.js +198 -0
  166. package/dist/esm/sessionPreviews.js +522 -0
  167. package/dist/esm/sessionPromptHistory.js +254 -0
  168. package/dist/esm/sessionPromptSignals.js +364 -0
  169. package/dist/esm/sessionStats.js +160 -0
  170. package/dist/esm/sessionTokenTotals.js +98 -0
  171. package/dist/esm/sessionTranscripts.js +49 -0
  172. package/dist/esm/stateFile.js +119 -0
  173. package/dist/esm/statusline/BurnRateCalculator.js +40 -0
  174. package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
  175. package/dist/esm/statusline/formatter.js +122 -0
  176. package/dist/esm/statusline/index.js +8 -0
  177. package/dist/esm/terminalSync.js +95 -0
  178. package/dist/esm/testing/credentialGuard.js +197 -0
  179. package/dist/esm/testing/index.js +12 -0
  180. package/dist/esm/testing/sessionLogFixtures.js +128 -0
  181. package/dist/esm/tokenEstimation.js +67 -0
  182. package/dist/esm/tokenSummary.js +113 -0
  183. package/dist/esm/toolCategory.js +82 -0
  184. package/dist/esm/transcript.js +257 -0
  185. package/dist/esm/turns/assistantTurn.js +373 -0
  186. package/dist/esm/types/codex.js +23 -0
  187. package/dist/esm/types/decisionLog.js +5 -0
  188. package/dist/esm/types/historicalData.js +47 -0
  189. package/dist/esm/types/knowledgeNote.js +15 -0
  190. package/dist/esm/types/observedSessionV1.js +257 -0
  191. package/dist/esm/types/opencode.js +12 -0
  192. package/dist/esm/types/plan.js +7 -0
  193. package/dist/esm/types/sessionEvent.js +10 -0
  194. package/dist/esm/types/taskPersistence.js +12 -0
  195. package/dist/esm/usage/billingBlocks.js +148 -0
  196. package/dist/esm/usage/claudeUsageDedupe.js +189 -0
  197. package/dist/esm/usage/importSessionHistory.js +84 -0
  198. package/dist/esm/usage/subagentUsage.js +18 -0
  199. package/dist/esm/usage/usageEvents.js +402 -0
  200. package/dist/esm/usage/usageReports.js +202 -0
  201. package/dist/esm/usageNormalization.js +95 -0
  202. package/dist/esm/watchers/eventBridge.js +219 -0
  203. package/dist/esm/watchers/factory.js +63 -0
  204. package/dist/esm/watchers/jsonlTail.js +289 -0
  205. package/dist/esm/watchers/jsonlWatcher.js +435 -0
  206. package/dist/esm/watchers/providerReaderWatcher.js +118 -0
  207. package/dist/esm/watchers/sqliteWatcher.js +285 -0
  208. package/dist/esm/watchers/types.js +4 -0
  209. package/dist/esm/workspaceRoots.js +56 -0
  210. package/dist/esm/writers/atomic.js +375 -0
  211. package/dist/esm/writers/decisions.js +51 -0
  212. package/dist/esm/writers/notes.js +46 -0
  213. package/dist/esm/writers/tasks.js +63 -0
  214. package/dist/esm/zaiQuota.js +323 -0
  215. package/dist/esm/zaiQuotaApi.js +271 -0
  216. package/dist/esm/zaiQuotaWatcher.js +265 -0
  217. package/dist/fileHead.d.ts +22 -0
  218. package/dist/fileHead.js +33 -0
  219. package/dist/index.d.ts +16 -7
  220. package/dist/index.js +49 -15
  221. package/dist/node.d.ts +13 -1
  222. package/dist/node.js +43 -2
  223. package/dist/parsers/codexHistory.js +2 -2
  224. package/dist/parsers/codexParser.d.ts +12 -0
  225. package/dist/parsers/codexParser.js +34 -12
  226. package/dist/parsers/debugLogParser.js +2 -2
  227. package/dist/parsers/sessionPathResolver.js +5 -4
  228. package/dist/promptHistory.d.ts +32 -0
  229. package/dist/promptHistory.js +115 -59
  230. package/dist/providerFailure.d.ts +12 -6
  231. package/dist/providerFailure.js +45 -0
  232. package/dist/providers/claudeCode.js +7 -99
  233. package/dist/providers/claudeListing.d.ts +22 -0
  234. package/dist/providers/claudeListing.js +158 -0
  235. package/dist/providers/codex.d.ts +3 -1
  236. package/dist/providers/codex.js +26 -227
  237. package/dist/providers/codexRollouts.d.ts +44 -0
  238. package/dist/providers/codexRollouts.js +261 -0
  239. package/dist/providers/detect.js +4 -4
  240. package/dist/readers/plans.js +3 -3
  241. package/dist/schemas/index.d.ts +2 -0
  242. package/dist/schemas/index.js +7 -2
  243. package/dist/schemas/providerFailure.d.ts +154 -0
  244. package/dist/schemas/providerFailure.js +40 -0
  245. package/dist/sessionPromptHistory.d.ts +12 -7
  246. package/dist/sessionPromptHistory.js +15 -3
  247. package/dist/sessionPromptSignals.d.ts +9 -0
  248. package/dist/sessionPromptSignals.js +13 -0
  249. package/dist/testing/credentialGuard.d.ts +23 -0
  250. package/dist/testing/credentialGuard.js +233 -0
  251. package/dist/testing/index.d.ts +14 -0
  252. package/dist/testing/index.js +22 -0
  253. package/dist/testing/sessionLogFixtures.d.ts +28 -0
  254. package/dist/testing/sessionLogFixtures.js +169 -0
  255. package/dist/tokenEstimation.d.ts +2 -0
  256. package/dist/tokenEstimation.js +21 -11
  257. package/dist/toolCategory.d.ts +18 -0
  258. package/dist/toolCategory.js +86 -0
  259. package/dist/types/codex.d.ts +4 -0
  260. package/dist/usage/usageEvents.d.ts +16 -0
  261. package/dist/usage/usageEvents.js +53 -9
  262. package/dist/watchers/jsonlTail.d.ts +26 -1
  263. package/dist/watchers/jsonlTail.js +136 -28
  264. package/dist/watchers/jsonlWatcher.js +7 -0
  265. package/dist/watchers/providerReaderWatcher.js +1 -0
  266. package/dist/watchers/types.d.ts +6 -0
  267. package/dist/workspaceRoots.d.ts +23 -0
  268. package/dist/workspaceRoots.js +93 -0
  269. package/package.json +22 -2
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Quota poller with exponential backoff and cached fallback.
3
+ *
4
+ * Wraps fetchQuota() with automatic polling, retry logic,
5
+ * and cached state for transient failures.
6
+ *
7
+ * @module quotaPoller
8
+ */
9
+ import { fetchQuota } from './quota';
10
+ import { onAccountsChanged } from './accountChanges';
11
+ /**
12
+ * Polls Claude Max quota usage with exponential backoff on transient errors,
13
+ * cached fallback, and configurable active/idle intervals.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const poller = new QuotaPoller({
18
+ * getAccessToken: async () => readClaudeMaxAccessTokenSync()!,
19
+ * activeIntervalMs: 30_000,
20
+ * });
21
+ *
22
+ * poller.onUpdate(state => {
23
+ * console.log(`5h: ${state.fiveHour.utilization}%`);
24
+ * });
25
+ *
26
+ * poller.start();
27
+ * // later...
28
+ * poller.stop();
29
+ * ```
30
+ */
31
+ export class QuotaPoller {
32
+ activeIntervalMs;
33
+ idleIntervalMs;
34
+ maxBackoffMs;
35
+ getAccessToken;
36
+ hasAccount;
37
+ subscribeAccountsChanged;
38
+ timer = null;
39
+ listeners = [];
40
+ latest = null;
41
+ isActive = false;
42
+ consecutiveFailures = 0;
43
+ stopped = false;
44
+ polling = false;
45
+ pendingWake = false;
46
+ dormant = false;
47
+ accountSubscription = null;
48
+ constructor(options) {
49
+ this.activeIntervalMs = options.activeIntervalMs ?? 60_000;
50
+ this.idleIntervalMs = options.idleIntervalMs ?? 300_000;
51
+ this.maxBackoffMs = options.maxBackoffMs ?? 120_000;
52
+ this.getAccessToken = options.getAccessToken;
53
+ this.hasAccount = options.hasAccount;
54
+ this.subscribeAccountsChanged = options.subscribeAccountsChanged ?? onAccountsChanged;
55
+ }
56
+ /**
57
+ * Starts polling. First poll is immediate.
58
+ */
59
+ start() {
60
+ this.stopped = false;
61
+ this.isActive = true;
62
+ this.accountSubscription ??= this.subscribeAccountsChanged(() => this.wake());
63
+ this.wake();
64
+ }
65
+ /**
66
+ * Stops polling and clears timers.
67
+ */
68
+ stop() {
69
+ this.stopped = true;
70
+ this.dormant = false;
71
+ if (this.timer) {
72
+ clearTimeout(this.timer);
73
+ this.timer = null;
74
+ }
75
+ this.accountSubscription?.dispose();
76
+ this.accountSubscription = null;
77
+ }
78
+ /**
79
+ * Switches to active polling interval.
80
+ */
81
+ setActive() {
82
+ this.isActive = true;
83
+ }
84
+ /**
85
+ * Switches to idle polling interval.
86
+ */
87
+ setIdle() {
88
+ this.isActive = false;
89
+ }
90
+ /**
91
+ * Registers a callback for quota state updates.
92
+ *
93
+ * @param cb - Called with the latest QuotaState after each successful or cached poll
94
+ * @returns Disposable to unsubscribe
95
+ */
96
+ onUpdate(cb) {
97
+ this.listeners.push(cb);
98
+ return {
99
+ dispose: () => {
100
+ const idx = this.listeners.indexOf(cb);
101
+ if (idx >= 0)
102
+ this.listeners.splice(idx, 1);
103
+ },
104
+ };
105
+ }
106
+ /**
107
+ * Returns the most recent quota state, or null if never fetched.
108
+ */
109
+ getLatest() {
110
+ return this.latest;
111
+ }
112
+ async poll() {
113
+ if (this.stopped)
114
+ return;
115
+ if (this.polling) {
116
+ this.pendingWake = true;
117
+ return;
118
+ }
119
+ this.polling = true;
120
+ this.pendingWake = false;
121
+ this.dormant = false;
122
+ let nextDelayOverride;
123
+ try {
124
+ if (this.hasAccount && !(await this.hasAccount())) {
125
+ this.dormant = true;
126
+ return;
127
+ }
128
+ const token = await this.getAccessToken();
129
+ if (!token) {
130
+ if (this.hasAccount) {
131
+ // The account exists but its token was unreadable (locked keychain,
132
+ // transient credential I/O). That never changes the account
133
+ // fingerprint, so no wake would arrive — retry at the idle cadence
134
+ // instead of parking forever.
135
+ nextDelayOverride = this.idleIntervalMs;
136
+ }
137
+ else {
138
+ // Without a hasAccount probe, a missing token is the no-account
139
+ // signal; the account-change subscription wakes the poller on login.
140
+ this.dormant = true;
141
+ }
142
+ return;
143
+ }
144
+ const state = await fetchQuota(token);
145
+ if (state.available) {
146
+ this.latest = state;
147
+ this.consecutiveFailures = 0;
148
+ this.notify(state);
149
+ }
150
+ else if (state.failureKind === 'auth') {
151
+ // Keep polling at the idle cadence so signing in again recovers
152
+ // without an extension/CLI restart.
153
+ this.latest = state;
154
+ this.notify(state);
155
+ this.consecutiveFailures = 0;
156
+ nextDelayOverride = this.idleIntervalMs;
157
+ return;
158
+ }
159
+ else {
160
+ // Transient error: increment backoff, use cached state
161
+ this.consecutiveFailures++;
162
+ if (this.latest) {
163
+ // Serve cached value with the error info attached
164
+ const cached = {
165
+ ...this.latest,
166
+ error: state.error,
167
+ failureKind: state.failureKind,
168
+ };
169
+ this.notify(cached);
170
+ }
171
+ else {
172
+ this.notify(state);
173
+ }
174
+ }
175
+ }
176
+ catch (error) {
177
+ this.consecutiveFailures++;
178
+ const state = {
179
+ fiveHour: { utilization: 0, resetsAt: '' },
180
+ sevenDay: { utilization: 0, resetsAt: '' },
181
+ available: false,
182
+ error: error instanceof Error ? error.message : String(error),
183
+ failureKind: 'auth',
184
+ };
185
+ this.latest = state;
186
+ this.notify(state);
187
+ }
188
+ finally {
189
+ this.polling = false;
190
+ if (this.pendingWake) {
191
+ this.pendingWake = false;
192
+ void this.poll();
193
+ }
194
+ else if (!this.dormant) {
195
+ this.scheduleNext(nextDelayOverride);
196
+ }
197
+ }
198
+ }
199
+ wake() {
200
+ if (this.stopped)
201
+ return;
202
+ if (this.timer) {
203
+ clearTimeout(this.timer);
204
+ this.timer = null;
205
+ }
206
+ if (this.polling) {
207
+ this.pendingWake = true;
208
+ return;
209
+ }
210
+ void this.poll();
211
+ }
212
+ scheduleNext(overrideMs) {
213
+ if (this.stopped)
214
+ return;
215
+ const baseInterval = this.isActive ? this.activeIntervalMs : this.idleIntervalMs;
216
+ const backoff = Math.min(baseInterval * Math.pow(2, this.consecutiveFailures), Math.max(this.maxBackoffMs, baseInterval));
217
+ const delay = overrideMs ?? (this.consecutiveFailures > 0 ? backoff : baseInterval);
218
+ this.timer = setTimeout(() => void this.poll(), delay);
219
+ }
220
+ notify(state) {
221
+ for (const cb of this.listeners) {
222
+ try {
223
+ cb(state);
224
+ }
225
+ catch {
226
+ // Listener errors should not break the poller
227
+ }
228
+ }
229
+ }
230
+ }
@@ -0,0 +1,97 @@
1
+ function formatRetryAfter(ms) {
2
+ if (ms <= 0)
3
+ return 'now';
4
+ const totalSeconds = Math.ceil(ms / 1000);
5
+ const days = Math.floor(totalSeconds / 86_400);
6
+ const hours = Math.floor((totalSeconds % 86_400) / 3_600);
7
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
8
+ const seconds = totalSeconds % 60;
9
+ const parts = [];
10
+ if (days > 0)
11
+ parts.push(`${days}d`);
12
+ if (hours > 0)
13
+ parts.push(`${hours}h`);
14
+ if (minutes > 0 && days === 0)
15
+ parts.push(`${minutes}m`);
16
+ if (parts.length === 0)
17
+ parts.push(`${seconds}s`);
18
+ return parts.join(' ');
19
+ }
20
+ export function describeQuotaFailure(quota) {
21
+ if (!quota || quota.available || !quota.failureKind)
22
+ return null;
23
+ const isCodex = quota.providerId === 'codex';
24
+ const apiName = isCodex ? 'Codex' : 'Anthropic';
25
+ switch (quota.failureKind) {
26
+ case 'auth':
27
+ if (isCodex) {
28
+ return {
29
+ severity: 'error',
30
+ title: quota.httpStatus ? 'Codex sign-in expired' : 'Codex sign-in required',
31
+ message: quota.error ?? 'The current Codex login could not be used.',
32
+ detail: 'Run `codex login` to sign in, then retry quota refresh.',
33
+ alertKey: `auth:${quota.httpStatus ?? 'no-credentials'}`,
34
+ isRetryable: false,
35
+ };
36
+ }
37
+ if (quota.error === 'No OAuth token available') {
38
+ return {
39
+ severity: 'error',
40
+ title: 'Sign in required',
41
+ message: 'No Claude Code credentials are available in this environment.',
42
+ detail: 'Run `claude` to sign in, then retry quota refresh.',
43
+ alertKey: 'auth:no-credentials',
44
+ isRetryable: false,
45
+ };
46
+ }
47
+ return {
48
+ severity: 'error',
49
+ title: 'Claude Code sign-in expired',
50
+ message: 'The current Claude Code OAuth session was rejected.',
51
+ detail: 'Sign in again to refresh subscription quota.',
52
+ alertKey: `auth:${quota.httpStatus ?? 'unknown'}`,
53
+ isRetryable: false,
54
+ };
55
+ case 'network':
56
+ return {
57
+ severity: 'warning',
58
+ title: 'Quota API unreachable',
59
+ message: `Could not reach ${apiName} from the current environment.`,
60
+ detail: 'Check connectivity, proxy, or firewall settings, then retry.',
61
+ alertKey: 'network',
62
+ isRetryable: true,
63
+ };
64
+ case 'rate_limit':
65
+ return {
66
+ severity: 'warning',
67
+ title: 'Quota API rate limited',
68
+ message: quota.retryAfterMs != null
69
+ ? `Retry in ${formatRetryAfter(quota.retryAfterMs)}.`
70
+ : 'Retry shortly.',
71
+ detail: quota.httpStatus != null ? `${apiName} returned HTTP ${quota.httpStatus}.` : undefined,
72
+ alertKey: `rate_limit:${quota.httpStatus ?? 429}`,
73
+ isRetryable: true,
74
+ };
75
+ case 'server':
76
+ return {
77
+ severity: 'warning',
78
+ title: 'Quota API unavailable',
79
+ message: quota.httpStatus != null
80
+ ? `${apiName} returned HTTP ${quota.httpStatus}. Try again shortly.`
81
+ : `${apiName} quota data is temporarily unavailable.`,
82
+ alertKey: `server:${quota.httpStatus ?? 'unknown'}`,
83
+ isRetryable: true,
84
+ };
85
+ case 'unknown':
86
+ return {
87
+ severity: 'error',
88
+ title: 'Unexpected quota response',
89
+ message: quota.httpStatus != null
90
+ ? `${apiName} returned HTTP ${quota.httpStatus}.`
91
+ : (quota.error ?? 'Quota data could not be retrieved.'),
92
+ detail: 'This failure is not classified as retryable.',
93
+ alertKey: `unknown:${quota.httpStatus ?? 'none'}`,
94
+ isRetryable: false,
95
+ };
96
+ }
97
+ }
@@ -0,0 +1,231 @@
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 { resolveActiveClaudeAccount } from './accounts';
23
+ import { getActiveCodexAccount, resolveActiveCodexAccount } from './codexProfiles';
24
+ import { enrichCodexQuota, fetchCodexQuotaFromApi, resolveCodexQuotaFromLocalSources, } from './codexQuota';
25
+ import { readClaudeMaxCredentials } from './credentials';
26
+ import { classifyQuotaFreshness, fetchQuota, withQuotaProjections } from './quota';
27
+ import { describeQuotaFailure } from './quotaPresentation';
28
+ import { readQuotaSnapshot, writeQuotaSnapshot } from './quotaSnapshots';
29
+ import { enrichZaiQuota, fetchZaiQuotaFromApi } from './zaiQuotaApi';
30
+ const ZAI_DEFAULT_ACCOUNT_ID = 'default';
31
+ const CLAUDE_NO_CREDENTIALS_ERROR = 'No OAuth token available';
32
+ function snapshotProviderId(providerId) {
33
+ return providerId;
34
+ }
35
+ function defaultResolveCodexAccount(options) {
36
+ // Self-heal the saved pointer to the live login first so the snapshot key and
37
+ // the display identity both track reality after a native `codex login`.
38
+ resolveActiveCodexAccount(options);
39
+ return getActiveCodexAccount();
40
+ }
41
+ function resolveIdentity(options) {
42
+ const selfHeal = options.selfHeal ?? true;
43
+ switch (options.providerId) {
44
+ case 'claude-code': {
45
+ const account = (options.resolveClaudeAccount ?? resolveActiveClaudeAccount)({ selfHeal });
46
+ return {
47
+ accountId: options.accountId ?? account.registryAccountId,
48
+ codexAccount: null,
49
+ accountLabel: account.label ?? account.email,
50
+ accountDetail: account.email,
51
+ };
52
+ }
53
+ case 'codex': {
54
+ const profile = (options.resolveCodexAccount ?? defaultResolveCodexAccount)({ selfHeal });
55
+ return {
56
+ accountId: options.accountId ?? profile?.id,
57
+ codexAccount: profile,
58
+ accountLabel: profile?.label,
59
+ accountDetail: profile?.email,
60
+ };
61
+ }
62
+ case 'zai':
63
+ return { accountId: options.accountId ?? ZAI_DEFAULT_ACCOUNT_ID, codexAccount: null };
64
+ }
65
+ }
66
+ async function defaultClaudeAccessToken() {
67
+ const credentials = await readClaudeMaxCredentials();
68
+ return credentials?.accessToken ?? null;
69
+ }
70
+ function zeroWindows() {
71
+ return { fiveHour: { utilization: 0, resetsAt: '' }, sevenDay: { utilization: 0, resetsAt: '' } };
72
+ }
73
+ function unavailableState(providerId, error, capturedAt) {
74
+ return {
75
+ ...zeroWindows(),
76
+ available: false,
77
+ error,
78
+ failureKind: 'unknown',
79
+ providerId,
80
+ capturedAt,
81
+ ...(providerId === 'codex' ? { fiveHourLabel: 'Primary', sevenDayLabel: 'Secondary' } : {}),
82
+ };
83
+ }
84
+ async function fetchFromApi(options, identity, capturedAt) {
85
+ switch (options.providerId) {
86
+ case 'claude-code': {
87
+ const token = await (options.getClaudeAccessToken ?? defaultClaudeAccessToken)();
88
+ if (!token) {
89
+ return {
90
+ ...zeroWindows(),
91
+ available: false,
92
+ error: CLAUDE_NO_CREDENTIALS_ERROR,
93
+ failureKind: 'auth',
94
+ providerId: 'claude-code',
95
+ source: 'api',
96
+ capturedAt,
97
+ };
98
+ }
99
+ const state = await fetchQuota(token, { fetchImpl: options.fetchImpl });
100
+ return { ...state, providerId: 'claude-code', source: 'api', capturedAt };
101
+ }
102
+ case 'codex':
103
+ return fetchCodexQuotaFromApi({
104
+ codexHome: options.codexHome,
105
+ fetchImpl: options.fetchImpl,
106
+ accessToken: options.codexAccessToken,
107
+ accountId: identity.codexAccount?.providerAccountId,
108
+ capturedAt,
109
+ });
110
+ case 'zai':
111
+ return fetchZaiQuotaFromApi({ ...options.zai, fetchImpl: options.fetchImpl, capturedAt });
112
+ }
113
+ }
114
+ function withAge(state, now) {
115
+ if (state.ageMs !== undefined && state.freshness !== undefined)
116
+ return state;
117
+ const capturedMs = state.capturedAt ? Date.parse(state.capturedAt) : NaN;
118
+ const ageMs = Number.isFinite(capturedMs) ? Math.max(0, now.getTime() - capturedMs) : undefined;
119
+ return {
120
+ ...state,
121
+ ...(ageMs !== undefined ? { ageMs } : {}),
122
+ freshness: classifyQuotaFreshness(ageMs),
123
+ };
124
+ }
125
+ function enrich(providerId, state, identity) {
126
+ switch (providerId) {
127
+ case 'claude-code':
128
+ return {
129
+ ...withQuotaProjections(state),
130
+ runtimeProvider: 'claude',
131
+ providerId: 'claude-code',
132
+ accountLabel: identity.accountLabel,
133
+ accountDetail: identity.accountDetail,
134
+ };
135
+ case 'codex':
136
+ return enrichCodexQuota(state, identity.codexAccount);
137
+ default:
138
+ return enrichZaiQuota(state);
139
+ }
140
+ }
141
+ function snapshotResolution(state) {
142
+ if (state.freshness === 'fresh')
143
+ return 'snapshot-fresh';
144
+ if (state.freshness === 'aging')
145
+ return 'snapshot-aging';
146
+ return 'snapshot-stale';
147
+ }
148
+ function captureTimeMs(state) {
149
+ const capturedMs = state.capturedAt ? Date.parse(state.capturedAt) : NaN;
150
+ return Number.isFinite(capturedMs) ? capturedMs : Number.NEGATIVE_INFINITY;
151
+ }
152
+ /**
153
+ * Resolve the current quota for one provider with the shared precedence
154
+ * described at the top of this file.
155
+ *
156
+ * The result always has `runtimeProvider`, `resolution`, `freshness`, and
157
+ * (when a capture time is known) `ageMs`; `failure` describes an API failure
158
+ * even when older numbers were returned from a snapshot.
159
+ */
160
+ export async function resolveQuota(options) {
161
+ const providerId = options.providerId;
162
+ const now = options.now ?? new Date();
163
+ const capturedAt = now.toISOString();
164
+ const preferFresh = options.preferFresh ?? true;
165
+ const allowApi = options.allowApi ?? true;
166
+ const apiFirst = !preferFresh && allowApi;
167
+ const readSnapshot = options.readSnapshot ?? readQuotaSnapshot;
168
+ const writeSnapshot = options.writeSnapshot ?? writeQuotaSnapshot;
169
+ const identity = resolveIdentity(options);
170
+ const finish = (state, resolution) => {
171
+ const enriched = withAge(enrich(providerId, state, identity), now);
172
+ return {
173
+ ...enriched,
174
+ failure: enriched.failure ?? describeQuotaFailure(enriched),
175
+ resolution,
176
+ };
177
+ };
178
+ const stored = identity.accountId
179
+ ? readSnapshot(snapshotProviderId(providerId), identity.accountId, now)
180
+ : null;
181
+ const snapshot = stored?.available ? stored : null;
182
+ if (preferFresh && snapshot && snapshot.freshness === 'fresh') {
183
+ return finish(snapshot, 'snapshot-fresh');
184
+ }
185
+ const readLocalCodex = () => resolveCodexQuotaFromLocalSources({
186
+ workspacePath: options.workspacePath,
187
+ provider: options.codexProvider,
188
+ activeAccount: identity.codexAccount,
189
+ codexHome: options.codexHome,
190
+ // Only rollout hits count as "session-derived"; the snapshot fallback is
191
+ // handled below so it can be labelled by age.
192
+ readSnapshot: () => null,
193
+ // Inspect candidates without overwriting the persisted fallback. Only
194
+ // the selected session sample is written, using the resolved account key.
195
+ writeSnapshot: () => { },
196
+ });
197
+ const finishLocal = (local) => {
198
+ if (identity.accountId)
199
+ writeSnapshot('codex', identity.accountId, local);
200
+ return finish(local, 'session');
201
+ };
202
+ if (providerId === 'codex' && !apiFirst) {
203
+ const local = readLocalCodex();
204
+ if (local?.available)
205
+ return finishLocal(local);
206
+ }
207
+ let apiState = null;
208
+ if (allowApi) {
209
+ apiState = await fetchFromApi(options, identity, capturedAt);
210
+ if (apiState.available) {
211
+ if (identity.accountId) {
212
+ writeSnapshot(snapshotProviderId(providerId), identity.accountId, apiState);
213
+ }
214
+ return finish(apiState, 'api');
215
+ }
216
+ }
217
+ if (providerId === 'codex' && apiFirst) {
218
+ const local = readLocalCodex();
219
+ if (local?.available && (!snapshot || captureTimeMs(local) > captureTimeMs(snapshot))) {
220
+ return { ...finishLocal(local), failure: describeQuotaFailure(apiState) };
221
+ }
222
+ }
223
+ if (snapshot) {
224
+ const fallback = finish(snapshot, snapshotResolution(snapshot));
225
+ return apiState ? { ...fallback, failure: describeQuotaFailure(apiState) } : fallback;
226
+ }
227
+ return finish(apiState ??
228
+ unavailableState(providerId, stored
229
+ ? 'The last persisted quota sample was unavailable.'
230
+ : 'No quota data is available yet; run the provider once so a sample can be recorded.', capturedAt), 'unavailable');
231
+ }
@@ -0,0 +1,130 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { getConfigDir } from './paths';
4
+ import { atomicWriteJsonSync, withFileLockSync } from './writers/atomic';
5
+ import { classifyQuotaFreshness } from './quota';
6
+ import { isAggregateCodexLimit } from './types/codex';
7
+ function getQuotaSnapshotPath() {
8
+ return path.join(getConfigDir(), 'quota-snapshots.json');
9
+ }
10
+ function ensureConfigDir() {
11
+ fs.mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
12
+ }
13
+ function readStore() {
14
+ try {
15
+ const parsed = JSON.parse(fs.readFileSync(getQuotaSnapshotPath(), 'utf8'));
16
+ if (parsed?.version === 1 && Array.isArray(parsed.snapshots)) {
17
+ return parsed;
18
+ }
19
+ }
20
+ catch {
21
+ // Ignore absent or malformed store.
22
+ }
23
+ return {
24
+ version: 1,
25
+ snapshots: [],
26
+ };
27
+ }
28
+ function writeStore(store) {
29
+ ensureConfigDir();
30
+ atomicWriteJsonSync(getQuotaSnapshotPath(), store);
31
+ }
32
+ /**
33
+ * Serializes snapshot read-modify-write cycles across processes on the shared
34
+ * store lock. The lock file path is unchanged from the previous local
35
+ * implementation, so processes running older versions still exclude correctly.
36
+ */
37
+ function withSnapshotStoreLock(operation) {
38
+ ensureConfigDir();
39
+ return withFileLockSync(`${getQuotaSnapshotPath()}.lock`, operation);
40
+ }
41
+ function snapshotTimeMs(quota) {
42
+ const capturedAt = quota.capturedAt ? Date.parse(quota.capturedAt) : NaN;
43
+ return Number.isFinite(capturedAt) ? capturedAt : 0;
44
+ }
45
+ function windowResetMs(value) {
46
+ const ms = Date.parse(value);
47
+ return Number.isFinite(ms) ? ms : 0;
48
+ }
49
+ // Preserve the best-known same-window snapshot while still allowing lower
50
+ // utilization after Codex advances to a newer reset window.
51
+ function shouldKeepExistingSnapshot(existing, next) {
52
+ // Family rank first (Codex): the aggregate plan-quota family ("codex") must never be
53
+ // replaced or blocked by a model/feature-specific family (e.g. codex_bengalfox), whose
54
+ // later-resetting 0% window would otherwise win the reset-window comparison below. This
55
+ // is a no-op for providers whose limitId is consistent across samples (Claude: absent;
56
+ // z.ai: zai-*), where both sides share the same aggregate-ness.
57
+ const existingAggregate = isAggregateCodexLimit(existing.limitId);
58
+ const nextAggregate = isAggregateCodexLimit(next.limitId);
59
+ if (existingAggregate !== nextAggregate)
60
+ return existingAggregate;
61
+ const existingPrimaryReset = windowResetMs(existing.fiveHour.resetsAt);
62
+ const nextPrimaryReset = windowResetMs(next.fiveHour.resetsAt);
63
+ if (existingPrimaryReset !== nextPrimaryReset)
64
+ return existingPrimaryReset > nextPrimaryReset;
65
+ const existingSecondaryReset = windowResetMs(existing.sevenDay.resetsAt);
66
+ const nextSecondaryReset = windowResetMs(next.sevenDay.resetsAt);
67
+ if (existingSecondaryReset !== nextSecondaryReset)
68
+ return existingSecondaryReset > nextSecondaryReset;
69
+ const existingUtilization = existing.fiveHour.utilization + existing.sevenDay.utilization;
70
+ const nextUtilization = next.fiveHour.utilization + next.sevenDay.utilization;
71
+ if (existingUtilization !== nextUtilization)
72
+ return existingUtilization > nextUtilization;
73
+ return snapshotTimeMs(existing) > snapshotTimeMs(next);
74
+ }
75
+ export function writeQuotaSnapshot(providerId, accountId, quota) {
76
+ withSnapshotStoreLock(() => {
77
+ const store = readStore();
78
+ const index = store.snapshots.findIndex((item) => item.providerId === providerId && item.accountId === accountId);
79
+ const existingQuota = index >= 0 ? store.snapshots[index].quota : undefined;
80
+ // `ageMs`, `freshness`, `stale`, and `capturedSource` are read-time facts: a
81
+ // sample that came back from `readQuotaSnapshot()` and is written again keeps
82
+ // its original source rather than being stored as `cache`.
83
+ const { capturedSource, ...sample } = quota;
84
+ delete sample.ageMs;
85
+ delete sample.freshness;
86
+ const snapshot = {
87
+ ...sample,
88
+ providerId,
89
+ capturedAt: quota.capturedAt ?? new Date().toISOString(),
90
+ source: sample.source && sample.source !== 'cache' ? sample.source : (capturedSource ?? 'session'),
91
+ stale: false,
92
+ resetCredits: quota.resetCredits ?? (providerId === 'codex' ? existingQuota?.resetCredits : undefined),
93
+ };
94
+ if (index >= 0 && shouldKeepExistingSnapshot(store.snapshots[index].quota, snapshot))
95
+ return;
96
+ const record = { providerId, accountId, quota: snapshot };
97
+ if (index >= 0)
98
+ store.snapshots[index] = record;
99
+ else
100
+ store.snapshots.push(record);
101
+ writeStore(store);
102
+ });
103
+ }
104
+ /**
105
+ * Read the persisted snapshot for one provider/account.
106
+ *
107
+ * `stale: true` and `source: 'cache'` mean "not a live fetch"; the age of the
108
+ * sample is reported separately as `ageMs` and `freshness` so a reader can
109
+ * tell a ten-second-old snapshot from a two-day-old one, and the original
110
+ * origin is kept as `capturedSource` (`statusline`, `session`, or `api`).
111
+ */
112
+ export function readQuotaSnapshot(providerId, accountId, now = new Date()) {
113
+ const store = readStore();
114
+ const snapshot = store.snapshots.find((item) => item.providerId === providerId && item.accountId === accountId);
115
+ if (!snapshot)
116
+ return null;
117
+ const capturedMs = snapshotTimeMs(snapshot.quota);
118
+ const ageMs = capturedMs > 0 ? Math.max(0, now.getTime() - capturedMs) : undefined;
119
+ const storedSource = snapshot.quota.capturedSource ?? snapshot.quota.source;
120
+ const capturedSource = storedSource && storedSource !== 'cache' ? storedSource : undefined;
121
+ return {
122
+ ...snapshot.quota,
123
+ providerId,
124
+ source: 'cache',
125
+ ...(capturedSource ? { capturedSource } : {}),
126
+ stale: true,
127
+ ...(ageMs !== undefined ? { ageMs } : {}),
128
+ freshness: classifyQuotaFreshness(ageMs),
129
+ };
130
+ }