ccdeck 3.29.10 → 3.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +28 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
  175. package/dist/web/assets/index-C1YFI9f4.js +0 -298
  176. package/dist/web/assets/index-Ck2RAla3.css +0 -1
@@ -2,23 +2,16 @@
2
2
  // ccusage reads the local ~/.claude (and other agent) logs and reports cost +
3
3
  // token usage grouped by day.
4
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 { createRequire } from "node:module";
15
- import { spawn } from "node:child_process";
16
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
- import path from "node:path";
18
- import os from "node:os";
19
- import { killTree, pathLookup, shimPath, spawnSpec } from "./exec.mjs";
20
- import { oneLine, termColumns } from "./term.mjs";
21
- import { PRODUCT } from "./brand.mjs";
5
+ // Which copy of ccusage runs, and running it, is ccusage-runner.mjs's; the copy
6
+ // is usually the deck's own managed install, which ccusage-install.mjs builds
7
+ // and keeps current; what is said when a run or an install fails is
8
+ // ccusage-failure.mjs's. What is left here is the range: what to ask, how many
9
+ // asks may be in flight, and what the answer means.
10
+ import { note, stamp, tagged } from "./ccusage-failure.mjs";
11
+ import {
12
+ backgroundUpdateDue, installsDisabled, maybeBackgroundUpdate, resolveEntry, startInstall,
13
+ } from "./ccusage-install.mjs";
14
+ import { overrideIsThere, runCcusage, userCcusage } from "./ccusage-runner.mjs";
22
15
 
23
16
  // 60s, and it is the panel's poll interval rather than a number of its own: the
24
17
  // Usage panel asks once a minute and expects a reading that has actually moved,
@@ -26,13 +19,6 @@ import { PRODUCT } from "./brand.mjs";
26
19
  // lie. Two tabs polling out of phase still share one run, which is what the
27
20
  // cache is for here. The modal is manual-open and unaffected either way.
28
21
  const CACHE_MS = 60_000;
29
- const TIMEOUT_MS = 90_000;
30
- const INSTALL_TIMEOUT_MS = 120_000; // first-run npm install can be slow
31
- const UPDATE_CHECK_MS = 24 * 3600_000; // check npm for a newer ccusage once/day
32
-
33
- const CACHE_DIR = path.join(os.homedir(), ".agents-deck", "ccusage");
34
- const PKG_DIR = path.join(CACHE_DIR, "node_modules", "ccusage");
35
- const MARKER = path.join(CACHE_DIR, ".last-update-check");
36
22
 
37
23
  const _cache = new Map(); // key `${since}|${until}` → { result, at }
38
24
 
@@ -105,727 +91,6 @@ function rememberRange(key, result, at) {
105
91
  }
106
92
  }
107
93
 
108
- /**
109
- * The name to hand cmd.exe for one of npm's Windows shims: its full path when
110
- * one can be found, and the bare name only when none can.
111
- *
112
- * The full path is not a tidiness preference, it is the fix for #456. A shim
113
- * launched by bare name computes `%~dp0` — which is where it looks for
114
- * npm-prefix.js, npm-cli.js and npx-cli.js — from the deck's WORKING DIRECTORY
115
- * rather than from its own, so on a deck started from `C:\Users\vceban` both
116
- * shims died with `Cannot find module 'C:\Users\vceban\node_modules\npm\bin\…'`
117
- * on a machine whose npm was perfectly healthy. shimPath in exec.mjs carries
118
- * the whole account; what matters here is that BOTH the managed install and the
119
- * npx fallback are launched this way, so both failed, and diagnosing either
120
- * half alone could never have explained the other.
121
- *
122
- * Falling back to the bare name is deliberate: it is exactly what this did
123
- * before, so a layout shimPath cannot see is no worse off than it was, and on
124
- * such a machine cmd.exe's own PATH search still gets its turn.
125
- */
126
- const winShim = (name, deps) => shimPath(name, deps) ?? name;
127
-
128
- // npm is a .cmd shim on Windows, which spawn can only launch through cmd.exe.
129
- // `shell: true` is the tempting way to get there and the wrong one: Node then
130
- // joins file and args with single spaces and no quoting, so on a profile like
131
- // C:\Users\John Smith the `--prefix <CACHE_DIR>` below arrived as
132
- // `--prefix C:\Users\John` plus a bogus package spec `Smith\.agents-deck\...`,
133
- // npm exited non-zero, and the managed install never materialised. spawnSpec
134
- // routes the .cmd through cmd.exe with every argument quoted, and hands back
135
- // the argument vector untouched everywhere else.
136
- //
137
- // POSIX is untouched by any of this: `npm` there is a real executable on PATH,
138
- // not a batch file, so isBatch is false, viaCmd never runs, and the vector goes
139
- // to spawn exactly as it always has.
140
- function npmSpec(args, platform = process.platform, deps) {
141
- return spawnSpec(platform === "win32" ? winShim("npm.cmd", deps) : "npm", args, platform);
142
- }
143
-
144
- /**
145
- * What `spawn` gets for `npm install ccusage@<spec>`.
146
- * Exported for tests: the platform is a parameter so the Windows command line
147
- * can be checked from any OS, and `deps` stands in for the Windows filesystem
148
- * the shim lookup asks about.
149
- */
150
- export const installSpec = (spec = "latest", platform = process.platform, deps) =>
151
- npmSpec(["install", `ccusage@${spec}`, "--prefix", CACHE_DIR,
152
- "--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform, deps);
153
-
154
- // npx is the same kind of shim as npm, and the fallback run needs the same
155
- // treatment for a second, sharper reason: its argument vector carries a
156
- // user-supplied `--since`. `shell: true` handed `[file, ...args].join(" ")` to
157
- // /bin/sh -c, so `GET /api/ccusage?since=1;id;` ran `id` — and because that
158
- // route is a GET, any page open in the user's browser could aim it at the
159
- // loopback port with no CORS, no preflight and no need to read the answer.
160
- // spawnSpec quotes each argument into the cmd.exe line on Windows and spawns
161
- // the vector untouched everywhere else, so nothing in it is ever parsed as
162
- // syntax. Naming the file `npx.cmd` on Windows is what makes that work: only a
163
- // .cmd/.bat file routes through cmd.exe, and a bare `npx` there is not a file
164
- // spawn can launch at all (PATHEXT is a shell's job), so asking for the
165
- // extensionless name would trade a shell injection for an ENOENT. What it must
166
- // NOT be is the bare `npx.cmd` this said until #456 — see winShim.
167
- function npxSpec(args, platform = process.platform, deps) {
168
- return spawnSpec(platform === "win32" ? winShim("npx.cmd", deps) : "npx", args, platform);
169
- }
170
-
171
- /**
172
- * What `spawn` gets for the portable `npx -y ccusage@latest <args>` fallback.
173
- * Exported for tests: the platform is a parameter so the Windows command line
174
- * can be checked from any OS, and `deps` stands in for the Windows filesystem
175
- * the shim lookup asks about.
176
- */
177
- export const fallbackSpec = (args = [], platform = process.platform, deps) =>
178
- npxSpec(["-y", "ccusage@latest", ...args], platform, deps);
179
-
180
- let _installing = null; // Promise guard so concurrent calls share one install
181
- let _checkedThisRun = false; // only kick the daily check once per process boot
182
-
183
- // The last `npm install` that failed, in one line of its own words.
184
- //
185
- // Module scope rather than a value thrown out of the install, because the two
186
- // callers that matter never see that throw. primeCcusage runs the install at
187
- // boot and only logs the rejection; a second modal open that arrives while an
188
- // install is in flight awaits the SHARED promise, whose rejection has already
189
- // been handled by the first. Both then land on the npx fallback with nothing to
190
- // say about why they were there — which is precisely how three rounds of
191
- // debugging went to the fallback's stderr while the install's own account
192
- // stayed on a terminal row the deck had already painted over.
193
- //
194
- // One line, not the whole dump: this is written into a sentence in a 46ch box.
195
- // The full text still goes to the terminal through note() below.
196
- let _lastInstallError = null;
197
- const INSTALL_ERROR_ROOM = 240;
198
-
199
- /**
200
- * Start the one shared install, remembering how it ended.
201
- *
202
- * Deduped through `_installing` the way it always was, so concurrent callers
203
- * cost one `npm install` between them, and the outcome survives the promise.
204
- *
205
- * This used to read `_installing = (async () => { installSync(spec); })()`, and
206
- * that wrapper was the whole of #476: an async function body runs synchronously
207
- * up to its first `await`, and there was none, so the IIFE turned a throw into
208
- * a rejection and bought no asynchrony at all. `installSync` was a `spawnSync`
209
- * of `npm install` with a two-minute deadline, and it ran on the caller's
210
- * stack — which at boot is bin/deck.js's, beside jobs that really are async.
211
- * primeCcusage answered `{ state: "installing" }`, a sentence that says the
212
- * wait was deferred, while the process could not accept an HTTP connection,
213
- * write an SSE frame, ingest a hook or repaint the pulse line until npm exited.
214
- * On a first run, with nothing cached, that is the one boot a new user judges
215
- * the tool by.
216
- *
217
- * `install` below is the fix, and there is now exactly one install mechanism in
218
- * this module rather than a synchronous one and a background one.
219
- */
220
- function startInstall(spec = "latest") {
221
- if (!_installing) {
222
- _installing = install(spec)
223
- .then(() => { _lastInstallError = null; })
224
- .catch(e => {
225
- _lastInstallError = oneLine(e?.message ?? e, INSTALL_ERROR_ROOM);
226
- note("install failed", e);
227
- })
228
- .finally(() => { _installing = null; });
229
- }
230
- return _installing;
231
- }
232
-
233
- // AGENTS_DECK_NO_INSTALL=1 is documented as "never install or update
234
- // claude-swap / ccusage, and never ask npm about releases", so it has to hold
235
- // on the lazy path too — opening the usage-history modal must not be a way to
236
- // pull ccusage off the registry behind the user's back. Read per call rather
237
- // than once at import, because the module is imported lazily and a test (or an
238
- // embedder) may set it after load.
239
- function installsDisabled() {
240
- return process.env.AGENTS_DECK_NO_INSTALL === "1";
241
- }
242
-
243
- // A run can end four ways that mean four different things to whoever opened the
244
- // modal — installs are forbidden, the deadline expired, the output was not usage
245
- // data, the CLI exited on its own — and all four used to arrive as one `error`
246
- // string, leaving the modal nothing to say but whatever `err.message` happened
247
- // to be. The code rides on the error and comes back out as `reason`; `error`
248
- // keeps the raw text, which the modal shows only on hover.
249
- function tagged(reason, message) {
250
- return Object.assign(new Error(message), { reason });
251
- }
252
-
253
- // Everything this module says out loud goes through here, and it says one line.
254
- //
255
- // Both failures below carry a subprocess's entire stderr as their `message`,
256
- // and on a machine whose npm shim is broken that is a fifteen-line Node stack
257
- // trace. Handed to console.error whole, it landed across the deck's own status
258
- // rows and its `\r`-repainted pulse line — and because primeCcusage runs at
259
- // boot, it was the first thing such a machine ever showed (#432). The evidence
260
- // is not lost by shortening this: a failed RUN carries its full text back to
261
- // the browser in `error`, which the usage-history modal keeps on the status
262
- // line's title, one hover away — the same division of labour admin-failure.ts
263
- // states for claude-swap's output. What this line is for is the operator
264
- // watching the terminal, who needs to know which of the two things failed and
265
- // why, not to read a stack.
266
- //
267
- // The width is read per call: a terminal can be resized while the deck runs,
268
- // and this can fire hours in.
269
- function note(what, err) {
270
- const head = `${PRODUCT} ccusage: ${what}: `;
271
- console.error(head + oneLine(err?.message ?? err, termColumns(process.stderr) - head.length));
272
- }
273
-
274
- /**
275
- * Node saying it could not load a file it was pointed at.
276
- *
277
- * Both spellings are here because both happen: CommonJS throws
278
- * `MODULE_NOT_FOUND`, ESM throws `ERR_MODULE_NOT_FOUND` with the wording
279
- * "Cannot find package" for a bare specifier, and ccusage's entry point is ESM
280
- * while the npm/npx shims it may be launched through are not.
281
- *
282
- * Exported for tests. The one caller is the managed-install branch of
283
- * runCcusage, where the child is `node <our entry>` and nothing else — so a
284
- * module Node cannot resolve is by construction a file of OURS that is missing,
285
- * never the user's npm.
286
- */
287
- export const cannotLoadModule = (text) =>
288
- /\b(?:ERR_)?MODULE_NOT_FOUND\b|cannot find (?:module|package)/i.test(String(text ?? ""));
289
-
290
- // ── managed install ─────────────────────────────────────────────────────────
291
-
292
- // Absolute path to ccusage's CLI entry inside our managed install, or null if
293
- // not installed. Reads the package's `bin` field (currently "./src/cli.js").
294
- //
295
- // `bin` is a string out of a package.json downloaded from the registry, and it
296
- // is joined onto PKG_DIR and then handed to `spawn(node, [entry])` — so a `bin`
297
- // of "../../../evil.js" names a file outside the managed install and gets run.
298
- // existsSync alone does not notice: it is asked whether the escaped path is
299
- // there, and for an attacker who put something there the answer is yes.
300
- //
301
- // The narrowness is worth stating rather than leaving implied: a package that
302
- // can choose its own `bin` can also ship an install script, so this is not the
303
- // weak link in a hostile-package scenario. What it does close is the case where
304
- // only the file is influenced — a tampered or half-written package.json in the
305
- // cache dir, or a `bin` that walks out of the install by accident — and it
306
- // costs one comparison.
307
- //
308
- // Exported for tests: the containment rule is the whole point of the function
309
- // and there is no other way to reach it without an install on disk.
310
- export function resolveEntry() {
311
- try {
312
- const pkg = JSON.parse(readFileSync(path.join(PKG_DIR, "package.json"), "utf8"));
313
- let rel = pkg.bin;
314
- if (rel && typeof rel === "object") rel = rel.ccusage ?? Object.values(rel)[0];
315
- if (typeof rel !== "string") return null;
316
- const entry = path.resolve(PKG_DIR, rel);
317
- // path.resolve, not path.join, so an ABSOLUTE `bin` is measured as the
318
- // absolute path it is rather than being silently re-rooted under PKG_DIR
319
- // and passing on a technicality. The trailing separator is what stops a
320
- // sibling directory whose name merely starts with PKG_DIR's — and it also
321
- // rejects PKG_DIR itself, which is a directory and no kind of entry point.
322
- if (!entry.startsWith(path.resolve(PKG_DIR) + path.sep)) return null;
323
- return existsSync(entry) ? { entry, version: pkg.version } : null;
324
- } catch {
325
- return null;
326
- }
327
- }
328
-
329
- /**
330
- * Everything a failed install is known to have said, in the order the answer is
331
- * usually in.
332
- *
333
- * The old line read `(r.stderr || "").trim() || r.status`, which threw away the
334
- * two things most likely to be the whole story. `error` is where a failure to
335
- * LAUNCH lands — ENOENT for a comspec that is not there, EINVAL for a .cmd Node
336
- * refuses to spawn directly, and the expired deadline — and it comes with no
337
- * status at all, so what reached the terminal in every one of those cases was
338
- * the word "null". And npm has never confined itself to stderr: a shim that
339
- * dies before npm starts writes wherever Node chose, and `npm ERR!` blocks have
340
- * landed on stdout across majors.
341
- *
342
- * The four fields are spawnSync's, because that is where they were first read
343
- * off; `install` above now fills the same shape from the 'error' event, the
344
- * exit status and the two collected streams.
345
- */
346
- function installFailureText(r) {
347
- const parts = [];
348
- if (r?.error?.message) parts.push(String(r.error.message));
349
- const stderr = String(r?.stderr ?? "").trim();
350
- const stdout = String(r?.stdout ?? "").trim();
351
- if (stderr) parts.push(stderr);
352
- if (stdout) parts.push(stdout);
353
- if (!parts.length) parts.push(r?.status === null || r?.status === undefined
354
- ? "npm exited without a status and said nothing"
355
- : `npm exited ${r.status} and said nothing`);
356
- return parts.join(" — ");
357
- }
358
-
359
- /**
360
- * Which level of the managed install `npm install --prefix` actually produced.
361
- *
362
- * This is the honest answer to the question nobody could answer from a
363
- * screenshot: an install that exits 0 and leaves a tree resolveEntry cannot use
364
- * is reported as SUCCESS by an exit code alone, and #432 found that exact shape
365
- * once already. Naming the first level that is not there turns "ccusage could
366
- * not report usage" into a sentence about this machine's disk — whether npm
367
- * wrote nothing, wrote a node_modules with no ccusage in it, or wrote a package
368
- * whose entry point this deck refuses.
369
- */
370
- function installTreeReport() {
371
- const levels = [
372
- [CACHE_DIR, "the prefix directory"],
373
- [path.join(CACHE_DIR, "node_modules"), "node_modules under it"],
374
- [PKG_DIR, "node_modules/ccusage"],
375
- [path.join(PKG_DIR, "package.json"), "node_modules/ccusage/package.json"],
376
- ];
377
- for (const [where, name] of levels) {
378
- if (!existsSync(where)) return `${name} is not there`;
379
- }
380
- // Every level exists, so resolveEntry refused for one of its own reasons: an
381
- // unreadable or bin-less package.json, or a `bin` pointing outside the
382
- // package. All three are about the package rather than about npm.
383
- return "the package is there but its bin entry could not be read or does not point inside it";
384
- }
385
-
386
- /**
387
- * Run `npm install ccusage@<spec> --prefix CACHE_DIR`, off this stack.
388
- *
389
- * The ONE install in this module, awaited by startInstall and dropped on the
390
- * floor by the daily update path below. It was two — a `spawnSync` for the cold
391
- * path and a fire-and-forget `spawn` for the background one — and the sync half
392
- * is what #476 removes: nothing here needs an answer before the next tick, and
393
- * a two-minute `spawnSync` at boot is two minutes of a dead process.
394
- *
395
- * Everything the sync path was careful about is carried over unchanged, because
396
- * every one of those cares was bought with a bug report:
397
- *
398
- * - the vector is `installSpec`'s, spread into spawn exactly as spawnSync got
399
- * it, which is what keeps #456's absolute `npm.cmd` and #362's per-argument
400
- * quoting on Windows. `opts` is spread LAST for the same reason it was
401
- * before: it carries windowsVerbatimArguments, and nothing above it may win.
402
- * - `windowsHide`, so no console window flashes up.
403
- * - INSTALL_TIMEOUT_MS, as a deadline this module enforces itself rather than
404
- * spawn's own `timeout` option. That option sends one signal to the process
405
- * it started, and on Windows a `.cmd` runs THROUGH cmd.exe — so the signal
406
- * would land on the wrapper and leave npm downloading, which is the same
407
- * distinction exec.mjs's `run` states and the reason killTree exists. Same
408
- * shape as runOnce below, so this file has one deadline pattern rather than
409
- * two.
410
- *
411
- * Diagnosis is unchanged too: `installFailureText` is handed the same four
412
- * fields spawnSync used to hand it — a failure to LAUNCH in `error`, the exit
413
- * status, and both output streams, because npm has never confined itself to
414
- * stderr.
415
- */
416
- function install(spec = "latest") {
417
- return new Promise((resolve, reject) => {
418
- mkdirSync(CACHE_DIR, { recursive: true });
419
- const { file, args, opts } = installSpec(spec);
420
- const child = spawn(file, args, { windowsHide: true, ...opts });
421
- let out = "", err = "", settled = false;
422
- let timer = null;
423
- const finish = (fn, value) => {
424
- if (settled) return;
425
- settled = true;
426
- clearTimeout(timer);
427
- fn(value);
428
- };
429
- // A child that dies emits 'error' AND THEN 'close', and a deadline that
430
- // fires kills the child and so provokes both — hence `settled`. Whichever
431
- // arrives first is the account that travels.
432
- const failed = (r) => finish(reject, new Error(`npm install ccusage failed: ${installFailureText(r)}`));
433
- timer = setTimeout(() => {
434
- // The verdict is stated before the kill, the order exec.mjs's `run` uses:
435
- // the answer must not depend on the killed child cooperating.
436
- failed({
437
- error: { message: `npm install timed out after ${INSTALL_TIMEOUT_MS}ms` },
438
- stdout: out,
439
- stderr: err,
440
- });
441
- killTree(child);
442
- }, INSTALL_TIMEOUT_MS);
443
- timer.unref?.();
444
- child.stdout?.on("data", d => { out += d; });
445
- child.stderr?.on("data", d => { err += d; });
446
- child.on("error", e => failed({ error: e, stdout: out, stderr: err }));
447
- child.on("close", status => {
448
- if (status !== 0) return failed({ status, stdout: out, stderr: err });
449
- // A zero exit is npm's opinion, not a fact about the disk, and the caller
450
- // treats "the install resolved" as "there is something to run". Checking
451
- // here is what stops a silent success: getRunner used to call
452
- // resolveEntry(), get null, and fall through to the npx fallback with
453
- // NOTHING recorded anywhere about why — so the modal explained the
454
- // fallback's stderr and the install's half of the story was never written
455
- // down at all.
456
- if (!resolveEntry()) {
457
- return finish(reject, new Error(
458
- `npm install ccusage exited 0 but left nothing runnable under ${CACHE_DIR}: `
459
- + `${installTreeReport()}`,
460
- ));
461
- }
462
- finish(resolve, undefined);
463
- });
464
- });
465
- }
466
-
467
- // The daily update path's install: the same one above, with nobody listening.
468
- //
469
- // It has no shared promise and no `_lastInstallError` on purpose. This runs
470
- // behind a ccusage that already works, so a failed upgrade is not news the
471
- // modal should lead with — the copy on disk still answers, and the check comes
472
- // round again tomorrow.
473
- function installInBackground(spec = "latest") {
474
- install(spec).catch(() => { /* best-effort */ });
475
- }
476
-
477
- // True at most once per UPDATE_CHECK_MS, gated by the marker file's mtime so the
478
- // throttle survives restarts.
479
- function updateCheckDue() {
480
- try {
481
- return Date.now() - statSync(MARKER).mtimeMs > UPDATE_CHECK_MS;
482
- } catch {
483
- return true; // no marker yet → due
484
- }
485
- }
486
- // (Re)write the marker so its mtime marks "now" as the last check time.
487
- function touchMarker() {
488
- try {
489
- mkdirSync(CACHE_DIR, { recursive: true });
490
- writeFileSync(MARKER, String(Date.now()));
491
- } catch { /* ignore */ }
492
- }
493
-
494
- // Non-blocking: compare installed version to npm `latest`; install if newer.
495
- function maybeBackgroundUpdate(installedVersion) {
496
- if (installsDisabled()) return; // no `npm view`, no upgrade install
497
- if (_checkedThisRun || !updateCheckDue()) return;
498
- _checkedThisRun = true;
499
- touchMarker();
500
- try {
501
- const { file, args, opts } = npmSpec(["view", "ccusage", "version"]);
502
- const child = spawn(file, args, { windowsHide: true, ...opts });
503
- let out = "";
504
- child.stdout.on("data", d => { out += d; });
505
- child.on("error", () => {});
506
- child.on("close", () => {
507
- const latest = out.trim();
508
- if (latest && latest !== installedVersion) installInBackground(latest);
509
- });
510
- } catch { /* ignore */ }
511
- }
512
-
513
- // ── the user's own copy ─────────────────────────────────────────────────────
514
-
515
- /**
516
- * A ccusage the USER put somewhere, either by naming it outright or by having
517
- * it on PATH — or null when there is no such thing.
518
- *
519
- * This is #433, and the case for it is not that a PATH search is nice to have.
520
- * The deck has been telling people "put ccusage on PATH yourself" in
521
- * admin-failure.ts for as long as that sentence has existed, and nothing
522
- * anywhere ever looked: a user who did exactly what they were told, and could
523
- * prove it with `ccusage --version` in their own shell, got the identical
524
- * failure on the next click. Two ways out of that, and the other one is to
525
- * delete the promise. It is kept because there is a configuration that needs it
526
- * and has no other:
527
- *
528
- * - `AGENTS_DECK_NO_INSTALL=1` is documented as "never install or update
529
- * claude-swap / ccusage". Someone who sets it AND installs ccusage
530
- * themselves has done the only thing that flag can sensibly mean, and until
531
- * now the deck answered "ccusage is not installed" while it was installed
532
- * and on their PATH. There was NO combination of settings that made a
533
- * self-managed ccusage work.
534
- * - The #432 machine — npm and npx both unusable on disk — cannot create the
535
- * managed install and cannot run the npx fallback. A copy the user already
536
- * has is the only route left, and it was the one route the deck refused to
537
- * look down.
538
- *
539
- * The override wins over PATH the way AGENTS_DECK_CSWAP does over cswap's own
540
- * search, and is never remembered, because somebody debugging a bad resolution
541
- * needs a change to it to take effect on the next click rather than the next
542
- * restart. Nothing here is cached for the same reason, and it is affordable:
543
- * a lookup is a handful of stats, once per uncached fetch, against a process
544
- * spawn that follows it.
545
- *
546
- * `platform` and `deps` are parameters, and this is exported, for the reason
547
- * every other Windows answer in this module is: the PATHEXT walk and the
548
- * `.cmd` spelling have to be checkable from a machine that cannot run Windows.
549
- */
550
- export function userCcusage(platform = process.platform, deps, env = process.env) {
551
- const named = env.AGENTS_DECK_CCUSAGE;
552
- if (named) {
553
- // A path the user typed is used as typed — pathLookup would refuse it
554
- // anyway, since re-rooting a name that carries a directory is a way to run
555
- // something other than what was asked for. Whether it EXISTS is the
556
- // caller's question, because "the path you gave me is not there" is a
557
- // different sentence from "you have no ccusage".
558
- return { file: String(named), named: true };
559
- }
560
- const found = pathLookup("ccusage", platform, deps);
561
- return found ? { file: found, named: false } : null;
562
- }
563
-
564
- /** Does the file an explicit override names actually exist? Split out so the
565
- * existence check is injectable alongside the lookup above. */
566
- const overrideIsThere = (file, { exists = existsSync } = {}) => {
567
- try {
568
- return exists(file);
569
- } catch {
570
- return false;
571
- }
572
- };
573
-
574
- // Ensure a runnable ccusage. Returns { kind:"node", entry } for the managed
575
- // install, { kind:"path", file } for a copy the user provided, or { kind:"npx" }
576
- // as the portable fallback.
577
- //
578
- // The order is the whole design decision, so it is stated rather than implied.
579
- //
580
- // An explicit AGENTS_DECK_CCUSAGE is first and is never fallen through: a user
581
- // who pointed the deck at a file and got silently ignored has been given a
582
- // setting that does nothing, which is worse than not having one. If it does not
583
- // resolve, the failure names THAT — "npx is not on this deck's PATH" would be
584
- // both true and completely beside the point.
585
- //
586
- // The managed install then comes BEFORE the PATH copy, which is the opposite of
587
- // what #433 proposed, and deliberately. Preferring PATH would silently change
588
- // which ccusage runs on every machine that already has both — a deck that works
589
- // today would start running a copy it has never run, and an old global ccusage
590
- // without `daily --json` would turn a working panel into a broken one for no
591
- // reason the user asked for. The PATH copy is an ESCAPE ROUTE, and it wants to
592
- // be reached exactly when the managed install is not there; someone who wants
593
- // their own copy to win over the deck's says so with AGENTS_DECK_CCUSAGE, which
594
- // is unambiguous in a way a precedence rule never is.
595
- //
596
- // PATH comes before INSTALLING, though. Downloading a second copy of a tool
597
- // that is already on the machine is not something to do to somebody, and it is
598
- // what makes the order identical with and without AGENTS_DECK_NO_INSTALL=1 —
599
- // that flag now removes a step rather than changing the sequence.
600
- async function getRunner() {
601
- const mine = userCcusage();
602
- if (mine?.named) {
603
- if (!overrideIsThere(mine.file)) {
604
- throw tagged("bad_override",
605
- `AGENTS_DECK_CCUSAGE points at ${mine.file}, and there is no such file`);
606
- }
607
- return { kind: "path", file: mine.file, named: true };
608
- }
609
- let resolved = resolveEntry();
610
- if (resolved) {
611
- maybeBackgroundUpdate(resolved.version);
612
- return { kind: "node", entry: resolved.entry };
613
- }
614
- if (mine) return { kind: "path", file: mine.file, named: false };
615
- // Nothing installed, nothing of the user's to run, and installs are
616
- // forbidden. The npx fallback is not an escape hatch — `npx -y ccusage@latest`
617
- // downloads and runs the same package — so there is no runner to hand back.
618
- // Fail with the reason, which the usage-history modal shows, instead of
619
- // quietly installing.
620
- if (installsDisabled()) {
621
- throw tagged("no_install", "ccusage is not installed, and installs are off (AGENTS_DECK_NO_INSTALL=1)");
622
- }
623
- // Cold: install once (deduped across concurrent callers).
624
- await startInstall("latest");
625
- resolved = resolveEntry();
626
- if (resolved) { touchMarker(); return { kind: "node", entry: resolved.entry }; }
627
- // npm unavailable / offline → fall back to npx, carrying WHY the managed
628
- // install is not here. Without that the modal can only describe the fallback,
629
- // and the fallback is the second thing that failed.
630
- return { kind: "npx", installError: _lastInstallError };
631
- }
632
-
633
- /** Which of ccusage's three paths a failure came from, in the deck's own words.
634
- * `stage` travels to the browser; the modal leads with it rather than making
635
- * the reader guess from a stack trace which half of this module they are
636
- * looking at. */
637
- const STAGE = { node: "managed", path: "path", npx: "npx" };
638
-
639
- /** Mark a failure with the path that produced it, and with the managed
640
- * install's own account when that is why this path was taken at all. Set once:
641
- * the retry below re-stamps with the runner that actually failed, and an error
642
- * that already knows where it came from is not overwritten. */
643
- function stamp(err, runner) {
644
- if (err && typeof err === "object") {
645
- if (!err.stage) err.stage = STAGE[runner?.kind] ?? "npx";
646
- // Which file, when the answer is a file the deck did not put there. The
647
- // stage alone says "your own copy failed" and leaves the reader to work out
648
- // WHICH copy, and on a machine with an override, a PATH entry and a managed
649
- // install that is exactly the question they cannot answer from here.
650
- if (runner?.kind === "path" && err.bin === undefined) err.bin = runner.file;
651
- if (runner?.installError && err.install === undefined) err.install = runner.installError;
652
- }
653
- return err;
654
- }
655
-
656
- // ── invocation ──────────────────────────────────────────────────────────────
657
-
658
- // At most one repair per process, so a package that is simply unrunnable —
659
- // reinstalled and still broken — cannot put this into a loop of npm installs.
660
- let _repairedThisRun = false;
661
-
662
- /**
663
- * Throw away a managed install that resolves but cannot run, so the next
664
- * attempt builds a new one.
665
- *
666
- * This is the only genuinely permanent failure in this module, and it is the
667
- * one a broken npm creates: `npm install` that dies partway leaves a
668
- * package.json and an entry file on disk, resolveEntry answers "installed", and
669
- * getRunner then hands back that entry FOREVER. Nothing re-checks it —
670
- * maybeBackgroundUpdate only reinstalls when the registry has a newer version,
671
- * so even a repaired npm never repaired the install. Every run failed
672
- * identically, the modal said "try again", and the only thing that actually
673
- * worked was deleting ~/.agents-deck/ccusage by hand, which nothing in the
674
- * product tells anyone to do.
675
- *
676
- * Only the managed branch qualifies: the npx fallback's failures are npm's own
677
- * and none of this deck's business to delete anything over.
678
- *
679
- * Not done under AGENTS_DECK_NO_INSTALL=1. That variable is a promise not to
680
- * fetch, and removing the only copy on a machine that cannot replace it would
681
- * turn a broken feature into an absent one.
682
- */
683
- function discardDamagedInstall(runner, err) {
684
- if (_repairedThisRun || runner.kind !== "node" || installsDisabled()) return false;
685
- if (!cannotLoadModule(err?.message)) return false;
686
- // THE FLAG IS SPENT ON A REPAIR THAT HAPPENED, not on one that was attempted
687
- // (#790). It used to be set here, before the try — and the rm below fails on
688
- // Windows for the reason its own maxRetries comment gives: the `node <entry>`
689
- // child that just exited still holds a handle, the unlink marks the file
690
- // delete-pending, and rmdir answers ENOTEMPTY past all ten retries. The catch
691
- // returned false with NOTHING removed, and every later modal open and every
692
- // 60s poll for the life of the deck then short-circuited on this same flag —
693
- // including seconds later, once the handle was gone and the rm would have
694
- // worked. The user's only way out was deleting ~/.agents-deck/ccusage by
695
- // hand, which nothing tells them.
696
- //
697
- // The budget exists to stop a loop of INSTALLS. Here it was being consumed by
698
- // a repair that never happened and never reached an install.
699
- try {
700
- // maxRetries because of what has just happened: the deck ran `node <entry>`
701
- // out of this very directory a moment ago, and on Windows a file any handle
702
- // still holds cannot be deleted — the unlink marks it delete-pending, the
703
- // name stays, and rmdir on the parent answers ENOTEMPTY. A child still
704
- // exiting is exactly that handle. Node retries EBUSY, EMFILE, ENFILE,
705
- // ENOTEMPTY and EPERM with a linear backoff when asked to; unasked, it
706
- // tries once and gives up, which turned a repairable install into the
707
- // permanent failure below.
708
- rmSync(PKG_DIR, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 });
709
- } catch {
710
- // Windows holds a lock on a file inside a directory being removed more
711
- // readily than POSIX does, and a half-removed install is still a resolvable
712
- // one. Say the repair did not happen so the caller reports the real failure
713
- // rather than retrying into the same broken entry point.
714
- return false;
715
- }
716
- const gone = !resolveEntry();
717
- // Only now. The directory is really gone, so this deck has spent its one
718
- // repair and the budget is doing its job. A failed `rm` returned false above
719
- // without touching the flag, so the next poll — seconds later, once the
720
- // handle is released — is free to try again.
721
- if (gone) _repairedThisRun = true;
722
- return gone;
723
- }
724
-
725
- /**
726
- * What `spawn` gets for a ccusage the user provided.
727
- *
728
- * `file` is always an absolute path by the time it reaches here — pathLookup
729
- * resolves the directory and an override is a path the user typed — and that is
730
- * load-bearing rather than tidy. On Windows the thing on PATH is `ccusage.cmd`,
731
- * a batch file, so spawnSpec routes it through cmd.exe; a batch file launched
732
- * by BARE name computes `%~dp0` from the deck's working directory and goes
733
- * looking for its payload there, which is #456 exactly. Resolving first and
734
- * quoting second is the same order the npm and npx shims go through.
735
- *
736
- * Exported for tests: the platform is a parameter so the Windows command line
737
- * can be checked from any OS.
738
- */
739
- export const userSpec = (file, args = [], platform = process.platform) =>
740
- spawnSpec(file, args, platform);
741
-
742
- /**
743
- * The platform binary ccusage's own cli.js would run, resolved the way cli.js
744
- * resolves it (`@ccusage/ccusage-<platform>-<arch>/bin/ccusage[.exe]`, from
745
- * the package's own directory), or null when there is none.
746
- *
747
- * WINDOWS ONLY, and for one reason: cli.js starts that binary WITHOUT hiding
748
- * its console. The deck runs with no console of its own (detached, or inside
749
- * the desktop app), so the binary is given a new one — and with Windows
750
- * Terminal as the default terminal that is a window flashing up on every
751
- * usage read, measured on a Windows 10 box as a CASCADIA_HOSTING_WINDOW_CLASS
752
- * hosting ccusage. Running the binary directly, with windowsHide, is what
753
- * cli.js does minus the window. Elsewhere nothing flashes, and the wrapper
754
- * stays in the path it has always been in.
755
- *
756
- * Exported for tests; `resolve` is injectable.
757
- */
758
- export function nativeCcusage(entry, platform = process.platform, arch = process.arch, resolve = null) {
759
- if (platform !== "win32" || typeof entry !== "string" || !entry) return null;
760
- const pkg = arch === "arm64" ? "@ccusage/ccusage-win32-arm64" : arch === "x64" ? "@ccusage/ccusage-win32-x64" : null;
761
- if (!pkg) return null;
762
- try {
763
- const req = resolve ?? createRequire(entry).resolve;
764
- return req(`${pkg}/bin/ccusage.exe`);
765
- } catch {
766
- return null;
767
- }
768
- }
769
-
770
- // One attempt with one runner. No branch gets a shell. The managed install is
771
- // `node <entry> …`, which never needed one; the user's own copy and the npx
772
- // fallback are routed through spawnSpec instead — see npxSpec and userSpec, and
773
- // note that `args` here ends in whatever /api/ccusage was asked for.
774
- function runOnce(runner, args) {
775
- const native = runner.kind === "node" ? nativeCcusage(runner.entry) : null;
776
- const { file, args: full, opts } = native
777
- ? { file: native, args, opts: {} }
778
- : runner.kind === "node"
779
- ? { file: process.execPath, args: [runner.entry, ...args], opts: {} }
780
- : runner.kind === "path"
781
- ? userSpec(runner.file, args)
782
- : fallbackSpec(args);
783
- return new Promise((resolve, reject) => {
784
- const child = spawn(file, full, { windowsHide: true, ...opts });
785
- let out = "", err = "";
786
- const timer = setTimeout(() => {
787
- // The npx fallback goes through cmd.exe on Windows, so `child` is the
788
- // wrapper there and npx is a grandchild that a plain kill would leave
789
- // downloading.
790
- killTree(child);
791
- reject(tagged("timeout", "ccusage timed out"));
792
- }, TIMEOUT_MS);
793
- child.stdout.on("data", d => { out += d; });
794
- child.stderr.on("data", d => { err += d; });
795
- child.on("error", e => { clearTimeout(timer); reject(e); });
796
- child.on("close", code => {
797
- clearTimeout(timer);
798
- if (code === 0) resolve(out);
799
- else reject(new Error(err.trim() || `ccusage exited ${code}`));
800
- });
801
- });
802
- }
803
-
804
- // Run ccusage with the given args, resolve raw stdout AND the runner that
805
- // produced it. One retry, and only for the one failure that is otherwise
806
- // permanent — see discardDamagedInstall. The second getRunner() is what rebuilds
807
- // the install, or falls through to npx when npm cannot.
808
- //
809
- // The runner comes back with the output because the caller has to say which
810
- // path answered: `extractJson` below can fail on a run that started perfectly
811
- // well, and "ccusage ran but printed no usage data" reads differently depending
812
- // on which ccusage that was.
813
- async function runCcusage(args) {
814
- const runner = await getRunner();
815
- try {
816
- return { out: await runOnce(runner, args), runner };
817
- } catch (err) {
818
- if (!discardDamagedInstall(runner, err)) throw stamp(err, runner);
819
- note("managed install was unusable, rebuilding it", err);
820
- const rebuilt = await getRunner();
821
- try {
822
- return { out: await runOnce(rebuilt, args), runner: rebuilt };
823
- } catch (again) {
824
- throw stamp(again, rebuilt);
825
- }
826
- }
827
- }
828
-
829
94
  // ── the per-agent split ─────────────────────────────────────────────────────
830
95
 
831
96
  /**
@@ -856,8 +121,8 @@ const BY_AGENT = "--by-agent";
856
121
  * The npx fallback resolves `ccusage@latest` and so is never the stale case,
857
122
  * but a managed install that has not had its once-a-day update yet can be, and
858
123
  * a copy the user put on PATH themselves can be any age at all. Process-scoped,
859
- * like `_checkedThisRun` and `_repairedThisRun`: a deck restarted after
860
- * upgrading ccusage asks again.
124
+ * like ccusage-install.mjs's `_checkedThisRun` and `_repairedThisRun`: a deck
125
+ * restarted after upgrading ccusage asks again.
861
126
  */
862
127
  let _byAgentUnsupported = false;
863
128
 
@@ -1028,14 +293,6 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
1028
293
  return run;
1029
294
  }
1030
295
 
1031
- /**
1032
- * One ccusage run, once the queue has let it through.
1033
- *
1034
- * `now` is read here rather than carried in from the call, so `fetchedAt` and
1035
- * the cache stamp both mean "when this reading was taken" even for a run that
1036
- * waited its turn. With an empty queue that is the same instant the caller
1037
- * asked, which is what this always did.
1038
- */
1039
296
  /**
1040
297
  * The same range, grouped by session rather than by day.
1041
298
  *
@@ -1068,6 +325,14 @@ async function readSessions(sinceArg, until) {
1068
325
  }
1069
326
  }
1070
327
 
328
+ /**
329
+ * One ccusage run, once the queue has let it through.
330
+ *
331
+ * `now` is read here rather than carried in from the call, so `fetchedAt` and
332
+ * the cache stamp both mean "when this reading was taken" even for a run that
333
+ * waited its turn. With an empty queue that is the same instant the caller
334
+ * asked, which is what this always did.
335
+ */
1071
336
  async function readRange(sinceArg, until, key) {
1072
337
  const now = Date.now();
1073
338
  let result;
@@ -1207,7 +472,7 @@ export function primeCcusage() {
1207
472
  const resolved = resolveEntry();
1208
473
  if (resolved) {
1209
474
  // Already installed: the daily check may still queue a background upgrade.
1210
- const due = !_checkedThisRun && updateCheckDue();
475
+ const due = backgroundUpdateDue();
1211
476
  maybeBackgroundUpdate(resolved.version);
1212
477
  return { state: due ? "updating" : "present", version: resolved.version };
1213
478
  }