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.
- package/README.md +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- 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
|
+
}
|