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.
@@ -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-uANyTzBq.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DGqLVo1U.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.18.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,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
+ }