agents-deck 1.20.1 → 1.21.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.
@@ -1,21 +1,145 @@
1
- // Fetches Claude rate-limit quota by running `claude --print /usage`.
2
- // On Windows the binary is a .cmd wrapper — we use exec() (shell-based)
3
- // so that cmd.exe handles quoting and stdin redirect correctly.
4
- // Caches the result for 2 minutes.
1
+ // Fetches Claude rate-limit quota.
2
+ //
3
+ // Primary source: Anthropic's OAuth usage API
4
+ // GET https://api.anthropic.com/api/oauth/usage
5
+ // Auth: Bearer token from ~/.claude/.credentials.json (claudeAiOauth.accessToken)
6
+ // This is instant and exact — same data the `/usage` command shows, but with no
7
+ // cold-start gap (the CLI omits the quota lines on its first invocation after
8
+ // idle). Mechanism reverse-engineered from steipete/CodexBar.
9
+ //
10
+ // Fallback: parse `claude --print /usage` CLI output (used only if the API call
11
+ // fails — no token, expired token, network error). On Windows the binary is a
12
+ // .cmd wrapper, so we use exec() (shell-based) for correct quoting + stdin.
13
+ //
14
+ // Result is cached for 60s.
5
15
  import { exec } from "node:child_process";
6
16
  import { promisify } from "node:util";
7
17
  import { existsSync } from "node:fs";
18
+ import { readFile } from "node:fs/promises";
8
19
  import { join } from "node:path";
9
20
  import { homedir, platform } from "node:os";
10
21
 
11
22
  const execAsync = promisify(exec);
12
23
  const IS_WIN = platform() === "win32";
13
24
 
25
+ const CREDS_PATH = join(homedir(), ".claude", ".credentials.json");
26
+ const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
27
+ const BETA_HEADER = "oauth-2025-04-20";
28
+ const WIN_5H_SEC = 18000;
29
+ const WIN_7D_SEC = 604800;
30
+
31
+ // 429 cooldown gate — after a rate-limit, skip the API until this passes.
32
+ let _rateLimitedUntil = 0;
33
+
34
+ async function readOAuthToken() {
35
+ try {
36
+ const raw = await readFile(CREDS_PATH, "utf8");
37
+ const auth = JSON.parse(raw)?.claudeAiOauth;
38
+ if (!auth?.accessToken) return null;
39
+ // expiresAt is epoch milliseconds. If expired, the CLI fallback handles it.
40
+ if (auth.expiresAt && Date.now() >= auth.expiresAt) return null;
41
+ return auth.accessToken;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // ISO-8601 → "Jun 19, 1:19pm" (local time, matching the CLI display format).
48
+ function fmtResetIso(iso) {
49
+ if (!iso) return null;
50
+ const d = new Date(iso);
51
+ if (isNaN(d.getTime())) return null;
52
+ // "Jun 19, 1:19 PM" → "Jun 19, 1:19pm" (matches the CLI display format)
53
+ return d.toLocaleString("en-US", {
54
+ month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true,
55
+ }).replace(/\s+(AM|PM)/, (_, p) => p.toLowerCase());
56
+ }
57
+
58
+ function isoToSec(iso) {
59
+ if (!iso) return null;
60
+ const t = new Date(iso).getTime();
61
+ return isNaN(t) ? null : Math.floor(t / 1000);
62
+ }
63
+
64
+ // Map the OAuth usage JSON to our quota result shape.
65
+ // utilization is already a 0–100 percentage. 5h falls back to 7d if absent.
66
+ function mapOAuthUsage(data) {
67
+ const fh = data?.five_hour;
68
+ const sd = data?.seven_day;
69
+ const son = data?.seven_day_sonnet;
70
+ const opus = data?.seven_day_opus;
71
+
72
+ const primary = (fh?.utilization != null) ? fh : sd;
73
+ if (!primary || primary.utilization == null) return null;
74
+
75
+ const round = (v) => Math.min(100, Math.max(0, Math.round(v)));
76
+ const result = {
77
+ session5hPct: round(primary.utilization),
78
+ session5hWindowSec: WIN_5H_SEC,
79
+ session5hReset: fmtResetIso(primary.resets_at),
80
+ session5hResetAt: isoToSec(primary.resets_at),
81
+ week7dWindowSec: WIN_7D_SEC,
82
+ };
83
+ if (sd?.utilization != null) {
84
+ result.week7dPct = round(sd.utilization);
85
+ result.week7dReset = fmtResetIso(sd.resets_at);
86
+ result.week7dResetAt = isoToSec(sd.resets_at);
87
+ } else {
88
+ result.week7dPct = 0;
89
+ }
90
+ if (son?.utilization != null) result.weekSonnetPct = round(son.utilization);
91
+ if (opus?.utilization != null) result.weekOpusPct = round(opus.utilization);
92
+
93
+ // extra usage credits (pay-as-you-go top-up), if enabled
94
+ const extra = data?.extra_usage;
95
+ if (extra?.is_enabled) {
96
+ result.extraEnabled = true;
97
+ if (extra.used_credits != null) result.extraUsedCredits = extra.used_credits;
98
+ if (extra.monthly_limit != null) result.extraMonthlyLimit = extra.monthly_limit;
99
+ if (extra.currency) result.extraCurrency = extra.currency;
100
+ }
101
+ return result;
102
+ }
103
+
104
+ async function fetchOAuthUsage() {
105
+ if (Date.now() < _rateLimitedUntil) return null;
106
+ const token = await readOAuthToken();
107
+ if (!token) return null;
108
+
109
+ try {
110
+ const res = await fetch(USAGE_URL, {
111
+ headers: {
112
+ "Authorization": `Bearer ${token}`,
113
+ "anthropic-beta": BETA_HEADER,
114
+ "Accept": "application/json",
115
+ "Content-Type": "application/json",
116
+ "User-Agent": "claude-code/2.1.0",
117
+ },
118
+ signal: AbortSignal.timeout(15_000),
119
+ });
120
+
121
+ if (res.status === 429) {
122
+ const retryAfter = parseInt(res.headers.get("retry-after") ?? "", 10);
123
+ const cooldownMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 5 * 60_000;
124
+ _rateLimitedUntil = Date.now() + cooldownMs;
125
+ return null;
126
+ }
127
+ if (!res.ok) return null;
128
+
129
+ return mapOAuthUsage(await res.json());
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
14
135
  let _cache = null;
15
136
  let _cacheAt = 0;
16
137
  let _inflight = null; // deduplicates concurrent exec() calls
138
+ let _lastGood = null; // last result that had real quota percentages
17
139
  const CACHE_MS = 60_000;
18
140
 
141
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
142
+
19
143
  function stripAnsi(s) {
20
144
  return s
21
145
  .replace(/\x1B\[[0-9;]*[A-Za-z]/g, "")
@@ -132,11 +256,11 @@ export async function fetchClaudeQuota({ force = false } = {}) {
132
256
  return _inflight;
133
257
  }
134
258
 
135
- async function _doFetch(now) {
136
- const shellCmd = buildQuotaShellCmd();
137
- let parsed = null;
138
- let cliOk = false;
139
-
259
+ // Run `claude --print /usage` once. Returns { cliOk, parsed }.
260
+ // cliOk the CLI ran and we recognized its output (preamble present)
261
+ // parsed quota percentages object, or null if the "Current session/week"
262
+ // lines were absent (CLI cold-start, or genuinely <1% usage)
263
+ async function _execOnce(shellCmd) {
140
264
  try {
141
265
  const { stdout, stderr } = await execAsync(shellCmd, {
142
266
  timeout: 15_000,
@@ -144,38 +268,71 @@ async function _doFetch(now) {
144
268
  maxBuffer: 1024 * 1024,
145
269
  });
146
270
  const combined = stdout + "\n" + stderr;
147
- cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
148
- parsed = parseUsageText(combined);
271
+ const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
272
+ return { cliOk, parsed: parseUsageText(combined) };
149
273
  } catch (err) {
150
274
  const msg = err?.stderr ? stripAnsi(err.stderr).trim() : (err?.message ?? String(err));
151
275
  console.error("agents-deck quota: claude CLI failed:", msg);
152
276
  if (err?.stdout || err?.stderr) {
153
277
  const combined = (err.stdout ?? "") + "\n" + (err.stderr ?? "");
154
- cliOk = /subscription/i.test(combined);
155
- parsed = parseUsageText(combined);
278
+ return { cliOk: /subscription/i.test(combined), parsed: parseUsageText(combined) };
156
279
  }
280
+ return { cliOk: false, parsed: null };
157
281
  }
282
+ }
158
283
 
159
- // CLI ran OK but quota lines absent — this happens when:
160
- // (a) near-zero usage in the rolling window (Claude omits bars below ~1%), or
161
- // (b) CLI cold-start: the server-side rolling window hasn't been computed yet.
162
- // In case (b) the real value appears within ~60s. Use a 5s short-cache so
163
- // the UI polls again quickly and shows real values as soon as they're ready.
164
- const shortCache = cliOk && !parsed;
165
- if (shortCache) {
166
- parsed = {
167
- session5hPct: 0, session5hWindowSec: 18000,
168
- week7dPct: 0, week7dWindowSec: 604800,
169
- };
284
+ async function _doFetch(now) {
285
+ // Primary: OAuth usage API instant, exact, no cold-start gap.
286
+ const api = await fetchOAuthUsage();
287
+ if (api) {
288
+ const result = { ok: true, ...api, source: "api", fetchedAt: now };
289
+ _cache = result; _cacheAt = now;
290
+ _lastGood = result;
291
+ return result;
170
292
  }
171
293
 
172
- const result = parsed
173
- ? { ok: true, ...parsed, fetchedAt: now }
174
- : { ok: false, fetchedAt: now };
294
+ // Fallback: parse `claude --print /usage` CLI output.
295
+ const shellCmd = buildQuotaShellCmd();
296
+
297
+ // The CLI sometimes omits the "Current session/week" quota lines on a cold
298
+ // invocation (right after the server starts, or after the page is hard-
299
+ // refreshed). The real lines appear on a subsequent call. Retry a couple
300
+ // times before giving up so the first paint already shows real values.
301
+ let cliOk = false;
302
+ let parsed = null;
303
+ for (let attempt = 0; attempt < 3; attempt++) {
304
+ if (attempt > 0) await sleep(1200);
305
+ const r = await _execOnce(shellCmd);
306
+ cliOk = r.cliOk || cliOk;
307
+ if (r.parsed) { parsed = r.parsed; break; }
308
+ }
309
+
310
+ // Got real quota lines — cache normally and remember as last-known-good.
311
+ if (parsed) {
312
+ const result = { ok: true, ...parsed, fetchedAt: now };
313
+ _cache = result; _cacheAt = now;
314
+ _lastGood = result;
315
+ return result;
316
+ }
175
317
 
176
- _cache = result;
177
- // Short-cache the 0% fallback so the UI retries in ~5s rather than 60s.
178
- _cacheAt = shortCache ? now - (CACHE_MS - 5_000) : now;
318
+ // No quota lines after retries. If we've ever seen real values, keep showing
319
+ // them rather than regressing to 0% on a transient empty read. Short-cache so
320
+ // we retry the CLI again soon.
321
+ if (_lastGood) {
322
+ const result = { ..._lastGood, fetchedAt: now, stale: true };
323
+ _cache = result;
324
+ _cacheAt = now - (CACHE_MS - 5_000);
325
+ return result;
326
+ }
327
+
328
+ // Never had good data. CLI ran but lines absent → treat as genuine <1%.
329
+ // CLI failed entirely → ok:false. Either way short-cache for a quick retry.
330
+ const result = cliOk
331
+ ? { ok: true, session5hPct: 0, session5hWindowSec: 18000,
332
+ week7dPct: 0, week7dWindowSec: 604800, fetchedAt: now }
333
+ : { ok: false, fetchedAt: now };
334
+ _cache = result;
335
+ _cacheAt = now - (CACHE_MS - 5_000);
179
336
  return result;
180
337
  }
181
338