agents-deck 1.18.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/README.md +71 -33
- package/dist/web/assets/{index-DGqLVo1U.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 +33 -0
- package/src/server/quota.mjs +170 -0
- package/dist/web/assets/index-uANyTzBq.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,6 +993,36 @@ function handleSse(req, res) {
|
|
|
993
993
|
});
|
|
994
994
|
}
|
|
995
995
|
|
|
996
|
+
async function handleQuota(req, res) {
|
|
997
|
+
const { fetchClaudeQuota } = await import(
|
|
998
|
+
pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
|
|
999
|
+
);
|
|
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 });
|
|
1023
|
+
send(res, 200, quota);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
996
1026
|
function handleHealth(_req, res) {
|
|
997
1027
|
send(res, 200, {
|
|
998
1028
|
ok: true,
|
|
@@ -1059,6 +1089,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1059
1089
|
if (req.method === "POST" && url.pathname === "/api/event") return handleEventIngest(req, res);
|
|
1060
1090
|
if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
|
|
1061
1091
|
if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
|
|
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);
|
|
1062
1095
|
|
|
1063
1096
|
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
1064
1097
|
const since = Number(url.searchParams.get("since") ?? 0);
|
|
@@ -0,0 +1,170 @@
|
|
|
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.
|
|
5
|
+
import { exec } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir, platform } from "node:os";
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
const IS_WIN = platform() === "win32";
|
|
13
|
+
|
|
14
|
+
let _cache = null;
|
|
15
|
+
let _cacheAt = 0;
|
|
16
|
+
const CACHE_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
function stripAnsi(s) {
|
|
19
|
+
return s
|
|
20
|
+
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "")
|
|
21
|
+
.replace(/\x1B\][^\x07]*\x07/g, "")
|
|
22
|
+
.replace(/\x1B[()][AB012]/g, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse `claude --print /usage` output.
|
|
27
|
+
*
|
|
28
|
+
* Observed format (Claude Code ≥ 1.x):
|
|
29
|
+
* "Current session: 84% used · resets Jun 18, 4:09pm (Europe/Chisinau)"
|
|
30
|
+
* "Current week (all models): 85% used · resets Jun 21, 8:59am (Europe/Chisinau)"
|
|
31
|
+
* "Current week (Sonnet only): 48% used · resets Jun 21, 9am (Europe/Chisinau)"
|
|
32
|
+
* "Current week (Opus only): ..." (if present)
|
|
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
|
+
|
|
49
|
+
function parseUsageText(raw) {
|
|
50
|
+
const text = stripAnsi(raw);
|
|
51
|
+
const result = {};
|
|
52
|
+
|
|
53
|
+
// Helper: find "X% used · resets <rest>" on a line matching a label.
|
|
54
|
+
const extract = (labelRe) => {
|
|
55
|
+
const line = text.split("\n").find(l => labelRe.test(l));
|
|
56
|
+
if (!line) return null;
|
|
57
|
+
const pctM = line.match(/(\d{1,3})\s*%/);
|
|
58
|
+
const resetM = line.match(/resets\s+(.+)/i);
|
|
59
|
+
const resetFull = resetM
|
|
60
|
+
? resetM[1].replace(/\(.*?\)/g, "").replace(/·/g, "").trim()
|
|
61
|
+
: null;
|
|
62
|
+
return {
|
|
63
|
+
pct: pctM ? Math.min(100, parseInt(pctM[1], 10)) : null,
|
|
64
|
+
reset: resetFull,
|
|
65
|
+
resetAt: parseResetToSec(resetFull),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const session = extract(/current session/i);
|
|
70
|
+
if (session?.pct != null) {
|
|
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;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const weekAll = extract(/current week\s*\(all models\)/i) || extract(/current week\s*[:·]/i);
|
|
78
|
+
if (weekAll?.pct != null) {
|
|
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;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const weekSon = extract(/current week\s*\(sonnet/i);
|
|
86
|
+
if (weekSon?.pct != null) result.weekSonnetPct = weekSon.pct;
|
|
87
|
+
|
|
88
|
+
const weekOpus = extract(/current week\s*\(opus/i);
|
|
89
|
+
if (weekOpus?.pct != null) result.weekOpusPct = weekOpus.pct;
|
|
90
|
+
|
|
91
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
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() {
|
|
102
|
+
if (IS_WIN) {
|
|
103
|
+
const npmBin = join(homedir(), "AppData", "Roaming", "npm", "claude.cmd");
|
|
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`;
|
|
108
|
+
}
|
|
109
|
+
const candidates = [
|
|
110
|
+
"claude",
|
|
111
|
+
join(homedir(), ".local", "bin", "claude"),
|
|
112
|
+
"/usr/local/bin/claude",
|
|
113
|
+
"/opt/homebrew/bin/claude",
|
|
114
|
+
];
|
|
115
|
+
for (const c of candidates) {
|
|
116
|
+
if (!c.includes("/") || existsSync(c)) return `${c} --print /usage < /dev/null`;
|
|
117
|
+
}
|
|
118
|
+
return "claude --print /usage < /dev/null";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function fetchClaudeQuota({ force = false } = {}) {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
124
|
+
|
|
125
|
+
const shellCmd = buildQuotaShellCmd();
|
|
126
|
+
let parsed = null;
|
|
127
|
+
|
|
128
|
+
let cliOk = false;
|
|
129
|
+
try {
|
|
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);
|
|
139
|
+
} catch (err) {
|
|
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
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = parsed
|
|
159
|
+
? { ok: true, ...parsed, fetchedAt: now }
|
|
160
|
+
: { ok: false, fetchedAt: now };
|
|
161
|
+
|
|
162
|
+
_cache = result;
|
|
163
|
+
_cacheAt = now;
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function invalidateQuotaCache() {
|
|
168
|
+
_cache = null;
|
|
169
|
+
_cacheAt = 0;
|
|
170
|
+
}
|