agent-dag 3.22.0 → 3.22.3

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