ccdeck 3.29.11 → 3.31.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 (178) hide show
  1. package/README.md +4 -3
  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-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -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 +40 -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 +7 -3
  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 +2 -2
  67. package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,459 @@
1
+ // The managed ccusage install: where it lives, how npm is asked to build and
2
+ // update it, what is said when that fails, and the one repair a broken one
3
+ // gets. ccusage.mjs decides when to run ccusage and reads what it prints; this
4
+ // is only about there being a copy of the deck's own to run.
5
+ //
6
+ // Performance: we do NOT run `npx -y ccusage@latest` on every call — that hits
7
+ // the npm registry to resolve `@latest` (and re-downloads when the npx cache is
8
+ // cold), so each modal open waited seconds. Instead we keep our OWN managed
9
+ // install under ~/.agents-deck/ccusage and invoke it directly with
10
+ // `node <pkg>/src/cli.js` (no npx, no registry round-trip). A throttled
11
+ // once-per-day background check upgrades it when a newer ccusage ships, while
12
+ // the current call always serves from the already-installed copy. If the
13
+ // managed install is missing/broken we fall back to the old npx path so the
14
+ // feature still works on a fresh machine.
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, shimPath, spawnSpec } from "./exec.mjs";
20
+ import { oneLine } from "./term.mjs";
21
+ import { note } from "./ccusage-failure.mjs";
22
+
23
+ const INSTALL_TIMEOUT_MS = 120_000; // first-run npm install can be slow
24
+ const UPDATE_CHECK_MS = 24 * 3600_000; // check npm for a newer ccusage once/day
25
+
26
+ const CACHE_DIR = path.join(os.homedir(), ".agents-deck", "ccusage");
27
+ const PKG_DIR = path.join(CACHE_DIR, "node_modules", "ccusage");
28
+ const MARKER = path.join(CACHE_DIR, ".last-update-check");
29
+
30
+ /**
31
+ * The name to hand cmd.exe for one of npm's Windows shims: its full path when
32
+ * one can be found, and the bare name only when none can.
33
+ *
34
+ * The full path is not a tidiness preference, it is the fix for #456. A shim
35
+ * launched by bare name computes `%~dp0` — which is where it looks for
36
+ * npm-prefix.js, npm-cli.js and npx-cli.js — from the deck's WORKING DIRECTORY
37
+ * rather than from its own, so on a deck started from `C:\Users\vceban` both
38
+ * shims died with `Cannot find module 'C:\Users\vceban\node_modules\npm\bin\…'`
39
+ * on a machine whose npm was perfectly healthy. shimPath in exec.mjs carries
40
+ * the whole account; what matters here is that BOTH the managed install and the
41
+ * npx fallback are launched this way, so both failed, and diagnosing either
42
+ * half alone could never have explained the other.
43
+ *
44
+ * Falling back to the bare name is deliberate: it is exactly what this did
45
+ * before, so a layout shimPath cannot see is no worse off than it was, and on
46
+ * such a machine cmd.exe's own PATH search still gets its turn.
47
+ */
48
+ export const winShim = (name, deps) => shimPath(name, deps) ?? name;
49
+
50
+ // npm is a .cmd shim on Windows, which spawn can only launch through cmd.exe.
51
+ // `shell: true` is the tempting way to get there and the wrong one: Node then
52
+ // joins file and args with single spaces and no quoting, so on a profile like
53
+ // C:\Users\John Smith the `--prefix <CACHE_DIR>` below arrived as
54
+ // `--prefix C:\Users\John` plus a bogus package spec `Smith\.agents-deck\...`,
55
+ // npm exited non-zero, and the managed install never materialised. spawnSpec
56
+ // routes the .cmd through cmd.exe with every argument quoted, and hands back
57
+ // the argument vector untouched everywhere else.
58
+ //
59
+ // POSIX is untouched by any of this: `npm` there is a real executable on PATH,
60
+ // not a batch file, so isBatch is false, viaCmd never runs, and the vector goes
61
+ // to spawn exactly as it always has.
62
+ function npmSpec(args, platform = process.platform, deps) {
63
+ return spawnSpec(platform === "win32" ? winShim("npm.cmd", deps) : "npm", args, platform);
64
+ }
65
+
66
+ /**
67
+ * What `spawn` gets for `npm install ccusage@<spec>`.
68
+ * Exported for tests: the platform is a parameter so the Windows command line
69
+ * can be checked from any OS, and `deps` stands in for the Windows filesystem
70
+ * the shim lookup asks about.
71
+ */
72
+ export const installSpec = (spec = "latest", platform = process.platform, deps) =>
73
+ npmSpec(["install", `ccusage@${spec}`, "--prefix", CACHE_DIR,
74
+ "--no-save", "--no-audit", "--no-fund", "--loglevel", "error"], platform, deps);
75
+
76
+ let _installing = null; // Promise guard so concurrent calls share one install
77
+ let _checkedThisRun = false; // only kick the daily check once per process boot
78
+
79
+ // The last `npm install` that failed, in one line of its own words.
80
+ //
81
+ // Module scope rather than a value thrown out of the install, because the two
82
+ // callers that matter never see that throw. primeCcusage runs the install at
83
+ // boot and only logs the rejection; a second modal open that arrives while an
84
+ // install is in flight awaits the SHARED promise, whose rejection has already
85
+ // been handled by the first. Both then land on the npx fallback with nothing to
86
+ // say about why they were there — which is precisely how three rounds of
87
+ // debugging went to the fallback's stderr while the install's own account
88
+ // stayed on a terminal row the deck had already painted over.
89
+ //
90
+ // One line, not the whole dump: this is written into a sentence in a 46ch box.
91
+ // The full text still goes to the terminal through note() below.
92
+ let _lastInstallError = null;
93
+ const INSTALL_ERROR_ROOM = 240;
94
+
95
+ /** The last failed install's line, or null once an install has worked — what
96
+ * getRunner hands the npx fallback to carry. */
97
+ export function lastInstallError() { return _lastInstallError; }
98
+
99
+ /**
100
+ * Start the one shared install, remembering how it ended.
101
+ *
102
+ * Deduped through `_installing` the way it always was, so concurrent callers
103
+ * cost one `npm install` between them, and the outcome survives the promise.
104
+ *
105
+ * This used to read `_installing = (async () => { installSync(spec); })()`, and
106
+ * that wrapper was the whole of #476: an async function body runs synchronously
107
+ * up to its first `await`, and there was none, so the IIFE turned a throw into
108
+ * a rejection and bought no asynchrony at all. `installSync` was a `spawnSync`
109
+ * of `npm install` with a two-minute deadline, and it ran on the caller's
110
+ * stack — which at boot is bin/deck.js's, beside jobs that really are async.
111
+ * primeCcusage answered `{ state: "installing" }`, a sentence that says the
112
+ * wait was deferred, while the process could not accept an HTTP connection,
113
+ * write an SSE frame, ingest a hook or repaint the pulse line until npm exited.
114
+ * On a first run, with nothing cached, that is the one boot a new user judges
115
+ * the tool by.
116
+ *
117
+ * `install` below is the fix, and there is now exactly one install mechanism in
118
+ * this module rather than a synchronous one and a background one.
119
+ */
120
+ export function startInstall(spec = "latest") {
121
+ if (!_installing) {
122
+ _installing = install(spec)
123
+ .then(() => { _lastInstallError = null; })
124
+ .catch(e => {
125
+ _lastInstallError = oneLine(e?.message ?? e, INSTALL_ERROR_ROOM);
126
+ note("install failed", e);
127
+ })
128
+ .finally(() => { _installing = null; });
129
+ }
130
+ return _installing;
131
+ }
132
+
133
+ // AGENTS_DECK_NO_INSTALL=1 is documented as "never install or update
134
+ // claude-swap / ccusage, and never ask npm about releases", so it has to hold
135
+ // on the lazy path too — opening the usage-history modal must not be a way to
136
+ // pull ccusage off the registry behind the user's back. Read per call rather
137
+ // than once at import, because the module is imported lazily and a test (or an
138
+ // embedder) may set it after load.
139
+ export function installsDisabled() {
140
+ return process.env.AGENTS_DECK_NO_INSTALL === "1";
141
+ }
142
+
143
+ /**
144
+ * Node saying it could not load a file it was pointed at.
145
+ *
146
+ * Both spellings are here because both happen: CommonJS throws
147
+ * `MODULE_NOT_FOUND`, ESM throws `ERR_MODULE_NOT_FOUND` with the wording
148
+ * "Cannot find package" for a bare specifier, and ccusage's entry point is ESM
149
+ * while the npm/npx shims it may be launched through are not.
150
+ *
151
+ * Exported for tests. The one caller is the managed-install branch of
152
+ * runCcusage, where the child is `node <our entry>` and nothing else — so a
153
+ * module Node cannot resolve is by construction a file of OURS that is missing,
154
+ * never the user's npm.
155
+ */
156
+ export const cannotLoadModule = (text) =>
157
+ /\b(?:ERR_)?MODULE_NOT_FOUND\b|cannot find (?:module|package)/i.test(String(text ?? ""));
158
+
159
+ // ── managed install ─────────────────────────────────────────────────────────
160
+
161
+ // Absolute path to ccusage's CLI entry inside our managed install, or null if
162
+ // not installed. Reads the package's `bin` field (currently "./src/cli.js").
163
+ //
164
+ // `bin` is a string out of a package.json downloaded from the registry, and it
165
+ // is joined onto PKG_DIR and then handed to `spawn(node, [entry])` — so a `bin`
166
+ // of "../../../evil.js" names a file outside the managed install and gets run.
167
+ // existsSync alone does not notice: it is asked whether the escaped path is
168
+ // there, and for an attacker who put something there the answer is yes.
169
+ //
170
+ // The narrowness is worth stating rather than leaving implied: a package that
171
+ // can choose its own `bin` can also ship an install script, so this is not the
172
+ // weak link in a hostile-package scenario. What it does close is the case where
173
+ // only the file is influenced — a tampered or half-written package.json in the
174
+ // cache dir, or a `bin` that walks out of the install by accident — and it
175
+ // costs one comparison.
176
+ //
177
+ // Exported for tests: the containment rule is the whole point of the function
178
+ // and there is no other way to reach it without an install on disk.
179
+ export function resolveEntry() {
180
+ try {
181
+ const pkg = JSON.parse(readFileSync(path.join(PKG_DIR, "package.json"), "utf8"));
182
+ let rel = pkg.bin;
183
+ if (rel && typeof rel === "object") rel = rel.ccusage ?? Object.values(rel)[0];
184
+ if (typeof rel !== "string") return null;
185
+ const entry = path.resolve(PKG_DIR, rel);
186
+ // path.resolve, not path.join, so an ABSOLUTE `bin` is measured as the
187
+ // absolute path it is rather than being silently re-rooted under PKG_DIR
188
+ // and passing on a technicality. The trailing separator is what stops a
189
+ // sibling directory whose name merely starts with PKG_DIR's — and it also
190
+ // rejects PKG_DIR itself, which is a directory and no kind of entry point.
191
+ if (!entry.startsWith(path.resolve(PKG_DIR) + path.sep)) return null;
192
+ return existsSync(entry) ? { entry, version: pkg.version } : null;
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Everything a failed install is known to have said, in the order the answer is
200
+ * usually in.
201
+ *
202
+ * The old line read `(r.stderr || "").trim() || r.status`, which threw away the
203
+ * two things most likely to be the whole story. `error` is where a failure to
204
+ * LAUNCH lands — ENOENT for a comspec that is not there, EINVAL for a .cmd Node
205
+ * refuses to spawn directly, and the expired deadline — and it comes with no
206
+ * status at all, so what reached the terminal in every one of those cases was
207
+ * the word "null". And npm has never confined itself to stderr: a shim that
208
+ * dies before npm starts writes wherever Node chose, and `npm ERR!` blocks have
209
+ * landed on stdout across majors.
210
+ *
211
+ * The four fields are spawnSync's, because that is where they were first read
212
+ * off; `install` below now fills the same shape from the 'error' event, the
213
+ * exit status and the two collected streams.
214
+ */
215
+ function installFailureText(r) {
216
+ const parts = [];
217
+ if (r?.error?.message) parts.push(String(r.error.message));
218
+ const stderr = String(r?.stderr ?? "").trim();
219
+ const stdout = String(r?.stdout ?? "").trim();
220
+ if (stderr) parts.push(stderr);
221
+ if (stdout) parts.push(stdout);
222
+ if (!parts.length) parts.push(r?.status === null || r?.status === undefined
223
+ ? "npm exited without a status and said nothing"
224
+ : `npm exited ${r.status} and said nothing`);
225
+ return parts.join(" — ");
226
+ }
227
+
228
+ /**
229
+ * Which level of the managed install `npm install --prefix` actually produced.
230
+ *
231
+ * This is the honest answer to the question nobody could answer from a
232
+ * screenshot: an install that exits 0 and leaves a tree resolveEntry cannot use
233
+ * is reported as SUCCESS by an exit code alone, and #432 found that exact shape
234
+ * once already. Naming the first level that is not there turns "ccusage could
235
+ * not report usage" into a sentence about this machine's disk — whether npm
236
+ * wrote nothing, wrote a node_modules with no ccusage in it, or wrote a package
237
+ * whose entry point this deck refuses.
238
+ */
239
+ function installTreeReport() {
240
+ const levels = [
241
+ [CACHE_DIR, "the prefix directory"],
242
+ [path.join(CACHE_DIR, "node_modules"), "node_modules under it"],
243
+ [PKG_DIR, "node_modules/ccusage"],
244
+ [path.join(PKG_DIR, "package.json"), "node_modules/ccusage/package.json"],
245
+ ];
246
+ for (const [where, name] of levels) {
247
+ if (!existsSync(where)) return `${name} is not there`;
248
+ }
249
+ // Every level exists, so resolveEntry refused for one of its own reasons: an
250
+ // unreadable or bin-less package.json, or a `bin` pointing outside the
251
+ // package. All three are about the package rather than about npm.
252
+ return "the package is there but its bin entry could not be read or does not point inside it";
253
+ }
254
+
255
+ /**
256
+ * Run `npm install ccusage@<spec> --prefix CACHE_DIR`, off this stack.
257
+ *
258
+ * The ONE install in this module, awaited by startInstall and dropped on the
259
+ * floor by the daily update path below. It was two — a `spawnSync` for the cold
260
+ * path and a fire-and-forget `spawn` for the background one — and the sync half
261
+ * is what #476 removes: nothing here needs an answer before the next tick, and
262
+ * a two-minute `spawnSync` at boot is two minutes of a dead process.
263
+ *
264
+ * Everything the sync path was careful about is carried over unchanged, because
265
+ * every one of those cares was bought with a bug report:
266
+ *
267
+ * - the vector is `installSpec`'s, spread into spawn exactly as spawnSync got
268
+ * it, which is what keeps #456's absolute `npm.cmd` and #362's per-argument
269
+ * quoting on Windows. `opts` is spread LAST for the same reason it was
270
+ * before: it carries windowsVerbatimArguments, and nothing above it may win.
271
+ * - `windowsHide`, so no console window flashes up.
272
+ * - INSTALL_TIMEOUT_MS, as a deadline this module enforces itself rather than
273
+ * spawn's own `timeout` option. That option sends one signal to the process
274
+ * it started, and on Windows a `.cmd` runs THROUGH cmd.exe — so the signal
275
+ * would land on the wrapper and leave npm downloading, which is the same
276
+ * distinction exec.mjs's `run` states and the reason killTree exists. Same
277
+ * shape as runOnce in ccusage-runner.mjs, so ccusage's children have one
278
+ * deadline pattern rather than two.
279
+ *
280
+ * Diagnosis is unchanged too: `installFailureText` is handed the same four
281
+ * fields spawnSync used to hand it — a failure to LAUNCH in `error`, the exit
282
+ * status, and both output streams, because npm has never confined itself to
283
+ * stderr.
284
+ */
285
+ function install(spec = "latest") {
286
+ return new Promise((resolve, reject) => {
287
+ mkdirSync(CACHE_DIR, { recursive: true });
288
+ const { file, args, opts } = installSpec(spec);
289
+ const child = spawn(file, args, { windowsHide: true, ...opts });
290
+ let out = "", err = "", settled = false;
291
+ let timer = null;
292
+ const finish = (fn, value) => {
293
+ if (settled) return;
294
+ settled = true;
295
+ clearTimeout(timer);
296
+ fn(value);
297
+ };
298
+ // A child that dies emits 'error' AND THEN 'close', and a deadline that
299
+ // fires kills the child and so provokes both — hence `settled`. Whichever
300
+ // arrives first is the account that travels.
301
+ const failed = (r) => finish(reject, new Error(`npm install ccusage failed: ${installFailureText(r)}`));
302
+ timer = setTimeout(() => {
303
+ // The verdict is stated before the kill, the order exec.mjs's `run` uses:
304
+ // the answer must not depend on the killed child cooperating.
305
+ failed({
306
+ error: { message: `npm install timed out after ${INSTALL_TIMEOUT_MS}ms` },
307
+ stdout: out,
308
+ stderr: err,
309
+ });
310
+ killTree(child);
311
+ }, INSTALL_TIMEOUT_MS);
312
+ timer.unref?.();
313
+ child.stdout?.on("data", d => { out += d; });
314
+ child.stderr?.on("data", d => { err += d; });
315
+ child.on("error", e => failed({ error: e, stdout: out, stderr: err }));
316
+ child.on("close", status => {
317
+ if (status !== 0) return failed({ status, stdout: out, stderr: err });
318
+ // A zero exit is npm's opinion, not a fact about the disk, and the caller
319
+ // treats "the install resolved" as "there is something to run". Checking
320
+ // here is what stops a silent success: getRunner used to call
321
+ // resolveEntry(), get null, and fall through to the npx fallback with
322
+ // NOTHING recorded anywhere about why — so the modal explained the
323
+ // fallback's stderr and the install's half of the story was never written
324
+ // down at all.
325
+ if (!resolveEntry()) {
326
+ // The reason before the path (#1672). startInstall fits this to
327
+ // INSTALL_ERROR_ROOM from the start, and with the path first a long home
328
+ // directory used the room up, so the level that was missing, the one
329
+ // thing this sentence is for, was what got cut.
330
+ return finish(reject, new Error(
331
+ `npm install ccusage exited 0 but left nothing runnable: ${installTreeReport()}, `
332
+ + `under ${CACHE_DIR}`,
333
+ ));
334
+ }
335
+ finish(resolve, undefined);
336
+ });
337
+ });
338
+ }
339
+
340
+ // The daily update path's install: the same one above, with nobody listening.
341
+ //
342
+ // It has no shared promise and no `_lastInstallError` on purpose. This runs
343
+ // behind a ccusage that already works, so a failed upgrade is not news the
344
+ // modal should lead with — the copy on disk still answers, and the check comes
345
+ // round again tomorrow.
346
+ function installInBackground(spec = "latest") {
347
+ install(spec).catch(() => { /* best-effort */ });
348
+ }
349
+
350
+ // True at most once per UPDATE_CHECK_MS, gated by the marker file's mtime so the
351
+ // throttle survives restarts.
352
+ function updateCheckDue() {
353
+ try {
354
+ return Date.now() - statSync(MARKER).mtimeMs > UPDATE_CHECK_MS;
355
+ } catch {
356
+ return true; // no marker yet → due
357
+ }
358
+ }
359
+ // (Re)write the marker so its mtime marks "now" as the last check time.
360
+ export function touchMarker() {
361
+ try {
362
+ mkdirSync(CACHE_DIR, { recursive: true });
363
+ writeFileSync(MARKER, String(Date.now()));
364
+ } catch { /* ignore */ }
365
+ }
366
+
367
+ /** Whether the daily check is due: this process has not checked yet, and the
368
+ * marker says the day is up. maybeBackgroundUpdate asks it before going to
369
+ * npm, and primeCcusage asks the same question to report `updating`, so the
370
+ * two cannot disagree about what the boot row says is happening. */
371
+ export function backgroundUpdateDue() {
372
+ return !_checkedThisRun && updateCheckDue();
373
+ }
374
+
375
+ // Non-blocking: compare installed version to npm `latest`; install if newer.
376
+ export function maybeBackgroundUpdate(installedVersion) {
377
+ if (installsDisabled()) return; // no `npm view`, no upgrade install
378
+ if (!backgroundUpdateDue()) return;
379
+ _checkedThisRun = true;
380
+ touchMarker();
381
+ try {
382
+ const { file, args, opts } = npmSpec(["view", "ccusage", "version"]);
383
+ const child = spawn(file, args, { windowsHide: true, ...opts });
384
+ let out = "";
385
+ child.stdout.on("data", d => { out += d; });
386
+ child.on("error", () => {});
387
+ child.on("close", () => {
388
+ const latest = out.trim();
389
+ if (latest && latest !== installedVersion) installInBackground(latest);
390
+ });
391
+ } catch { /* ignore */ }
392
+ }
393
+
394
+ // At most one repair per process, so a package that is simply unrunnable —
395
+ // reinstalled and still broken — cannot put this into a loop of npm installs.
396
+ let _repairedThisRun = false;
397
+
398
+ /**
399
+ * Throw away a managed install that resolves but cannot run, so the next
400
+ * attempt builds a new one.
401
+ *
402
+ * This is the only genuinely permanent failure in this module, and it is the
403
+ * one a broken npm creates: `npm install` that dies partway leaves a
404
+ * package.json and an entry file on disk, resolveEntry answers "installed", and
405
+ * getRunner then hands back that entry FOREVER. Nothing re-checks it —
406
+ * maybeBackgroundUpdate only reinstalls when the registry has a newer version,
407
+ * so even a repaired npm never repaired the install. Every run failed
408
+ * identically, the modal said "try again", and the only thing that actually
409
+ * worked was deleting ~/.agents-deck/ccusage by hand, which nothing in the
410
+ * product tells anyone to do.
411
+ *
412
+ * Only the managed branch qualifies: the npx fallback's failures are npm's own
413
+ * and none of this deck's business to delete anything over.
414
+ *
415
+ * Not done under AGENTS_DECK_NO_INSTALL=1. That variable is a promise not to
416
+ * fetch, and removing the only copy on a machine that cannot replace it would
417
+ * turn a broken feature into an absent one.
418
+ */
419
+ export function discardDamagedInstall(runner, err) {
420
+ if (_repairedThisRun || runner.kind !== "node" || installsDisabled()) return false;
421
+ if (!cannotLoadModule(err?.message)) return false;
422
+ // THE FLAG IS SPENT ON A REPAIR THAT HAPPENED, not on one that was attempted
423
+ // (#790). It used to be set here, before the try — and the rm below fails on
424
+ // Windows for the reason its own maxRetries comment gives: the `node <entry>`
425
+ // child that just exited still holds a handle, the unlink marks the file
426
+ // delete-pending, and rmdir answers ENOTEMPTY past all ten retries. The catch
427
+ // returned false with NOTHING removed, and every later modal open and every
428
+ // 60s poll for the life of the deck then short-circuited on this same flag —
429
+ // including seconds later, once the handle was gone and the rm would have
430
+ // worked. The user's only way out was deleting ~/.agents-deck/ccusage by
431
+ // hand, which nothing tells them.
432
+ //
433
+ // The budget exists to stop a loop of INSTALLS. Here it was being consumed by
434
+ // a repair that never happened and never reached an install.
435
+ try {
436
+ // maxRetries because of what has just happened: the deck ran `node <entry>`
437
+ // out of this very directory a moment ago, and on Windows a file any handle
438
+ // still holds cannot be deleted — the unlink marks it delete-pending, the
439
+ // name stays, and rmdir on the parent answers ENOTEMPTY. A child still
440
+ // exiting is exactly that handle. Node retries EBUSY, EMFILE, ENFILE,
441
+ // ENOTEMPTY and EPERM with a linear backoff when asked to; unasked, it
442
+ // tries once and gives up, which turned a repairable install into the
443
+ // permanent failure below.
444
+ rmSync(PKG_DIR, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 });
445
+ } catch {
446
+ // Windows holds a lock on a file inside a directory being removed more
447
+ // readily than POSIX does, and a half-removed install is still a resolvable
448
+ // one. Say the repair did not happen so the caller reports the real failure
449
+ // rather than retrying into the same broken entry point.
450
+ return false;
451
+ }
452
+ const gone = !resolveEntry();
453
+ // Only now. The directory is really gone, so this deck has spent its one
454
+ // repair and the budget is doing its job. A failed `rm` returned false above
455
+ // without touching the flag, so the next poll — seconds later, once the
456
+ // handle is released — is free to try again.
457
+ if (gone) _repairedThisRun = true;
458
+ return gone;
459
+ }