ccdeck 1.42.0 → 1.44.1

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.
@@ -0,0 +1,1052 @@
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.
4
+ //
5
+ // Performance: we do NOT run `npx -y ccusage@latest` on every call — that hits
6
+ // the npm registry to resolve `@latest` (and re-downloads when the npx cache is
7
+ // cold), so each modal open waited seconds. Instead we keep our OWN managed
8
+ // install under ~/.agents-deck/ccusage and invoke it directly with
9
+ // `node <pkg>/src/cli.js` (no npx, no registry round-trip). A throttled
10
+ // once-per-day background check upgrades it when a newer ccusage ships, while
11
+ // the current call always serves from the already-installed copy. If the
12
+ // managed install is missing/broken we fall back to the old npx path so the
13
+ // feature still works on a fresh machine.
14
+ import { spawn } from "node:child_process";
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+ import { killTree, pathLookup, shimPath, spawnSpec } from "./exec.mjs";
19
+ import { oneLine, termColumns } from "./term.mjs";
20
+ import { PRODUCT } from "./brand.mjs";
21
+
22
+ const CACHE_MS = 120_000; // 2 min — modal is manual-open; cheap to keep warm
23
+ const TIMEOUT_MS = 90_000;
24
+ const INSTALL_TIMEOUT_MS = 120_000; // first-run npm install can be slow
25
+ const UPDATE_CHECK_MS = 24 * 3600_000; // check npm for a newer ccusage once/day
26
+
27
+ const CACHE_DIR = path.join(os.homedir(), ".agents-deck", "ccusage");
28
+ const PKG_DIR = path.join(CACHE_DIR, "node_modules", "ccusage");
29
+ const MARKER = path.join(CACHE_DIR, ".last-update-check");
30
+
31
+ const _cache = new Map(); // key `${since}|${until}` → { result, at }
32
+
33
+ // How much a read is allowed to cost. /api/ccusage is a GET, deliberately: a
34
+ // cross-site read of the loopback port is an ordinary top-level navigation and
35
+ // isTrustedRead is right not to refuse it. What was missing is a ceiling on
36
+ // what one of those reads may start. Before #544, thirty distinct `since`
37
+ // values were thirty concurrent `node <PKG_DIR>/src/cli.js daily --json`
38
+ // children — each with a ninety-second deadline, each walking the whole
39
+ // ~/.claude log tree, doubled again whenever runDaily retried flagless — and
40
+ // thirty permanent Map entries. isCliDate admits all 10^8 eight-digit strings
41
+ // on purpose (a date outside the logs is ccusage's question to answer, not the
42
+ // deck's to guess at), so the map's key space was 10^8 and its eviction policy
43
+ // was none.
44
+ //
45
+ // Both numbers are set against what the feature does rather than against the
46
+ // attack. The usage-history modal asks for one range at a time and offers three
47
+ // presets, so four ranges outstanding at once is already more than a human
48
+ // clicking as fast as they can, and thirty-two remembered ranges is more than
49
+ // one sitting will ever look at. Whatever exceeds either is not a reader.
50
+ const CACHE_MAX = 32;
51
+ const MAX_OUTSTANDING = 4;
52
+
53
+ /** Ranges being fetched right now, keyed exactly as `_cache` is, so two callers
54
+ * asking the same question wait on one child instead of starting a second. The
55
+ * cache alone could never do this: it is written when a run finishes, and the
56
+ * whole window this is about is the ninety seconds before that. */
57
+ const _inflight = new Map();
58
+
59
+ /** The tail of the run queue, and how many runs are alive behind it.
60
+ *
61
+ * Serialised rather than merely counted, because two ccusage runs are two
62
+ * walks of the same directory tree: running them at once is slower than
63
+ * running them in sequence, so the queue costs a concurrent caller nothing it
64
+ * was actually going to get. What it buys is that a flood is one child at a
65
+ * time rather than a child per request. */
66
+ let _chain = Promise.resolve();
67
+ let _outstanding = 0;
68
+
69
+ /** Run `job` after every run already queued, whatever became of them — a run
70
+ * that threw must not take the queue down with it. */
71
+ function queued(job) {
72
+ _outstanding += 1;
73
+ const started = _chain.then(job, job);
74
+ _chain = started.then(() => {}, () => {});
75
+ return started.finally(() => { _outstanding -= 1; });
76
+ }
77
+
78
+ /**
79
+ * Remember one range's answer, and keep the map from being somewhere a caller
80
+ * can grow without limit.
81
+ *
82
+ * Entries past CACHE_MS can never be served again, so they are the ones to drop
83
+ * first; only when dropping all of them is still not enough does the oldest
84
+ * surviving entry go, which Map's insertion order hands over for free.
85
+ * Re-writing a key moves it to the back, so the range someone is actually
86
+ * polling is the last one evicted rather than the first.
87
+ */
88
+ function rememberRange(key, result, at) {
89
+ _cache.delete(key);
90
+ _cache.set(key, { result, at });
91
+ if (_cache.size <= CACHE_MAX) return;
92
+ for (const [k, v] of _cache) {
93
+ if (_cache.size <= CACHE_MAX) break;
94
+ if (at - v.at >= CACHE_MS) _cache.delete(k);
95
+ }
96
+ for (const k of _cache.keys()) {
97
+ if (_cache.size <= CACHE_MAX) break;
98
+ _cache.delete(k);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The name to hand cmd.exe for one of npm's Windows shims: its full path when
104
+ * one can be found, and the bare name only when none can.
105
+ *
106
+ * The full path is not a tidiness preference, it is the fix for #456. A shim
107
+ * launched by bare name computes `%~dp0` — which is where it looks for
108
+ * npm-prefix.js, npm-cli.js and npx-cli.js — from the deck's WORKING DIRECTORY
109
+ * rather than from its own, so on a deck started from `C:\Users\vceban` both
110
+ * shims died with `Cannot find module 'C:\Users\vceban\node_modules\npm\bin\…'`
111
+ * on a machine whose npm was perfectly healthy. shimPath in exec.mjs carries
112
+ * the whole account; what matters here is that BOTH the managed install and the
113
+ * npx fallback are launched this way, so both failed, and diagnosing either
114
+ * half alone could never have explained the other.
115
+ *
116
+ * Falling back to the bare name is deliberate: it is exactly what this did
117
+ * before, so a layout shimPath cannot see is no worse off than it was, and on
118
+ * such a machine cmd.exe's own PATH search still gets its turn.
119
+ */
120
+ const winShim = (name, deps) => shimPath(name, deps) ?? name;
121
+
122
+ // npm is a .cmd shim on Windows, which spawn can only launch through cmd.exe.
123
+ // `shell: true` is the tempting way to get there and the wrong one: Node then
124
+ // joins file and args with single spaces and no quoting, so on a profile like
125
+ // C:\Users\John Smith the `--prefix <CACHE_DIR>` below arrived as
126
+ // `--prefix C:\Users\John` plus a bogus package spec `Smith\.agents-deck\...`,
127
+ // npm exited non-zero, and the managed install never materialised. spawnSpec
128
+ // routes the .cmd through cmd.exe with every argument quoted, and hands back
129
+ // the argument vector untouched everywhere else.
130
+ //
131
+ // POSIX is untouched by any of this: `npm` there is a real executable on PATH,
132
+ // not a batch file, so isBatch is false, viaCmd never runs, and the vector goes
133
+ // to spawn exactly as it always has.
134
+ function npmSpec(args, platform = process.platform, deps) {
135
+ return spawnSpec(platform === "win32" ? winShim("npm.cmd", deps) : "npm", args, platform);
136
+ }
137
+
138
+ /**
139
+ * What `spawn` gets for `npm install ccusage@<spec>`.
140
+ * Exported for tests: the platform is a parameter so the Windows command line
141
+ * can be checked from any OS, and `deps` stands in for the Windows filesystem
142
+ * the shim lookup asks about.
143
+ */
144
+ export const installSpec = (spec = "latest", platform = process.platform, deps) =>
145
+ npmSpec(["install", `ccusage@${spec}`, "--prefix", CACHE_DIR,
146
+ "--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform, deps);
147
+
148
+ // npx is the same kind of shim as npm, and the fallback run needs the same
149
+ // treatment for a second, sharper reason: its argument vector carries a
150
+ // user-supplied `--since`. `shell: true` handed `[file, ...args].join(" ")` to
151
+ // /bin/sh -c, so `GET /api/ccusage?since=1;id;` ran `id` — and because that
152
+ // route is a GET, any page open in the user's browser could aim it at the
153
+ // loopback port with no CORS, no preflight and no need to read the answer.
154
+ // spawnSpec quotes each argument into the cmd.exe line on Windows and spawns
155
+ // the vector untouched everywhere else, so nothing in it is ever parsed as
156
+ // syntax. Naming the file `npx.cmd` on Windows is what makes that work: only a
157
+ // .cmd/.bat file routes through cmd.exe, and a bare `npx` there is not a file
158
+ // spawn can launch at all (PATHEXT is a shell's job), so asking for the
159
+ // extensionless name would trade a shell injection for an ENOENT. What it must
160
+ // NOT be is the bare `npx.cmd` this said until #456 — see winShim.
161
+ function npxSpec(args, platform = process.platform, deps) {
162
+ return spawnSpec(platform === "win32" ? winShim("npx.cmd", deps) : "npx", args, platform);
163
+ }
164
+
165
+ /**
166
+ * What `spawn` gets for the portable `npx -y ccusage@latest <args>` fallback.
167
+ * Exported for tests: the platform is a parameter so the Windows command line
168
+ * can be checked from any OS, and `deps` stands in for the Windows filesystem
169
+ * the shim lookup asks about.
170
+ */
171
+ export const fallbackSpec = (args = [], platform = process.platform, deps) =>
172
+ npxSpec(["-y", "ccusage@latest", ...args], platform, deps);
173
+
174
+ let _installing = null; // Promise guard so concurrent calls share one install
175
+ let _checkedThisRun = false; // only kick the daily check once per process boot
176
+
177
+ // The last `npm install` that failed, in one line of its own words.
178
+ //
179
+ // Module scope rather than a value thrown out of the install, because the two
180
+ // callers that matter never see that throw. primeCcusage runs the install at
181
+ // boot and only logs the rejection; a second modal open that arrives while an
182
+ // install is in flight awaits the SHARED promise, whose rejection has already
183
+ // been handled by the first. Both then land on the npx fallback with nothing to
184
+ // say about why they were there — which is precisely how three rounds of
185
+ // debugging went to the fallback's stderr while the install's own account
186
+ // stayed on a terminal row the deck had already painted over.
187
+ //
188
+ // One line, not the whole dump: this is written into a sentence in a 46ch box.
189
+ // The full text still goes to the terminal through note() below.
190
+ let _lastInstallError = null;
191
+ const INSTALL_ERROR_ROOM = 240;
192
+
193
+ /**
194
+ * Start the one shared install, remembering how it ended.
195
+ *
196
+ * Deduped through `_installing` the way it always was, so concurrent callers
197
+ * cost one `npm install` between them, and the outcome survives the promise.
198
+ *
199
+ * This used to read `_installing = (async () => { installSync(spec); })()`, and
200
+ * that wrapper was the whole of #476: an async function body runs synchronously
201
+ * up to its first `await`, and there was none, so the IIFE turned a throw into
202
+ * a rejection and bought no asynchrony at all. `installSync` was a `spawnSync`
203
+ * of `npm install` with a two-minute deadline, and it ran on the caller's
204
+ * stack — which at boot is bin/deck.js's, beside jobs that really are async.
205
+ * primeCcusage answered `{ state: "installing" }`, a sentence that says the
206
+ * wait was deferred, while the process could not accept an HTTP connection,
207
+ * write an SSE frame, ingest a hook or repaint the pulse line until npm exited.
208
+ * On a first run, with nothing cached, that is the one boot a new user judges
209
+ * the tool by.
210
+ *
211
+ * `install` below is the fix, and there is now exactly one install mechanism in
212
+ * this module rather than a synchronous one and a background one.
213
+ */
214
+ function startInstall(spec = "latest") {
215
+ if (!_installing) {
216
+ _installing = install(spec)
217
+ .then(() => { _lastInstallError = null; })
218
+ .catch(e => {
219
+ _lastInstallError = oneLine(e?.message ?? e, INSTALL_ERROR_ROOM);
220
+ note("install failed", e);
221
+ })
222
+ .finally(() => { _installing = null; });
223
+ }
224
+ return _installing;
225
+ }
226
+
227
+ // AGENTS_DECK_NO_INSTALL=1 is documented as "never install or update
228
+ // claude-swap / ccusage, and never ask npm about releases", so it has to hold
229
+ // on the lazy path too — opening the usage-history modal must not be a way to
230
+ // pull ccusage off the registry behind the user's back. Read per call rather
231
+ // than once at import, because the module is imported lazily and a test (or an
232
+ // embedder) may set it after load.
233
+ function installsDisabled() {
234
+ return process.env.AGENTS_DECK_NO_INSTALL === "1";
235
+ }
236
+
237
+ // A run can end four ways that mean four different things to whoever opened the
238
+ // modal — installs are forbidden, the deadline expired, the output was not usage
239
+ // data, the CLI exited on its own — and all four used to arrive as one `error`
240
+ // string, leaving the modal nothing to say but whatever `err.message` happened
241
+ // to be. The code rides on the error and comes back out as `reason`; `error`
242
+ // keeps the raw text, which the modal shows only on hover.
243
+ function tagged(reason, message) {
244
+ return Object.assign(new Error(message), { reason });
245
+ }
246
+
247
+ // Everything this module says out loud goes through here, and it says one line.
248
+ //
249
+ // Both failures below carry a subprocess's entire stderr as their `message`,
250
+ // and on a machine whose npm shim is broken that is a fifteen-line Node stack
251
+ // trace. Handed to console.error whole, it landed across the deck's own status
252
+ // rows and its `\r`-repainted pulse line — and because primeCcusage runs at
253
+ // boot, it was the first thing such a machine ever showed (#432). The evidence
254
+ // is not lost by shortening this: a failed RUN carries its full text back to
255
+ // the browser in `error`, which the usage-history modal keeps on the status
256
+ // line's title, one hover away — the same division of labour admin-failure.ts
257
+ // states for claude-swap's output. What this line is for is the operator
258
+ // watching the terminal, who needs to know which of the two things failed and
259
+ // why, not to read a stack.
260
+ //
261
+ // The width is read per call: a terminal can be resized while the deck runs,
262
+ // and this can fire hours in.
263
+ function note(what, err) {
264
+ const head = `${PRODUCT} ccusage: ${what}: `;
265
+ console.error(head + oneLine(err?.message ?? err, termColumns(process.stderr) - head.length));
266
+ }
267
+
268
+ /**
269
+ * Node saying it could not load a file it was pointed at.
270
+ *
271
+ * Both spellings are here because both happen: CommonJS throws
272
+ * `MODULE_NOT_FOUND`, ESM throws `ERR_MODULE_NOT_FOUND` with the wording
273
+ * "Cannot find package" for a bare specifier, and ccusage's entry point is ESM
274
+ * while the npm/npx shims it may be launched through are not.
275
+ *
276
+ * Exported for tests. The one caller is the managed-install branch of
277
+ * runCcusage, where the child is `node <our entry>` and nothing else — so a
278
+ * module Node cannot resolve is by construction a file of OURS that is missing,
279
+ * never the user's npm.
280
+ */
281
+ export const cannotLoadModule = (text) =>
282
+ /\b(?:ERR_)?MODULE_NOT_FOUND\b|cannot find (?:module|package)/i.test(String(text ?? ""));
283
+
284
+ // ── managed install ─────────────────────────────────────────────────────────
285
+
286
+ // Absolute path to ccusage's CLI entry inside our managed install, or null if
287
+ // not installed. Reads the package's `bin` field (currently "./src/cli.js").
288
+ //
289
+ // `bin` is a string out of a package.json downloaded from the registry, and it
290
+ // is joined onto PKG_DIR and then handed to `spawn(node, [entry])` — so a `bin`
291
+ // of "../../../evil.js" names a file outside the managed install and gets run.
292
+ // existsSync alone does not notice: it is asked whether the escaped path is
293
+ // there, and for an attacker who put something there the answer is yes.
294
+ //
295
+ // The narrowness is worth stating rather than leaving implied: a package that
296
+ // can choose its own `bin` can also ship an install script, so this is not the
297
+ // weak link in a hostile-package scenario. What it does close is the case where
298
+ // only the file is influenced — a tampered or half-written package.json in the
299
+ // cache dir, or a `bin` that walks out of the install by accident — and it
300
+ // costs one comparison.
301
+ //
302
+ // Exported for tests: the containment rule is the whole point of the function
303
+ // and there is no other way to reach it without an install on disk.
304
+ export function resolveEntry() {
305
+ try {
306
+ const pkg = JSON.parse(readFileSync(path.join(PKG_DIR, "package.json"), "utf8"));
307
+ let rel = pkg.bin;
308
+ if (rel && typeof rel === "object") rel = rel.ccusage ?? Object.values(rel)[0];
309
+ if (typeof rel !== "string") return null;
310
+ const entry = path.resolve(PKG_DIR, rel);
311
+ // path.resolve, not path.join, so an ABSOLUTE `bin` is measured as the
312
+ // absolute path it is rather than being silently re-rooted under PKG_DIR
313
+ // and passing on a technicality. The trailing separator is what stops a
314
+ // sibling directory whose name merely starts with PKG_DIR's — and it also
315
+ // rejects PKG_DIR itself, which is a directory and no kind of entry point.
316
+ if (!entry.startsWith(path.resolve(PKG_DIR) + path.sep)) return null;
317
+ return existsSync(entry) ? { entry, version: pkg.version } : null;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Everything a failed install is known to have said, in the order the answer is
325
+ * usually in.
326
+ *
327
+ * The old line read `(r.stderr || "").trim() || r.status`, which threw away the
328
+ * two things most likely to be the whole story. `error` is where a failure to
329
+ * LAUNCH lands — ENOENT for a comspec that is not there, EINVAL for a .cmd Node
330
+ * refuses to spawn directly, and the expired deadline — and it comes with no
331
+ * status at all, so what reached the terminal in every one of those cases was
332
+ * the word "null". And npm has never confined itself to stderr: a shim that
333
+ * dies before npm starts writes wherever Node chose, and `npm ERR!` blocks have
334
+ * landed on stdout across majors.
335
+ *
336
+ * The four fields are spawnSync's, because that is where they were first read
337
+ * off; `install` above now fills the same shape from the 'error' event, the
338
+ * exit status and the two collected streams.
339
+ */
340
+ function installFailureText(r) {
341
+ const parts = [];
342
+ if (r?.error?.message) parts.push(String(r.error.message));
343
+ const stderr = String(r?.stderr ?? "").trim();
344
+ const stdout = String(r?.stdout ?? "").trim();
345
+ if (stderr) parts.push(stderr);
346
+ if (stdout) parts.push(stdout);
347
+ if (!parts.length) parts.push(r?.status === null || r?.status === undefined
348
+ ? "npm exited without a status and said nothing"
349
+ : `npm exited ${r.status} and said nothing`);
350
+ return parts.join(" — ");
351
+ }
352
+
353
+ /**
354
+ * Which level of the managed install `npm install --prefix` actually produced.
355
+ *
356
+ * This is the honest answer to the question nobody could answer from a
357
+ * screenshot: an install that exits 0 and leaves a tree resolveEntry cannot use
358
+ * is reported as SUCCESS by an exit code alone, and #432 found that exact shape
359
+ * once already. Naming the first level that is not there turns "ccusage could
360
+ * not report usage" into a sentence about this machine's disk — whether npm
361
+ * wrote nothing, wrote a node_modules with no ccusage in it, or wrote a package
362
+ * whose entry point this deck refuses.
363
+ */
364
+ function installTreeReport() {
365
+ const levels = [
366
+ [CACHE_DIR, "the prefix directory"],
367
+ [path.join(CACHE_DIR, "node_modules"), "node_modules under it"],
368
+ [PKG_DIR, "node_modules/ccusage"],
369
+ [path.join(PKG_DIR, "package.json"), "node_modules/ccusage/package.json"],
370
+ ];
371
+ for (const [where, name] of levels) {
372
+ if (!existsSync(where)) return `${name} is not there`;
373
+ }
374
+ // Every level exists, so resolveEntry refused for one of its own reasons: an
375
+ // unreadable or bin-less package.json, or a `bin` pointing outside the
376
+ // package. All three are about the package rather than about npm.
377
+ return "the package is there but its bin entry could not be read or does not point inside it";
378
+ }
379
+
380
+ /**
381
+ * Run `npm install ccusage@<spec> --prefix CACHE_DIR`, off this stack.
382
+ *
383
+ * The ONE install in this module, awaited by startInstall and dropped on the
384
+ * floor by the daily update path below. It was two — a `spawnSync` for the cold
385
+ * path and a fire-and-forget `spawn` for the background one — and the sync half
386
+ * is what #476 removes: nothing here needs an answer before the next tick, and
387
+ * a two-minute `spawnSync` at boot is two minutes of a dead process.
388
+ *
389
+ * Everything the sync path was careful about is carried over unchanged, because
390
+ * every one of those cares was bought with a bug report:
391
+ *
392
+ * - the vector is `installSpec`'s, spread into spawn exactly as spawnSync got
393
+ * it, which is what keeps #456's absolute `npm.cmd` and #362's per-argument
394
+ * quoting on Windows. `opts` is spread LAST for the same reason it was
395
+ * before: it carries windowsVerbatimArguments, and nothing above it may win.
396
+ * - `windowsHide`, so no console window flashes up.
397
+ * - INSTALL_TIMEOUT_MS, as a deadline this module enforces itself rather than
398
+ * spawn's own `timeout` option. That option sends one signal to the process
399
+ * it started, and on Windows a `.cmd` runs THROUGH cmd.exe — so the signal
400
+ * would land on the wrapper and leave npm downloading, which is the same
401
+ * distinction exec.mjs's `run` states and the reason killTree exists. Same
402
+ * shape as runOnce below, so this file has one deadline pattern rather than
403
+ * two.
404
+ *
405
+ * Diagnosis is unchanged too: `installFailureText` is handed the same four
406
+ * fields spawnSync used to hand it — a failure to LAUNCH in `error`, the exit
407
+ * status, and both output streams, because npm has never confined itself to
408
+ * stderr.
409
+ */
410
+ function install(spec = "latest") {
411
+ return new Promise((resolve, reject) => {
412
+ mkdirSync(CACHE_DIR, { recursive: true });
413
+ const { file, args, opts } = installSpec(spec);
414
+ const child = spawn(file, args, { windowsHide: true, ...opts });
415
+ let out = "", err = "", settled = false;
416
+ let timer = null;
417
+ const finish = (fn, value) => {
418
+ if (settled) return;
419
+ settled = true;
420
+ clearTimeout(timer);
421
+ fn(value);
422
+ };
423
+ // A child that dies emits 'error' AND THEN 'close', and a deadline that
424
+ // fires kills the child and so provokes both — hence `settled`. Whichever
425
+ // arrives first is the account that travels.
426
+ const failed = (r) => finish(reject, new Error(`npm install ccusage failed: ${installFailureText(r)}`));
427
+ timer = setTimeout(() => {
428
+ // The verdict is stated before the kill, the order exec.mjs's `run` uses:
429
+ // the answer must not depend on the killed child cooperating.
430
+ failed({
431
+ error: { message: `npm install timed out after ${INSTALL_TIMEOUT_MS}ms` },
432
+ stdout: out,
433
+ stderr: err,
434
+ });
435
+ killTree(child);
436
+ }, INSTALL_TIMEOUT_MS);
437
+ timer.unref?.();
438
+ child.stdout?.on("data", d => { out += d; });
439
+ child.stderr?.on("data", d => { err += d; });
440
+ child.on("error", e => failed({ error: e, stdout: out, stderr: err }));
441
+ child.on("close", status => {
442
+ if (status !== 0) return failed({ status, stdout: out, stderr: err });
443
+ // A zero exit is npm's opinion, not a fact about the disk, and the caller
444
+ // treats "the install resolved" as "there is something to run". Checking
445
+ // here is what stops a silent success: getRunner used to call
446
+ // resolveEntry(), get null, and fall through to the npx fallback with
447
+ // NOTHING recorded anywhere about why — so the modal explained the
448
+ // fallback's stderr and the install's half of the story was never written
449
+ // down at all.
450
+ if (!resolveEntry()) {
451
+ return finish(reject, new Error(
452
+ `npm install ccusage exited 0 but left nothing runnable under ${CACHE_DIR}: `
453
+ + `${installTreeReport()}`,
454
+ ));
455
+ }
456
+ finish(resolve, undefined);
457
+ });
458
+ });
459
+ }
460
+
461
+ // The daily update path's install: the same one above, with nobody listening.
462
+ //
463
+ // It has no shared promise and no `_lastInstallError` on purpose. This runs
464
+ // behind a ccusage that already works, so a failed upgrade is not news the
465
+ // modal should lead with — the copy on disk still answers, and the check comes
466
+ // round again tomorrow.
467
+ function installInBackground(spec = "latest") {
468
+ install(spec).catch(() => { /* best-effort */ });
469
+ }
470
+
471
+ // True at most once per UPDATE_CHECK_MS, gated by the marker file's mtime so the
472
+ // throttle survives restarts.
473
+ function updateCheckDue() {
474
+ try {
475
+ return Date.now() - statSync(MARKER).mtimeMs > UPDATE_CHECK_MS;
476
+ } catch {
477
+ return true; // no marker yet → due
478
+ }
479
+ }
480
+ // (Re)write the marker so its mtime marks "now" as the last check time.
481
+ function touchMarker() {
482
+ try {
483
+ mkdirSync(CACHE_DIR, { recursive: true });
484
+ writeFileSync(MARKER, String(Date.now()));
485
+ } catch { /* ignore */ }
486
+ }
487
+
488
+ // Non-blocking: compare installed version to npm `latest`; install if newer.
489
+ function maybeBackgroundUpdate(installedVersion) {
490
+ if (installsDisabled()) return; // no `npm view`, no upgrade install
491
+ if (_checkedThisRun || !updateCheckDue()) return;
492
+ _checkedThisRun = true;
493
+ touchMarker();
494
+ try {
495
+ const { file, args, opts } = npmSpec(["view", "ccusage", "version"]);
496
+ const child = spawn(file, args, { windowsHide: true, ...opts });
497
+ let out = "";
498
+ child.stdout.on("data", d => { out += d; });
499
+ child.on("error", () => {});
500
+ child.on("close", () => {
501
+ const latest = out.trim();
502
+ if (latest && latest !== installedVersion) installInBackground(latest);
503
+ });
504
+ } catch { /* ignore */ }
505
+ }
506
+
507
+ // ── the user's own copy ─────────────────────────────────────────────────────
508
+
509
+ /**
510
+ * A ccusage the USER put somewhere, either by naming it outright or by having
511
+ * it on PATH — or null when there is no such thing.
512
+ *
513
+ * This is #433, and the case for it is not that a PATH search is nice to have.
514
+ * The deck has been telling people "put ccusage on PATH yourself" in
515
+ * admin-failure.ts for as long as that sentence has existed, and nothing
516
+ * anywhere ever looked: a user who did exactly what they were told, and could
517
+ * prove it with `ccusage --version` in their own shell, got the identical
518
+ * failure on the next click. Two ways out of that, and the other one is to
519
+ * delete the promise. It is kept because there is a configuration that needs it
520
+ * and has no other:
521
+ *
522
+ * - `AGENTS_DECK_NO_INSTALL=1` is documented as "never install or update
523
+ * claude-swap / ccusage". Someone who sets it AND installs ccusage
524
+ * themselves has done the only thing that flag can sensibly mean, and until
525
+ * now the deck answered "ccusage is not installed" while it was installed
526
+ * and on their PATH. There was NO combination of settings that made a
527
+ * self-managed ccusage work.
528
+ * - The #432 machine — npm and npx both unusable on disk — cannot create the
529
+ * managed install and cannot run the npx fallback. A copy the user already
530
+ * has is the only route left, and it was the one route the deck refused to
531
+ * look down.
532
+ *
533
+ * The override wins over PATH the way AGENTS_DECK_CSWAP does over cswap's own
534
+ * search, and is never remembered, because somebody debugging a bad resolution
535
+ * needs a change to it to take effect on the next click rather than the next
536
+ * restart. Nothing here is cached for the same reason, and it is affordable:
537
+ * a lookup is a handful of stats, once per uncached fetch, against a process
538
+ * spawn that follows it.
539
+ *
540
+ * `platform` and `deps` are parameters, and this is exported, for the reason
541
+ * every other Windows answer in this module is: the PATHEXT walk and the
542
+ * `.cmd` spelling have to be checkable from a machine that cannot run Windows.
543
+ */
544
+ export function userCcusage(platform = process.platform, deps, env = process.env) {
545
+ const named = env.AGENTS_DECK_CCUSAGE;
546
+ if (named) {
547
+ // A path the user typed is used as typed — pathLookup would refuse it
548
+ // anyway, since re-rooting a name that carries a directory is a way to run
549
+ // something other than what was asked for. Whether it EXISTS is the
550
+ // caller's question, because "the path you gave me is not there" is a
551
+ // different sentence from "you have no ccusage".
552
+ return { file: String(named), named: true };
553
+ }
554
+ const found = pathLookup("ccusage", platform, deps);
555
+ return found ? { file: found, named: false } : null;
556
+ }
557
+
558
+ /** Does the file an explicit override names actually exist? Split out so the
559
+ * existence check is injectable alongside the lookup above. */
560
+ const overrideIsThere = (file, { exists = existsSync } = {}) => {
561
+ try {
562
+ return exists(file);
563
+ } catch {
564
+ return false;
565
+ }
566
+ };
567
+
568
+ // Ensure a runnable ccusage. Returns { kind:"node", entry } for the managed
569
+ // install, { kind:"path", file } for a copy the user provided, or { kind:"npx" }
570
+ // as the portable fallback.
571
+ //
572
+ // The order is the whole design decision, so it is stated rather than implied.
573
+ //
574
+ // An explicit AGENTS_DECK_CCUSAGE is first and is never fallen through: a user
575
+ // who pointed the deck at a file and got silently ignored has been given a
576
+ // setting that does nothing, which is worse than not having one. If it does not
577
+ // resolve, the failure names THAT — "npx is not on this deck's PATH" would be
578
+ // both true and completely beside the point.
579
+ //
580
+ // The managed install then comes BEFORE the PATH copy, which is the opposite of
581
+ // what #433 proposed, and deliberately. Preferring PATH would silently change
582
+ // which ccusage runs on every machine that already has both — a deck that works
583
+ // today would start running a copy it has never run, and an old global ccusage
584
+ // without `daily --json` would turn a working panel into a broken one for no
585
+ // reason the user asked for. The PATH copy is an ESCAPE ROUTE, and it wants to
586
+ // be reached exactly when the managed install is not there; someone who wants
587
+ // their own copy to win over the deck's says so with AGENTS_DECK_CCUSAGE, which
588
+ // is unambiguous in a way a precedence rule never is.
589
+ //
590
+ // PATH comes before INSTALLING, though. Downloading a second copy of a tool
591
+ // that is already on the machine is not something to do to somebody, and it is
592
+ // what makes the order identical with and without AGENTS_DECK_NO_INSTALL=1 —
593
+ // that flag now removes a step rather than changing the sequence.
594
+ async function getRunner() {
595
+ const mine = userCcusage();
596
+ if (mine?.named) {
597
+ if (!overrideIsThere(mine.file)) {
598
+ throw tagged("bad_override",
599
+ `AGENTS_DECK_CCUSAGE points at ${mine.file}, and there is no such file`);
600
+ }
601
+ return { kind: "path", file: mine.file, named: true };
602
+ }
603
+ let resolved = resolveEntry();
604
+ if (resolved) {
605
+ maybeBackgroundUpdate(resolved.version);
606
+ return { kind: "node", entry: resolved.entry };
607
+ }
608
+ if (mine) return { kind: "path", file: mine.file, named: false };
609
+ // Nothing installed, nothing of the user's to run, and installs are
610
+ // forbidden. The npx fallback is not an escape hatch — `npx -y ccusage@latest`
611
+ // downloads and runs the same package — so there is no runner to hand back.
612
+ // Fail with the reason, which the usage-history modal shows, instead of
613
+ // quietly installing.
614
+ if (installsDisabled()) {
615
+ throw tagged("no_install", "ccusage is not installed, and installs are off (AGENTS_DECK_NO_INSTALL=1)");
616
+ }
617
+ // Cold: install once (deduped across concurrent callers).
618
+ await startInstall("latest");
619
+ resolved = resolveEntry();
620
+ if (resolved) { touchMarker(); return { kind: "node", entry: resolved.entry }; }
621
+ // npm unavailable / offline → fall back to npx, carrying WHY the managed
622
+ // install is not here. Without that the modal can only describe the fallback,
623
+ // and the fallback is the second thing that failed.
624
+ return { kind: "npx", installError: _lastInstallError };
625
+ }
626
+
627
+ /** Which of ccusage's three paths a failure came from, in the deck's own words.
628
+ * `stage` travels to the browser; the modal leads with it rather than making
629
+ * the reader guess from a stack trace which half of this module they are
630
+ * looking at. */
631
+ const STAGE = { node: "managed", path: "path", npx: "npx" };
632
+
633
+ /** Mark a failure with the path that produced it, and with the managed
634
+ * install's own account when that is why this path was taken at all. Set once:
635
+ * the retry below re-stamps with the runner that actually failed, and an error
636
+ * that already knows where it came from is not overwritten. */
637
+ function stamp(err, runner) {
638
+ if (err && typeof err === "object") {
639
+ if (!err.stage) err.stage = STAGE[runner?.kind] ?? "npx";
640
+ // Which file, when the answer is a file the deck did not put there. The
641
+ // stage alone says "your own copy failed" and leaves the reader to work out
642
+ // WHICH copy, and on a machine with an override, a PATH entry and a managed
643
+ // install that is exactly the question they cannot answer from here.
644
+ if (runner?.kind === "path" && err.bin === undefined) err.bin = runner.file;
645
+ if (runner?.installError && err.install === undefined) err.install = runner.installError;
646
+ }
647
+ return err;
648
+ }
649
+
650
+ // ── invocation ──────────────────────────────────────────────────────────────
651
+
652
+ // At most one repair per process, so a package that is simply unrunnable —
653
+ // reinstalled and still broken — cannot put this into a loop of npm installs.
654
+ let _repairedThisRun = false;
655
+
656
+ /**
657
+ * Throw away a managed install that resolves but cannot run, so the next
658
+ * attempt builds a new one.
659
+ *
660
+ * This is the only genuinely permanent failure in this module, and it is the
661
+ * one a broken npm creates: `npm install` that dies partway leaves a
662
+ * package.json and an entry file on disk, resolveEntry answers "installed", and
663
+ * getRunner then hands back that entry FOREVER. Nothing re-checks it —
664
+ * maybeBackgroundUpdate only reinstalls when the registry has a newer version,
665
+ * so even a repaired npm never repaired the install. Every run failed
666
+ * identically, the modal said "try again", and the only thing that actually
667
+ * worked was deleting ~/.agents-deck/ccusage by hand, which nothing in the
668
+ * product tells anyone to do.
669
+ *
670
+ * Only the managed branch qualifies: the npx fallback's failures are npm's own
671
+ * and none of this deck's business to delete anything over.
672
+ *
673
+ * Not done under AGENTS_DECK_NO_INSTALL=1. That variable is a promise not to
674
+ * fetch, and removing the only copy on a machine that cannot replace it would
675
+ * turn a broken feature into an absent one.
676
+ */
677
+ function discardDamagedInstall(runner, err) {
678
+ if (_repairedThisRun || runner.kind !== "node" || installsDisabled()) return false;
679
+ if (!cannotLoadModule(err?.message)) return false;
680
+ _repairedThisRun = true;
681
+ try {
682
+ // maxRetries because of what has just happened: the deck ran `node <entry>`
683
+ // out of this very directory a moment ago, and on Windows a file any handle
684
+ // still holds cannot be deleted — the unlink marks it delete-pending, the
685
+ // name stays, and rmdir on the parent answers ENOTEMPTY. A child still
686
+ // exiting is exactly that handle. Node retries EBUSY, EMFILE, ENFILE,
687
+ // ENOTEMPTY and EPERM with a linear backoff when asked to; unasked, it
688
+ // tries once and gives up, which turned a repairable install into the
689
+ // permanent failure below.
690
+ rmSync(PKG_DIR, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 });
691
+ } catch {
692
+ // Windows holds a lock on a file inside a directory being removed more
693
+ // readily than POSIX does, and a half-removed install is still a resolvable
694
+ // one. Say the repair did not happen so the caller reports the real failure
695
+ // rather than retrying into the same broken entry point.
696
+ return false;
697
+ }
698
+ return !resolveEntry();
699
+ }
700
+
701
+ /**
702
+ * What `spawn` gets for a ccusage the user provided.
703
+ *
704
+ * `file` is always an absolute path by the time it reaches here — pathLookup
705
+ * resolves the directory and an override is a path the user typed — and that is
706
+ * load-bearing rather than tidy. On Windows the thing on PATH is `ccusage.cmd`,
707
+ * a batch file, so spawnSpec routes it through cmd.exe; a batch file launched
708
+ * by BARE name computes `%~dp0` from the deck's working directory and goes
709
+ * looking for its payload there, which is #456 exactly. Resolving first and
710
+ * quoting second is the same order the npm and npx shims go through.
711
+ *
712
+ * Exported for tests: the platform is a parameter so the Windows command line
713
+ * can be checked from any OS.
714
+ */
715
+ export const userSpec = (file, args = [], platform = process.platform) =>
716
+ spawnSpec(file, args, platform);
717
+
718
+ // One attempt with one runner. No branch gets a shell. The managed install is
719
+ // `node <entry> …`, which never needed one; the user's own copy and the npx
720
+ // fallback are routed through spawnSpec instead — see npxSpec and userSpec, and
721
+ // note that `args` here ends in whatever /api/ccusage was asked for.
722
+ function runOnce(runner, args) {
723
+ const { file, args: full, opts } = runner.kind === "node"
724
+ ? { file: process.execPath, args: [runner.entry, ...args], opts: {} }
725
+ : runner.kind === "path"
726
+ ? userSpec(runner.file, args)
727
+ : fallbackSpec(args);
728
+ return new Promise((resolve, reject) => {
729
+ const child = spawn(file, full, { windowsHide: true, ...opts });
730
+ let out = "", err = "";
731
+ const timer = setTimeout(() => {
732
+ // The npx fallback goes through cmd.exe on Windows, so `child` is the
733
+ // wrapper there and npx is a grandchild that a plain kill would leave
734
+ // downloading.
735
+ killTree(child);
736
+ reject(tagged("timeout", "ccusage timed out"));
737
+ }, TIMEOUT_MS);
738
+ child.stdout.on("data", d => { out += d; });
739
+ child.stderr.on("data", d => { err += d; });
740
+ child.on("error", e => { clearTimeout(timer); reject(e); });
741
+ child.on("close", code => {
742
+ clearTimeout(timer);
743
+ if (code === 0) resolve(out);
744
+ else reject(new Error(err.trim() || `ccusage exited ${code}`));
745
+ });
746
+ });
747
+ }
748
+
749
+ // Run ccusage with the given args, resolve raw stdout AND the runner that
750
+ // produced it. One retry, and only for the one failure that is otherwise
751
+ // permanent — see discardDamagedInstall. The second getRunner() is what rebuilds
752
+ // the install, or falls through to npx when npm cannot.
753
+ //
754
+ // The runner comes back with the output because the caller has to say which
755
+ // path answered: `extractJson` below can fail on a run that started perfectly
756
+ // well, and "ccusage ran but printed no usage data" reads differently depending
757
+ // on which ccusage that was.
758
+ async function runCcusage(args) {
759
+ const runner = await getRunner();
760
+ try {
761
+ return { out: await runOnce(runner, args), runner };
762
+ } catch (err) {
763
+ if (!discardDamagedInstall(runner, err)) throw stamp(err, runner);
764
+ note("managed install was unusable, rebuilding it", err);
765
+ const rebuilt = await getRunner();
766
+ try {
767
+ return { out: await runOnce(rebuilt, args), runner: rebuilt };
768
+ } catch (again) {
769
+ throw stamp(again, rebuilt);
770
+ }
771
+ }
772
+ }
773
+
774
+ // ── the per-agent split ─────────────────────────────────────────────────────
775
+
776
+ /**
777
+ * The flag that stops ccusage merging every CLI it read into one row.
778
+ *
779
+ * ccusage groups by day and, by default, adds Claude Code's spend to Codex's —
780
+ * and to OpenCode's, Amp's, Gemini CLI's and the dozen other sources it now
781
+ * reads — before it prints anything. The deck then drew that single number under
782
+ * a subtitle naming two CLIs, so "how much of this is Codex?" had no answer
783
+ * anywhere on the panel (#431).
784
+ *
785
+ * Measured against ccusage 20.0.20 rather than read off its README, because the
786
+ * whole reason to send this is what comes back. Two runs over the same range,
787
+ * with and without it, differ in exactly one way: each day gains an `agents`
788
+ * array, one entry per CLI, carrying that CLI's own `totalCost`, token counts
789
+ * and `modelBreakdowns`. Every key the parser below already reads survives byte
790
+ * for byte, the day's `totalCost` is unchanged and still equals the sum of its
791
+ * agents, the day count is the same and `totals` is untouched. The flag is
792
+ * purely additive, which is what makes it safe to send unconditionally: the
793
+ * merged view the deck has always drawn stays available for free, and a browser
794
+ * that ignores `agents` sees the reply it has always seen.
795
+ */
796
+ const BY_AGENT = "--by-agent";
797
+
798
+ /**
799
+ * A ccusage on this machine that does not know `--by-agent`, remembered.
800
+ *
801
+ * The npx fallback resolves `ccusage@latest` and so is never the stale case,
802
+ * but a managed install that has not had its once-a-day update yet can be, and
803
+ * a copy the user put on PATH themselves can be any age at all. Process-scoped,
804
+ * like `_checkedThisRun` and `_repairedThisRun`: a deck restarted after
805
+ * upgrading ccusage asks again.
806
+ */
807
+ let _byAgentUnsupported = false;
808
+
809
+ /**
810
+ * A failure that is about this flag rather than about this machine.
811
+ *
812
+ * Every argument parser that rejects an unknown option quotes the option back —
813
+ * "Unknown option '--by-agent'", "unrecognized option --by-agent", "unexpected
814
+ * argument '--by-agent' found", "Unknown argument: by-agent" — so the flag's own
815
+ * name is the one token they all agree on, and matching it needs no table of
816
+ * parsers or versions. The dashes are deliberately not part of the match, since
817
+ * one of those spellings drops them.
818
+ *
819
+ * This decides only whether to REMEMBER, never whether to retry: a run that
820
+ * failed for its own reasons and then happened to succeed on the second attempt
821
+ * must not leave the deck convinced, for the rest of the process, that this
822
+ * ccusage cannot report a split it can report perfectly well.
823
+ */
824
+ const blamesByAgent = (text) => /by-agent/i.test(String(text ?? ""));
825
+
826
+ /**
827
+ * Run `daily` for a range, asking for the per-agent split, and give up the
828
+ * split rather than the whole answer when this ccusage will not produce one.
829
+ *
830
+ * Retrying is chosen over gating on `resolveEntry().version`, which the module
831
+ * already has in hand, for one reason: a version table only knows about the
832
+ * ccusage versions that existed when it was written, and two of the three
833
+ * runners here are copies the deck did not install and cannot date — an
834
+ * AGENTS_DECK_CCUSAGE override and whatever is on PATH. Retrying degrades
835
+ * correctly for ANY unknown ccusage rather than only for old ones.
836
+ *
837
+ * The retry is narrow, because a second attempt is a second process and on a
838
+ * genuinely broken machine that is a second wait. It fires only when the CLI
839
+ * itself failed — an untagged error, which is this module's word for "the child
840
+ * exited non-zero, or never started" — and never for the four failures that are
841
+ * already understood: `timeout` (where a retry would cost another 90 seconds
842
+ * for nothing), `no_install` and `bad_override` (thrown before any process
843
+ * runs), and `bad_output` (thrown after a run that ccusage considered a
844
+ * success, so the flag was accepted). An old ccusage lands squarely in the
845
+ * untagged case: measured, it exits 2 with an empty stdout and
846
+ * `Unknown option '--by-agent'` on stderr, which is unambiguous — it cannot be
847
+ * confused with a successful run that happened to have no data.
848
+ *
849
+ * When the retry ALSO fails, its failure is the one that travels, not the first
850
+ * one. Both runs failed, and the one without the deck's flag on it is the
851
+ * honest account of this machine: reporting the first would blame a flag that
852
+ * has just been shown to make no difference. Nothing is remembered in that case
853
+ * either, so the split is asked for again on the next attempt.
854
+ *
855
+ * The retry is therefore broad and the MEMORY of it is narrow — see
856
+ * blamesByAgent. Those are different questions with different costs: guessing
857
+ * wrong about whether to retry costs one process on a machine that is already
858
+ * failing, and guessing wrong about whether to remember costs the split for the
859
+ * life of the deck.
860
+ */
861
+ async function runDaily(args) {
862
+ if (_byAgentUnsupported) return runCcusage(args);
863
+ try {
864
+ return await runCcusage([...args, BY_AGENT]);
865
+ } catch (err) {
866
+ if (err?.reason !== undefined) throw err;
867
+ const plain = await runCcusage(args);
868
+ if (blamesByAgent(err?.message)) _byAgentUnsupported = true;
869
+ return plain;
870
+ }
871
+ }
872
+
873
+ // ccusage prints the JSON object somewhere in stdout; slice first { to last }.
874
+ function extractJson(out) {
875
+ const start = out.indexOf("{");
876
+ const end = out.lastIndexOf("}");
877
+ if (start === -1 || end === -1) throw tagged("bad_output", "no JSON in ccusage output");
878
+ try {
879
+ return JSON.parse(out.slice(start, end + 1));
880
+ } catch (err) {
881
+ // A ccusage that printed a progress line containing braces, or was cut off
882
+ // mid-object, lands here rather than on the branch above. Same failure to
883
+ // the reader either way: it ran, and what came back was not usage data.
884
+ throw tagged("bad_output", `unreadable ccusage output: ${err?.message ?? err}`);
885
+ }
886
+ }
887
+
888
+ // YYYYMMDD for the CLI's --since/--until.
889
+ function toCliDate(d) {
890
+ return d.toISOString().slice(0, 10).replace(/-/g, "");
891
+ }
892
+
893
+ /**
894
+ * Fetch daily usage from ccusage for a date range.
895
+ * @param {{ since?: string, until?: string, force?: boolean }} opts
896
+ * since/until are YYYYMMDD strings (CLI format). Defaults to last 30 days.
897
+ * @returns {{ ok, days, totals, since, until, fetchedAt } | { ok:false, reason, error }}
898
+ */
899
+ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
900
+ const now = Date.now();
901
+ const sinceArg = since || toCliDate(new Date(now - 30 * 86400_000));
902
+ const key = `${sinceArg}|${until ?? ""}`;
903
+
904
+ const cached = _cache.get(key);
905
+ if (!force && cached && now - cached.at < CACHE_MS) return cached.result;
906
+
907
+ // A run for this exact range is already going: join it. `force` joins too,
908
+ // rather than starting a competing child — what ?refresh=1 asks for is a
909
+ // reading newer than the cache, and a run still in progress is one.
910
+ const already = _inflight.get(key);
911
+ if (already) return already;
912
+
913
+ // Refused here, before anything is spawned or remembered. The caller that
914
+ // reaches this is the fifth distinct range in flight at once, which the modal
915
+ // cannot produce; queueing it would mean holding a request open behind up to
916
+ // four ninety-second deadlines, which is a worse answer than saying no.
917
+ if (_outstanding >= MAX_OUTSTANDING) {
918
+ return {
919
+ ok: false,
920
+ reason: "busy",
921
+ error: `${MAX_OUTSTANDING} usage ranges are already being read \u2014 try again in a moment`,
922
+ fetchedAt: now,
923
+ };
924
+ }
925
+
926
+ const run = queued(() => readRange(sinceArg, until, key))
927
+ .finally(() => { _inflight.delete(key); });
928
+ _inflight.set(key, run);
929
+ return run;
930
+ }
931
+
932
+ /**
933
+ * One ccusage run, once the queue has let it through.
934
+ *
935
+ * `now` is read here rather than carried in from the call, so `fetchedAt` and
936
+ * the cache stamp both mean "when this reading was taken" even for a run that
937
+ * waited its turn. With an empty queue that is the same instant the caller
938
+ * asked, which is what this always did.
939
+ */
940
+ async function readRange(sinceArg, until, key) {
941
+ const now = Date.now();
942
+ let result;
943
+ let ran = null; // the runner that answered, for stamping a bad_output failure
944
+ try {
945
+ const args = ["daily", "--json", "--since", sinceArg];
946
+ if (until) args.push("--until", until);
947
+ ran = await runDaily(args);
948
+ const raw = extractJson(ran.out);
949
+ // Passed through whole, `agents` array and all. Every day ccusage returns
950
+ // under `--by-agent` is a superset of the day it returns without one, so
951
+ // there is nothing here to reshape: the browser reads the merged totals it
952
+ // always read, and reads the split when it is there. A run that fell back
953
+ // to the flagless form simply carries days with no `agents`, which the
954
+ // usage-history modal treats the same way it treats a range with one CLI in
955
+ // it — no split, and no new chrome.
956
+ const days = Array.isArray(raw.daily) ? raw.daily : [];
957
+ result = {
958
+ ok: true,
959
+ days,
960
+ totals: raw.totals ?? null,
961
+ since: sinceArg,
962
+ until: until ?? null,
963
+ fetchedAt: now,
964
+ };
965
+ } catch (err) {
966
+ // `extractJson` throws about a run that started fine, so it arrives here
967
+ // knowing nothing about which ccusage it read. The runner does.
968
+ if (ran) stamp(err, ran.runner);
969
+ // Naming the path in the terminal too. "fetch failed" alone was true of
970
+ // both, and an operator reading a boot report has the same question the
971
+ // modal's reader has: which of the two.
972
+ note(err?.stage === "managed" ? "fetch failed (managed install)"
973
+ : err?.stage === "npx" ? "fetch failed (npx fallback)"
974
+ : "fetch failed", err);
975
+ // Anything untagged got here from the child itself — a non-zero exit, or a
976
+ // spawn that never started one — which is exactly what run_failed means.
977
+ // `error` keeps the child's WHOLE output, stack trace and all: it is the
978
+ // modal's hover title, which is where the raw bytes are meant to live, and
979
+ // it is what somebody pastes into an issue. Only the terminal gets a line.
980
+ //
981
+ // `stage` and `install` are the halves that used to be lost. They are what
982
+ // let the modal say WHICH path failed and why, on screen, instead of
983
+ // guessing it from the shape of a stack trace — the guess that shipped two
984
+ // wrong diagnoses in a row (#432, #450). `bin` joined them for #433: with a
985
+ // third path, "your own copy failed" is only half an answer until it names
986
+ // which file that was. Undefined when nothing ran, and JSON.stringify drops
987
+ // them, so an older browser sees the reply it expects.
988
+ result = {
989
+ ok: false,
990
+ reason: err?.reason ?? "run_failed",
991
+ stage: err?.stage,
992
+ install: err?.install,
993
+ bin: err?.bin,
994
+ error: String(err?.message ?? err),
995
+ fetchedAt: now,
996
+ };
997
+ }
998
+
999
+ rememberRange(key, result, now);
1000
+ return result;
1001
+ }
1002
+
1003
+ /**
1004
+ * Get ccusage ready at startup instead of on first use.
1005
+ *
1006
+ * The lazy path is fine for correctness but means the first person to open the
1007
+ * usage-history modal on a fresh machine waits out an npm install with no
1008
+ * explanation. Called from the CLI so that cost is paid while the deck is
1009
+ * still booting.
1010
+ *
1011
+ * Returns { state: "present" | "user" | "installing" | "updating" |
1012
+ * "unavailable" }. Never throws and never blocks on the install itself — a slow
1013
+ * registry must not hold up the server. That second half was a claim rather
1014
+ * than a fact until #476: `startInstall` wrapped a `spawnSync` in an async IIFE
1015
+ * with nothing to await in it, so `{ state: "installing" }` was returned only
1016
+ * after the install had already happened, on this stack.
1017
+ *
1018
+ * The order here is getRunner's order, and it has to be: a boot that installs a
1019
+ * managed copy while the user already has one on PATH would make getRunner's
1020
+ * "PATH before installing" true only until the first restart, and would spend a
1021
+ * download saying so. `user` is reported without a version because finding one
1022
+ * means RUNNING the thing, and the boot is not the place to spawn a process to
1023
+ * fill in a status row.
1024
+ */
1025
+ export function primeCcusage() {
1026
+ const mine = userCcusage();
1027
+ // An override that names a file which is not there is not reported here at
1028
+ // all: the boot has nothing useful to say about it and getRunner will say it
1029
+ // properly, with the path, the first time the modal is opened.
1030
+ if (mine && (!mine.named || overrideIsThere(mine.file))) {
1031
+ const resolved = resolveEntry();
1032
+ // The managed install still wins when it exists — see getRunner — so an
1033
+ // existing deck's boot row does not change.
1034
+ if (mine.named || !resolved) return { state: "user", bin: mine.file };
1035
+ }
1036
+ // The CLI already skips this call under AGENTS_DECK_NO_INSTALL=1; repeating
1037
+ // the check here keeps the promise a property of the module rather than of
1038
+ // one caller.
1039
+ if (installsDisabled()) {
1040
+ const have = resolveEntry();
1041
+ return have ? { state: "present", version: have.version } : { state: "unavailable" };
1042
+ }
1043
+ const resolved = resolveEntry();
1044
+ if (resolved) {
1045
+ // Already installed: the daily check may still queue a background upgrade.
1046
+ const due = !_checkedThisRun && updateCheckDue();
1047
+ maybeBackgroundUpdate(resolved.version);
1048
+ return { state: due ? "updating" : "present", version: resolved.version };
1049
+ }
1050
+ startInstall("latest");
1051
+ return { state: "installing" };
1052
+ }