agents-deck 1.19.0 → 1.20.1

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,18 +1,20 @@
1
1
  // Fetches Claude rate-limit quota by running `claude --print /usage`.
2
- // On Windows the binary is a .cmd wrapper — we run it via cmd.exe.
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.
3
4
  // Caches the result for 2 minutes.
4
- import { execFile } from "node:child_process";
5
+ import { exec } from "node:child_process";
5
6
  import { promisify } from "node:util";
6
7
  import { existsSync } from "node:fs";
7
8
  import { join } from "node:path";
8
9
  import { homedir, platform } from "node:os";
9
10
 
10
- const execFileAsync = promisify(execFile);
11
+ const execAsync = promisify(exec);
11
12
  const IS_WIN = platform() === "win32";
12
13
 
13
- let _cache = null;
14
- let _cacheAt = 0;
15
- const CACHE_MS = 120_000;
14
+ let _cache = null;
15
+ let _cacheAt = 0;
16
+ let _inflight = null; // deduplicates concurrent exec() calls
17
+ const CACHE_MS = 60_000;
16
18
 
17
19
  function stripAnsi(s) {
18
20
  return s
@@ -30,6 +32,21 @@ function stripAnsi(s) {
30
32
  * "Current week (Sonnet only): 48% used · resets Jun 21, 9am (Europe/Chisinau)"
31
33
  * "Current week (Opus only): ..." (if present)
32
34
  */
35
+ // Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
36
+ // Claude shows times in the user's local timezone, so parsing as local is correct.
37
+ function parseResetToSec(resetStr) {
38
+ if (!resetStr) return null;
39
+ try {
40
+ const year = new Date().getFullYear();
41
+ // "4:09pm" → "4:09 PM" so Date.parse handles it
42
+ const norm = resetStr
43
+ .replace(/(\d{1,2}:\d{2})(am|pm)/i, "$1 $2")
44
+ .trim();
45
+ const d = new Date(`${norm} ${year}`);
46
+ return isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
47
+ } catch { return null; }
48
+ }
49
+
33
50
  function parseUsageText(raw) {
34
51
  const text = stripAnsi(raw);
35
52
  const result = {};
@@ -40,27 +57,30 @@ function parseUsageText(raw) {
40
57
  if (!line) return null;
41
58
  const pctM = line.match(/(\d{1,3})\s*%/);
42
59
  const resetM = line.match(/resets\s+(.+)/i);
60
+ const resetFull = resetM
61
+ ? resetM[1].replace(/\(.*?\)/g, "").replace(/·/g, "").trim()
62
+ : null;
43
63
  return {
44
- pct: pctM ? Math.min(100, parseInt(pctM[1], 10)) : null,
45
- reset: resetM
46
- ? resetM[1]
47
- .replace(/\(.*?\)/g, "") // strip timezone in parens
48
- .replace(/·/g, "")
49
- .trim()
50
- : null,
64
+ pct: pctM ? Math.min(100, parseInt(pctM[1], 10)) : null,
65
+ reset: resetFull,
66
+ resetAt: parseResetToSec(resetFull),
51
67
  };
52
68
  };
53
69
 
54
70
  const session = extract(/current session/i);
55
71
  if (session?.pct != null) {
56
- result.session5hPct = session.pct;
57
- if (session.reset) result.session5hReset = session.reset;
72
+ result.session5hPct = session.pct;
73
+ result.session5hWindowSec = 18000;
74
+ if (session.reset) result.session5hReset = session.reset;
75
+ if (session.resetAt) result.session5hResetAt = session.resetAt;
58
76
  }
59
77
 
60
78
  const weekAll = extract(/current week\s*\(all models\)/i) || extract(/current week\s*[:·]/i);
61
79
  if (weekAll?.pct != null) {
62
- result.week7dPct = weekAll.pct;
63
- if (weekAll.reset) result.week7dReset = weekAll.reset;
80
+ result.week7dPct = weekAll.pct;
81
+ result.week7dWindowSec = 604800;
82
+ if (weekAll.reset) result.week7dReset = weekAll.reset;
83
+ if (weekAll.resetAt) result.week7dResetAt = weekAll.resetAt;
64
84
  }
65
85
 
66
86
  const weekSon = extract(/current week\s*\(sonnet/i);
@@ -72,17 +92,21 @@ function parseUsageText(raw) {
72
92
  return Object.keys(result).length > 0 ? result : null;
73
93
  }
74
94
 
75
- /** Resolve the claude binary.
76
- * On Windows, claude is a .cmd wrapper — return the full path so we can
77
- * invoke it via cmd.exe. On Unix, look for the binary in common locations. */
78
- function findClaudeBin() {
95
+ /** Build the shell command string for `claude --print /usage`.
96
+ *
97
+ * We use exec() (shell-based) so cmd.exe / sh processes redirects.
98
+ * On Windows: `< nul` closes stdin immediately, preventing the 3-second
99
+ * "no stdin data" wait the claude CLI does when it detects a pipe.
100
+ * On Unix: `< /dev/null` has the same effect.
101
+ */
102
+ function buildQuotaShellCmd() {
79
103
  if (IS_WIN) {
80
104
  const npmBin = join(homedir(), "AppData", "Roaming", "npm", "claude.cmd");
81
- if (existsSync(npmBin)) return { cmd: "cmd.exe", args: ["/c", npmBin] };
82
- // Fallback: let cmd.exe find it via PATH.
83
- return { cmd: "cmd.exe", args: ["/c", "claude.cmd"] };
105
+ const bin = existsSync(npmBin) ? npmBin : "claude.cmd";
106
+ // exec() on Windows uses cmd /c, so < nul redirect works fine.
107
+ // Wrap path in quotes in case of spaces in username.
108
+ return `"${bin}" --print /usage < nul`;
84
109
  }
85
- // Unix: check PATH, then known install locations.
86
110
  const candidates = [
87
111
  "claude",
88
112
  join(homedir(), ".local", "bin", "claude"),
@@ -90,40 +114,68 @@ function findClaudeBin() {
90
114
  "/opt/homebrew/bin/claude",
91
115
  ];
92
116
  for (const c of candidates) {
93
- if (!c.includes("/")) return { cmd: c, args: [] };
94
- if (existsSync(c)) return { cmd: c, args: [] };
117
+ if (!c.includes("/") || existsSync(c)) return `${c} --print /usage < /dev/null`;
95
118
  }
96
- return { cmd: "claude", args: [] };
119
+ return "claude --print /usage < /dev/null";
97
120
  }
98
121
 
99
122
  export async function fetchClaudeQuota({ force = false } = {}) {
100
123
  const now = Date.now();
101
124
  if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
102
125
 
103
- const { cmd, args } = findClaudeBin();
126
+ // If another exec() is already in flight, wait for it instead of spawning a
127
+ // second concurrent process (which can return empty output and overwrite the
128
+ // good result with 0%).
129
+ if (_inflight) return _inflight;
130
+
131
+ _inflight = _doFetch(now).finally(() => { _inflight = null; });
132
+ return _inflight;
133
+ }
134
+
135
+ async function _doFetch(now) {
136
+ const shellCmd = buildQuotaShellCmd();
104
137
  let parsed = null;
138
+ let cliOk = false;
105
139
 
106
140
  try {
107
- const { stdout, stderr } = await execFileAsync(
108
- cmd,
109
- [...args, "--print", "/usage"],
110
- {
111
- timeout: 12_000,
112
- env: { ...process.env, NO_COLOR: "1", TERM: "dumb" },
113
- }
114
- );
115
- parsed = parseUsageText(stdout + "\n" + stderr);
141
+ const { stdout, stderr } = await execAsync(shellCmd, {
142
+ timeout: 15_000,
143
+ env: { ...process.env, NO_COLOR: "1", TERM: "dumb" },
144
+ maxBuffer: 1024 * 1024,
145
+ });
146
+ const combined = stdout + "\n" + stderr;
147
+ cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
148
+ parsed = parseUsageText(combined);
116
149
  } catch (err) {
117
- // Binary not found or timed out degrade gracefully.
118
- console.error("agents-deck quota: claude CLI failed:", err?.message ?? err);
150
+ const msg = err?.stderr ? stripAnsi(err.stderr).trim() : (err?.message ?? String(err));
151
+ console.error("agents-deck quota: claude CLI failed:", msg);
152
+ if (err?.stdout || err?.stderr) {
153
+ const combined = (err.stdout ?? "") + "\n" + (err.stderr ?? "");
154
+ cliOk = /subscription/i.test(combined);
155
+ parsed = parseUsageText(combined);
156
+ }
157
+ }
158
+
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
+ };
119
170
  }
120
171
 
121
172
  const result = parsed
122
173
  ? { ok: true, ...parsed, fetchedAt: now }
123
174
  : { ok: false, fetchedAt: now };
124
175
 
125
- _cache = result;
126
- _cacheAt = now;
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;
127
179
  return result;
128
180
  }
129
181