ccdeck 3.29.11 → 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 +22 -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-CyAFTvAY.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  175. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  176. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -27,66 +27,40 @@
27
27
  //
28
28
  // 2 and 3 are rate-floored (SELF_POLL_MS) and gated behind the same 429
29
29
  // cooldown; 1 is not, because it is a local file read.
30
+ //
31
+ // CLAUDE'S SAVED LIMIT RESETS (#1308) ride on source 2 rather than being a
32
+ // fourth source. The usage endpoint returns them in a `cedar_ember` block when
33
+ // the request asks with `cedar_ember=1` — which is what Claude Code itself
34
+ // sends — so on source 2 they arrive in the very response the windows come
35
+ // from: no extra request, and no way for them to belong to a different account
36
+ // than the bars beside them. Neither of the other two sources carries them:
37
+ // claude-swap does not ask for the block, and `claude --print /usage` does not
38
+ // print it. On source 1 they are therefore a separate read with the same
39
+ // token, floored at CREDITS_POLL_MS, and published only once the token's owner
40
+ // has been checked against the account claude-swap says is active — see
41
+ // refreshStoreResetCredits. Source 3 gets none: it runs when there is no token
42
+ // to read with, or when the token is in a 429 cooldown.
43
+ //
44
+ // There is no claude.ai web session here, on purpose. The organization
45
+ // endpoint Claude's own Settings → Usage page reads is reported to refuse an
46
+ // OAuth token (`403 oauth_token_not_accepted`), so reaching it would take the
47
+ // browser's session cookie, and a browser's cookie store is not something this
48
+ // deck reads.
49
+ //
50
+ // WHERE THE PARTS LIVE. This module is the chain: which source to ask, on what
51
+ // floor, and what to publish. Each source's answer is mapped to the panel's
52
+ // shape in quota-shape.mjs; the token, source 2's request and the cooldown are
53
+ // quota-oauth.mjs's; running source 3 is quota-cli.mjs's; and the store path's
54
+ // reset inventory is quota-store-resets.mjs's.
30
55
  import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
31
- import { claudeCliCandidates, claudeConfigDir } from "./claude-dir.mjs";
32
- import { pathLookup, run } from "./exec.mjs";
33
- import { existsSync } from "node:fs";
34
- import { readFile } from "node:fs/promises";
35
- import { join } from "node:path";
36
- import { homedir } from "node:os";
37
- import { PRODUCT } from "./brand.mjs";
38
- // One ANSI stripper for the whole deck. The private copy that used to live
39
- // here accepted only the BEL terminator for an OSC sequence, while term.mjs's
40
- // also accepts ESC \\ — so a hyperlink written the other legal way survived
41
- // into text this module then parsed for quota lines.
42
- import { stripAnsi } from "./term.mjs";
43
- import { resetLabelIso } from "./reset-label.mjs";
44
-
45
- const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
46
- const BETA_HEADER = "oauth-2025-04-20";
47
- const WIN_5H_SEC = 18000;
48
- const WIN_7D_SEC = 604800;
49
-
50
- // 429 cooldown gate — after a rate-limit, skip the API until this passes.
51
- let _rateLimitedUntil = 0;
52
-
53
- /**
54
- * Where Claude Code keeps the OAuth credentials this module borrows a token
55
- * from.
56
- *
57
- * It is `.credentials.json` inside the Claude config dir, and that dir moves:
58
- * CLAUDE_CONFIG_DIR replaces ~/.claude wholesale rather than overlaying it, so
59
- * on a machine where it is set there is no ~/.claude to read at all. Hardcoding
60
- * ~/.claude here did not fail loudly — it made readOAuthToken() return null
61
- * forever, which reads exactly like "this machine keeps its credentials in the
62
- * Keychain", and the quota chain quietly fell through to source 3 on every poll
63
- * it was allowed to make. See src/server/claude-dir.mjs, which owns the rule
64
- * and is the only place it is spelled.
65
- *
66
- * Resolved per call rather than frozen into a module-level constant, for the
67
- * same reason claudeConfigDir() is a function: a constant captured at import
68
- * time is a value nothing can observe or correct afterwards, and this module is
69
- * imported lazily by the /api/quota route rather than at a point in startup
70
- * anyone here controls.
71
- *
72
- * Exported for tests — it is the whole of the bug, and it is pure.
73
- */
74
- export function credentialsPath() {
75
- return join(claudeConfigDir(), ".credentials.json");
76
- }
77
-
78
- async function readOAuthToken() {
79
- try {
80
- const raw = await readFile(credentialsPath(), "utf8");
81
- const auth = JSON.parse(raw)?.claudeAiOauth;
82
- if (!auth?.accessToken) return null;
83
- // expiresAt is epoch milliseconds. If expired, the CLI fallback handles it.
84
- if (auth.expiresAt && Date.now() >= auth.expiresAt) return null;
85
- return auth.accessToken;
86
- } catch {
87
- return null;
88
- }
89
- }
56
+ import { quotaFromStore, WIN_5H_SEC, WIN_7D_SEC } from "./quota-shape.mjs";
57
+ import {
58
+ SELF_POLL_MS, clearCooldown, coolingDown, cooldownUntil, fetchOAuthUsage, hasSubscriptionCredential,
59
+ } from "./quota-oauth.mjs";
60
+ import {
61
+ accountOf, clearResetCreditsFloor, forgetResetCredits, heldResetCredits, refreshStoreResetCredits,
62
+ } from "./quota-store-resets.mjs";
63
+ import { runUsageOnce, quotaClaudeBin } from "./quota-cli.mjs";
90
64
 
91
65
  /**
92
66
  * Whether we may spend a request of the user's budget right now.
@@ -101,144 +75,6 @@ export function maySelfPoll({ now, force, lastSelfPollAt, rateLimitedUntil }) {
101
75
 
102
76
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
103
77
 
104
- /**
105
- * A cooldown from a `retry-after`, kept inside limits the deck can live with.
106
- *
107
- * Unclamped, the header decided the poller's fate in both directions: `0` (or a
108
- * value the server rounds down to it) defeats the cooldown entirely and the
109
- * next tick asks again immediately, which is the loop a 429 exists to stop; a
110
- * large one — a day is a legal value — freezes the reader for the life of the
111
- * process, and nothing here re-reads it. Both are the remote side deciding how
112
- * this deck behaves, which a header is not entitled to do.
113
- *
114
- * The floor is the deck's own minimum backoff and the ceiling is an hour: long
115
- * enough to be a real retreat, short enough that a quota panel is not dead for
116
- * the rest of the day because one reply said so.
117
- */
118
- export function cooldownFromHeader(raw, fallbackMs, minMs = 30_000, maxMs = 3600_000) {
119
- const seconds = parseInt(String(raw ?? ""), 10);
120
- if (!Number.isFinite(seconds)) return fallbackMs;
121
- return Math.min(Math.max(seconds * 1000, minMs), maxMs);
122
- }
123
-
124
- /**
125
- * WHETHER THIS MACHINE HAS A SUBSCRIPTION TO REPORT ON AT ALL.
126
- *
127
- * Every source here needs a Claude.ai OAuth credential: the claude-swap store
128
- * holds one, `claudeAiOauth` in the credentials file is one, and
129
- * `claude --print /usage` prints windows only for a session signed in with one.
130
- * An API-key, Bedrock or Vertex install has none — and there is no quota to
131
- * read, because those are billed per token rather than in five-hour windows.
132
- *
133
- * That mattered because of what the CLI does on such a machine: it RUNS, prints
134
- * no quota lines, and the branch below used to read that as "genuine <1%" and
135
- * publish `ok: true` with two zeroes. The panel then drew empty bars, which is
136
- * a measurement nobody took. Codex already answers this properly, with
137
- * `api_key_mode` as its own reason and its own sentence.
138
- *
139
- * Cheap and synchronous: environment first, because a machine configured for
140
- * Bedrock or Vertex says so there, then the presence of the OAuth block in the
141
- * credentials file. `readOAuthToken` above answers a different question — it
142
- * also rejects an EXPIRED token, and an expired subscription is still a
143
- * subscription.
144
- */
145
- export async function hasSubscriptionCredential(env = process.env) {
146
- if (env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_VERTEX === "1") return false;
147
- try {
148
- const raw = await readFile(credentialsPath(), "utf8");
149
- if (JSON.parse(raw)?.claudeAiOauth?.accessToken) return true;
150
- } catch { /* absent or unreadable, decided below */ }
151
- // A key in the environment and no OAuth block beside it is the API-key
152
- // install. Without either, this deck simply has not been signed in yet, and
153
- // "sign in" is the right thing to say — which is the `waiting` branch, not
154
- // this one.
155
- return !(env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN);
156
- }
157
-
158
- // ISO-8601 → "Jun 19, 1:19pm" (local time, matching the CLI display format).
159
- //
160
- // The body moved to reset-label.mjs in #374: codex-quota.mjs had a copy that
161
- // claimed in its own comment to match this one and did not, so the Codex lanes
162
- // and the Claude lanes printed the same instant two different ways in the same
163
- // panel. This rendering is the one both surfaces use now. The alias stays so
164
- // the four call sites below read the way they always have.
165
- const fmtResetIso = resetLabelIso;
166
-
167
- function isoToSec(iso) {
168
- if (!iso) return null;
169
- const t = new Date(iso).getTime();
170
- return isNaN(t) ? null : Math.floor(t / 1000);
171
- }
172
-
173
- // Map the OAuth usage JSON to our quota result shape.
174
- // utilization is already a 0–100 percentage. 5h falls back to 7d if absent.
175
- function mapOAuthUsage(data) {
176
- const fh = data?.five_hour;
177
- const sd = data?.seven_day;
178
- const son = data?.seven_day_sonnet;
179
- const opus = data?.seven_day_opus;
180
-
181
- const primary = (fh?.utilization != null) ? fh : sd;
182
- if (!primary || primary.utilization == null) return null;
183
-
184
- const round = (v) => Math.min(100, Math.max(0, Math.round(v)));
185
- const result = {
186
- session5hPct: round(primary.utilization),
187
- session5hWindowSec: WIN_5H_SEC,
188
- session5hReset: fmtResetIso(primary.resets_at),
189
- session5hResetAt: isoToSec(primary.resets_at),
190
- week7dWindowSec: WIN_7D_SEC,
191
- };
192
- if (sd?.utilization != null) {
193
- result.week7dPct = round(sd.utilization);
194
- result.week7dReset = fmtResetIso(sd.resets_at);
195
- result.week7dResetAt = isoToSec(sd.resets_at);
196
- } else {
197
- result.week7dPct = 0;
198
- }
199
- if (son?.utilization != null) result.weekSonnetPct = round(son.utilization);
200
- if (opus?.utilization != null) result.weekOpusPct = round(opus.utilization);
201
-
202
- // extra usage credits (pay-as-you-go top-up), if enabled
203
- const extra = data?.extra_usage;
204
- if (extra?.is_enabled) {
205
- result.extraEnabled = true;
206
- if (extra.used_credits != null) result.extraUsedCredits = extra.used_credits;
207
- if (extra.monthly_limit != null) result.extraMonthlyLimit = extra.monthly_limit;
208
- if (extra.currency) result.extraCurrency = extra.currency;
209
- }
210
- return result;
211
- }
212
-
213
- async function fetchOAuthUsage() {
214
- if (Date.now() < _rateLimitedUntil) return null;
215
- const token = await readOAuthToken();
216
- if (!token) return null;
217
-
218
- try {
219
- const res = await fetch(USAGE_URL, {
220
- headers: {
221
- "Authorization": `Bearer ${token}`,
222
- "anthropic-beta": BETA_HEADER,
223
- "Accept": "application/json",
224
- "Content-Type": "application/json",
225
- "User-Agent": "claude-code/2.1.0",
226
- },
227
- signal: AbortSignal.timeout(15_000),
228
- });
229
-
230
- if (res.status === 429) {
231
- _rateLimitedUntil = Date.now() + cooldownFromHeader(res.headers.get("retry-after"), 5 * 60_000);
232
- return null;
233
- }
234
- if (!res.ok) return null;
235
-
236
- return mapOAuthUsage(await res.json());
237
- } catch {
238
- return null;
239
- }
240
- }
241
-
242
78
  let _cache = null;
243
79
  let _cacheAt = 0;
244
80
  let _inflight = null; // deduplicates concurrent CLI probes
@@ -252,13 +88,22 @@ let _generation = 0;
252
88
 
253
89
  const CACHE_MS = 60_000;
254
90
 
255
- // Floor between two polls WE pay for. Twelve an hour against a budget of
256
- // ~28-30 leaves claude-swap room to collect for every account, which is what
257
- // the accounts panel is made of. Only reached when the store cannot answer.
258
- const SELF_POLL_MS = 5 * 60_000;
259
-
260
- // The refresh button may beat that floor, but not turn into a poll loop when
261
- // held down. It never beats the 429 cooldown.
91
+ // How long an answer with no fresh numbers in it stays cached: the "no reading
92
+ // yet" of the poll-floor branch, and both answers _doFetch gives when the CLI
93
+ // printed no windows — the last good reading held over, or the zero or failure
94
+ // when there never was one. A whole CACHE_MS would keep the panel on one of
95
+ // those for a minute when the next attempt may well succeed, so they are
96
+ // stamped CACHE_MS - SHORT_CACHE_MS in the past and expire this long after they
97
+ // were written.
98
+ const SHORT_CACHE_MS = 5_000;
99
+
100
+ /** The cache stamp that makes an answer published at `now` expire
101
+ * SHORT_CACHE_MS later. */
102
+ const shortLived = (now) => now - (CACHE_MS - SHORT_CACHE_MS);
103
+
104
+ // The refresh button may beat SELF_POLL_MS — the floor for polls we pay for,
105
+ // kept in quota-oauth.mjs beside the cooldown — but not turn into a poll loop
106
+ // when held down. It never beats the 429 cooldown.
262
107
  const FORCE_POLL_MS = 60_000;
263
108
 
264
109
  // How old a claude-swap row may be before we stop treating it as the answer.
@@ -295,217 +140,6 @@ const STORE_TRUSTED_MS = 45 * 60_000;
295
140
  */
296
141
  export const QUOTA_DEADLINE_MS = 5_000;
297
142
 
298
- /**
299
- * claude-swap's row for the active account, in the shape the panel speaks.
300
- *
301
- * Exported for tests: the mapping is where a wrong number would come from, and
302
- * it is pure.
303
- */
304
- export function quotaFromStore(entry) {
305
- const good = entry?.lastGood;
306
- const fh = good?.five_hour;
307
- const sd = good?.seven_day;
308
- const primary = (typeof fh?.pct === "number") ? fh : sd;
309
- if (typeof primary?.pct !== "number") return null;
310
-
311
- const round = (v) => Math.min(100, Math.max(0, Math.round(v)));
312
- const out = {
313
- ok: true,
314
- source: "claude-swap",
315
- session5hPct: round(primary.pct),
316
- session5hWindowSec: WIN_5H_SEC,
317
- session5hReset: fmtResetIso(primary.resets_at),
318
- session5hResetAt: isoToSec(primary.resets_at),
319
- week7dWindowSec: WIN_7D_SEC,
320
- week7dPct: typeof sd?.pct === "number" ? round(sd.pct) : 0,
321
- week7dReset: fmtResetIso(sd?.resets_at),
322
- week7dResetAt: isoToSec(sd?.resets_at),
323
- // The age of the DATA, not of our read of it. The panel prints this, and
324
- // "30s ago" over numbers claude-swap collected twenty minutes back is the
325
- // kind of true-looking lie this whole change exists to remove.
326
- fetchedAt: entry.fetchedAt,
327
- };
328
- // claude-swap keeps per-model windows in a named list rather than fixed
329
- // fields, because which ones an account has depends on its plan.
330
- for (const s of Array.isArray(good.scoped) ? good.scoped : []) {
331
- if (typeof s?.pct !== "number") continue;
332
- if (/sonnet/i.test(s.name ?? "")) out.weekSonnetPct = round(s.pct);
333
- else if (/opus/i.test(s.name ?? "")) out.weekOpusPct = round(s.pct);
334
- }
335
- return out;
336
- }
337
-
338
-
339
- // Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
340
- // Claude shows times in the user's local timezone, so parsing as local is correct.
341
- // `now` is injectable so the year-boundary case is testable.
342
- export function parseResetToSec(resetStr, now = Date.now()) {
343
- if (!resetStr) return null;
344
- try {
345
- // "4:09pm" → "4:09 PM" so Date.parse handles it. Minutes are optional in
346
- // the CLI's output ("9am"); Date.parse rejects "9 AM", so supply ":00".
347
- const norm = resetStr
348
- .replace(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i,
349
- (_all, h, mm, ampm) => `${h}:${mm ?? "00"} ${ampm}`)
350
- .trim();
351
- // The CLI prints no year, so we have to supply one. Stamping the current
352
- // year blindly puts a "Jan 2" reset read on Dec 30 eleven months in the
353
- // past, which hides the countdown and pins the pace marker at 100%. A
354
- // reset is never more than a week away, so the neighbouring year that
355
- // lands nearest to `now` is the one Claude meant.
356
- const thisYear = new Date(now).getFullYear();
357
- let best = null;
358
- for (const year of [thisYear - 1, thisYear, thisYear + 1]) {
359
- const t = new Date(`${norm} ${year}`).getTime();
360
- if (isNaN(t)) continue;
361
- if (best === null || Math.abs(t - now) < Math.abs(best - now)) best = t;
362
- }
363
- return best === null ? null : Math.floor(best / 1000);
364
- } catch { return null; }
365
- }
366
-
367
- /**
368
- * Parse `claude --print /usage` output.
369
- *
370
- * Observed format (Claude Code ≥ 1.x):
371
- * "Current session: 84% used · resets Jun 18, 4:09pm (Europe/Chisinau)"
372
- * "Current week (all models): 85% used · resets Jun 21, 8:59am (Europe/Chisinau)"
373
- * "Current week (Sonnet only): 48% used · resets Jun 21, 9am (Europe/Chisinau)"
374
- * "Current week (Opus only): ..." (if present)
375
- */
376
- function parseUsageText(raw) {
377
- const text = stripAnsi(raw);
378
- const result = {};
379
-
380
- // Helper: find "X% used · resets <rest>" on a line matching a label.
381
- const extract = (labelRe) => {
382
- const line = text.split("\n").find(l => labelRe.test(l));
383
- if (!line) return null;
384
- const pctM = line.match(/(\d{1,3})\s*%/);
385
- const resetM = line.match(/resets\s+(.+)/i);
386
- const resetFull = resetM
387
- ? resetM[1].replace(/\(.*?\)/g, "").replace(/·/g, "").trim()
388
- : null;
389
- return {
390
- pct: pctM ? Math.min(100, parseInt(pctM[1], 10)) : null,
391
- reset: resetFull,
392
- resetAt: parseResetToSec(resetFull),
393
- };
394
- };
395
-
396
- const session = extract(/current session/i);
397
- if (session?.pct != null) {
398
- result.session5hPct = session.pct;
399
- result.session5hWindowSec = 18000;
400
- if (session.reset) result.session5hReset = session.reset;
401
- if (session.resetAt) result.session5hResetAt = session.resetAt;
402
- }
403
-
404
- const weekAll = extract(/current week\s*\(all models\)/i) || extract(/current week\s*[:·]/i);
405
- if (weekAll?.pct != null) {
406
- result.week7dPct = weekAll.pct;
407
- result.week7dWindowSec = 604800;
408
- if (weekAll.reset) result.week7dReset = weekAll.reset;
409
- if (weekAll.resetAt) result.week7dResetAt = weekAll.resetAt;
410
- }
411
-
412
- const weekSon = extract(/current week\s*\(sonnet/i);
413
- if (weekSon?.pct != null) result.weekSonnetPct = weekSon.pct;
414
-
415
- const weekOpus = extract(/current week\s*\(opus/i);
416
- if (weekOpus?.pct != null) result.weekOpusPct = weekOpus.pct;
417
-
418
- return Object.keys(result).length > 0 ? result : null;
419
- }
420
-
421
- // Where the `claude` CLI can be. The list moved to claude-dir.mjs, which is the
422
- // module that owns every "where does Claude Code live" answer the deck has —
423
- // the config dir was already there, and the boot-time presence check that reads
424
- // this same list had no business importing a quota poller to get at it.
425
-
426
- /** Which `claude` to run for `--print /usage`: the first candidate that exists.
427
- *
428
- * This used to hand back a whole shell command line — `"<bin>" --print /usage
429
- * < /dev/null` — for `exec()` to parse. Double quotes are not escaping on
430
- * POSIX: `$(…)`, backticks and `\` all still work inside them, and every
431
- * ingredient of that line came from the environment (`%APPDATA%`, `homedir()`),
432
- * so a home directory named `/home/a$(id)b` was shell code the quota poll ran
433
- * every minute. A bare `$` was the duller half of the same bug — it expanded
434
- * to nothing and the probe looked for a binary at a path that did not exist.
435
- *
436
- * There is nothing left to escape once there is no shell: exec.mjs's `run`
437
- * spawns the argument vector as given, resolves the Windows `.cmd`/`.exe`
438
- * spelling itself, and closes the child's stdin — which is what `< /dev/null`
439
- * was for, since `claude --print` waits three seconds on a stdin pipe nobody
440
- * is writing to.
441
- *
442
- * Exported, with everything it touches injectable, so the Windows branch is
443
- * testable from the platforms this repo is actually developed on.
444
- *
445
- * WHY THE BARE NAME HAS TO EARN ITS PLACE (#553). This used to be a `.find`
446
- * over `!c.includes(sep) || exists(c)`, which reads as "a bare name always
447
- * answers, a full path only when it is there". On Windows that is harmless —
448
- * the bare name is LAST in the list — but on POSIX it is FIRST, so the `||`
449
- * short-circuited on candidate one and `exists` was never called even once:
450
- * `~/.local/bin/claude`, `/usr/local/bin/claude` and `/opt/homebrew/bin/claude`
451
- * were in a list nothing ever read. The user this broke is the one
452
- * claude-dir.mjs names out loud: Claude Code installed by the official
453
- * installer, so the binary is at `~/.local/bin/claude`, and the deck launched
454
- * from something whose PATH never sourced a shell rc — a LaunchAgent, a
455
- * systemd user unit, pm2, a desktop shortcut. `hasClaudeInstalled()` stats the
456
- * absolute paths and says yes, so hooks install and the Claude surface turns
457
- * on; every `claude --print /usage` spawn is then a bare-name ENOENT logged as
458
- * `quota: claude CLI failed`. On macOS there is no `.credentials.json` to fall
459
- * back to (the token is in the Keychain, #360), so the quota panel simply stays
460
- * dark on a machine that plainly has Claude Code. The identical install on
461
- * Windows worked, because there the ordering already said what this now says.
462
- *
463
- * WHICH WINS. The candidate list's own order decides, unchanged on both
464
- * platforms — PATH first on POSIX, the two known install directories first on
465
- * Windows — because the ordering question here is the one getRunner in
466
- * ccusage.mjs already answered: preferring a different copy would silently
467
- * change which binary runs on every machine that has two, and a deck that
468
- * works today must not start running a `claude` it has never run. A user with
469
- * a current claude on PATH via nvm/mise/volta and a stale one left in
470
- * `~/.local/bin` keeps getting the one their own shell gives them. All that
471
- * changes is that a bare name is now only ANSWERED WITH when PATH actually
472
- * holds it, which is the same rule claudeCliOnDisk in claude-dir.mjs has
473
- * always applied to this very list — the two readers of one list can no longer
474
- * disagree about whether the deck can run what it says is installed.
475
- *
476
- * WHAT IT COSTS. One PATH walk, stopping at the first hit, and only for the
477
- * bare candidate; the absolute paths are stat'ed only once PATH has come up
478
- * empty. That is the trade ccusage.mjs already priced for the same shape of
479
- * question — "a handful of stats, once per uncached fetch, against a process
480
- * spawn that follows it" — and here the spawn that follows is a whole Claude
481
- * Code process measured at ~3s, behind the SELF_POLL_MS floor.
482
- *
483
- * `pathLookup` is used as a yes/no gate rather than for the path it found, on
484
- * purpose: answering with the bare name keeps spawn's own resolution (and, on
485
- * Windows, exec.mjs's PATHEXT candidate walk) in charge of the PATH case
486
- * exactly as before, so a PATH entry that merely LOOKS like a hit — a
487
- * directory named `claude` — cannot become the answer.
488
- */
489
- export function quotaClaudeBin(platform = process.platform, env = process.env,
490
- home = homedir(), exists = existsSync) {
491
- const sep = platform === "win32" ? "\\" : "/";
492
- // process.env is case-insensitive on Windows; an injected plain object in a
493
- // test is not, and %Path% is how the variable is actually spelled there.
494
- const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
495
- for (const c of claudeCliCandidates(platform, env, home)) {
496
- // A full path is worth a single stat; a bare name means "ask PATH", which
497
- // is pathLookup's walk — PATHEXT included, since `claude` on Windows is
498
- // spelled `claude.exe` or `claude.cmd` and never the bare word.
499
- if (c.includes(sep)) { if (exists(c)) return c; }
500
- else if (pathLookup(c, platform, { pathEnv, exists })) return c;
501
- }
502
- // Nothing on PATH and nothing at any known install directory. The bare name
503
- // is still the right last resort — POSIX `execvp` and cmd.exe's own search
504
- // both deserve their turn at a layout no list here knows — and the ENOENT it
505
- // produces is what `quota: claude CLI failed` reports.
506
- return "claude";
507
- }
508
-
509
143
  /**
510
144
  * @param deadlineMs how long the CALLER is prepared to wait. Zero — the
511
145
  * default, and what every internal caller uses — waits for the read however
@@ -576,10 +210,16 @@ export function answerWithin(read, deadlineMs, now = Date.now()) {
576
210
  return Promise.race([read, expired]).finally(() => clearTimeout(bell));
577
211
  }
578
212
 
213
+ /** What the deck says when it holds no reading at all: that a 429 is being
214
+ * waited out, or simply that the first reading has not arrived. */
215
+ function noReading(now) {
216
+ return { ok: false, reason: coolingDown(now) ? "rate_limited" : "waiting", fetchedAt: now };
217
+ }
218
+
579
219
  /** What the deck can honestly say about a reading it has not finished taking. */
580
220
  function notYet(now) {
581
221
  if (_lastGood) return { ..._lastGood, stale: true };
582
- return { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
222
+ return noReading(now);
583
223
  }
584
224
 
585
225
  /**
@@ -619,12 +259,25 @@ function publish(gen, result, at, { good = false } = {}) {
619
259
  */
620
260
  async function storeQuota() {
621
261
  try {
622
- return quotaFromStore(await activeAccountUsage());
262
+ const entry = await activeAccountUsage();
263
+ const quota = quotaFromStore(entry);
264
+ if (!quota) return null;
265
+ const account = accountOf(entry.email, entry.organizationUuid);
266
+ const credits = heldResetCredits(account, Date.now());
267
+ const reading = credits ? { ...quota, resetCredits: credits } : quota;
268
+ if (account) _accountOfReading.set(reading, account);
269
+ return reading;
623
270
  } catch {
624
271
  return null;
625
272
  }
626
273
  }
627
274
 
275
+ // Which account a store reading is about, for the inventory read it starts.
276
+ // Beside the reading rather than on it: the reading is sent to the browser as
277
+ // it is, and an identity is not something the panel needs. Weak, so a reading
278
+ // nothing holds any more takes its entry with it.
279
+ const _accountOfReading = new WeakMap();
280
+
628
281
  // After asking claude-swap to collect, how long to keep looking for the row it
629
282
  // writes. Its fetch is a single HTTPS call; three tries covers a slow one
630
283
  // without making the refresh button feel stuck.
@@ -659,78 +312,48 @@ async function nudgeAndReread(previous) {
659
312
  return previous;
660
313
  }
661
314
 
662
- // Run `claude --print /usage` once. Returns { cliOk, parsed }.
663
- // cliOk — the CLI ran and we recognized its output (preamble present)
664
- // parsed — quota percentages object, or null if the "Current session/week"
665
- // lines were absent (CLI cold-start, or genuinely <1% usage)
666
- /**
667
- * The failure this last said out loud, so a standing one is said once.
668
- *
669
- * #742. A Windows user with no Claude Code installed sent a screenshot of three
670
- * identical lines — `ccdeck quota: claude CLI failed: claude exited ENOENT` —
671
- * interleaved with the deck's pulse line, and they keep coming for as long as
672
- * the deck runs. Every poll ran the loop below three times, and every attempt
673
- * printed. A CLI that is not installed is not news three times a minute; it is
674
- * a condition, and a condition is worth exactly one line.
675
- *
676
- * Cleared on the first run that works, so a `claude` installed while the deck
677
- * is up can still report its next genuine failure.
678
- */
679
- let _saidFailure = null;
680
-
681
- /** Exported for its test, and for the same reason resetCswapBin is: a module
682
- * that remembers something across calls needs a way to be asked twice.
683
- *
684
- * Deliberately NOT folded into invalidateQuotaCache, which production calls
685
- * after an account switch — forgetting the notice there would put the same
686
- * sentence back on the terminal every time somebody changed accounts. */
687
- export function forgetQuotaFailureNotice() { _saidFailure = null; }
688
-
689
315
  /** The rate floor, cleared. `maySelfPoll` keeps a self-poll to one a minute
690
316
  * even under `force`, which is correct for a user's budget and is a test
691
317
  * asking the same question three times running into a wall. */
692
318
  export function resetQuotaPollFloor() {
693
319
  _lastSelfPollAt = 0;
694
- _rateLimitedUntil = 0;
320
+ clearCooldown();
321
+ clearResetCreditsFloor();
695
322
  }
696
323
 
697
- async function _execOnce(bin) {
698
- const r = await run(bin, ["--print", "/usage"], {
699
- timeout: 15_000,
700
- maxBuffer: 1024 * 1024,
701
- // Marks this Claude Code run as the deck's own. `claude --print /usage`
702
- // is a full invocation, so it fires the hooks we installed, and every
703
- // quota poll was drawing itself onto the canvas as a fresh session with
704
- // no prompt and no tools. Hooks inherit the environment, so hook.js
705
- // sees this and stays quiet.
706
- env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
707
- });
708
- // `run` never rejects, so there is one path rather than two — and the output
709
- // is kept either way, which matters because the CLI writes the quota lines to
710
- // stdout and can still exit non-zero afterwards.
711
- const combined = r.stdout + "\n" + r.stderr;
712
- // `run` normalises a binary that is not there to this, on every platform —
713
- // see exec.mjs. It is the difference between "Claude Code answered badly",
714
- // which is worth retrying and worth saying, and "there is no Claude Code on
715
- // this machine", which is neither.
716
- const missing = r.code === "ENOENT";
717
- if (!r.ok) {
718
- const msg = stripAnsi(r.stderr).trim() || `claude exited ${r.code}`;
719
- if (msg !== _saidFailure) {
720
- _saidFailure = msg;
721
- console.error(`${PRODUCT} quota: claude CLI failed:`, msg);
722
- }
723
- } else {
724
- _saidFailure = null;
324
+ // How many times source 3 is run before its silence is taken at its word, and
325
+ // how far apart. Like REREAD_TRIES above, a count and a gap rather than a
326
+ // deadline; QUOTA_DEADLINE_MS's note adds up what the two cost a caller.
327
+ const CLI_ATTEMPTS = 3;
328
+ const CLI_RETRY_GAP_MS = 1200;
329
+
330
+ /**
331
+ * Source 3, attempted until it prints windows: `{ cliOk, cliRan, parsed }`,
332
+ * where the first two say whether ANY attempt was recognised and whether any
333
+ * ran cleanly — the no-numbers branch of _doFetch needs both — and `parsed` is
334
+ * the first set of windows printed, or null.
335
+ */
336
+ async function readCliUsage(bin) {
337
+ // The CLI sometimes omits the "Current session/week" quota lines on a cold
338
+ // invocation (right after the server starts, or after the page is hard-
339
+ // refreshed). The real lines appear on a subsequent call. Retry a couple
340
+ // times before giving up so the first paint already shows real values.
341
+ let cliOk = false;
342
+ let cliRan = false;
343
+ let parsed = null;
344
+ for (let attempt = 0; attempt < CLI_ATTEMPTS; attempt++) {
345
+ if (attempt > 0) await sleep(CLI_RETRY_GAP_MS);
346
+ const r = await runUsageOnce(bin);
347
+ cliOk = r.cliOk || cliOk;
348
+ cliRan = r.ran || cliRan;
349
+ if (r.parsed) { parsed = r.parsed; break; }
350
+ // The retry exists for a CLI that RAN and left the quota lines out of a cold
351
+ // invocation. A CLI that is not installed will not be installed 1.2 seconds
352
+ // from now, and asking twice more spends two spawns and 2.4 seconds of the
353
+ // caller's wait to print the same sentence three times. See runUsageOnce.
354
+ if (r.missing) break;
725
355
  }
726
- const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
727
- // WHETHER THE RUN ITSELF SUCCEEDED, which `cliOk` does not answer. `cliOk` is
728
- // a test of the OUTPUT — it means "we recognised what came back" — and a CLI
729
- // that printed its banner and then failed satisfies it. That is the right
730
- // rule for the parse above (see the note there: the quota lines can be on
731
- // stdout and the exit non-zero), and the wrong one for the no-numbers
732
- // fallback in _doFetch, which was publishing 0% for a run that errored.
733
- return { cliOk, ran: r.ok, missing, parsed: parseUsageText(combined) };
356
+ return { cliOk, cliRan, parsed };
734
357
  }
735
358
 
736
359
  async function _doFetch(now, force = false, gen = _generation) {
@@ -750,12 +373,15 @@ async function _doFetch(now, force = false, gen = _generation) {
750
373
  // throttle inside is shared with the accounts panel, so two open panels
751
374
  // ask no more often than one.
752
375
  if (!force) requestCollection().catch(() => {});
376
+ // The saved limit resets are the one thing the store cannot say. Read
377
+ // behind this answer, never in front of it; see refreshStoreResetCredits.
378
+ refreshStoreResetCredits({ now, force, account: _accountOfReading.get(store) });
753
379
  return publish(gen, store, now, { good: true });
754
380
  }
755
381
 
756
382
  // Nothing usable in the store. Everything below spends the user's budget, so
757
383
  // it happens on a floor, and not at all while a 429 cooldown is running.
758
- if (!maySelfPoll({ now, force, lastSelfPollAt: _lastSelfPollAt, rateLimitedUntil: _rateLimitedUntil })) {
384
+ if (!maySelfPoll({ now, force, lastSelfPollAt: _lastSelfPollAt, rateLimitedUntil: cooldownUntil() })) {
759
385
  // A stale row still beats an empty panel, and says how stale it is — but
760
386
  // it must be the freshest thing we hold, not just the store. Preferring
761
387
  // the store here threw away readings we had already paid for: after a boot
@@ -764,8 +390,8 @@ async function _doFetch(now, force = false, gen = _generation) {
764
390
  // next poll, because the store had not moved.
765
391
  const held = freshest(store, _lastGood);
766
392
  if (held) return publish(gen, { ...held, stale: true }, now);
767
- const result = { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
768
- return publish(gen, result, now - (CACHE_MS - 5_000));
393
+ const result = noReading(now);
394
+ return publish(gen, result, shortLived(now));
769
395
  }
770
396
  _lastSelfPollAt = now;
771
397
 
@@ -776,27 +402,7 @@ async function _doFetch(now, force = false, gen = _generation) {
776
402
  }
777
403
 
778
404
  // Source 3: parse `claude --print /usage` CLI output.
779
- const bin = quotaClaudeBin();
780
-
781
- // The CLI sometimes omits the "Current session/week" quota lines on a cold
782
- // invocation (right after the server starts, or after the page is hard-
783
- // refreshed). The real lines appear on a subsequent call. Retry a couple
784
- // times before giving up so the first paint already shows real values.
785
- let cliOk = false;
786
- let cliRan = false;
787
- let parsed = null;
788
- for (let attempt = 0; attempt < 3; attempt++) {
789
- if (attempt > 0) await sleep(1200);
790
- const r = await _execOnce(bin);
791
- cliOk = r.cliOk || cliOk;
792
- cliRan = r.ran || cliRan;
793
- if (r.parsed) { parsed = r.parsed; break; }
794
- // The retry exists for a CLI that RAN and left the quota lines out of a cold
795
- // invocation. A CLI that is not installed will not be installed 1.2 seconds
796
- // from now, and asking twice more spends two spawns and 2.4 seconds of the
797
- // caller's wait to print the same sentence three times. See _execOnce.
798
- if (r.missing) break;
799
- }
405
+ const { cliOk, cliRan, parsed } = await readCliUsage(quotaClaudeBin());
800
406
 
801
407
  // Got real quota lines — cache normally and remember as last-known-good.
802
408
  if (parsed) {
@@ -811,7 +417,7 @@ async function _doFetch(now, force = false, gen = _generation) {
811
417
  // vouches for numbers this branch already knows are stale. Short-cache so we
812
418
  // retry the CLI again soon.
813
419
  if (_lastGood) {
814
- return publish(gen, { ..._lastGood, stale: true }, now - (CACHE_MS - 5_000));
420
+ return publish(gen, { ..._lastGood, stale: true }, shortLived(now));
815
421
  }
816
422
 
817
423
  // Never had good data. A CLI that RAN and printed no quota lines is two
@@ -841,10 +447,10 @@ async function _doFetch(now, force = false, gen = _generation) {
841
447
  // and absence plus failure is not a measurement.
842
448
  const subscribed = cliOk && cliRan ? await hasSubscriptionCredential() : false;
843
449
  const result = cliOk && cliRan && subscribed
844
- ? { ok: true, session5hPct: 0, session5hWindowSec: 18000,
845
- week7dPct: 0, week7dWindowSec: 604800, fetchedAt: now }
450
+ ? { ok: true, session5hPct: 0, session5hWindowSec: WIN_5H_SEC,
451
+ week7dPct: 0, week7dWindowSec: WIN_7D_SEC, fetchedAt: now }
846
452
  : { ok: false, reason: cliOk && cliRan ? "no_subscription" : "cli_failed", fetchedAt: now };
847
- return publish(gen, result, now - (CACHE_MS - 5_000));
453
+ return publish(gen, result, shortLived(now));
848
454
  }
849
455
 
850
456
  /**
@@ -894,4 +500,12 @@ export function invalidateQuotaCache() {
894
500
  _lastGood = null;
895
501
  _generation++;
896
502
  _inflight = null;
503
+ // Forgotten with the rest, because it is about the account the deck just
504
+ // left. It is not what keeps those resets off the next card, though:
505
+ // heldResetCredits compares accounts at every read, which also covers the
506
+ // switches this function never hears about — `cswap` in a terminal, or
507
+ // claude-swap's own auto-switch. The inventory floor survives, for the reason
508
+ // the self-poll floor does, and an account not tried yet is on the short one
509
+ // anyway.
510
+ forgetResetCredits();
897
511
  }