agents-deck 1.20.0 → 1.20.2

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agents-deck</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
8
- <script type="module" crossorigin src="/assets/index-xwyRF1NY.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-Cb1bIpZv.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-_qWQOt8_.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-deck",
3
- "version": "1.20.0",
3
+ "version": "1.20.2",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,20 +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
 
14
- let _cache = null;
15
- let _cacheAt = 0;
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
+
135
+ let _cache = null;
136
+ let _cacheAt = 0;
137
+ let _inflight = null; // deduplicates concurrent exec() calls
138
+ let _lastGood = null; // last result that had real quota percentages
16
139
  const CACHE_MS = 60_000;
17
140
 
141
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
142
+
18
143
  function stripAnsi(s) {
19
144
  return s
20
145
  .replace(/\x1B\[[0-9;]*[A-Za-z]/g, "")
@@ -122,10 +247,20 @@ export async function fetchClaudeQuota({ force = false } = {}) {
122
247
  const now = Date.now();
123
248
  if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
124
249
 
125
- const shellCmd = buildQuotaShellCmd();
126
- let parsed = null;
250
+ // If another exec() is already in flight, wait for it instead of spawning a
251
+ // second concurrent process (which can return empty output and overwrite the
252
+ // good result with 0%).
253
+ if (_inflight) return _inflight;
127
254
 
128
- let cliOk = false;
255
+ _inflight = _doFetch(now).finally(() => { _inflight = null; });
256
+ return _inflight;
257
+ }
258
+
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) {
129
264
  try {
130
265
  const { stdout, stderr } = await execAsync(shellCmd, {
131
266
  timeout: 15_000,
@@ -133,34 +268,71 @@ export async function fetchClaudeQuota({ force = false } = {}) {
133
268
  maxBuffer: 1024 * 1024,
134
269
  });
135
270
  const combined = stdout + "\n" + stderr;
136
- // CLI ran successfully if we see the subscription preamble.
137
- cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
138
- parsed = parseUsageText(combined);
271
+ const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
272
+ return { cliOk, parsed: parseUsageText(combined) };
139
273
  } catch (err) {
140
274
  const msg = err?.stderr ? stripAnsi(err.stderr).trim() : (err?.message ?? String(err));
141
275
  console.error("agents-deck quota: claude CLI failed:", msg);
142
276
  if (err?.stdout || err?.stderr) {
143
277
  const combined = (err.stdout ?? "") + "\n" + (err.stderr ?? "");
144
- cliOk = /subscription/i.test(combined);
145
- parsed = parseUsageText(combined);
278
+ return { cliOk: /subscription/i.test(combined), parsed: parseUsageText(combined) };
146
279
  }
280
+ return { cliOk: false, parsed: null };
147
281
  }
282
+ }
148
283
 
149
- // CLI ran OK but quota lines were absent — this happens when the rolling
150
- // window has near-zero usage (Claude omits bars below ~1%). Treat as 0%.
151
- if (cliOk && !parsed) {
152
- parsed = {
153
- session5hPct: 0, session5hWindowSec: 18000,
154
- week7dPct: 0, week7dWindowSec: 604800,
155
- };
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;
156
292
  }
157
293
 
158
- const result = parsed
159
- ? { ok: true, ...parsed, fetchedAt: now }
160
- : { 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
+ }
161
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
+ }
317
+
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 };
162
334
  _cache = result;
163
- _cacheAt = now;
335
+ _cacheAt = now - (CACHE_MS - 5_000);
164
336
  return result;
165
337
  }
166
338