ccdeck 3.29.11 → 3.31.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 (178) hide show
  1. package/README.md +4 -3
  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-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -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 +40 -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 +7 -3
  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 +2 -2
  67. package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,190 @@
1
+ // Claude Code's OAuth token, as quota.mjs borrows it: where it is kept, whether
2
+ // this machine has one at all, how a request made with it introduces itself,
3
+ // and the two limits every such request is under — the floor between two the
4
+ // deck pays for, and the 429 cooldown they all share.
5
+ //
6
+ // Source 2 of the chain lives here too — one GET of the usage endpoint, mapped
7
+ // by quota-shape.mjs — because it is nothing more than a request with this
8
+ // token. Whether to make it is quota.mjs's decision, taken against the
9
+ // self-poll floor; this module knows only how, and when a 429 says to stop.
10
+ import { claudeConfigDir } from "./claude-dir.mjs";
11
+ import { readFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { mapOAuthUsage } from "./quota-shape.mjs";
14
+
15
+ export const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
16
+ const BETA_HEADER = "oauth-2025-04-20";
17
+
18
+ // Source 2's request, asking for the reset inventory as well as the windows.
19
+ const USAGE_WITH_RESETS_URL = `${USAGE_URL}?cedar_ember=1`;
20
+
21
+ /**
22
+ * How these requests introduce themselves, in the format Claude Code uses for
23
+ * its own.
24
+ *
25
+ * The endpoint decides whether to send the reset inventory by client surface:
26
+ * a User-Agent it does not recognise as Claude Code's CLI gets `eligible:
27
+ * false, ineligible_reason: "surface"` and no grants, and a CLI version it
28
+ * considers too old gets `"cli_version"`. This deck used to send
29
+ * `claude-code/2.1.0`: written to look like Claude Code, in a format the
30
+ * endpoint does not take for it. The windows do not depend on any of this —
31
+ * claude-swap reads the same ones as `claude-swap/1.0`.
32
+ *
33
+ * Pinned rather than read off the installed binary: the version lives in a
34
+ * different place under every install method, and a pin that ages out fails
35
+ * closed — the endpoint stops sending grants, the row disappears, and no number
36
+ * on the panel is wrong. Raising it is the fix when that happens.
37
+ */
38
+ const USER_AGENT = "claude-cli/2.1.283 (external, cli)";
39
+
40
+ // Floor between two polls WE pay for. Twelve an hour against a budget of
41
+ // ~28-30 leaves claude-swap room to collect for every account, which is what
42
+ // the accounts panel is made of. Only reached when the store cannot answer.
43
+ export const SELF_POLL_MS = 5 * 60_000;
44
+
45
+ // 429 cooldown gate — after a rate-limit, skip the API until this passes.
46
+ let _rateLimitedUntil = 0;
47
+
48
+ /** When the cooldown ends, or 0 when there is none. For the budget rules,
49
+ * which take it as an argument so they stay pure. */
50
+ export function cooldownUntil() { return _rateLimitedUntil; }
51
+
52
+ /** Whether a 429 is still being waited out at `now`. */
53
+ export function coolingDown(now) { return now < _rateLimitedUntil; }
54
+
55
+ /** A 429 arrived: hold every request this token makes off for as long as its
56
+ * `retry-after` says, within the limits cooldownFromHeader keeps. One token,
57
+ * one budget, so both requests that can be refused back off the same way. */
58
+ export function startCooldown(res) {
59
+ _rateLimitedUntil = Date.now() + cooldownFromHeader(res.headers.get("retry-after"), 5 * 60_000);
60
+ }
61
+
62
+ /** The cooldown, forgotten — for resetQuotaPollFloor. */
63
+ export function clearCooldown() { _rateLimitedUntil = 0; }
64
+
65
+ /**
66
+ * Where Claude Code keeps the OAuth credentials this module borrows a token
67
+ * from.
68
+ *
69
+ * It is `.credentials.json` inside the Claude config dir, and that dir moves:
70
+ * CLAUDE_CONFIG_DIR replaces ~/.claude wholesale rather than overlaying it, so
71
+ * on a machine where it is set there is no ~/.claude to read at all. Hardcoding
72
+ * ~/.claude here did not fail loudly — it made readOAuthToken() return null
73
+ * forever, which reads exactly like "this machine keeps its credentials in the
74
+ * Keychain", and the quota chain quietly fell through to source 3 on every poll
75
+ * it was allowed to make. See src/server/claude-dir.mjs, which owns the rule
76
+ * and is the only place it is spelled.
77
+ *
78
+ * Resolved per call rather than frozen into a module-level constant, for the
79
+ * same reason claudeConfigDir() is a function: a constant captured at import
80
+ * time is a value nothing can observe or correct afterwards, and this module is
81
+ * imported lazily by the /api/quota route rather than at a point in startup
82
+ * anyone here controls.
83
+ *
84
+ * Exported for tests — it is the whole of the bug, and it is pure.
85
+ */
86
+ export function credentialsPath() {
87
+ return join(claudeConfigDir(), ".credentials.json");
88
+ }
89
+
90
+ export async function readOAuthToken() {
91
+ try {
92
+ const raw = await readFile(credentialsPath(), "utf8");
93
+ const auth = JSON.parse(raw)?.claudeAiOauth;
94
+ if (!auth?.accessToken) return null;
95
+ // expiresAt is epoch milliseconds. If expired, the CLI fallback handles it.
96
+ if (auth.expiresAt && Date.now() >= auth.expiresAt) return null;
97
+ return auth.accessToken;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * A cooldown from a `retry-after`, kept inside limits the deck can live with.
105
+ *
106
+ * Unclamped, the header decided the poller's fate in both directions: `0` (or a
107
+ * value the server rounds down to it) defeats the cooldown entirely and the
108
+ * next tick asks again immediately, which is the loop a 429 exists to stop; a
109
+ * large one — a day is a legal value — freezes the reader for the life of the
110
+ * process, and nothing here re-reads it. Both are the remote side deciding how
111
+ * this deck behaves, which a header is not entitled to do.
112
+ *
113
+ * The floor is the deck's own minimum backoff and the ceiling is an hour: long
114
+ * enough to be a real retreat, short enough that a quota panel is not dead for
115
+ * the rest of the day because one reply said so.
116
+ */
117
+ export function cooldownFromHeader(raw, fallbackMs, minMs = 30_000, maxMs = 3600_000) {
118
+ const seconds = parseInt(String(raw ?? ""), 10);
119
+ if (!Number.isFinite(seconds)) return fallbackMs;
120
+ return Math.min(Math.max(seconds * 1000, minMs), maxMs);
121
+ }
122
+
123
+ /**
124
+ * WHETHER THIS MACHINE HAS A SUBSCRIPTION TO REPORT ON AT ALL.
125
+ *
126
+ * Every source here needs a Claude.ai OAuth credential: the claude-swap store
127
+ * holds one, `claudeAiOauth` in the credentials file is one, and
128
+ * `claude --print /usage` prints windows only for a session signed in with one.
129
+ * An API-key, Bedrock or Vertex install has none — and there is no quota to
130
+ * read, because those are billed per token rather than in five-hour windows.
131
+ *
132
+ * That mattered because of what the CLI does on such a machine: it RUNS, prints
133
+ * no quota lines, and the no-numbers branch of quota.mjs's _doFetch used to
134
+ * read that as "genuine <1%" and publish `ok: true` with two zeroes. The panel
135
+ * then drew empty bars, which is a measurement nobody took. Codex already
136
+ * answers this properly, with `api_key_mode` as its own reason and its own
137
+ * 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
+ /** The headers every request made with the Claude Code token sends. */
159
+ export function oauthHeaders(token) {
160
+ return {
161
+ "Authorization": `Bearer ${token}`,
162
+ "anthropic-beta": BETA_HEADER,
163
+ "Accept": "application/json",
164
+ "Content-Type": "application/json",
165
+ "User-Agent": USER_AGENT,
166
+ };
167
+ }
168
+
169
+ export async function fetchOAuthUsage() {
170
+ if (coolingDown(Date.now())) return null;
171
+ const token = await readOAuthToken();
172
+ if (!token) return null;
173
+
174
+ try {
175
+ const res = await fetch(USAGE_WITH_RESETS_URL, {
176
+ headers: oauthHeaders(token),
177
+ signal: AbortSignal.timeout(15_000),
178
+ });
179
+
180
+ if (res.status === 429) {
181
+ startCooldown(res);
182
+ return null;
183
+ }
184
+ if (!res.ok) return null;
185
+
186
+ return mapOAuthUsage(await res.json());
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
@@ -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
+ }