agents-deck 1.19.0 → 1.20.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/dist/web/assets/{index-CVsr6RfQ.css → index-_qWQOt8_.css} +1 -1
- package/dist/web/assets/index-xwyRF1NY.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/server/codex-quota.mjs +114 -0
- package/src/server/codex-usage.mjs +162 -0
- package/src/server/index.mjs +27 -3
- package/src/server/quota.mjs +75 -38
- package/dist/web/assets/index-Bs89BdrR.js +0 -62
package/dist/web/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-xwyRF1NY.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-_qWQOt8_.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Fetches Codex/ChatGPT quota percentages from chatgpt.com/backend-api/wham/usage.
|
|
2
|
+
// Auth: reads access_token from ~/.codex/auth.json (written by the Codex CLI login).
|
|
3
|
+
// Returns the same shape as Claude quota so the UI can use identical QuotaBar components.
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
const CODEX_HOME = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
9
|
+
const AUTH_PATH = join(CODEX_HOME, "auth.json");
|
|
10
|
+
const WHAM_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
11
|
+
|
|
12
|
+
let _cache = null;
|
|
13
|
+
let _cacheAt = 0;
|
|
14
|
+
const CACHE_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
async function readAccessToken() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(AUTH_PATH, "utf8");
|
|
19
|
+
const auth = JSON.parse(raw);
|
|
20
|
+
return auth?.tokens?.access_token ?? null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Format a unix timestamp into "Jun 18, 4:09pm" style (same as claude quota output).
|
|
27
|
+
function fmtReset(unixSec) {
|
|
28
|
+
if (!unixSec) return null;
|
|
29
|
+
const d = new Date(unixSec * 1000);
|
|
30
|
+
return d.toLocaleString("en-US", {
|
|
31
|
+
month: "short",
|
|
32
|
+
day: "numeric",
|
|
33
|
+
hour: "numeric",
|
|
34
|
+
minute: "2-digit",
|
|
35
|
+
hour12: true,
|
|
36
|
+
}).replace(",", "").toLowerCase().replace(/\s+am/, "am").replace(/\s+pm/, "pm");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function fetchCodexQuota({ force = false } = {}) {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
42
|
+
|
|
43
|
+
const token = await readAccessToken();
|
|
44
|
+
if (!token) {
|
|
45
|
+
const r = { ok: false, reason: "no_token", fetchedAt: now };
|
|
46
|
+
_cache = r; _cacheAt = now;
|
|
47
|
+
return r;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(WHAM_URL, {
|
|
53
|
+
headers: {
|
|
54
|
+
"Authorization": `Bearer ${token}`,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
"User-Agent": "Mozilla/5.0 (compatible; agents-deck)",
|
|
57
|
+
},
|
|
58
|
+
signal: AbortSignal.timeout(12_000),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const r = { ok: false, reason: `http_${res.status}`, fetchedAt: now };
|
|
63
|
+
_cache = r; _cacheAt = now;
|
|
64
|
+
return r;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
const rl = data?.rate_limit;
|
|
69
|
+
const pw = rl?.primary_window; // 5-hour session window
|
|
70
|
+
const sw = rl?.secondary_window; // 7-day weekly window
|
|
71
|
+
|
|
72
|
+
const creds = data?.credits;
|
|
73
|
+
result = {
|
|
74
|
+
ok: true,
|
|
75
|
+
limitReached: rl?.limit_reached ?? false,
|
|
76
|
+
session5hPct: pw?.used_percent ?? null,
|
|
77
|
+
session5hReset: pw?.reset_at ? fmtReset(pw.reset_at) : null,
|
|
78
|
+
session5hResetAt: pw?.reset_at ?? null, // unix seconds
|
|
79
|
+
session5hWindowSec: pw?.limit_window_seconds ?? 18000,
|
|
80
|
+
week7dPct: sw?.used_percent ?? null,
|
|
81
|
+
week7dReset: sw?.reset_at ? fmtReset(sw.reset_at) : null,
|
|
82
|
+
week7dResetAt: sw?.reset_at ?? null, // unix seconds
|
|
83
|
+
week7dWindowSec: sw?.limit_window_seconds ?? 604800,
|
|
84
|
+
// credits (ChatGPT Plus top-up credits if any)
|
|
85
|
+
creditsBalance: creds?.has_credits ? creds.balance : null,
|
|
86
|
+
creditsUnlimited: creds?.unlimited ?? false,
|
|
87
|
+
planType: data?.plan_type ?? null,
|
|
88
|
+
fetchedAt: now,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Additional model-specific limits (e.g. Codex Spark)
|
|
92
|
+
const extra = rl?.additional_rate_limits;
|
|
93
|
+
if (extra && typeof extra === "object") {
|
|
94
|
+
for (const [key, win] of Object.entries(extra)) {
|
|
95
|
+
if (win?.used_percent != null) {
|
|
96
|
+
result[`extra_${key}_pct`] = win.used_percent;
|
|
97
|
+
if (win.reset_at) result[`extra_${key}_reset`] = fmtReset(win.reset_at);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error("agents-deck codex-quota: fetch failed:", err?.message ?? err);
|
|
103
|
+
result = { ok: false, reason: "fetch_error", fetchedAt: now };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_cache = result;
|
|
107
|
+
_cacheAt = now;
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function invalidateCodexQuotaCache() {
|
|
112
|
+
_cache = null;
|
|
113
|
+
_cacheAt = 0;
|
|
114
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Aggregates Codex token usage from ~/.codex/sessions rollout JSONL files.
|
|
2
|
+
// Unlike Claude, Codex has no CLI quota command — we derive usage from the
|
|
3
|
+
// actual session logs for 5h and 7d rolling windows.
|
|
4
|
+
import { readdir, open, stat } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
const CODEX_HOME = process.env.CODEX_HOME
|
|
9
|
+
? process.env.CODEX_HOME
|
|
10
|
+
: join(homedir(), ".codex");
|
|
11
|
+
const CODEX_SESSIONS_DIR = join(CODEX_HOME, "sessions");
|
|
12
|
+
|
|
13
|
+
// Cache results for 60s (lighter than Claude quota — reads more files)
|
|
14
|
+
let _cache = null;
|
|
15
|
+
let _cacheAt = 0;
|
|
16
|
+
const CACHE_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
|
|
19
|
+
const WINDOW_7D_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
// Tail-read the last CHUNK bytes of a file, split on newlines, find the last
|
|
22
|
+
// token_count event. Returns the total_token_usage object or null.
|
|
23
|
+
const TAIL_CHUNK = 32_768; // 32 KB — enough for a few recent token_count lines
|
|
24
|
+
|
|
25
|
+
async function readLastTokenCount(filePath) {
|
|
26
|
+
let fd;
|
|
27
|
+
try {
|
|
28
|
+
fd = await open(filePath, "r");
|
|
29
|
+
const { size } = await fd.stat();
|
|
30
|
+
if (size === 0) return null;
|
|
31
|
+
const readSize = Math.min(size, TAIL_CHUNK);
|
|
32
|
+
const buf = Buffer.alloc(readSize);
|
|
33
|
+
await fd.read(buf, 0, readSize, size - readSize);
|
|
34
|
+
const text = buf.toString("utf8");
|
|
35
|
+
// Split into lines (may start mid-line — skip first if partial)
|
|
36
|
+
const lines = text.split("\n");
|
|
37
|
+
// Process from the end
|
|
38
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
39
|
+
const line = lines[i].trim();
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
try {
|
|
42
|
+
const obj = JSON.parse(line);
|
|
43
|
+
if (obj.type === "event_msg" && obj.payload?.type === "token_count") {
|
|
44
|
+
return obj.payload.info?.total_token_usage ?? null;
|
|
45
|
+
}
|
|
46
|
+
} catch { /* malformed — keep searching */ }
|
|
47
|
+
}
|
|
48
|
+
} catch { /* file gone or unreadable */ }
|
|
49
|
+
finally { fd?.close().catch(() => {}); }
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Parse session start time from rollout filename.
|
|
54
|
+
// Format: rollout-YYYY-MM-DDTHH-MM-SS-<uuid>.jsonl
|
|
55
|
+
// The timestamp portion uses dashes instead of colons (Windows-safe).
|
|
56
|
+
function parseRolloutTime(filename) {
|
|
57
|
+
// e.g. rollout-2026-06-17T12-39-01-019ed4f2-c821-...jsonl
|
|
58
|
+
const m = filename.match(/^rollout-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})-/);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
// Replace the last two dashes in time part with colons
|
|
61
|
+
const iso = m[1].replace(/T(\d{2})-(\d{2})-(\d{2})$/, "T$1:$2:$3") + "Z";
|
|
62
|
+
const t = Date.parse(iso);
|
|
63
|
+
return isNaN(t) ? null : t;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// List rollout files whose start times fall within the given window.
|
|
67
|
+
async function listRolloutFiles(sinceMs) {
|
|
68
|
+
const out = [];
|
|
69
|
+
let years;
|
|
70
|
+
try { years = (await readdir(CODEX_SESSIONS_DIR)).filter(d => /^\d{4}$/.test(d)).sort().reverse(); }
|
|
71
|
+
catch { return out; }
|
|
72
|
+
|
|
73
|
+
const nowMs = Date.now();
|
|
74
|
+
for (const y of years) {
|
|
75
|
+
// Skip years that can't possibly contain files within the window
|
|
76
|
+
if (parseInt(y, 10) < new Date(nowMs - sinceMs - 86400000).getFullYear()) break;
|
|
77
|
+
let months;
|
|
78
|
+
try { months = (await readdir(join(CODEX_SESSIONS_DIR, y))).sort().reverse(); } catch { continue; }
|
|
79
|
+
for (const m of months) {
|
|
80
|
+
let days;
|
|
81
|
+
try { days = (await readdir(join(CODEX_SESSIONS_DIR, y, m))).sort().reverse(); } catch { continue; }
|
|
82
|
+
for (const d of days) {
|
|
83
|
+
const dir = join(CODEX_SESSIONS_DIR, y, m, d);
|
|
84
|
+
let files;
|
|
85
|
+
try { files = await readdir(dir); } catch { continue; }
|
|
86
|
+
for (const f of files) {
|
|
87
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
88
|
+
const t = parseRolloutTime(f);
|
|
89
|
+
if (t != null && nowMs - t <= sinceMs) {
|
|
90
|
+
out.push({ path: join(dir, f), startMs: t });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function emptyWindow() {
|
|
100
|
+
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, totalTokens: 0, sessionCount: 0 };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function fetchCodexUsage({ force = false } = {}) {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
106
|
+
|
|
107
|
+
const w5h = emptyWindow();
|
|
108
|
+
const w7d = emptyWindow();
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// Need files from last 7 days (superset covers both windows)
|
|
112
|
+
const files = await listRolloutFiles(WINDOW_7D_MS);
|
|
113
|
+
|
|
114
|
+
await Promise.all(files.map(async ({ path, startMs }) => {
|
|
115
|
+
const usage = await readLastTokenCount(path);
|
|
116
|
+
if (!usage) return;
|
|
117
|
+
|
|
118
|
+
const age = now - startMs;
|
|
119
|
+
const in5h = age <= WINDOW_5H_MS;
|
|
120
|
+
const in7d = age <= WINDOW_7D_MS; // always true here but explicit
|
|
121
|
+
|
|
122
|
+
const inp = usage.input_tokens ?? 0;
|
|
123
|
+
const out = usage.output_tokens ?? 0;
|
|
124
|
+
const cacheR = usage.cached_input_tokens ?? 0;
|
|
125
|
+
const cacheC = 0; // rollout doesn't track cache creation separately
|
|
126
|
+
const total = usage.total_tokens ?? (inp + out);
|
|
127
|
+
|
|
128
|
+
if (in5h) {
|
|
129
|
+
w5h.inputTokens += inp;
|
|
130
|
+
w5h.outputTokens += out;
|
|
131
|
+
w5h.cacheReadTokens += cacheR;
|
|
132
|
+
w5h.cacheCreateTokens += cacheC;
|
|
133
|
+
w5h.totalTokens += total;
|
|
134
|
+
w5h.sessionCount++;
|
|
135
|
+
}
|
|
136
|
+
if (in7d) {
|
|
137
|
+
w7d.inputTokens += inp;
|
|
138
|
+
w7d.outputTokens += out;
|
|
139
|
+
w7d.cacheReadTokens += cacheR;
|
|
140
|
+
w7d.cacheCreateTokens += cacheC;
|
|
141
|
+
w7d.totalTokens += total;
|
|
142
|
+
w7d.sessionCount++;
|
|
143
|
+
}
|
|
144
|
+
}));
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error("agents-deck codex-usage: scan failed:", err?.message ?? err);
|
|
147
|
+
const result = { ok: false, fetchedAt: now };
|
|
148
|
+
_cache = result;
|
|
149
|
+
_cacheAt = now;
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = { ok: true, window5h: w5h, window7d: w7d, fetchedAt: now };
|
|
154
|
+
_cache = result;
|
|
155
|
+
_cacheAt = now;
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function invalidateCodexUsageCache() {
|
|
160
|
+
_cache = null;
|
|
161
|
+
_cacheAt = 0;
|
|
162
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -993,11 +993,33 @@ function handleSse(req, res) {
|
|
|
993
993
|
});
|
|
994
994
|
}
|
|
995
995
|
|
|
996
|
-
async function handleQuota(
|
|
996
|
+
async function handleQuota(req, res) {
|
|
997
997
|
const { fetchClaudeQuota } = await import(
|
|
998
998
|
pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
|
|
999
999
|
);
|
|
1000
|
-
const
|
|
1000
|
+
const url = new URL(req.url, "http://localhost");
|
|
1001
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
1002
|
+
const quota = await fetchClaudeQuota({ force });
|
|
1003
|
+
send(res, 200, quota);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function handleCodexUsage(req, res) {
|
|
1007
|
+
const { fetchCodexUsage } = await import(
|
|
1008
|
+
pathToFileURL(join(PKG_ROOT, "src/server/codex-usage.mjs")).href
|
|
1009
|
+
);
|
|
1010
|
+
const url = new URL(req.url, "http://localhost");
|
|
1011
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
1012
|
+
const usage = await fetchCodexUsage({ force });
|
|
1013
|
+
send(res, 200, usage);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function handleCodexQuota(req, res) {
|
|
1017
|
+
const { fetchCodexQuota } = await import(
|
|
1018
|
+
pathToFileURL(join(PKG_ROOT, "src/server/codex-quota.mjs")).href
|
|
1019
|
+
);
|
|
1020
|
+
const url = new URL(req.url, "http://localhost");
|
|
1021
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
1022
|
+
const quota = await fetchCodexQuota({ force });
|
|
1001
1023
|
send(res, 200, quota);
|
|
1002
1024
|
}
|
|
1003
1025
|
|
|
@@ -1067,7 +1089,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1067
1089
|
if (req.method === "POST" && url.pathname === "/api/event") return handleEventIngest(req, res);
|
|
1068
1090
|
if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
|
|
1069
1091
|
if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
|
|
1070
|
-
if (req.method === "GET" && url.pathname === "/api/quota")
|
|
1092
|
+
if (req.method === "GET" && url.pathname === "/api/quota") return handleQuota(req, res);
|
|
1093
|
+
if (req.method === "GET" && url.pathname === "/api/codex-usage") return handleCodexUsage(req, res);
|
|
1094
|
+
if (req.method === "GET" && url.pathname === "/api/codex-quota") return handleCodexQuota(req, res);
|
|
1071
1095
|
|
|
1072
1096
|
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
1073
1097
|
const since = Number(url.searchParams.get("since") ?? 0);
|
package/src/server/quota.mjs
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
// Fetches Claude rate-limit quota by running `claude --print /usage`.
|
|
2
|
-
// On Windows the binary is a .cmd wrapper — we
|
|
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 {
|
|
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
|
|
11
|
+
const execAsync = promisify(exec);
|
|
11
12
|
const IS_WIN = platform() === "win32";
|
|
12
13
|
|
|
13
14
|
let _cache = null;
|
|
14
15
|
let _cacheAt = 0;
|
|
15
|
-
const CACHE_MS =
|
|
16
|
+
const CACHE_MS = 60_000;
|
|
16
17
|
|
|
17
18
|
function stripAnsi(s) {
|
|
18
19
|
return s
|
|
@@ -30,6 +31,21 @@ function stripAnsi(s) {
|
|
|
30
31
|
* "Current week (Sonnet only): 48% used · resets Jun 21, 9am (Europe/Chisinau)"
|
|
31
32
|
* "Current week (Opus only): ..." (if present)
|
|
32
33
|
*/
|
|
34
|
+
// Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
|
|
35
|
+
// Claude shows times in the user's local timezone, so parsing as local is correct.
|
|
36
|
+
function parseResetToSec(resetStr) {
|
|
37
|
+
if (!resetStr) return null;
|
|
38
|
+
try {
|
|
39
|
+
const year = new Date().getFullYear();
|
|
40
|
+
// "4:09pm" → "4:09 PM" so Date.parse handles it
|
|
41
|
+
const norm = resetStr
|
|
42
|
+
.replace(/(\d{1,2}:\d{2})(am|pm)/i, "$1 $2")
|
|
43
|
+
.trim();
|
|
44
|
+
const d = new Date(`${norm} ${year}`);
|
|
45
|
+
return isNaN(d.getTime()) ? null : Math.floor(d.getTime() / 1000);
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
function parseUsageText(raw) {
|
|
34
50
|
const text = stripAnsi(raw);
|
|
35
51
|
const result = {};
|
|
@@ -40,27 +56,30 @@ function parseUsageText(raw) {
|
|
|
40
56
|
if (!line) return null;
|
|
41
57
|
const pctM = line.match(/(\d{1,3})\s*%/);
|
|
42
58
|
const resetM = line.match(/resets\s+(.+)/i);
|
|
59
|
+
const resetFull = resetM
|
|
60
|
+
? resetM[1].replace(/\(.*?\)/g, "").replace(/·/g, "").trim()
|
|
61
|
+
: null;
|
|
43
62
|
return {
|
|
44
|
-
pct:
|
|
45
|
-
reset:
|
|
46
|
-
|
|
47
|
-
.replace(/\(.*?\)/g, "") // strip timezone in parens
|
|
48
|
-
.replace(/·/g, "")
|
|
49
|
-
.trim()
|
|
50
|
-
: null,
|
|
63
|
+
pct: pctM ? Math.min(100, parseInt(pctM[1], 10)) : null,
|
|
64
|
+
reset: resetFull,
|
|
65
|
+
resetAt: parseResetToSec(resetFull),
|
|
51
66
|
};
|
|
52
67
|
};
|
|
53
68
|
|
|
54
69
|
const session = extract(/current session/i);
|
|
55
70
|
if (session?.pct != null) {
|
|
56
|
-
result.session5hPct
|
|
57
|
-
|
|
71
|
+
result.session5hPct = session.pct;
|
|
72
|
+
result.session5hWindowSec = 18000;
|
|
73
|
+
if (session.reset) result.session5hReset = session.reset;
|
|
74
|
+
if (session.resetAt) result.session5hResetAt = session.resetAt;
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
const weekAll = extract(/current week\s*\(all models\)/i) || extract(/current week\s*[:·]/i);
|
|
61
78
|
if (weekAll?.pct != null) {
|
|
62
|
-
result.week7dPct
|
|
63
|
-
|
|
79
|
+
result.week7dPct = weekAll.pct;
|
|
80
|
+
result.week7dWindowSec = 604800;
|
|
81
|
+
if (weekAll.reset) result.week7dReset = weekAll.reset;
|
|
82
|
+
if (weekAll.resetAt) result.week7dResetAt = weekAll.resetAt;
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
const weekSon = extract(/current week\s*\(sonnet/i);
|
|
@@ -72,17 +91,21 @@ function parseUsageText(raw) {
|
|
|
72
91
|
return Object.keys(result).length > 0 ? result : null;
|
|
73
92
|
}
|
|
74
93
|
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
|
|
94
|
+
/** Build the shell command string for `claude --print /usage`.
|
|
95
|
+
*
|
|
96
|
+
* We use exec() (shell-based) so cmd.exe / sh processes redirects.
|
|
97
|
+
* On Windows: `< nul` closes stdin immediately, preventing the 3-second
|
|
98
|
+
* "no stdin data" wait the claude CLI does when it detects a pipe.
|
|
99
|
+
* On Unix: `< /dev/null` has the same effect.
|
|
100
|
+
*/
|
|
101
|
+
function buildQuotaShellCmd() {
|
|
79
102
|
if (IS_WIN) {
|
|
80
103
|
const npmBin = join(homedir(), "AppData", "Roaming", "npm", "claude.cmd");
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
104
|
+
const bin = existsSync(npmBin) ? npmBin : "claude.cmd";
|
|
105
|
+
// exec() on Windows uses cmd /c, so < nul redirect works fine.
|
|
106
|
+
// Wrap path in quotes in case of spaces in username.
|
|
107
|
+
return `"${bin}" --print /usage < nul`;
|
|
84
108
|
}
|
|
85
|
-
// Unix: check PATH, then known install locations.
|
|
86
109
|
const candidates = [
|
|
87
110
|
"claude",
|
|
88
111
|
join(homedir(), ".local", "bin", "claude"),
|
|
@@ -90,32 +113,46 @@ function findClaudeBin() {
|
|
|
90
113
|
"/opt/homebrew/bin/claude",
|
|
91
114
|
];
|
|
92
115
|
for (const c of candidates) {
|
|
93
|
-
if (!c.includes("/")) return {
|
|
94
|
-
if (existsSync(c)) return { cmd: c, args: [] };
|
|
116
|
+
if (!c.includes("/") || existsSync(c)) return `${c} --print /usage < /dev/null`;
|
|
95
117
|
}
|
|
96
|
-
return
|
|
118
|
+
return "claude --print /usage < /dev/null";
|
|
97
119
|
}
|
|
98
120
|
|
|
99
121
|
export async function fetchClaudeQuota({ force = false } = {}) {
|
|
100
122
|
const now = Date.now();
|
|
101
123
|
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
102
124
|
|
|
103
|
-
const
|
|
125
|
+
const shellCmd = buildQuotaShellCmd();
|
|
104
126
|
let parsed = null;
|
|
105
127
|
|
|
128
|
+
let cliOk = false;
|
|
106
129
|
try {
|
|
107
|
-
const { stdout, stderr } = await
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
115
|
-
parsed = parseUsageText(
|
|
130
|
+
const { stdout, stderr } = await execAsync(shellCmd, {
|
|
131
|
+
timeout: 15_000,
|
|
132
|
+
env: { ...process.env, NO_COLOR: "1", TERM: "dumb" },
|
|
133
|
+
maxBuffer: 1024 * 1024,
|
|
134
|
+
});
|
|
135
|
+
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);
|
|
116
139
|
} catch (err) {
|
|
117
|
-
|
|
118
|
-
console.error("agents-deck quota: claude CLI failed:",
|
|
140
|
+
const msg = err?.stderr ? stripAnsi(err.stderr).trim() : (err?.message ?? String(err));
|
|
141
|
+
console.error("agents-deck quota: claude CLI failed:", msg);
|
|
142
|
+
if (err?.stdout || err?.stderr) {
|
|
143
|
+
const combined = (err.stdout ?? "") + "\n" + (err.stderr ?? "");
|
|
144
|
+
cliOk = /subscription/i.test(combined);
|
|
145
|
+
parsed = parseUsageText(combined);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
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
|
+
};
|
|
119
156
|
}
|
|
120
157
|
|
|
121
158
|
const result = parsed
|