agents-deck 1.20.2 → 1.22.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-_qWQOt8_.css → index-BRoNoC-Z.css} +1 -1
- package/dist/web/assets/index-Dc4xConU.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/server/ccusage.mjs +93 -0
- package/src/server/codex-usage.mjs +80 -54
- package/src/server/index.mjs +13 -0
- package/dist/web/assets/index-Cb1bIpZv.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-Dc4xConU.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BRoNoC-Z.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Fetches historical usage from the `ccusage` CLI (https://github.com/ccusage/ccusage).
|
|
2
|
+
// ccusage reads the local ~/.claude (and other agent) logs and reports cost +
|
|
3
|
+
// token usage grouped by day. We shell out to it via `npx -y ccusage@latest`
|
|
4
|
+
// — it is NOT a dependency; npx fetches it on first run (cached afterwards).
|
|
5
|
+
//
|
|
6
|
+
// The whole backend is: spawn the CLI with --json, slice the JSON out of stdout
|
|
7
|
+
// (npx can print banner noise), JSON.parse, cache. Ported from the task-board
|
|
8
|
+
// project's three Next.js routes, collapsed to one daily fetch.
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
const CACHE_MS = 120_000; // 2 min — ccusage spawn is heavy; modal is manual-open
|
|
12
|
+
const TIMEOUT_MS = 90_000;
|
|
13
|
+
|
|
14
|
+
const _cache = new Map(); // key `${since}|${until}` → { result, at }
|
|
15
|
+
|
|
16
|
+
// Run `ccusage <args> --json` and resolve its raw stdout.
|
|
17
|
+
function runCcusage(args) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
// shell:true + windowsHide:true so `npx` resolves on Windows without a
|
|
20
|
+
// popup console. ccusage@latest is pinned so behavior is stable.
|
|
21
|
+
const child = spawn("npx", ["-y", "ccusage@latest", ...args], {
|
|
22
|
+
shell: true,
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
});
|
|
25
|
+
let out = "", err = "";
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
child.kill();
|
|
28
|
+
reject(new Error("ccusage timed out"));
|
|
29
|
+
}, TIMEOUT_MS);
|
|
30
|
+
child.stdout.on("data", d => { out += d; });
|
|
31
|
+
child.stderr.on("data", d => { err += d; });
|
|
32
|
+
child.on("error", e => { clearTimeout(timer); reject(e); });
|
|
33
|
+
child.on("close", code => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
if (code === 0) resolve(out);
|
|
36
|
+
else reject(new Error(err.trim() || `ccusage exited ${code}`));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ccusage prints the JSON object somewhere in stdout; slice first { to last }.
|
|
42
|
+
function extractJson(out) {
|
|
43
|
+
const start = out.indexOf("{");
|
|
44
|
+
const end = out.lastIndexOf("}");
|
|
45
|
+
if (start === -1 || end === -1) throw new Error("no JSON in ccusage output");
|
|
46
|
+
return JSON.parse(out.slice(start, end + 1));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// YYYYMMDD for the CLI's --since/--until.
|
|
50
|
+
function toCliDate(d) {
|
|
51
|
+
return d.toISOString().slice(0, 10).replace(/-/g, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fetch daily usage from ccusage for a date range.
|
|
56
|
+
* @param {{ since?: string, until?: string, force?: boolean }} opts
|
|
57
|
+
* since/until are YYYYMMDD strings (CLI format). Defaults to last 30 days.
|
|
58
|
+
* @returns {{ ok, days, totals, since, until, fetchedAt } | { ok:false, error }}
|
|
59
|
+
*/
|
|
60
|
+
export async function fetchCcusageDaily({ since, until, force = false } = {}) {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const sinceArg = since || toCliDate(new Date(now - 30 * 86400_000));
|
|
63
|
+
const key = `${sinceArg}|${until ?? ""}`;
|
|
64
|
+
|
|
65
|
+
const cached = _cache.get(key);
|
|
66
|
+
if (!force && cached && now - cached.at < CACHE_MS) return cached.result;
|
|
67
|
+
|
|
68
|
+
let result;
|
|
69
|
+
try {
|
|
70
|
+
const args = ["daily", "--json", "--since", sinceArg];
|
|
71
|
+
if (until) args.push("--until", until);
|
|
72
|
+
const raw = extractJson(await runCcusage(args));
|
|
73
|
+
const days = Array.isArray(raw.daily) ? raw.daily : [];
|
|
74
|
+
result = {
|
|
75
|
+
ok: true,
|
|
76
|
+
days,
|
|
77
|
+
totals: raw.totals ?? null,
|
|
78
|
+
since: sinceArg,
|
|
79
|
+
until: until ?? null,
|
|
80
|
+
fetchedAt: now,
|
|
81
|
+
};
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error("agents-deck ccusage: fetch failed:", err?.message ?? err);
|
|
84
|
+
result = { ok: false, error: String(err?.message ?? err), fetchedAt: now };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_cache.set(key, { result, at: now });
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function invalidateCcusageCache() {
|
|
92
|
+
_cache.clear();
|
|
93
|
+
}
|
|
@@ -18,36 +18,72 @@ const CACHE_MS = 60_000;
|
|
|
18
18
|
const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
|
|
19
19
|
const WINDOW_7D_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
// token_count event.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
// Read the full series of cumulative token_count events from a rollout file.
|
|
22
|
+
// Each token_count event carries `info.total_token_usage` — the running total
|
|
23
|
+
// for the session at that point. We keep the whole series (with timestamps) so
|
|
24
|
+
// we can compute how many tokens were spent *within* a rolling window via a
|
|
25
|
+
// cumulative delta, rather than dumping a session's lifetime total into a bucket
|
|
26
|
+
// based on when it merely started.
|
|
27
|
+
//
|
|
28
|
+
// Returns an ascending-by-time array of { ts, inp, out, cacheR, total } where
|
|
29
|
+
// `inp` includes the cached portion (Codex reports input_tokens incl. cache),
|
|
30
|
+
// or null if the file has no usable token_count events.
|
|
31
|
+
async function readTokenSeries(filePath) {
|
|
26
32
|
let fd;
|
|
27
33
|
try {
|
|
28
34
|
fd = await open(filePath, "r");
|
|
29
35
|
const { size } = await fd.stat();
|
|
30
36
|
if (size === 0) return null;
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
if (!
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
const text = (await fd.readFile()).toString("utf8");
|
|
38
|
+
const series = [];
|
|
39
|
+
for (const raw of text.split("\n")) {
|
|
40
|
+
// Cheap pre-filter before the (relatively) expensive JSON.parse.
|
|
41
|
+
if (!raw.includes("total_token_usage")) continue;
|
|
42
|
+
let obj;
|
|
43
|
+
try { obj = JSON.parse(raw); } catch { continue; }
|
|
44
|
+
if (obj.type !== "event_msg" || obj.payload?.type !== "token_count") continue;
|
|
45
|
+
const u = obj.payload.info?.total_token_usage;
|
|
46
|
+
if (!u) continue;
|
|
47
|
+
const ts = obj.timestamp ? Date.parse(obj.timestamp) : NaN;
|
|
48
|
+
series.push({
|
|
49
|
+
ts: isNaN(ts) ? null : ts,
|
|
50
|
+
inp: u.input_tokens ?? 0,
|
|
51
|
+
out: u.output_tokens ?? 0,
|
|
52
|
+
cacheR: u.cached_input_tokens ?? 0,
|
|
53
|
+
total: u.total_tokens ?? ((u.input_tokens ?? 0) + (u.output_tokens ?? 0)),
|
|
54
|
+
});
|
|
47
55
|
}
|
|
48
|
-
|
|
56
|
+
return series.length ? series : null;
|
|
57
|
+
} catch { return null; }
|
|
49
58
|
finally { fd?.close().catch(() => {}); }
|
|
50
|
-
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Tokens spent within [windowStartMs, now]: the last cumulative snapshot minus
|
|
62
|
+
// the last snapshot taken *before* the window opened. If the session began
|
|
63
|
+
// inside the window (no prior snapshot), the baseline is zero and the full
|
|
64
|
+
// cumulative end counts. Fields are returned non-overlapping so they sum to
|
|
65
|
+
// `total`: `input` is fresh (non-cached) input, `cacheRead` is the cached
|
|
66
|
+
// portion, `output` is output. (Codex's input_tokens includes cache, so we
|
|
67
|
+
// subtract it out to avoid double-counting.)
|
|
68
|
+
function windowDelta(series, windowStartMs) {
|
|
69
|
+
if (!series || series.length === 0) return null;
|
|
70
|
+
const end = series[series.length - 1];
|
|
71
|
+
// Baseline = last event strictly before the window opened.
|
|
72
|
+
let base = null;
|
|
73
|
+
for (const e of series) {
|
|
74
|
+
if (e.ts != null && e.ts < windowStartMs) base = e;
|
|
75
|
+
else if (e.ts != null) break;
|
|
76
|
+
}
|
|
77
|
+
const dInp = Math.max(0, end.inp - (base?.inp ?? 0));
|
|
78
|
+
const dOut = Math.max(0, end.out - (base?.out ?? 0));
|
|
79
|
+
const dCacheR = Math.max(0, end.cacheR - (base?.cacheR ?? 0));
|
|
80
|
+
const dTotal = Math.max(0, end.total - (base?.total ?? 0));
|
|
81
|
+
return {
|
|
82
|
+
inputTokens: Math.max(0, dInp - dCacheR), // fresh (non-cached) input
|
|
83
|
+
outputTokens: dOut,
|
|
84
|
+
cacheReadTokens: dCacheR,
|
|
85
|
+
totalTokens: dTotal,
|
|
86
|
+
};
|
|
51
87
|
}
|
|
52
88
|
|
|
53
89
|
// Parse session start time from rollout filename.
|
|
@@ -106,41 +142,31 @@ export async function fetchCodexUsage({ force = false } = {}) {
|
|
|
106
142
|
|
|
107
143
|
const w5h = emptyWindow();
|
|
108
144
|
const w7d = emptyWindow();
|
|
145
|
+
const start5h = now - WINDOW_5H_MS;
|
|
146
|
+
const start7d = now - WINDOW_7D_MS;
|
|
147
|
+
|
|
148
|
+
const addTo = (win, d) => {
|
|
149
|
+
if (!d || d.totalTokens <= 0) return;
|
|
150
|
+
win.inputTokens += d.inputTokens;
|
|
151
|
+
win.outputTokens += d.outputTokens;
|
|
152
|
+
win.cacheReadTokens += d.cacheReadTokens;
|
|
153
|
+
win.totalTokens += d.totalTokens;
|
|
154
|
+
win.sessionCount++;
|
|
155
|
+
};
|
|
109
156
|
|
|
110
157
|
try {
|
|
111
|
-
//
|
|
158
|
+
// Files whose session *started* within 7d. A long session that started up
|
|
159
|
+
// to 7d ago but is still active is captured here too, and its share of the
|
|
160
|
+
// 5h window is recovered via the cumulative delta below — so bucketing no
|
|
161
|
+
// longer drops active-but-old sessions or over-counts the pre-window tail.
|
|
112
162
|
const files = await listRolloutFiles(WINDOW_7D_MS);
|
|
113
163
|
|
|
114
|
-
await Promise.all(files.map(async ({ path
|
|
115
|
-
const
|
|
116
|
-
if (!
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
}
|
|
164
|
+
await Promise.all(files.map(async ({ path }) => {
|
|
165
|
+
const series = await readTokenSeries(path);
|
|
166
|
+
if (!series) return;
|
|
167
|
+
// Same series feeds both windows; baseline differs per window start.
|
|
168
|
+
addTo(w5h, windowDelta(series, start5h));
|
|
169
|
+
addTo(w7d, windowDelta(series, start7d));
|
|
144
170
|
}));
|
|
145
171
|
} catch (err) {
|
|
146
172
|
console.error("agents-deck codex-usage: scan failed:", err?.message ?? err);
|
package/src/server/index.mjs
CHANGED
|
@@ -1023,6 +1023,18 @@ async function handleCodexQuota(req, res) {
|
|
|
1023
1023
|
send(res, 200, quota);
|
|
1024
1024
|
}
|
|
1025
1025
|
|
|
1026
|
+
async function handleCcusage(req, res) {
|
|
1027
|
+
const { fetchCcusageDaily } = await import(
|
|
1028
|
+
pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href
|
|
1029
|
+
);
|
|
1030
|
+
const url = new URL(req.url, "http://localhost");
|
|
1031
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
1032
|
+
const since = url.searchParams.get("since") || undefined;
|
|
1033
|
+
const until = url.searchParams.get("until") || undefined;
|
|
1034
|
+
const data = await fetchCcusageDaily({ since, until, force });
|
|
1035
|
+
send(res, 200, data);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1026
1038
|
function handleHealth(_req, res) {
|
|
1027
1039
|
send(res, 200, {
|
|
1028
1040
|
ok: true,
|
|
@@ -1092,6 +1104,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1092
1104
|
if (req.method === "GET" && url.pathname === "/api/quota") return handleQuota(req, res);
|
|
1093
1105
|
if (req.method === "GET" && url.pathname === "/api/codex-usage") return handleCodexUsage(req, res);
|
|
1094
1106
|
if (req.method === "GET" && url.pathname === "/api/codex-quota") return handleCodexQuota(req, res);
|
|
1107
|
+
if (req.method === "GET" && url.pathname === "/api/ccusage") return handleCcusage(req, res);
|
|
1095
1108
|
|
|
1096
1109
|
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
1097
1110
|
const since = Number(url.searchParams.get("since") ?? 0);
|