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.
@@ -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-Cb1bIpZv.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-_qWQOt8_.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-deck",
3
- "version": "1.20.2",
3
+ "version": "1.22.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,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
- // 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) {
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 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 */ }
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
- } catch { /* file gone or unreadable */ }
56
+ return series.length ? series : null;
57
+ } catch { return null; }
49
58
  finally { fd?.close().catch(() => {}); }
50
- return null;
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
- // Need files from last 7 days (superset covers both windows)
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, 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
- }
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);
@@ -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);