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.
@@ -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-Bs89BdrR.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CVsr6RfQ.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-deck",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
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": {
@@ -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
+ }
@@ -993,11 +993,33 @@ function handleSse(req, res) {
993
993
  });
994
994
  }
995
995
 
996
- async function handleQuota(_req, res) {
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 quota = await fetchClaudeQuota();
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") return handleQuota(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);
1071
1095
 
1072
1096
  if (req.method === "GET" && url.pathname === "/api/events") {
1073
1097
  const since = Number(url.searchParams.get("since") ?? 0);
@@ -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 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
14
  let _cache = null;
14
15
  let _cacheAt = 0;
15
- const CACHE_MS = 120_000;
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: 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,
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 = session.pct;
57
- if (session.reset) result.session5hReset = session.reset;
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 = weekAll.pct;
63
- if (weekAll.reset) result.week7dReset = weekAll.reset;
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
- /** 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() {
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
- 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"] };
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 { cmd: c, args: [] };
94
- if (existsSync(c)) return { cmd: c, args: [] };
116
+ if (!c.includes("/") || existsSync(c)) return `${c} --print /usage < /dev/null`;
95
117
  }
96
- return { cmd: "claude", args: [] };
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 { cmd, args } = findClaudeBin();
125
+ const shellCmd = buildQuotaShellCmd();
104
126
  let parsed = null;
105
127
 
128
+ let cliOk = false;
106
129
  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);
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
- // Binary not found or timed out degrade gracefully.
118
- console.error("agents-deck quota: claude CLI failed:", err?.message ?? 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
+ };
119
156
  }
120
157
 
121
158
  const result = parsed