agent-dag 3.22.1 → 3.22.4
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 +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/src/server/quota.mjs
DELETED
|
@@ -1,792 +0,0 @@
|
|
|
1
|
-
// Claude rate-limit quota, from whichever source costs least.
|
|
2
|
-
//
|
|
3
|
-
// All three sources below end at the same place: GET /api/oauth/usage, which
|
|
4
|
-
// Anthropic budgets at roughly 28-30 calls per rolling hour PER TOKEN, shared
|
|
5
|
-
// by every tool on the machine. That budget is the constraint this module is
|
|
6
|
-
// built around, because it was being blown by this module: a 60s poll is 60
|
|
7
|
-
// calls an hour on its own, and the account the deck was polling started
|
|
8
|
-
// answering http-429 to claude-swap, whose collections the accounts panel is
|
|
9
|
-
// entirely made of. One panel went stale so the other could be a minute
|
|
10
|
-
// fresher.
|
|
11
|
-
//
|
|
12
|
-
// 1. claude-swap's store — free. It polls the active account on its own
|
|
13
|
-
// schedule and writes what it got; reading that file costs nothing and
|
|
14
|
-
// spends none of the budget. Used whenever it holds a recent enough row.
|
|
15
|
-
// 2. The OAuth usage API directly, with the token from
|
|
16
|
-
// .credentials.json inside the Claude config dir — $CLAUDE_CONFIG_DIR when
|
|
17
|
-
// it is set, ~/.claude otherwise. Exact and instant. Mechanism
|
|
18
|
-
// reverse-engineered from steipete/CodexBar.
|
|
19
|
-
// 3. `claude --print /usage`, parsed. Used when there is no readable token —
|
|
20
|
-
// notably on macOS, where Claude Code keeps credentials in the Keychain
|
|
21
|
-
// and that file does not exist, so this is the ONLY self-service path
|
|
22
|
-
// there. It is also the most expensive: a whole Claude Code process per
|
|
23
|
-
// poll. On Windows the binary may be a .cmd wrapper, which spawn cannot
|
|
24
|
-
// launch directly — exec.mjs's `run` routes that case through cmd.exe with
|
|
25
|
-
// the argument vector intact, so no shell ever parses a path this module
|
|
26
|
-
// read out of the environment.
|
|
27
|
-
//
|
|
28
|
-
// 2 and 3 are rate-floored (SELF_POLL_MS) and gated behind the same 429
|
|
29
|
-
// cooldown; 1 is not, because it is a local file read.
|
|
30
|
-
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
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Whether we may spend a request of the user's budget right now.
|
|
93
|
-
*
|
|
94
|
-
* Exported for tests — this is the rule that stopped the deck from starving
|
|
95
|
-
* claude-swap, and it is worth pinning down.
|
|
96
|
-
*/
|
|
97
|
-
export function maySelfPoll({ now, force, lastSelfPollAt, rateLimitedUntil }) {
|
|
98
|
-
if (now < rateLimitedUntil) return false;
|
|
99
|
-
return now - lastSelfPollAt >= (force ? FORCE_POLL_MS : SELF_POLL_MS);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
103
|
-
|
|
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
|
-
let _cache = null;
|
|
243
|
-
let _cacheAt = 0;
|
|
244
|
-
let _inflight = null; // deduplicates concurrent CLI probes
|
|
245
|
-
let _lastGood = null; // last result that had real quota percentages
|
|
246
|
-
let _lastSelfPollAt = 0;
|
|
247
|
-
// Which account the readings below are about — as a counter, because the
|
|
248
|
-
// account's identity is not something this module holds. invalidateQuotaCache
|
|
249
|
-
// bumps it; every write in _doFetch is stamped with the value that was current
|
|
250
|
-
// when that read STARTED. See publish().
|
|
251
|
-
let _generation = 0;
|
|
252
|
-
|
|
253
|
-
const CACHE_MS = 60_000;
|
|
254
|
-
|
|
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.
|
|
262
|
-
const FORCE_POLL_MS = 60_000;
|
|
263
|
-
|
|
264
|
-
// How old a claude-swap row may be before we stop treating it as the answer.
|
|
265
|
-
// Its own default poll interval is 1800s, so a row older than this means its
|
|
266
|
-
// collector is backing off or not running — the case self-polling exists for.
|
|
267
|
-
const STORE_TRUSTED_MS = 45 * 60_000;
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* claude-swap's row for the active account, in the shape the panel speaks.
|
|
271
|
-
*
|
|
272
|
-
* Exported for tests: the mapping is where a wrong number would come from, and
|
|
273
|
-
* it is pure.
|
|
274
|
-
*/
|
|
275
|
-
export function quotaFromStore(entry) {
|
|
276
|
-
const good = entry?.lastGood;
|
|
277
|
-
const fh = good?.five_hour;
|
|
278
|
-
const sd = good?.seven_day;
|
|
279
|
-
const primary = (typeof fh?.pct === "number") ? fh : sd;
|
|
280
|
-
if (typeof primary?.pct !== "number") return null;
|
|
281
|
-
|
|
282
|
-
const round = (v) => Math.min(100, Math.max(0, Math.round(v)));
|
|
283
|
-
const out = {
|
|
284
|
-
ok: true,
|
|
285
|
-
source: "claude-swap",
|
|
286
|
-
session5hPct: round(primary.pct),
|
|
287
|
-
session5hWindowSec: WIN_5H_SEC,
|
|
288
|
-
session5hReset: fmtResetIso(primary.resets_at),
|
|
289
|
-
session5hResetAt: isoToSec(primary.resets_at),
|
|
290
|
-
week7dWindowSec: WIN_7D_SEC,
|
|
291
|
-
week7dPct: typeof sd?.pct === "number" ? round(sd.pct) : 0,
|
|
292
|
-
week7dReset: fmtResetIso(sd?.resets_at),
|
|
293
|
-
week7dResetAt: isoToSec(sd?.resets_at),
|
|
294
|
-
// The age of the DATA, not of our read of it. The panel prints this, and
|
|
295
|
-
// "30s ago" over numbers claude-swap collected twenty minutes back is the
|
|
296
|
-
// kind of true-looking lie this whole change exists to remove.
|
|
297
|
-
fetchedAt: entry.fetchedAt,
|
|
298
|
-
};
|
|
299
|
-
// claude-swap keeps per-model windows in a named list rather than fixed
|
|
300
|
-
// fields, because which ones an account has depends on its plan.
|
|
301
|
-
for (const s of Array.isArray(good.scoped) ? good.scoped : []) {
|
|
302
|
-
if (typeof s?.pct !== "number") continue;
|
|
303
|
-
if (/sonnet/i.test(s.name ?? "")) out.weekSonnetPct = round(s.pct);
|
|
304
|
-
else if (/opus/i.test(s.name ?? "")) out.weekOpusPct = round(s.pct);
|
|
305
|
-
}
|
|
306
|
-
return out;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
// Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
|
|
311
|
-
// Claude shows times in the user's local timezone, so parsing as local is correct.
|
|
312
|
-
// `now` is injectable so the year-boundary case is testable.
|
|
313
|
-
export function parseResetToSec(resetStr, now = Date.now()) {
|
|
314
|
-
if (!resetStr) return null;
|
|
315
|
-
try {
|
|
316
|
-
// "4:09pm" → "4:09 PM" so Date.parse handles it. Minutes are optional in
|
|
317
|
-
// the CLI's output ("9am"); Date.parse rejects "9 AM", so supply ":00".
|
|
318
|
-
const norm = resetStr
|
|
319
|
-
.replace(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i,
|
|
320
|
-
(_all, h, mm, ampm) => `${h}:${mm ?? "00"} ${ampm}`)
|
|
321
|
-
.trim();
|
|
322
|
-
// The CLI prints no year, so we have to supply one. Stamping the current
|
|
323
|
-
// year blindly puts a "Jan 2" reset read on Dec 30 eleven months in the
|
|
324
|
-
// past, which hides the countdown and pins the pace marker at 100%. A
|
|
325
|
-
// reset is never more than a week away, so the neighbouring year that
|
|
326
|
-
// lands nearest to `now` is the one Claude meant.
|
|
327
|
-
const thisYear = new Date(now).getFullYear();
|
|
328
|
-
let best = null;
|
|
329
|
-
for (const year of [thisYear - 1, thisYear, thisYear + 1]) {
|
|
330
|
-
const t = new Date(`${norm} ${year}`).getTime();
|
|
331
|
-
if (isNaN(t)) continue;
|
|
332
|
-
if (best === null || Math.abs(t - now) < Math.abs(best - now)) best = t;
|
|
333
|
-
}
|
|
334
|
-
return best === null ? null : Math.floor(best / 1000);
|
|
335
|
-
} catch { return null; }
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Parse `claude --print /usage` output.
|
|
340
|
-
*
|
|
341
|
-
* Observed format (Claude Code ≥ 1.x):
|
|
342
|
-
* "Current session: 84% used · resets Jun 18, 4:09pm (Europe/Chisinau)"
|
|
343
|
-
* "Current week (all models): 85% used · resets Jun 21, 8:59am (Europe/Chisinau)"
|
|
344
|
-
* "Current week (Sonnet only): 48% used · resets Jun 21, 9am (Europe/Chisinau)"
|
|
345
|
-
* "Current week (Opus only): ..." (if present)
|
|
346
|
-
*/
|
|
347
|
-
function parseUsageText(raw) {
|
|
348
|
-
const text = stripAnsi(raw);
|
|
349
|
-
const result = {};
|
|
350
|
-
|
|
351
|
-
// Helper: find "X% used · resets <rest>" on a line matching a label.
|
|
352
|
-
const extract = (labelRe) => {
|
|
353
|
-
const line = text.split("\n").find(l => labelRe.test(l));
|
|
354
|
-
if (!line) return null;
|
|
355
|
-
const pctM = line.match(/(\d{1,3})\s*%/);
|
|
356
|
-
const resetM = line.match(/resets\s+(.+)/i);
|
|
357
|
-
const resetFull = resetM
|
|
358
|
-
? resetM[1].replace(/\(.*?\)/g, "").replace(/·/g, "").trim()
|
|
359
|
-
: null;
|
|
360
|
-
return {
|
|
361
|
-
pct: pctM ? Math.min(100, parseInt(pctM[1], 10)) : null,
|
|
362
|
-
reset: resetFull,
|
|
363
|
-
resetAt: parseResetToSec(resetFull),
|
|
364
|
-
};
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
const session = extract(/current session/i);
|
|
368
|
-
if (session?.pct != null) {
|
|
369
|
-
result.session5hPct = session.pct;
|
|
370
|
-
result.session5hWindowSec = 18000;
|
|
371
|
-
if (session.reset) result.session5hReset = session.reset;
|
|
372
|
-
if (session.resetAt) result.session5hResetAt = session.resetAt;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const weekAll = extract(/current week\s*\(all models\)/i) || extract(/current week\s*[:·]/i);
|
|
376
|
-
if (weekAll?.pct != null) {
|
|
377
|
-
result.week7dPct = weekAll.pct;
|
|
378
|
-
result.week7dWindowSec = 604800;
|
|
379
|
-
if (weekAll.reset) result.week7dReset = weekAll.reset;
|
|
380
|
-
if (weekAll.resetAt) result.week7dResetAt = weekAll.resetAt;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const weekSon = extract(/current week\s*\(sonnet/i);
|
|
384
|
-
if (weekSon?.pct != null) result.weekSonnetPct = weekSon.pct;
|
|
385
|
-
|
|
386
|
-
const weekOpus = extract(/current week\s*\(opus/i);
|
|
387
|
-
if (weekOpus?.pct != null) result.weekOpusPct = weekOpus.pct;
|
|
388
|
-
|
|
389
|
-
return Object.keys(result).length > 0 ? result : null;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Where the `claude` CLI can be. The list moved to claude-dir.mjs, which is the
|
|
393
|
-
// module that owns every "where does Claude Code live" answer the deck has —
|
|
394
|
-
// the config dir was already there, and the boot-time presence check that reads
|
|
395
|
-
// this same list had no business importing a quota poller to get at it.
|
|
396
|
-
|
|
397
|
-
/** Which `claude` to run for `--print /usage`: the first candidate that exists.
|
|
398
|
-
*
|
|
399
|
-
* This used to hand back a whole shell command line — `"<bin>" --print /usage
|
|
400
|
-
* < /dev/null` — for `exec()` to parse. Double quotes are not escaping on
|
|
401
|
-
* POSIX: `$(…)`, backticks and `\` all still work inside them, and every
|
|
402
|
-
* ingredient of that line came from the environment (`%APPDATA%`, `homedir()`),
|
|
403
|
-
* so a home directory named `/home/a$(id)b` was shell code the quota poll ran
|
|
404
|
-
* every minute. A bare `$` was the duller half of the same bug — it expanded
|
|
405
|
-
* to nothing and the probe looked for a binary at a path that did not exist.
|
|
406
|
-
*
|
|
407
|
-
* There is nothing left to escape once there is no shell: exec.mjs's `run`
|
|
408
|
-
* spawns the argument vector as given, resolves the Windows `.cmd`/`.exe`
|
|
409
|
-
* spelling itself, and closes the child's stdin — which is what `< /dev/null`
|
|
410
|
-
* was for, since `claude --print` waits three seconds on a stdin pipe nobody
|
|
411
|
-
* is writing to.
|
|
412
|
-
*
|
|
413
|
-
* Exported, with everything it touches injectable, so the Windows branch is
|
|
414
|
-
* testable from the platforms this repo is actually developed on.
|
|
415
|
-
*
|
|
416
|
-
* WHY THE BARE NAME HAS TO EARN ITS PLACE (#553). This used to be a `.find`
|
|
417
|
-
* over `!c.includes(sep) || exists(c)`, which reads as "a bare name always
|
|
418
|
-
* answers, a full path only when it is there". On Windows that is harmless —
|
|
419
|
-
* the bare name is LAST in the list — but on POSIX it is FIRST, so the `||`
|
|
420
|
-
* short-circuited on candidate one and `exists` was never called even once:
|
|
421
|
-
* `~/.local/bin/claude`, `/usr/local/bin/claude` and `/opt/homebrew/bin/claude`
|
|
422
|
-
* were in a list nothing ever read. The user this broke is the one
|
|
423
|
-
* claude-dir.mjs names out loud: Claude Code installed by the official
|
|
424
|
-
* installer, so the binary is at `~/.local/bin/claude`, and the deck launched
|
|
425
|
-
* from something whose PATH never sourced a shell rc — a LaunchAgent, a
|
|
426
|
-
* systemd user unit, pm2, a desktop shortcut. `hasClaudeInstalled()` stats the
|
|
427
|
-
* absolute paths and says yes, so hooks install and the Claude surface turns
|
|
428
|
-
* on; every `claude --print /usage` spawn is then a bare-name ENOENT logged as
|
|
429
|
-
* `quota: claude CLI failed`. On macOS there is no `.credentials.json` to fall
|
|
430
|
-
* back to (the token is in the Keychain, #360), so the quota panel simply stays
|
|
431
|
-
* dark on a machine that plainly has Claude Code. The identical install on
|
|
432
|
-
* Windows worked, because there the ordering already said what this now says.
|
|
433
|
-
*
|
|
434
|
-
* WHICH WINS. The candidate list's own order decides, unchanged on both
|
|
435
|
-
* platforms — PATH first on POSIX, the two known install directories first on
|
|
436
|
-
* Windows — because the ordering question here is the one getRunner in
|
|
437
|
-
* ccusage.mjs already answered: preferring a different copy would silently
|
|
438
|
-
* change which binary runs on every machine that has two, and a deck that
|
|
439
|
-
* works today must not start running a `claude` it has never run. A user with
|
|
440
|
-
* a current claude on PATH via nvm/mise/volta and a stale one left in
|
|
441
|
-
* `~/.local/bin` keeps getting the one their own shell gives them. All that
|
|
442
|
-
* changes is that a bare name is now only ANSWERED WITH when PATH actually
|
|
443
|
-
* holds it, which is the same rule claudeCliOnDisk in claude-dir.mjs has
|
|
444
|
-
* always applied to this very list — the two readers of one list can no longer
|
|
445
|
-
* disagree about whether the deck can run what it says is installed.
|
|
446
|
-
*
|
|
447
|
-
* WHAT IT COSTS. One PATH walk, stopping at the first hit, and only for the
|
|
448
|
-
* bare candidate; the absolute paths are stat'ed only once PATH has come up
|
|
449
|
-
* empty. That is the trade ccusage.mjs already priced for the same shape of
|
|
450
|
-
* question — "a handful of stats, once per uncached fetch, against a process
|
|
451
|
-
* spawn that follows it" — and here the spawn that follows is a whole Claude
|
|
452
|
-
* Code process measured at ~3s, behind the SELF_POLL_MS floor.
|
|
453
|
-
*
|
|
454
|
-
* `pathLookup` is used as a yes/no gate rather than for the path it found, on
|
|
455
|
-
* purpose: answering with the bare name keeps spawn's own resolution (and, on
|
|
456
|
-
* Windows, exec.mjs's PATHEXT candidate walk) in charge of the PATH case
|
|
457
|
-
* exactly as before, so a PATH entry that merely LOOKS like a hit — a
|
|
458
|
-
* directory named `claude` — cannot become the answer.
|
|
459
|
-
*/
|
|
460
|
-
export function quotaClaudeBin(platform = process.platform, env = process.env,
|
|
461
|
-
home = homedir(), exists = existsSync) {
|
|
462
|
-
const sep = platform === "win32" ? "\\" : "/";
|
|
463
|
-
// process.env is case-insensitive on Windows; an injected plain object in a
|
|
464
|
-
// test is not, and %Path% is how the variable is actually spelled there.
|
|
465
|
-
const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
|
|
466
|
-
for (const c of claudeCliCandidates(platform, env, home)) {
|
|
467
|
-
// A full path is worth a single stat; a bare name means "ask PATH", which
|
|
468
|
-
// is pathLookup's walk — PATHEXT included, since `claude` on Windows is
|
|
469
|
-
// spelled `claude.exe` or `claude.cmd` and never the bare word.
|
|
470
|
-
if (c.includes(sep)) { if (exists(c)) return c; }
|
|
471
|
-
else if (pathLookup(c, platform, { pathEnv, exists })) return c;
|
|
472
|
-
}
|
|
473
|
-
// Nothing on PATH and nothing at any known install directory. The bare name
|
|
474
|
-
// is still the right last resort — POSIX `execvp` and cmd.exe's own search
|
|
475
|
-
// both deserve their turn at a layout no list here knows — and the ENOENT it
|
|
476
|
-
// produces is what `quota: claude CLI failed` reports.
|
|
477
|
-
return "claude";
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
export async function fetchClaudeQuota({ force = false } = {}) {
|
|
481
|
-
const now = Date.now();
|
|
482
|
-
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
483
|
-
|
|
484
|
-
// If another CLI probe is already in flight, wait for it instead of spawning a
|
|
485
|
-
// second concurrent process (which can return empty output and overwrite the
|
|
486
|
-
// good result with 0%).
|
|
487
|
-
if (_inflight) return _inflight;
|
|
488
|
-
|
|
489
|
-
// `_inflight === mine` rather than a bare clear: invalidateQuotaCache drops
|
|
490
|
-
// `_inflight` so the next caller starts a read that knows the account moved,
|
|
491
|
-
// and that read installs its own promise here. A read from before the switch
|
|
492
|
-
// finishing afterwards would otherwise clear the NEW one on its way out,
|
|
493
|
-
// letting a third caller spawn a second concurrent probe — which is the very
|
|
494
|
-
// thing this slot exists to prevent.
|
|
495
|
-
const mine = _doFetch(now, force, _generation)
|
|
496
|
-
.finally(() => { if (_inflight === mine) _inflight = null; });
|
|
497
|
-
_inflight = mine;
|
|
498
|
-
return mine;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* Write a reading into the caches, unless the account moved while it was being
|
|
503
|
-
* taken.
|
|
504
|
-
*
|
|
505
|
-
* Every one of _doFetch's writes happens after at least one await — a store
|
|
506
|
-
* read, a 15-second HTTPS call, up to three `claude --print /usage` spawns with
|
|
507
|
-
* 1.2s between them — and invalidateQuotaCache clears variables, which does
|
|
508
|
-
* nothing to a function that is already running and still holds the old
|
|
509
|
-
* account's answer in a local. So a switch landing mid-flight was followed,
|
|
510
|
-
* milliseconds later, by the pre-switch reading being written straight back over
|
|
511
|
-
* the cleared cache: the invalidation looked like it worked and was undone
|
|
512
|
-
* before anyone could observe it.
|
|
513
|
-
*
|
|
514
|
-
* The fetch is deliberately NOT cancelled. Whoever asked for it is still owed an
|
|
515
|
-
* answer, and the reading is not wrong — it is simply about an account that is
|
|
516
|
-
* no longer active, which makes it a fine return value and a bad cached one.
|
|
517
|
-
* `_lastGood` gets the same guard, and needs it more: it is the half that
|
|
518
|
-
* survives the result cache's minute and comes back under a "stale" label for as
|
|
519
|
-
* long as the store has nothing to say about the new account.
|
|
520
|
-
*/
|
|
521
|
-
function publish(gen, result, at, { good = false } = {}) {
|
|
522
|
-
if (gen !== _generation) return result;
|
|
523
|
-
_cache = result;
|
|
524
|
-
_cacheAt = at;
|
|
525
|
-
if (good) _lastGood = result;
|
|
526
|
-
return result;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* claude-swap's numbers for the active account, if it has any.
|
|
531
|
-
*
|
|
532
|
-
* Never throws and never blocks on the network: worst case the store is
|
|
533
|
-
* missing, unparseable, or about a different account than the one that is
|
|
534
|
-
* active, and the caller falls through to fetching for itself.
|
|
535
|
-
*/
|
|
536
|
-
async function storeQuota() {
|
|
537
|
-
try {
|
|
538
|
-
return quotaFromStore(await activeAccountUsage());
|
|
539
|
-
} catch {
|
|
540
|
-
return null;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// After asking claude-swap to collect, how long to keep looking for the row it
|
|
545
|
-
// writes. Its fetch is a single HTTPS call; three tries covers a slow one
|
|
546
|
-
// without making the refresh button feel stuck.
|
|
547
|
-
const REREAD_TRIES = 3;
|
|
548
|
-
const REREAD_GAP_MS = 800;
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Whichever of two readings was collected later, regardless of source.
|
|
552
|
-
*
|
|
553
|
-
* Quota numbers only ever move forward in time, so "newer" is the only ranking
|
|
554
|
-
* that makes sense between a store row and something we fetched ourselves. A
|
|
555
|
-
* five-hour window can also reset between the two, which makes an older reading
|
|
556
|
-
* not merely stale but wrong — 23% from before the reset, 3% after it.
|
|
557
|
-
*/
|
|
558
|
-
export function freshest(a, b) {
|
|
559
|
-
if (!a) return b ?? null;
|
|
560
|
-
if (!b) return a;
|
|
561
|
-
return (b.fetchedAt ?? 0) > (a.fetchedAt ?? 0) ? b : a;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/** Ask for a collection, then watch the store for the result. */
|
|
565
|
-
async function nudgeAndReread(previous) {
|
|
566
|
-
let asked = false;
|
|
567
|
-
try { asked = await requestCollection(); } catch { /* cswap missing */ }
|
|
568
|
-
if (!asked) return previous;
|
|
569
|
-
|
|
570
|
-
for (let i = 0; i < REREAD_TRIES; i++) {
|
|
571
|
-
await sleep(REREAD_GAP_MS);
|
|
572
|
-
const fresh = await storeQuota();
|
|
573
|
-
if (fresh && (!previous || fresh.fetchedAt > previous.fetchedAt)) return fresh;
|
|
574
|
-
}
|
|
575
|
-
return previous;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Run `claude --print /usage` once. Returns { cliOk, parsed }.
|
|
579
|
-
// cliOk — the CLI ran and we recognized its output (preamble present)
|
|
580
|
-
// parsed — quota percentages object, or null if the "Current session/week"
|
|
581
|
-
// lines were absent (CLI cold-start, or genuinely <1% usage)
|
|
582
|
-
/**
|
|
583
|
-
* The failure this last said out loud, so a standing one is said once.
|
|
584
|
-
*
|
|
585
|
-
* #742. A Windows user with no Claude Code installed sent a screenshot of three
|
|
586
|
-
* identical lines — `ccdeck quota: claude CLI failed: claude exited ENOENT` —
|
|
587
|
-
* interleaved with the deck's pulse line, and they keep coming for as long as
|
|
588
|
-
* the deck runs. Every poll ran the loop below three times, and every attempt
|
|
589
|
-
* printed. A CLI that is not installed is not news three times a minute; it is
|
|
590
|
-
* a condition, and a condition is worth exactly one line.
|
|
591
|
-
*
|
|
592
|
-
* Cleared on the first run that works, so a `claude` installed while the deck
|
|
593
|
-
* is up can still report its next genuine failure.
|
|
594
|
-
*/
|
|
595
|
-
let _saidFailure = null;
|
|
596
|
-
|
|
597
|
-
/** Exported for its test, and for the same reason resetCswapBin is: a module
|
|
598
|
-
* that remembers something across calls needs a way to be asked twice.
|
|
599
|
-
*
|
|
600
|
-
* Deliberately NOT folded into invalidateQuotaCache, which production calls
|
|
601
|
-
* after an account switch — forgetting the notice there would put the same
|
|
602
|
-
* sentence back on the terminal every time somebody changed accounts. */
|
|
603
|
-
export function forgetQuotaFailureNotice() { _saidFailure = null; }
|
|
604
|
-
|
|
605
|
-
/** The rate floor, cleared. `maySelfPoll` keeps a self-poll to one a minute
|
|
606
|
-
* even under `force`, which is correct for a user's budget and is a test
|
|
607
|
-
* asking the same question three times running into a wall. */
|
|
608
|
-
export function resetQuotaPollFloor() {
|
|
609
|
-
_lastSelfPollAt = 0;
|
|
610
|
-
_rateLimitedUntil = 0;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
async function _execOnce(bin) {
|
|
614
|
-
const r = await run(bin, ["--print", "/usage"], {
|
|
615
|
-
timeout: 15_000,
|
|
616
|
-
maxBuffer: 1024 * 1024,
|
|
617
|
-
// Marks this Claude Code run as the deck's own. `claude --print /usage`
|
|
618
|
-
// is a full invocation, so it fires the hooks we installed, and every
|
|
619
|
-
// quota poll was drawing itself onto the canvas as a fresh session with
|
|
620
|
-
// no prompt and no tools. Hooks inherit the environment, so hook.js
|
|
621
|
-
// sees this and stays quiet.
|
|
622
|
-
env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
|
|
623
|
-
});
|
|
624
|
-
// `run` never rejects, so there is one path rather than two — and the output
|
|
625
|
-
// is kept either way, which matters because the CLI writes the quota lines to
|
|
626
|
-
// stdout and can still exit non-zero afterwards.
|
|
627
|
-
const combined = r.stdout + "\n" + r.stderr;
|
|
628
|
-
// `run` normalises a binary that is not there to this, on every platform —
|
|
629
|
-
// see exec.mjs. It is the difference between "Claude Code answered badly",
|
|
630
|
-
// which is worth retrying and worth saying, and "there is no Claude Code on
|
|
631
|
-
// this machine", which is neither.
|
|
632
|
-
const missing = r.code === "ENOENT";
|
|
633
|
-
if (!r.ok) {
|
|
634
|
-
const msg = stripAnsi(r.stderr).trim() || `claude exited ${r.code}`;
|
|
635
|
-
if (msg !== _saidFailure) {
|
|
636
|
-
_saidFailure = msg;
|
|
637
|
-
console.error(`${PRODUCT} quota: claude CLI failed:`, msg);
|
|
638
|
-
}
|
|
639
|
-
} else {
|
|
640
|
-
_saidFailure = null;
|
|
641
|
-
}
|
|
642
|
-
const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
|
|
643
|
-
return { cliOk, missing, parsed: parseUsageText(combined) };
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
async function _doFetch(now, force = false, gen = _generation) {
|
|
647
|
-
// Source 1: claude-swap's store. Free, and already paid for.
|
|
648
|
-
let store = await storeQuota();
|
|
649
|
-
|
|
650
|
-
// Refresh asks for newer numbers, and the honest way to get them from this
|
|
651
|
-
// source is to ask the collector that owns it — which applies its own
|
|
652
|
-
// schedule and backoff, so this cannot become a poll loop.
|
|
653
|
-
if (force && (!store || now - store.fetchedAt > FORCE_POLL_MS)) {
|
|
654
|
-
store = await nudgeAndReread(store);
|
|
655
|
-
}
|
|
656
|
-
if (store && now - store.fetchedAt <= STORE_TRUSTED_MS) {
|
|
657
|
-
// Keep the store moving even when the accounts panel is closed. Without
|
|
658
|
-
// this the numbers only advance while something else asks — claude-swap's
|
|
659
|
-
// own schedule still decides whether this touches the network, and the
|
|
660
|
-
// throttle inside is shared with the accounts panel, so two open panels
|
|
661
|
-
// ask no more often than one.
|
|
662
|
-
if (!force) requestCollection().catch(() => {});
|
|
663
|
-
return publish(gen, store, now, { good: true });
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// Nothing usable in the store. Everything below spends the user's budget, so
|
|
667
|
-
// it happens on a floor, and not at all while a 429 cooldown is running.
|
|
668
|
-
if (!maySelfPoll({ now, force, lastSelfPollAt: _lastSelfPollAt, rateLimitedUntil: _rateLimitedUntil })) {
|
|
669
|
-
// A stale row still beats an empty panel, and says how stale it is — but
|
|
670
|
-
// it must be the freshest thing we hold, not just the store. Preferring
|
|
671
|
-
// the store here threw away readings we had already paid for: after a boot
|
|
672
|
-
// that fell through to the CLI, the panel showed 3% (fetched seconds ago)
|
|
673
|
-
// and then reverted to 23% (from a 48-minute-old store row) on the very
|
|
674
|
-
// next poll, because the store had not moved.
|
|
675
|
-
const held = freshest(store, _lastGood);
|
|
676
|
-
if (held) return publish(gen, { ...held, stale: true }, now);
|
|
677
|
-
const result = { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
|
|
678
|
-
return publish(gen, result, now - (CACHE_MS - 5_000));
|
|
679
|
-
}
|
|
680
|
-
_lastSelfPollAt = now;
|
|
681
|
-
|
|
682
|
-
// Source 2: OAuth usage API — instant, exact, no cold-start gap.
|
|
683
|
-
const api = await fetchOAuthUsage();
|
|
684
|
-
if (api) {
|
|
685
|
-
return publish(gen, { ok: true, ...api, source: "api", fetchedAt: now }, now, { good: true });
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// Source 3: parse `claude --print /usage` CLI output.
|
|
689
|
-
const bin = quotaClaudeBin();
|
|
690
|
-
|
|
691
|
-
// The CLI sometimes omits the "Current session/week" quota lines on a cold
|
|
692
|
-
// invocation (right after the server starts, or after the page is hard-
|
|
693
|
-
// refreshed). The real lines appear on a subsequent call. Retry a couple
|
|
694
|
-
// times before giving up so the first paint already shows real values.
|
|
695
|
-
let cliOk = false;
|
|
696
|
-
let parsed = null;
|
|
697
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
698
|
-
if (attempt > 0) await sleep(1200);
|
|
699
|
-
const r = await _execOnce(bin);
|
|
700
|
-
cliOk = r.cliOk || cliOk;
|
|
701
|
-
if (r.parsed) { parsed = r.parsed; break; }
|
|
702
|
-
// The retry exists for a CLI that RAN and left the quota lines out of a cold
|
|
703
|
-
// invocation. A CLI that is not installed will not be installed 1.2 seconds
|
|
704
|
-
// from now, and asking twice more spends two spawns and 2.4 seconds of the
|
|
705
|
-
// caller's wait to print the same sentence three times. See _execOnce.
|
|
706
|
-
if (r.missing) break;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// Got real quota lines — cache normally and remember as last-known-good.
|
|
710
|
-
if (parsed) {
|
|
711
|
-
return publish(gen, { ok: true, ...parsed, source: "cli", fetchedAt: now }, now, { good: true });
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// No quota lines after retries. If we've ever seen real values, keep showing
|
|
715
|
-
// them rather than regressing to 0% on a transient empty read — with the
|
|
716
|
-
// timestamp of the answer they actually are. Re-stamping them `now` put "just
|
|
717
|
-
// now" over percentages collected hours earlier for one poll in five, then let
|
|
718
|
-
// the label snap back to the true age: an age indicator that oscillates, and
|
|
719
|
-
// vouches for numbers this branch already knows are stale. Short-cache so we
|
|
720
|
-
// retry the CLI again soon.
|
|
721
|
-
if (_lastGood) {
|
|
722
|
-
return publish(gen, { ..._lastGood, stale: true }, now - (CACHE_MS - 5_000));
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Never had good data. A CLI that RAN and printed no quota lines is two
|
|
726
|
-
// different machines, and they need two different answers:
|
|
727
|
-
//
|
|
728
|
-
// * a subscription install on a cold invocation — the lines come back on a
|
|
729
|
-
// later call, and until then "<1%" is the honest reading of a window that
|
|
730
|
-
// has genuinely just reset;
|
|
731
|
-
// * an API-key, Bedrock or Vertex install, which has no windows at all.
|
|
732
|
-
// Publishing two zeroes there drew empty bars for a measurement nobody
|
|
733
|
-
// took, on a machine where no amount of retrying will ever produce one.
|
|
734
|
-
//
|
|
735
|
-
// A CLI that failed entirely is `ok: false` as it always was, and the reason
|
|
736
|
-
// says which of the two the reader is looking at.
|
|
737
|
-
const subscribed = cliOk ? await hasSubscriptionCredential() : false;
|
|
738
|
-
const result = cliOk && subscribed
|
|
739
|
-
? { ok: true, session5hPct: 0, session5hWindowSec: 18000,
|
|
740
|
-
week7dPct: 0, week7dWindowSec: 604800, fetchedAt: now }
|
|
741
|
-
: { ok: false, reason: cliOk ? "no_subscription" : "cli_failed", fetchedAt: now };
|
|
742
|
-
return publish(gen, result, now - (CACHE_MS - 5_000));
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* Forget every reading held for the account that was active when it was taken.
|
|
747
|
-
*
|
|
748
|
-
* `?refresh=1` is the browser asking for a fresher read; this is the server
|
|
749
|
-
* knowing the numbers it holds are the wrong account's. A Claude account switch
|
|
750
|
-
* makes them that — every percentage here belongs to whoever was active when it
|
|
751
|
-
* was collected — and the switch happens server-side, where no tab is in a
|
|
752
|
-
* position to send the flag: it is driven from the accounts panel, and the usage
|
|
753
|
-
* panel neither owns that state nor hears about it.
|
|
754
|
-
*
|
|
755
|
-
* `_lastGood` goes with the result cache, and it is the half that matters.
|
|
756
|
-
* Clearing `_cache` alone only shortens the wrong answer's life to the next
|
|
757
|
-
* poll, because both fallbacks in _doFetch hand `_lastGood` straight back — and
|
|
758
|
-
* freshest() ranks by fetchedAt, so a reading the deck already paid for beats
|
|
759
|
-
* any row the store holds for an account nobody has collected for since. The
|
|
760
|
-
* panel would print the previous account's percentages under a "stale" label
|
|
761
|
-
* instead of admitting it has no answer for this one yet.
|
|
762
|
-
*
|
|
763
|
-
* The self-poll floor deliberately survives: a switch is not a reason to spend
|
|
764
|
-
* the shared request budget, and one that reset it would make switching a way to
|
|
765
|
-
* hammer it. Until the store answers for the new account, "no reading yet" is
|
|
766
|
-
* the honest thing to serve.
|
|
767
|
-
*
|
|
768
|
-
* Clearing the three variables is not enough on its own, because a fetch that is
|
|
769
|
-
* already running is not a variable. `_doFetch` writes `_cache` and `_lastGood`
|
|
770
|
-
* AFTER its awaits, so one that read the store before the switch and resolves
|
|
771
|
-
* after it put the previous account's numbers back into both, undoing this call
|
|
772
|
-
* from the other side of an await — and callers arriving in that window were
|
|
773
|
-
* handed the same in-flight promise rather than a read that knows the account
|
|
774
|
-
* moved. The window is real: a forced fetch goes through nudgeAndReread, which
|
|
775
|
-
* sleeps REREAD_TRIES * REREAD_GAP_MS = 2.4 seconds by construction, comfortably
|
|
776
|
-
* longer than a `cswap switch`.
|
|
777
|
-
*
|
|
778
|
-
* So the generation counter moves too. Every write in `_doFetch` is stamped with
|
|
779
|
-
* the generation that was current when that read started, and publish() drops
|
|
780
|
-
* any write whose stamp is stale — the fetch still resolves, and whoever asked
|
|
781
|
-
* for it still gets its answer, but that answer no longer becomes this module's.
|
|
782
|
-
* `_inflight` is released for the same reason: the next caller must start a read
|
|
783
|
-
* of its own rather than join one that is describing the account the deck just
|
|
784
|
-
* left.
|
|
785
|
-
*/
|
|
786
|
-
export function invalidateQuotaCache() {
|
|
787
|
-
_cache = null;
|
|
788
|
-
_cacheAt = 0;
|
|
789
|
-
_lastGood = null;
|
|
790
|
-
_generation++;
|
|
791
|
-
_inflight = null;
|
|
792
|
-
}
|