ccdeck 3.29.10 → 3.30.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 (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +28 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
  175. package/dist/web/assets/index-C1YFI9f4.js +0 -298
  176. package/dist/web/assets/index-Ck2RAla3.css +0 -1
@@ -0,0 +1,229 @@
1
+ // What each of quota.mjs's three sources answers, in the shape the panel
2
+ // speaks.
3
+ //
4
+ // A claude-swap row, an OAuth usage body and the text `claude --print /usage`
5
+ // prints go in; the same `{ session5hPct, week7dPct, … }` comes out, whichever
6
+ // of the three it was. Nothing here reads a file, asks the network or holds
7
+ // anything between calls, and that is why it is a module of its own: quota.mjs
8
+ // decides which source to ask and when, which is state and budget, while a
9
+ // wrong number on the panel comes from one of these mappings — and a test can
10
+ // hand one a body without driving the chain that fetched it.
11
+ import { resetLabelIso } from "./reset-label.mjs";
12
+ import { resetCreditsFrom } from "./claude-reset-credits.mjs";
13
+ // One ANSI stripper for the whole deck. The private copy that used to live
14
+ // here accepted only the BEL terminator for an OSC sequence, while term.mjs's
15
+ // also accepts ESC \\ — so a hyperlink written the other legal way survived
16
+ // into text this module then parsed for quota lines.
17
+ import { stripAnsi } from "./term.mjs";
18
+
19
+ // The two windows' lengths. Every reading carries them, whichever source it
20
+ // came from — including the CLI's, which prints no length at all, and the
21
+ // zero reading quota.mjs publishes for a window that has just reset.
22
+ export const WIN_5H_SEC = 18000;
23
+ export const WIN_7D_SEC = 604800;
24
+
25
+ /**
26
+ * A reported utilisation as the whole percentage the panel draws: rounded, and
27
+ * held inside 0–100 whatever the source said.
28
+ *
29
+ * The OAuth body and the claude-swap row each carried this as a private arrow
30
+ * inside their mapping, the same one character for character. One rule written
31
+ * twice is a rule that can drift, and a clamp is where a drift prints 140% on
32
+ * a bar. The CLI's parse is not a third copy: it reads at most three digits
33
+ * off a line, so there is nothing below zero or between integers to handle.
34
+ *
35
+ * Exported for its test.
36
+ */
37
+ export function clampPct(v) {
38
+ return Math.min(100, Math.max(0, Math.round(v)));
39
+ }
40
+
41
+ // ISO-8601 → "Jun 19, 1:19pm" (local time, matching the CLI display format).
42
+ //
43
+ // The body moved to reset-label.mjs in #374: codex-quota.mjs had a copy that
44
+ // claimed in its own comment to match this one and did not, so the Codex lanes
45
+ // and the Claude lanes printed the same instant two different ways in the same
46
+ // panel. This rendering is the one both surfaces use now. The alias stays so
47
+ // the four call sites below read the way they always have.
48
+ const fmtResetIso = resetLabelIso;
49
+
50
+ function isoToSec(iso) {
51
+ if (!iso) return null;
52
+ const t = new Date(iso).getTime();
53
+ return isNaN(t) ? null : Math.floor(t / 1000);
54
+ }
55
+
56
+ // Map the OAuth usage JSON to our quota result shape.
57
+ // utilization is already a 0–100 percentage. 5h falls back to 7d if absent.
58
+ //
59
+ // A 7d window that is absent is UNKNOWN, and published as null (#1627). The
60
+ // endpoint can send `seven_day: null`, and this used to fill in a zero there —
61
+ // which the bar prints as "< 1%", a week nobody measured and a reader takes as
62
+ // almost all of it left. The same kind of zero #765 removed; the CLI's parse
63
+ // below already leaves the field out for a week line it did not see.
64
+ export function mapOAuthUsage(data) {
65
+ const fh = data?.five_hour;
66
+ const sd = data?.seven_day;
67
+ const son = data?.seven_day_sonnet;
68
+ const opus = data?.seven_day_opus;
69
+
70
+ const primary = (fh?.utilization != null) ? fh : sd;
71
+ if (!primary || primary.utilization == null) return null;
72
+
73
+ const result = {
74
+ session5hPct: clampPct(primary.utilization),
75
+ session5hWindowSec: WIN_5H_SEC,
76
+ session5hReset: fmtResetIso(primary.resets_at),
77
+ session5hResetAt: isoToSec(primary.resets_at),
78
+ week7dWindowSec: WIN_7D_SEC,
79
+ };
80
+ if (sd?.utilization != null) {
81
+ result.week7dPct = clampPct(sd.utilization);
82
+ result.week7dReset = fmtResetIso(sd.resets_at);
83
+ result.week7dResetAt = isoToSec(sd.resets_at);
84
+ } else {
85
+ result.week7dPct = null;
86
+ }
87
+ if (son?.utilization != null) result.weekSonnetPct = clampPct(son.utilization);
88
+ if (opus?.utilization != null) result.weekOpusPct = clampPct(opus.utilization);
89
+
90
+ // extra usage credits (pay-as-you-go top-up), if enabled
91
+ const extra = data?.extra_usage;
92
+ if (extra?.is_enabled) {
93
+ result.extraEnabled = true;
94
+ if (extra.used_credits != null) result.extraUsedCredits = extra.used_credits;
95
+ if (extra.monthly_limit != null) result.extraMonthlyLimit = extra.monthly_limit;
96
+ if (extra.currency) result.extraCurrency = extra.currency;
97
+ }
98
+
99
+ // Saved limit resets, from the same response and so from the same account as
100
+ // the windows above. Absent rather than null when the block says nothing
101
+ // readable, so a response without one maps exactly as it always has.
102
+ const credits = resetCreditsFrom(data?.cedar_ember, Date.now());
103
+ if (credits) result.resetCredits = credits;
104
+ return result;
105
+ }
106
+
107
+ /**
108
+ * claude-swap's row for the active account, in the shape the panel speaks.
109
+ *
110
+ * Exported for quota.mjs, which reads the store through it, and pinned by
111
+ * tests: the mapping is where a wrong number would come from, and it is pure.
112
+ */
113
+ export function quotaFromStore(entry) {
114
+ const good = entry?.lastGood;
115
+ const fh = good?.five_hour;
116
+ const sd = good?.seven_day;
117
+ const primary = (typeof fh?.pct === "number") ? fh : sd;
118
+ if (typeof primary?.pct !== "number") return null;
119
+
120
+ const out = {
121
+ ok: true,
122
+ source: "claude-swap",
123
+ session5hPct: clampPct(primary.pct),
124
+ session5hWindowSec: WIN_5H_SEC,
125
+ session5hReset: fmtResetIso(primary.resets_at),
126
+ session5hResetAt: isoToSec(primary.resets_at),
127
+ week7dWindowSec: WIN_7D_SEC,
128
+ // Unknown rather than 0 when the row has no 7d window, as mapOAuthUsage
129
+ // publishes it (#1627): claude-swap writes no `seven_day` at all when the
130
+ // endpoint sent none.
131
+ week7dPct: typeof sd?.pct === "number" ? clampPct(sd.pct) : null,
132
+ week7dReset: fmtResetIso(sd?.resets_at),
133
+ week7dResetAt: isoToSec(sd?.resets_at),
134
+ // The age of the DATA, not of our read of it. The panel prints this, and
135
+ // "30s ago" over numbers claude-swap collected twenty minutes back is the
136
+ // kind of true-looking lie this whole change exists to remove.
137
+ fetchedAt: entry.fetchedAt,
138
+ };
139
+ // claude-swap keeps per-model windows in a named list rather than fixed
140
+ // fields, because which ones an account has depends on its plan.
141
+ for (const s of Array.isArray(good.scoped) ? good.scoped : []) {
142
+ if (typeof s?.pct !== "number") continue;
143
+ if (/sonnet/i.test(s.name ?? "")) out.weekSonnetPct = clampPct(s.pct);
144
+ else if (/opus/i.test(s.name ?? "")) out.weekOpusPct = clampPct(s.pct);
145
+ }
146
+ return out;
147
+ }
148
+
149
+ // Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
150
+ // Claude shows times in the user's local timezone, so parsing as local is correct.
151
+ // `now` is injectable so the year-boundary case is testable.
152
+ export function parseResetToSec(resetStr, now = Date.now()) {
153
+ if (!resetStr) return null;
154
+ try {
155
+ // "4:09pm" → "4:09 PM" so Date.parse handles it. Minutes are optional in
156
+ // the CLI's output ("9am"); Date.parse rejects "9 AM", so supply ":00".
157
+ const norm = resetStr
158
+ .replace(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i,
159
+ (_all, h, mm, ampm) => `${h}:${mm ?? "00"} ${ampm}`)
160
+ .trim();
161
+ // The CLI prints no year, so we have to supply one. Stamping the current
162
+ // year blindly puts a "Jan 2" reset read on Dec 30 eleven months in the
163
+ // past, which hides the countdown and pins the pace marker at 100%. A
164
+ // reset is never more than a week away, so the neighbouring year that
165
+ // lands nearest to `now` is the one Claude meant.
166
+ const thisYear = new Date(now).getFullYear();
167
+ let best = null;
168
+ for (const year of [thisYear - 1, thisYear, thisYear + 1]) {
169
+ const t = new Date(`${norm} ${year}`).getTime();
170
+ if (isNaN(t)) continue;
171
+ if (best === null || Math.abs(t - now) < Math.abs(best - now)) best = t;
172
+ }
173
+ return best === null ? null : Math.floor(best / 1000);
174
+ } catch { return null; }
175
+ }
176
+
177
+ /**
178
+ * Parse `claude --print /usage` output.
179
+ *
180
+ * Observed format (Claude Code ≥ 1.x):
181
+ * "Current session: 84% used · resets Jun 18, 4:09pm (Europe/Chisinau)"
182
+ * "Current week (all models): 85% used · resets Jun 21, 8:59am (Europe/Chisinau)"
183
+ * "Current week (Sonnet only): 48% used · resets Jun 21, 9am (Europe/Chisinau)"
184
+ * "Current week (Opus only): ..." (if present)
185
+ */
186
+ export function parseUsageText(raw) {
187
+ const text = stripAnsi(raw);
188
+ const result = {};
189
+
190
+ // Helper: find "X% used · resets <rest>" on a line matching a label.
191
+ const extract = (labelRe) => {
192
+ const line = text.split("\n").find(l => labelRe.test(l));
193
+ if (!line) return null;
194
+ const pctM = line.match(/(\d{1,3})\s*%/);
195
+ const resetM = line.match(/resets\s+(.+)/i);
196
+ const resetFull = resetM
197
+ ? resetM[1].replace(/\(.*?\)/g, "").replace(/·/g, "").trim()
198
+ : null;
199
+ return {
200
+ pct: pctM ? Math.min(100, parseInt(pctM[1], 10)) : null,
201
+ reset: resetFull,
202
+ resetAt: parseResetToSec(resetFull),
203
+ };
204
+ };
205
+
206
+ const session = extract(/current session/i);
207
+ if (session?.pct != null) {
208
+ result.session5hPct = session.pct;
209
+ result.session5hWindowSec = WIN_5H_SEC;
210
+ if (session.reset) result.session5hReset = session.reset;
211
+ if (session.resetAt) result.session5hResetAt = session.resetAt;
212
+ }
213
+
214
+ const weekAll = extract(/current week\s*\(all models\)/i) || extract(/current week\s*[:·]/i);
215
+ if (weekAll?.pct != null) {
216
+ result.week7dPct = weekAll.pct;
217
+ result.week7dWindowSec = WIN_7D_SEC;
218
+ if (weekAll.reset) result.week7dReset = weekAll.reset;
219
+ if (weekAll.resetAt) result.week7dResetAt = weekAll.resetAt;
220
+ }
221
+
222
+ const weekSon = extract(/current week\s*\(sonnet/i);
223
+ if (weekSon?.pct != null) result.weekSonnetPct = weekSon.pct;
224
+
225
+ const weekOpus = extract(/current week\s*\(opus/i);
226
+ if (weekOpus?.pct != null) result.weekOpusPct = weekOpus.pct;
227
+
228
+ return Object.keys(result).length > 0 ? result : null;
229
+ }
@@ -0,0 +1,206 @@
1
+ // Claude's saved limit resets, on the store path (#1308).
2
+ //
3
+ // When claude-swap's store supplies the windows, nothing in its row says how
4
+ // many resets the account has saved, so the inventory is a read of its own
5
+ // with Claude Code's token: on a floor of its own, behind the quota read that
6
+ // starts it, and published only once the token's owner has been checked
7
+ // against the account claude-swap says is active. quota.mjs's header says why
8
+ // the other two sources need none of this; what is here is that check, the
9
+ // inventory it guards, and the floor it is read on.
10
+ import { availableResetCredits, readResetGrants } from "./claude-reset-credits.mjs";
11
+ import {
12
+ SELF_POLL_MS, USAGE_URL, cooldownUntil, oauthHeaders, readOAuthToken, startCooldown,
13
+ } from "./quota-oauth.mjs";
14
+ import { createHash } from "node:crypto";
15
+
16
+ // The store path's inventory read. `skip_spend=1` is what Claude Code adds to
17
+ // the same request, because the spend breakdown is not what it is asking for;
18
+ // neither is it here, where the windows come from claude-swap instead.
19
+ const RESETS_ONLY_URL = `${USAGE_URL}?cedar_ember=1&skip_spend=1`;
20
+ // Whose token this is. claude-swap asks the same endpoint the same question
21
+ // before it trusts a credential with an account; see tokenOwner.
22
+ const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
23
+
24
+ // Floor between two inventory reads while claude-swap supplies the windows. A
25
+ // grant is issued for a launch and lasts weeks, so this is two requests an hour
26
+ // out of a budget claude-swap plans to leave eight to ten of unspent. A forced
27
+ // refresh, or an account not tried yet, waits SELF_POLL_MS.
28
+ const CREDITS_POLL_MS = 30 * 60_000;
29
+
30
+ // How long an inventory is shown after the last read that succeeded. Expiry is
31
+ // worked out at every read, but a reset redeemed in Claude only leaves the
32
+ // inventory when a read says so, and a read that keeps failing must not keep
33
+ // promising it.
34
+ const CREDITS_TRUSTED_MS = 3 * CREDITS_POLL_MS;
35
+
36
+ // What the last successful read found, and for whom: `{ account, grants, at }`,
37
+ // where `grants` is readResetGrants's answer — a list, or null for a block that
38
+ // said nothing readable. Never an id: the parser does not keep one.
39
+ let _credits = null;
40
+ // When the store path last tried, successful or not, and for which account,
41
+ // so a failing read is on the same floor as a working one.
42
+ let _creditsTriedAt = 0;
43
+ let _creditsTriedFor = null;
44
+ let _creditsInflight = null;
45
+ // The last token whose owner was looked up, by hash, and the answer. A token
46
+ // belongs to one account for its whole life, so this is asked once per token
47
+ // rather than once per read. The token itself is not kept.
48
+ let _owner = null;
49
+
50
+ /**
51
+ * A Claude account as the two halves claude-swap keys one on, or null when
52
+ * either is missing, because half an identity cannot be matched against
53
+ * anything.
54
+ */
55
+ export function accountOf(email, org) {
56
+ const e = typeof email === "string" ? email.trim() : "";
57
+ const o = typeof org === "string" ? org.trim() : "";
58
+ return e && o ? { email: e, organizationUuid: o } : null;
59
+ }
60
+
61
+ /**
62
+ * Whether two identities are the same Claude account.
63
+ *
64
+ * Both halves, because claude-swap treats one address under two organizations
65
+ * as two accounts on purpose, and those two have separate inventories. The
66
+ * address is compared without case — it is the same mailbox either way — and
67
+ * the organization exactly.
68
+ *
69
+ * Exported for tests: this comparison is the whole of what keeps one account's
70
+ * resets off another account's card.
71
+ */
72
+ export function sameAccount(a, b) {
73
+ if (!a || !b) return false;
74
+ return a.email.toLowerCase() === b.email.toLowerCase() && a.organizationUuid === b.organizationUuid;
75
+ }
76
+
77
+ /** The held inventory, if it was read for `account` and recently enough. */
78
+ export function heldResetCredits(account, now) {
79
+ if (!_credits || !sameAccount(_credits.account, account)) return null;
80
+ if (now - _credits.at > CREDITS_TRUSTED_MS) return null;
81
+ return availableResetCredits(_credits.grants, now);
82
+ }
83
+
84
+ /**
85
+ * Whether the store path may spend a request on the inventory now.
86
+ *
87
+ * The long floor once this account has been tried, whether that worked or not
88
+ * — a profile lookup that keeps failing is not a reason to ask twelve times an
89
+ * hour. The short one for an account not tried yet, which is what an account
90
+ * switch produces, and for the refresh button.
91
+ *
92
+ * Exported for tests, like maySelfPoll, and for the same reason: it is a
93
+ * budget rule, and it is pure. The 429 cooldown is maySelfPoll's own — one
94
+ * token, one budget — so a 429 here holds source 2 off as well, and the other
95
+ * way round.
96
+ */
97
+ export function resetCreditsDue({ now, force, triedAt, rateLimitedUntil, triedThisAccount }) {
98
+ if (now < rateLimitedUntil) return false;
99
+ return now - triedAt >= (triedThisAccount && !force ? CREDITS_POLL_MS : SELF_POLL_MS);
100
+ }
101
+
102
+ /**
103
+ * The account a token belongs to, as `{ email, organizationUuid }`, or null
104
+ * when that cannot be established.
105
+ *
106
+ * `/api/oauth/profile` answers about the token it is sent, which makes it the
107
+ * one source that cannot describe a different account than the token will be
108
+ * used for. The Claude config's `oauthAccount` block would have been free, but
109
+ * it is a separate file from the credential, written at a separate moment, and
110
+ * "usually agrees" is not the bar for publishing an account's data.
111
+ */
112
+ async function tokenOwner(token) {
113
+ const key = createHash("sha256").update(token).digest("hex");
114
+ if (_owner?.key === key) return _owner.identity;
115
+ let identity = null;
116
+ try {
117
+ const res = await fetch(PROFILE_URL, {
118
+ headers: oauthHeaders(token),
119
+ cache: "no-store",
120
+ signal: AbortSignal.timeout(5_000),
121
+ });
122
+ if (res.ok) {
123
+ const body = await res.json();
124
+ identity = accountOf(body?.account?.email, body?.organization?.uuid);
125
+ }
126
+ } catch { /* unreachable or unreadable: no identity, so nothing is published */ }
127
+ if (identity) _owner = { key, identity };
128
+ return identity;
129
+ }
130
+
131
+ /**
132
+ * The inventory for `token`: readResetGrants's answer, or undefined when the
133
+ * request did not produce one — in which case whatever is held stays held.
134
+ */
135
+ async function fetchResetGrants(token) {
136
+ try {
137
+ const res = await fetch(RESETS_ONLY_URL, {
138
+ headers: oauthHeaders(token),
139
+ cache: "no-store",
140
+ signal: AbortSignal.timeout(5_000),
141
+ });
142
+ if (res.status === 429) {
143
+ startCooldown(res);
144
+ return undefined;
145
+ }
146
+ if (!res.ok) return undefined;
147
+ return readResetGrants((await res.json())?.cedar_ember);
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Read the reset inventory for the account claude-swap says is active, when
155
+ * the store is what the panel's windows came from.
156
+ *
157
+ * Best-effort in every direction, and never awaited by a quota read: it runs
158
+ * behind the read that started it, and the next read picks the answer up. It
159
+ * cannot delay, fail or change the windows, and nothing it catches is logged —
160
+ * there is nothing in a failure here the user can act on, and the one thing
161
+ * the error path must never do is print a request that carried a token.
162
+ *
163
+ * THE ACCOUNT CHECK IS THE POINT. The token is Claude Code's, from the Claude
164
+ * config dir; the windows are claude-swap's, for whichever slot it has marked
165
+ * active. Those are the same account after a `cswap switch`, and need not be
166
+ * after a `claude auth login` done in a terminal. So the token's owner is looked
167
+ * up first, and when it is not the store's active account the inventory
168
+ * request is never made. What is kept is stamped with the account it was
169
+ * checked against, and heldResetCredits compares that stamp with the store's
170
+ * active account again at every read — so a switch made anywhere, by the deck,
171
+ * by `cswap` in a terminal or by claude-swap's auto-switch, cannot carry one
172
+ * account's resets onto the next account's card.
173
+ */
174
+ export function refreshStoreResetCredits({ now, force = false, account }) {
175
+ if (_creditsInflight || !account) return;
176
+ const triedThisAccount = sameAccount(_creditsTriedFor, account);
177
+ if (!resetCreditsDue({ now, force, triedAt: _creditsTriedAt, rateLimitedUntil: cooldownUntil(), triedThisAccount })) return;
178
+ _creditsTriedAt = now;
179
+ _creditsTriedFor = account;
180
+ const run = (async () => {
181
+ const token = await readOAuthToken();
182
+ if (!token) return;
183
+ const owner = await tokenOwner(token);
184
+ if (!sameAccount(owner, account)) return;
185
+ const grants = await fetchResetGrants(token);
186
+ if (grants !== undefined) _credits = { account, grants, at: now };
187
+ })();
188
+ // Settles rather than rejects: nothing in here is news to anyone.
189
+ _creditsInflight = run.catch(() => {}).finally(() => { _creditsInflight = null; });
190
+ }
191
+
192
+ /** Exported for tests: the inventory read runs behind the quota read that
193
+ * started it, and a test has to be able to wait for it to land. */
194
+ export function resetCreditsSettled() {
195
+ return _creditsInflight ?? Promise.resolve();
196
+ }
197
+
198
+ /** The held inventory, forgotten — for invalidateQuotaCache. */
199
+ export function forgetResetCredits() { _credits = null; }
200
+
201
+ /** The inventory floor, cleared — for resetQuotaPollFloor, beside the
202
+ * self-poll floor it clears. */
203
+ export function clearResetCreditsFloor() {
204
+ _creditsTriedAt = 0;
205
+ _creditsTriedFor = null;
206
+ }