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,352 @@
1
+ // The in-app upgrade: whether this copy may install over itself, and the
2
+ // `npm i -g` that does it.
3
+ //
4
+ // This lived in src/server/self-update.mjs: the policy (upgradeSpec,
5
+ // upgradeBlockedReason, upgradeMode and the filesystem half, upgradeBlock) and
6
+ // the install itself (startUpgrade, the state it reports, and the line of npm's
7
+ // log the banner shows). It is the only part of the self-update code that
8
+ // writes to the user's machine, and it moved here whole. self-update.mjs asks it why an
9
+ // upgrade is refused and how the last one went for the version report, and
10
+ // re-exports every name, so index.mjs still reaches startUpgrade there. The
11
+ // code is unchanged.
12
+ import { inApp } from "./app-host.mjs";
13
+ import { accessSync, constants as FS, unlinkSync, writeFileSync } from "node:fs";
14
+ import { spawn } from "node:child_process";
15
+ import { join, resolve } from "node:path";
16
+ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
17
+ import {
18
+ frozenNameInstall, hostPackage, installedName, isGitCheckout, isNpxInstall,
19
+ PUBLISHED_NAME, successorRoot, upgradeCommand, upgradeName,
20
+ } from "./install-layout.mjs";
21
+
22
+ // ── installing ───────────────────────────────────────────────────────────────
23
+
24
+ const INSTALL_TIMEOUT_MS = 300_000; // a cold global install on a slow line
25
+
26
+ /**
27
+ * What `spawn` gets for `npm install -g <target>@latest`.
28
+ *
29
+ * This was the last caller still spelling it the way #362 and #456 were written
30
+ * to remove: `spawn("npm.cmd", args, { shell: true })`, with a comment claiming
31
+ * it was "the same pair the ccusage installer uses". ccusage stopped using that
32
+ * pair when #456 fixed it, and this one was never revisited — it does not go
33
+ * through `run`/`runInteractive`/`runDetached`, so #457's sweep of their callers
34
+ * could not see it and exec-shim-callers.test.ts never listed it.
35
+ *
36
+ * Both halves of the old spelling were wrong on Windows and only one of them
37
+ * bites today.
38
+ *
39
+ * The one that bites: a `.cmd` shim locates its payload relative to `%~dp0`,
40
+ * the drive and path of the command token cmd.exe was handed, and a BARE
41
+ * `npm.cmd` carries no directory — so `%~dp0` came out as the deck's working
42
+ * directory and npm's shim went looking for `node_modules\npm\bin\npm-cli.js`
43
+ * underneath it. A user with a global install, deck started from
44
+ * `C:\Users\vceban`, clicks Update now and npm dies with `Cannot find module
45
+ * 'C:\Users\vceban\node_modules\npm\bin\npm-cli.js'` — on a machine whose npm
46
+ * is perfectly healthy, and where typing the same command at the same prompt
47
+ * works. shimPath is the answer, and `?? "npm.cmd"` keeps a layout it cannot see
48
+ * exactly as well off as it was.
49
+ *
50
+ * The one that does not, yet: `shell: true` makes Node join file and args with
51
+ * single spaces and no quoting. These arguments contain no spaces, so it has
52
+ * never mattered here — but it is the #362 defect sitting one argument away, and
53
+ * spawnSpec removes it by quoting every token into the cmd.exe line.
54
+ *
55
+ * POSIX is untouched: `npm` there is a real executable, isBatch is false, and
56
+ * the vector goes to spawn exactly as it always has.
57
+ *
58
+ * Exported for tests: the platform is a parameter so the Windows command line
59
+ * can be checked from any OS, and `deps` stands in for the Windows filesystem
60
+ * the shim lookup asks about. The same shape ccusage.mjs's installSpec has.
61
+ */
62
+ export function upgradeSpec(target, platform = process.platform, deps) {
63
+ const args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
64
+ const file = platform === "win32" ? (shimPath("npm.cmd", deps) ?? "npm.cmd") : "npm";
65
+ return { ...spawnSpec(file, args, platform), plain: args };
66
+ }
67
+
68
+ /**
69
+ * Why an in-app upgrade would be wrong here, or null when it is fine.
70
+ *
71
+ * Pure so the policy can be read and tested on its own — it is the part that
72
+ * decides whether we are allowed to write to the user's machine.
73
+ */
74
+ export function upgradeBlockedReason({ git, npx, writable, optedOut, frozen }) {
75
+ if (optedOut) return "opted_out";
76
+ // The maintainer's own tree. Its version leads npm's, and installing over it
77
+ // would replace a working copy with a published tarball.
78
+ if (git) return "git_checkout";
79
+ // npx runs from a content-addressed cache directory that is never upgraded in
80
+ // place — `npx agents-deck@latest` fetches a DIFFERENT directory, which this
81
+ // process could not switch to even after restarting.
82
+ if (npx) return "npx";
83
+ // A flat install of a retired name: the one shape where `npm i -g <us>@latest`
84
+ // runs cleanly, reports success, and leaves no deck behind — the registry
85
+ // serves a 5 KB pointer for those names now, and npm's reify removes bin/,
86
+ // src/ and hook/ to make room for it. Every other refusal here is "this
87
+ // install cannot be written over"; this one is "what the write would fetch is
88
+ // not a deck", so it is asked before writability rather than after: on a
89
+ // prefix nobody can write, the remedy is still a different command, and
90
+ // naming the wrong one is what #975 is about.
91
+ if (frozen) return "retired_name";
92
+ // Almost always a root-owned global prefix. Failing inside npm with EACCES
93
+ // tells the user less than declining up front does.
94
+ if (!writable) return "not_writable";
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * How this copy can update itself, if it can at all.
100
+ *
101
+ * "install" — `npm i -g` here and restart into the new files.
102
+ * "npx" — nothing to install: the supervisor re-runs `npx -y <spec>`,
103
+ * which fetches a NEW cache directory and hands the port to it.
104
+ * null — a checkout, an unwritable prefix, or an explicit opt-out; the
105
+ * user gets the command and does it themselves.
106
+ *
107
+ * Pure, so the policy is one readable expression rather than three conditions
108
+ * spread across the server and the UI.
109
+ */
110
+ export function upgradeMode(blockedReason) {
111
+ if (blockedReason === null || blockedReason === undefined) return "install";
112
+ return blockedReason === "npx" ? "npx" : null;
113
+ }
114
+
115
+ /**
116
+ * Can this user write into `p`?
117
+ *
118
+ * ON WINDOWS THE QUESTION HAS TO BE ASKED BY WRITING (#795). libuv's
119
+ * `fs__access` short-circuits to success for anything carrying
120
+ * FILE_ATTRIBUTE_DIRECTORY — "Directories cannot be read-only on Windows" — and
121
+ * never consults the ACL; Node documents this. Both arguments this is called
122
+ * with are always directories, so `writable` was unconditionally true there,
123
+ * `upgradeBlockedReason` could never answer `not_writable`, and the banner
124
+ * offered "Update & restart" on the common nvm-windows layout where the global
125
+ * prefix is C:\Program Files\nodejs and a non-elevated shell cannot write it.
126
+ * npm then died with EPERM and the user got a truncated npm log tail in a
127
+ * 46-character box — where a POSIX user in the same position gets "the install
128
+ * directory is not writable by this user" and the command to paste. The comment
129
+ * on upgradeBlockedReason states the intent the platform defeated: "Failing
130
+ * inside npm with EACCES tells the user less than declining up front does."
131
+ *
132
+ * The probe is `createTemp`'s "wx" pattern, synchronously: a name nothing else
133
+ * can be using, created exclusively and removed at once. POSIX keeps
134
+ * `accessSync`, where it is correct and cheaper.
135
+ */
136
+ function dirWritable(p) {
137
+ if (process.platform !== "win32") {
138
+ try { accessSync(p, FS.W_OK); return true; } catch { return false; }
139
+ }
140
+ // `wx` fails if the name exists, so a collision reads as "not writable"
141
+ // rather than clobbering somebody's file — hence pid and a random suffix.
142
+ const probe = join(p, `.ccdeck-w-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
143
+ try {
144
+ writeFileSync(probe, "", { flag: "wx" });
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ } finally {
149
+ try { unlinkSync(probe); } catch { /* never created, or already gone */ }
150
+ }
151
+ }
152
+
153
+ /** The same question, answered against the real filesystem and environment. */
154
+ export function upgradeBlock(pkgRoot) {
155
+ // Whatever `npm i -g` would rewrite is what has to be writable, and under the
156
+ // stub layout that is not this directory: `npm i -g ccdeck` replaces
157
+ // <prefix>/lib/node_modules/ccdeck, and the copy running out of its nested
158
+ // node_modules goes with it. In practice both pairs answer the same, since
159
+ // one npm install created them with one owner — but the question is about the
160
+ // tree the command touches, and that tree is the host's.
161
+ //
162
+ // Asked with this build's own name rather than with the default, for the same
163
+ // reason upgradeName is: the host is recognised by the dependency it declares
164
+ // on us, and "us" is whatever the manifest here says — `agents-deck` under the
165
+ // stub npm publishes today, but nothing in this rule should assume that.
166
+ //
167
+ // successorRoot covers the case after such an upgrade has run: pkgRoot is
168
+ // gone, so hostPackage cannot recognise a host that no longer declares us,
169
+ // and asking accessSync about a deleted directory answers ENOENT — which this
170
+ // function reported as `not_writable`, telling the user their npm prefix was
171
+ // read-only when it was not.
172
+ const target = hostPackage(pkgRoot, installedName(pkgRoot))?.root
173
+ ?? successorRoot(pkgRoot)
174
+ ?? pkgRoot;
175
+ return upgradeBlockedReason({
176
+ git: isGitCheckout(pkgRoot),
177
+ npx: isNpxInstall(pkgRoot),
178
+ // npm -g rewrites the package directory and its parent (the global
179
+ // node_modules), so both have to be ours to write.
180
+ writable: dirWritable(target) && dirWritable(resolve(target, "..")),
181
+ // Inside the desktop app the app updates itself; an `npm i -g` here would
182
+ // install a copy it never runs (app-host.mjs).
183
+ optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1" || inApp(),
184
+ // Whether the package that write would fetch is still a deck. Kept out of
185
+ // the pure rule above, like every other input here, so the policy stays one
186
+ // readable expression and this file owns the filesystem half of it.
187
+ frozen: frozenNameInstall(pkgRoot) !== null,
188
+ });
189
+ }
190
+
191
+ // One install at a time, per process. State is deliberately coarse: the UI only
192
+ // needs to know whether to show a spinner, a version, or an error.
193
+ let _upgrade = { state: "idle", command: null, error: null, at: 0 };
194
+
195
+ /** Where this process's install stands: its state, the command it runs, and why
196
+ * it failed when it did, stamped with the moment it got there. Every change to
197
+ * the state goes through here, so no path can report one without its time. */
198
+ function setUpgrade(state, command, error = null) {
199
+ _upgrade = { state, command, error, at: Date.now() };
200
+ }
201
+
202
+ export function upgradeStatus() {
203
+ return { ..._upgrade };
204
+ }
205
+
206
+ /**
207
+ * Start `npm i -g <name>@latest` in the background.
208
+ *
209
+ * Returns immediately with the accepted command, or a refusal. Never installs
210
+ * anything except this deck — under one of its own three published names, and
211
+ * never at a version the caller chose. The argument vector is fixed here, not
212
+ * assembled from request input: the only thing that varies is which alias, and
213
+ * that is read off the install on disk and confined to ALIAS_PACKAGES.
214
+ */
215
+ export function startUpgrade({ pkgRoot, name = PUBLISHED_NAME }) {
216
+ if (_upgrade.state === "running") return { ok: true, already: true, command: _upgrade.command };
217
+ const blocked = upgradeBlock(pkgRoot);
218
+ if (blocked) return { ok: false, reason: blocked, command: upgradeCommand(pkgRoot, name) };
219
+
220
+ // The package this install can actually be replaced by — the stub for a
221
+ // `npm i -g ccdeck`, the published name everywhere else. Installing `name`
222
+ // there wrote a tree this process never reads, so the version on disk never
223
+ // moved and the same update was offered forever.
224
+ const target = upgradeName(pkgRoot, name);
225
+ const spec = upgradeSpec(target);
226
+ // The LOGICAL vector, not the cmd.exe line: this string is shown to the user
227
+ // and is the one they can paste. `cmd /d /s /c "…"` is an implementation
228
+ // detail of how this platform reaches npm, and pasting it would be advice
229
+ // about the deck rather than about their install.
230
+ const command = `npm ${spec.plain.join(" ")}`;
231
+ setUpgrade("running", command);
232
+
233
+ let child;
234
+ try {
235
+ child = spawn(spec.file, spec.args, { ...spec.opts, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
236
+ } catch (err) {
237
+ setUpgrade("failed", command, err?.message ?? String(err));
238
+ return { ok: false, reason: "spawn_failed", command };
239
+ }
240
+
241
+ // Only the tail is kept: npm's failures put the useful line near the end, and
242
+ // a full buffer of an install log is not something the browser should hold.
243
+ let err = "";
244
+ const keepTail = (s) => { err = (err + s).slice(-4000); };
245
+ child.stdout.on("data", d => keepTail(String(d)));
246
+ child.stderr.on("data", d => keepTail(String(d)));
247
+
248
+ // The deadline states the outcome itself, and only then kills.
249
+ //
250
+ // npm is a .cmd shim on Windows and is therefore spawned through cmd.exe, so
251
+ // `child` is cmd.exe and npm itself is a grandchild — a plain kill would
252
+ // report the install as timed out while it carried on writing to
253
+ // node_modules. killTree is what reaches it (taskkill /T there, the same
254
+ // plain signal everywhere else).
255
+ //
256
+ // Leaving the verdict to 'close' was the other half of the same bug. 'close'
257
+ // waits for the stdio pipes, which the grandchild inherited, so on Windows it
258
+ // could arrive minutes after the deadline — or never, if the tree kill could
259
+ // not run at all — and until it did, /api/version kept reporting
260
+ // `state: "running"` with the UI spinning on "installing…" and the guard at
261
+ // the top of this function refusing every retry. When it finally arrived it
262
+ // carried the killed wrapper's status, so a five-minute timeout was announced
263
+ // to the user as "npm exited null".
264
+ let timedOut = false;
265
+ const timer = setTimeout(() => {
266
+ timedOut = true;
267
+ setUpgrade("failed", command, `timed out after ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes`);
268
+ killTree(child);
269
+ }, INSTALL_TIMEOUT_MS);
270
+ timer.unref?.();
271
+
272
+ child.on("error", (e) => {
273
+ clearTimeout(timer);
274
+ // A kill can make the child emit one of these; whatever it says, the reason
275
+ // this install failed is the deadline that has already been reported.
276
+ if (timedOut) return;
277
+ setUpgrade("failed", command, e?.message ?? String(e));
278
+ });
279
+ child.on("close", (code) => {
280
+ clearTimeout(timer);
281
+ if (code === 0) {
282
+ // A clean exit is a real install even if it lands after the deadline —
283
+ // npm finishing in the same breath as the timer is the one case where the
284
+ // files on disk disagree with the verdict above, and the files win.
285
+ //
286
+ // Deliberately does not restart anything. The new files on disk make
287
+ // installedVersion() disagree with the running one, and the ordinary
288
+ // drift path takes it from there — including its wait for an idle moment.
289
+ setUpgrade("done", command);
290
+ } else if (!timedOut && _upgrade?.state !== "failed") {
291
+ // Not over a failure the 'error' handler already explained. A missing npm
292
+ // emits 'error' with ENOENT and THEN 'close' with a null code, and this
293
+ // branch used to replace "spawn npm ENOENT" with "npm exited -2" — the
294
+ // one message that says what is wrong, overwritten by the one that does
295
+ // not.
296
+ setUpgrade("failed", command, lastMeaningfulLine(err) || `npm exited ${code}`);
297
+ }
298
+ });
299
+
300
+ return { ok: true, command };
301
+ }
302
+
303
+ // The furniture around a Node crash, none of which is the reason anything
304
+ // failed. npm's own log needs none of this — it is `npm ERR!` lines and a rule —
305
+ // but the two get mixed the moment the thing npm's shim tried to load is
306
+ // missing, which is what #535's bare `npm.cmd` produced on every attempt.
307
+ const CRASH_NOISE = [
308
+ /^\s*at\s/, // stack frames
309
+ /^\s*\^+\s*$/, // the caret under the throw
310
+ /^node:internal\//, // the frame node leads with
311
+ /^\s*throw\s/,
312
+ /^Node\.js v/, // the last line, and the one that was quoted
313
+ /^\s*[{}]\s*$/, // the error object's braces
314
+ /^\s*(code|errno|syscall|path|requireStack|stack):/,
315
+ /^Require stack:/,
316
+ /^-+$/,
317
+ /^A complete log/,
318
+ ];
319
+
320
+ /** npm's real complaint, for the banner: the last line that is not furniture.
321
+ *
322
+ * Last rather than first, and that is the whole reason this is not npx.mjs's
323
+ * summariser under another name. The two read different documents. npm's log
324
+ * opens with its codes — `code EACCES`, `syscall mkdir` — and ends with the
325
+ * sentence a person can act on, so the last line wins. An npx failure is a Node
326
+ * crash dump, which opens with the sentence and ends with a stack, a brace and
327
+ * a version banner, so the first signal line wins there. Sharing one function
328
+ * would mean picking one of those and being wrong about the other half the
329
+ * time; sharing the NOISE list would be the copy this comment exists instead
330
+ * of.
331
+ *
332
+ * What was wrong was not the rule but its list. `lastMeaningfulLine` dropped
333
+ * `npm ERR!` prefixes, rules and "A complete log", and nothing else — so when
334
+ * #535's spawn failed with a MODULE_NOT_FOUND dump, the last surviving line was
335
+ * `Node.js v22.11.0`, and that string was the entire explanation the UI gave
336
+ * for a failed upgrade. */
337
+ export function lastMeaningfulLine(text) {
338
+ const lines = String(text ?? "").split(/\r?\n/)
339
+ .map(l => l.replace(/^npm (ERR!|WARN)\s*/, "").trim())
340
+ .filter(l => l && !CRASH_NOISE.some(re => re.test(l)));
341
+ if (!lines.length) return "";
342
+ // A line that names an error outranks a later line that does not, because
343
+ // what survives the filter after one is usually its context rather than its
344
+ // successor: `Require stack:` is furniture, but the paths listed under it are
345
+ // not shaped like furniture and would otherwise be the last thing standing.
346
+ // Still the LAST such line, not the first — npm's own log builds up to its
347
+ // sentence, and picking the first would answer `code EACCES` where the next
348
+ // line says which directory and why.
349
+ const named = lines.filter(l => /(^|\s)[A-Za-z]*Error:/.test(l));
350
+ const pick = named.length ? named[named.length - 1] : lines[lines.length - 1];
351
+ return pick.slice(0, 300);
352
+ }
@@ -51,7 +51,7 @@ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
51
51
  * pick something that is not npm.
52
52
  *
53
53
  * The path arithmetic is done on the string rather than through `node:path`,
54
- * for the same reason npxRoot in self-update.mjs does: `path` here is the
54
+ * for the same reason npxRoot in install-layout.mjs does: `path` here is the
55
55
  * platform running the SUITE, so a Windows layout checked from macOS would come
56
56
  * back with forward slashes and a `..` nothing resolves.
57
57
  */
@@ -134,6 +134,41 @@ export function npxPrefetchArgs(spec) {
134
134
  return ["-y", "--package", spec, "--call", "exit 0"];
135
135
  }
136
136
 
137
+ /** How much of an npx run's output is held as evidence. The END of it, because
138
+ * npm and Node both put the sentence worth quoting on their last lines. */
139
+ const OUTPUT_TAIL_CHARS = 8000;
140
+
141
+ /**
142
+ * An npx run's output, held as evidence for a failure that may not happen.
143
+ *
144
+ * `take` is the stream's data listener: each chunk is added to what is held,
145
+ * and only the last OUTPUT_TAIL_CHARS of it are kept. `release` is for a run
146
+ * that turned out to be the deck after all — what was held is written through
147
+ * `write`, and every later chunk goes straight there instead of being kept.
148
+ * `tail` is what was held, which is the text npxFailureSummary reads.
149
+ *
150
+ * The two places that hold an npx run's output kept their own copy of the
151
+ * trim, and the supervisor its own pair of variables for the hand-over; this
152
+ * is both, once.
153
+ */
154
+ export function holdOutput({ write = null, limit = OUTPUT_TAIL_CHARS } = {}) {
155
+ let held = "";
156
+ let passing = false;
157
+ return {
158
+ take(d) {
159
+ const s = String(d);
160
+ if (passing) { write?.(s); return; }
161
+ held = (held + s).slice(-limit);
162
+ },
163
+ release() {
164
+ if (passing) return;
165
+ passing = true;
166
+ if (held) write?.(held);
167
+ },
168
+ get tail() { return held; },
169
+ };
170
+ }
171
+
137
172
  /**
138
173
  * Resolve, download and unpack `spec` while the current deck keeps serving.
139
174
  *
@@ -169,13 +204,12 @@ export function npxPrefetch(spec, {
169
204
  }
170
205
  onChild?.(child);
171
206
 
172
- let tail = "";
173
- const keep = (d) => { tail = (tail + String(d)).slice(-8000); };
207
+ const output = holdOutput();
174
208
  // Both streams, one buffer: npm writes its errors to stderr and its
175
209
  // resolution notices to stdout, and which of the two carries the sentence
176
210
  // worth quoting depends on the failure.
177
- child.stdout?.on("data", keep);
178
- child.stderr?.on("data", keep);
211
+ child.stdout?.on("data", output.take);
212
+ child.stderr?.on("data", output.take);
179
213
 
180
214
  let settled = false;
181
215
  const settle = (result) => {
@@ -203,8 +237,8 @@ export function npxPrefetch(spec, {
203
237
  if (code === 0) { settle({ ok: true, error: null, hint: null }); return; }
204
238
  settle({
205
239
  ok: false,
206
- error: npxFailureSummary(tail) ?? `npx could not fetch ${spec} — exited ${code ?? signal}`,
207
- hint: npxFailureHint(tail),
240
+ error: npxFailureSummary(output.tail) ?? `npx could not fetch ${spec} — exited ${code ?? signal}`,
241
+ hint: npxFailureHint(output.tail),
208
242
  });
209
243
  });
210
244
  });
@@ -249,10 +249,11 @@ export function createOutputWatch(io = {}) {
249
249
  * nothing more unusual than several live sessions on a slow or
250
250
  * network-backed transcript directory.
251
251
  *
252
- * Its two siblings in index.mjs already refused re-entry: codexScanOnce with
253
- * a boolean, scanTranscript by handing a second caller the read already in
254
- * flight. The second shape is wrong here — a caller handed the same answer
255
- * would push the same blocks again — so this is the boolean. An overlapping
252
+ * Its two siblings already refused re-entry, both by handing a second caller
253
+ * the one already in flight: scanTranscript in transcript-scan.mjs its read,
254
+ * and codexScanOnce in codex-watch.mjs its scan (a boolean until #994 needed
255
+ * a scan the suite could await). That shape is wrong here — a caller handed the same answer
256
+ * would push the same blocks again — so this is a boolean. An overlapping
256
257
  * call answers with nothing and the call already inside answers for both.
257
258
  * Nothing is lost by it: whatever lands meanwhile is past the offset the
258
259
  * first call writes back, and the next tick reads it. It sits here rather
@@ -0,0 +1,110 @@
1
+ // The build this process runs, held in memory before anything can replace it
2
+ // on disk (#1042).
3
+ //
4
+ // This lived in src/server/index.mjs, after the shutdown route. It is the one
5
+ // thing an install has to wait for — the Upgrade press and the away-update
6
+ // both await it before npm is spawned, and the launcher starts it once the
7
+ // boot is over — and none of it touches the server's state, so it moved to a
8
+ // leaf those callers can share. The list and the loader are unchanged.
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+
12
+ // Resolved from this file's own directory, the way every module in src/server
13
+ // that imports by URL resolves it, so every URL below is the one the lazy
14
+ // imports in src/server name.
15
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
+
17
+ /**
18
+ * Every server module this process reaches only through `import()`, and so
19
+ * reads off disk the first time it is wanted rather than at boot.
20
+ *
21
+ * THAT IS WHAT A SELF-UPDATE BROKE (#1042). Node caches an evaluated module by
22
+ * its URL for the life of the process, so everything already loaded keeps the
23
+ * code it booted with — which is the whole of why startUpgrade can leave this
24
+ * process serving and let the drift path restart it at an idle moment. A module
25
+ * NOT yet loaded had no such protection: PKG_ROOT is a fixed path, and
26
+ * `npm i -g` rewrites the tree under it. So the first look at the accounts panel
27
+ * after an install evaluated the NEW cswap-admin.mjs beside the OLD
28
+ * claude-accounts.mjs and exec.mjs — a mixed build nobody has ever run — and
29
+ * while npm was mid-reify the same `import()` found no file at all, which
30
+ * lan-engine's roundWith catches and files as that PEER's failed round: a
31
+ * credential sync that did not happen, recorded against the other machine
32
+ * rather than against the install. The window is not short. The idle
33
+ * restart waits on presence and activity, and with autoUpdate off it never
34
+ * comes.
35
+ *
36
+ * Reproduced with the tree swap done in a temp copy of the package: after
37
+ * `POST /api/upgrade`, every one of /api/codex-usage, /api/codex-quota,
38
+ * /api/browser-watch and /api/claude-accounts/login answered 500, and each one
39
+ * had evaluated a module out of the tree npm had just written.
40
+ *
41
+ * So the build is pinned instead. Each module below is imported once, after
42
+ * the boot (markDeckReady) and before any install can start (handleUpgrade and
43
+ * awayUpdateTick both await it), and every lazy import in src/server is a cache
44
+ * hit from then on; their own static imports come with them. Measured, the
45
+ * whole list costs about 10ms and 8MB of RSS, and none of it does any work at
46
+ * import — nothing is spawned, read or written — so the laziness still does
47
+ * what it was for, which is keeping all of this off the path between
48
+ * `npx ccdeck` and a listening socket.
49
+ *
50
+ * The list is every local module that an `import()` in src/server names, and
51
+ * self-update-pins-build.test.ts reads the directory to keep it that way: a lazy
52
+ * import added later without a line here is this bug again.
53
+ */
54
+ const PINNED_MODULES = [
55
+ "self-update.mjs",
56
+ "claude-accounts.mjs",
57
+ "account-health.mjs",
58
+ "cswap-admin.mjs",
59
+ "cswap-auto.mjs",
60
+ "quota.mjs",
61
+ "codex-usage.mjs",
62
+ "codex-quota.mjs",
63
+ "ccusage.mjs",
64
+ "browser-watch.mjs",
65
+ "browser-watch-store.mjs",
66
+ "claude-fm.mjs",
67
+ "fm-station.mjs",
68
+ "lofi-girl.mjs",
69
+ "live-radio-mix.mjs",
70
+ "best-of-nostalgia.mjs",
71
+ "good-life-radio.mjs",
72
+ "cafe-music-bgm.mjs",
73
+ // The thermal readers' two (thermal-metrics.mjs imports both; macmon's
74
+ // download is still started from thermal-sampler.mjs), on the platforms that
75
+ // have them, and the one installer.mjs reaches for while it rewrites the hooks.
76
+ "macmon.mjs",
77
+ "hwmonitor.mjs",
78
+ "retire-sound-hook.mjs",
79
+ // The account-projects report: the rollup and the swap log its attribution
80
+ // reads, plus lan-sync for the account key — all reached only through
81
+ // import() from the report's route.
82
+ "account-projects.mjs",
83
+ "swap-log.mjs",
84
+ "lan-sync.mjs",
85
+ // The providers' status pages (#1311), reached only from their route.
86
+ "provider-status.mjs",
87
+ ];
88
+
89
+ let _pinned = null;
90
+
91
+ /**
92
+ * Load every module in PINNED_MODULES, once per process.
93
+ *
94
+ * One at a time rather than all together. It costs nothing measurable, and it
95
+ * means no two of them are ever evaluating at once — the shape that turned an
96
+ * import cycle into an empty namespace (see boot-module-graph.test.ts).
97
+ *
98
+ * A module that fails to load is passed over rather than allowed to stop the
99
+ * rest: waiting will not make it load, and one bad file must not leave the
100
+ * others to be read off whatever npm writes next. The promise never rejects.
101
+ */
102
+ export function pinRunningBuild() {
103
+ _pinned ??= (async () => {
104
+ for (const name of PINNED_MODULES) {
105
+ try { await import(pathToFileURL(join(PKG_ROOT, "src/server", name)).href); }
106
+ catch { /* see above */ }
107
+ }
108
+ })();
109
+ return _pinned;
110
+ }