agent-dag 3.22.0 → 3.22.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-3FWd7g_W.css +0 -1
  10. package/dist/web/assets/index-BOwtoP02.js +0 -266
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -392
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,1380 +0,0 @@
1
- // Tells the deck when the code it is executing is no longer the code on disk.
2
- //
3
- // Node caches every module at import. A deck that was already running when
4
- // `npm i -g agents-deck` replaced its files keeps executing the OLD code until
5
- // the process restarts — and nothing says so. The terminal banner still prints
6
- // the version it booted with, and the browser's __APP_VERSION__ is baked into
7
- // whichever bundle it happened to load, so the UI can even show the NEW number
8
- // while the server runs the old one.
9
- //
10
- // That is not theoretical. On 2026-08-12 a deck left running from before
11
- // v1.30.4 kept spawning `claude --print /usage` once a minute, burning the
12
- // whole hourly usage-endpoint budget and 429-ing claude-swap, while the fix sat
13
- // unused on disk. The user had no way to see it.
14
- //
15
- // So we report three distinct versions:
16
- // running — captured at boot by the caller, before an upgrade can land
17
- // installed — re-read from disk per call; what a restart would run
18
- // latest — npm's dist-tag, fetched at most once an hour (CHECK_MS below,
19
- // which also explains why it is not the daily cadence it was)
20
- //
21
- // Installing is opt-in and narrow. `npm i -g` runs only when the user asks for
22
- // it by name and only where it can actually work: a global install, on a
23
- // directory we can write, outside a git checkout and outside an npx cache.
24
- // Everywhere else this stays what it has always been — a printed command.
25
- import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
26
- import { spawn } from "node:child_process";
27
- import { homedir } from "node:os";
28
- import { dirname, join, resolve } from "node:path";
29
- import { killTree, shimPath, spawnSpec } from "./exec.mjs";
30
-
31
- // Once an hour, not once a day.
32
- //
33
- // The daily cadence was copied from the ccusage and cswap checks, where it is
34
- // right: those answer "is a different tool out of date", and nobody is waiting
35
- // on it. This one answers "is the thing you are looking at out of date", and a
36
- // day is long enough that a deck started shortly before a release shows nothing
37
- // at all until tomorrow — reported from a machine running `npx ccdeck`, which
38
- // is the case where it bites hardest, since npx runs are short-lived and each
39
- // one inherits the same stale marker. The request is ~20 bytes.
40
- const CHECK_MS = 3600_000;
41
- // A lookup that failed is not an answer, so it must not spend the hour an
42
- // answer buys. It still has to spend something: the reason to rate-limit is
43
- // gone, but a registry that is down would otherwise be asked again on every
44
- // single poll. Five minutes is the compromise — short enough that a network
45
- // coming back is noticed while the user is still looking at the deck, long
46
- // enough that a full npm outage costs a dozen ~20-byte requests an hour.
47
- const RETRY_MS = 300_000;
48
- const FETCH_TIMEOUT_MS = 6_000;
49
- // A third marker family: ccusage owns ~/.agents-deck/ccusage/.last-update-check
50
- // and cswap owns ~/.agents-deck/.cswap-update-check. Sharing one would make the
51
- // three features fight over a single daily slot.
52
- const MARKER_DIR = join(homedir(), ".agents-deck");
53
- // The name this deck was published under is part of the marker's identity.
54
- //
55
- // A single ~/.agents-deck/.self-update-check was shared by every deck on the
56
- // machine, so whichever one asked npm first pinned the answer for all of them
57
- // until the window expired — including a freshly started `npx ccdeck` that had
58
- // never written it, and including decks running a DIFFERENT package where the
59
- // cached version means nothing. Reported with four decks alive at once: three
60
- // served the same `checkedAt` to the millisecond and none of them showed the
61
- // seven releases that had shipped meanwhile. One file per package name means
62
- // `agents-deck`, `ccdeck` and `agent-dag` decks stop silencing each other; the
63
- // old shared path stays here only to be inherited from once.
64
- const LEGACY_MARKER = join(MARKER_DIR, ".self-update-check");
65
-
66
- // Dedupe concurrent /api/version calls into one fetch, per package name — two
67
- // names are two different questions and must not be answered with one answer.
68
- const _inflight = new Map();
69
-
70
- // ── what a forced check may cost ─────────────────────────────────────────────
71
- //
72
- // `_inflight` deduplicates callers that OVERLAP and nothing else. `checkDue`
73
- // answers `true` on `force` before it asks anything else, so a caller that
74
- // waited for one check to settle and then asked again got a fresh
75
- // `https://registry.npmjs.org/…/dist-tags` every time. Reads on this server are
76
- // deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
77
- // that `isTrustedMutation` does, because a cross-site read of
78
- // `http://127.0.0.1:4317` is an ordinary top-level navigation — so
79
- //
80
- // (async function spin() {
81
- // for (;;) await fetch("http://127.0.0.1:4317/api/version?refresh=1",
82
- // { mode: "no-cors" });
83
- // })();
84
- //
85
- // from any page the user had open was one registry request per turn, as fast as
86
- // the round trip allows, and every so often two of them: `runCheck` confirms a
87
- // tag it has not seen before against the version document. The requests are
88
- // small — the whole reason the dist-tags endpoint is used here rather than the
89
- // packument — but they leave the user's address, carrying the user-agent this
90
- // deck sets, and they are aimed at a third party rather than at the machine the
91
- // loop is running on. That makes it #580's shape with the cost pointed
92
- // outwards, which if anything is the worse direction.
93
- //
94
- // Nothing above this line was going to stop it. The hour is written to a marker
95
- // FILE, and `force` walks past it; `first` walks past it too; and on a machine
96
- // where `~/.agents-deck` cannot be written `writeMarker` swallows the failure,
97
- // so `checkDue` sees "never checked" on every single call and the hour is not
98
- // really there at all.
99
- //
100
- // So the floor sits under all of it, in this process's own memory, between the
101
- // last rule that admitted a check and the request it admitted. It is quota.mjs's
102
- // number and codex-quota.mjs's and codex-usage.mjs's — the five routes in this
103
- // deck's router that accept `?refresh=1` and can pay for it have no business
104
- // disagreeing about what it costs.
105
- const FORCE_POLL_MS = 60_000;
106
-
107
- // Stamped when a check STARTS rather than when npm answers, because what the
108
- // floor rations is the request. Per package name, like `_inflight` and like the
109
- // markers: two names are two different questions, and one of them being asked
110
- // is not a reason to refuse the other. Deliberately in memory rather than on
111
- // disk — the marker is shared by every deck running that package and this is
112
- // about what THIS process is sending.
113
- const _lastAskAt = new Map();
114
-
115
- /**
116
- * Whether we may spend a registry request right now.
117
- *
118
- * Exported for tests, for the same reason quota.mjs exports `maySelfPoll`,
119
- * codex-quota.mjs `mayFetchQuota` and codex-usage.mjs `mayScanUsage`: this is
120
- * the rule, it is pure, and it is worth pinning down away from the request it
121
- * guards.
122
- */
123
- export function mayAskNpm({ now, lastAskAt }) {
124
- // A stamp from the future is a clock that moved, not a check that just ran —
125
- // the same case `checkDue` answers twice below with `> now`, and the same
126
- // answer. Without it, a machine whose clock corrects backwards by an hour
127
- // would go an hour without a version check.
128
- if (lastAskAt > now) return true;
129
- return now - lastAskAt >= FORCE_POLL_MS;
130
- }
131
-
132
- // ── version comparison ───────────────────────────────────────────────────────
133
-
134
- /** True when `a` sorts before `b`. Numeric-segment compare — non-numeric
135
- * segments count as 0, missing segments pad with 0, so "1.30" < "1.30.1" and
136
- * "1.9.0" < "1.10.0".
137
- *
138
- * cswap-install.mjs had this written out a second time, without the type guard
139
- * below, and imports it from here now (#374). The two bodies were identical:
140
- * swept over 271,441 version-string pairs they disagreed on none, and the
141
- * guard was the whole difference — `isOlder(null, "1.0.0")` answers false here
142
- * and threw a TypeError there. Nothing could reach that call with a non-string
143
- * (both arguments are behind `typeof v === "string"` checks at the one call
144
- * site), so this is the copy with a test behind it absorbing the one without,
145
- * not a bug fix. */
146
- export function isOlder(a, b) {
147
- if (typeof a !== "string" || typeof b !== "string") return false;
148
- const seg = (v) => v.split(/[.\-+]/).map(n => parseInt(n, 10)).map(n => Number.isNaN(n) ? 0 : n);
149
- const x = seg(a), y = seg(b);
150
- for (let i = 0; i < Math.max(x.length, y.length); i++) {
151
- const d = (x[i] ?? 0) - (y[i] ?? 0);
152
- if (d !== 0) return d < 0;
153
- }
154
- return false;
155
- }
156
-
157
- // ── what is on disk ──────────────────────────────────────────────────────────
158
-
159
- /** A package directory's own manifest, or null when there is not one worth
160
- * reading there.
161
- *
162
- * Shared by everything below that asks a directory who it is, so the three
163
- * callers cannot drift on what a missing, truncated or non-object package.json
164
- * means. A JSON document is not necessarily an object — `null`, `"ccdeck"` and
165
- * `[]` all parse — and a manifest that is not an object has no fields to read,
166
- * so it is refused here once rather than guarded against three times. */
167
- function readManifest(dir) {
168
- try {
169
- const meta = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
170
- return meta && typeof meta === "object" ? meta : null;
171
- } catch {
172
- return null;
173
- }
174
- }
175
-
176
- /** Version currently written in the package's own package.json. Deliberately
177
- * read fresh on every call: that is the whole point — it changes under a
178
- * running process when npm replaces the install.
179
- *
180
- * And sometimes the directory changes with it. A `npm i -g ccdeck` performed
181
- * before #340 left the deck nested inside a launcher package; upgrading such an
182
- * install now writes the flat tarball over that launcher, and npm's reify takes
183
- * the nested copy — the one this process is running out of — with it. Reading
184
- * our own manifest then answers null, which pickNotice reads as "nothing is
185
- * installed" and turns into the same upgrade offered forever, with the restart
186
- * notice this module exists for never firing at all.
187
- *
188
- * The version is not lost, it moved one directory up. successorRoot is where
189
- * to, and it answers null in every layout where nothing moved — so the normal
190
- * case still reads exactly one manifest. */
191
- export function installedVersion(pkgRoot) {
192
- const own = readManifest(pkgRoot);
193
- if (typeof own?.version === "string") return own.version;
194
- const moved = successorRoot(pkgRoot);
195
- const v = moved ? readManifest(moved)?.version : null;
196
- return typeof v === "string" ? v : null;
197
- }
198
-
199
- /** npx keeps each package in its own content-addressed cache directory, so
200
- * `npm i -g` is the wrong advice there — nothing global exists to upgrade. */
201
- export function isNpxInstall(pkgRoot) {
202
- // Split on both separators, not `path.sep`: Windows paths reach here with
203
- // backslashes but a POSIX-style path is still a valid input there, and a
204
- // separator-agnostic test is what keeps this identical on all three
205
- // platforms. Segment-wise, so a directory merely named "my_npx-tools" is not
206
- // mistaken for the npx cache.
207
- return typeof pkgRoot === "string" && pkgRoot.split(/[\\/]/).includes("_npx");
208
- }
209
-
210
- /** A git checkout is the maintainer's own tree. Its version routinely sits
211
- * ahead of npm, and telling someone to `npm i -g` over their working copy is
212
- * actively wrong, so the registry side of the check is skipped there.
213
- *
214
- * Exported for one caller outside this file: the login item, which must not be
215
- * installed from a checkout for the same reason it must not be installed from
216
- * an npx run — the path is not one anybody promised to keep. Everything else it
217
- * gates stays internal, and each of those is already
218
- * asserted against a directory with a real `.git` in it — `upgradeCommand`
219
- * returning "git pull && npm run build", `upgradeName` refusing to move a
220
- * checkout onto a published alias, and `startUpgrade` refusing with
221
- * `git_checkout`. A test importing the predicate would restate what those three
222
- * already prove, one level further from the behaviour a user can see.
223
- *
224
- * `existsSync` and not a directory test, deliberately (#587). Git writes `.git`
225
- * as a directory only for an ordinary clone; a linked worktree and a submodule
226
- * each get a FILE whose whole content is one `gitdir:` line, and all three are
227
- * checkouts nobody may install over. Nothing here reads that line, which is
228
- * also what keeps the rule identical on Windows, where the path inside it
229
- * carries a drive letter and backslashes. Both shapes are covered in
230
- * worktree-git-file-587.test.ts and beside every checkout fixture in the
231
- * suite — narrowing this to `.isDirectory()` fails them. */
232
- export function isGitCheckout(pkgRoot) {
233
- try { return existsSync(join(pkgRoot, ".git")); } catch { return false; }
234
- }
235
-
236
- // ── npx ──────────────────────────────────────────────────────────────────────
237
- //
238
- // An npx run lives in ~/.npm/_npx/<hash>/node_modules/<pkg>. The hash is over
239
- // the SPEC the user typed, so upgrading means fetching a different directory —
240
- // there is nothing to install over. What there IS, is the spec itself: npm
241
- // writes it into <hash>/package.json as `_npx.packages`, which is the only
242
- // record of whether the user typed `ccdeck`, `agent-dag` or `agents-deck`.
243
- // Re-running the wrong one would work but would leave them on a package they
244
- // never asked for, so it is worth reading rather than guessing.
245
-
246
- /** The `_npx/<hash>` directory this package was unpacked into, or null. Pure —
247
- * path arithmetic only, so both platforms' separators can be tested. */
248
- export function npxRoot(pkgRoot) {
249
- if (typeof pkgRoot !== "string") return null;
250
- const parts = pkgRoot.split(/[\\/]/);
251
- const i = parts.lastIndexOf("_npx");
252
- if (i === -1 || i + 1 >= parts.length) return null;
253
- // Keep the separator the input used: a Windows path must come back as one.
254
- const sep = pkgRoot.includes("\\") && !pkgRoot.includes("/") ? "\\" : "/";
255
- return parts.slice(0, i + 2).join(sep);
256
- }
257
-
258
- /** Package name out of an npm spec, scope intact: `ccdeck@1.2.3` → `ccdeck`,
259
- * `@scope/pkg` → `@scope/pkg`. Null for anything that is not a plain name —
260
- * a tarball URL or a git spec is not something to re-run with `@latest`. */
261
- export function bareSpecName(spec) {
262
- if (typeof spec !== "string") return null;
263
- const s = spec.trim();
264
- if (!s) return null;
265
- const at = s.lastIndexOf("@");
266
- const name = at > 0 ? s.slice(0, at) : s;
267
- return /^@?[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)?$/i.test(name) ? name : null;
268
- }
269
-
270
- /** What to hand `npx -y`, read from the cache directory's own metadata.
271
- *
272
- * `fallback` is what to answer when that metadata names nothing usable — the
273
- * package name, for the caller whose job is to re-run SOMETHING. Pass null and
274
- * the answer is null instead, which is what invoked-as.mjs needs: it asks which
275
- * name the user typed, and there the package name is not a lesser answer, it is
276
- * a wrong one. */
277
- export function npxSpecFromMeta(meta, fallback = "agents-deck") {
278
- const list = meta && meta._npx && Array.isArray(meta._npx.packages) ? meta._npx.packages : [];
279
- for (const entry of list) {
280
- const name = bareSpecName(entry);
281
- if (name) return `${name}@latest`;
282
- }
283
- return fallback ? `${fallback}@latest` : null;
284
- }
285
-
286
- /** The same, answered against the filesystem. Null when this is not an npx run,
287
- * and — for a caller that passed no fallback — when the metadata cannot be
288
- * read. */
289
- export function npxRestartSpec(pkgRoot, name = "agents-deck") {
290
- const root = npxRoot(pkgRoot);
291
- if (!root) return null;
292
- let meta = null;
293
- try { meta = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); } catch { /* fall back to the name */ }
294
- return npxSpecFromMeta(meta, name);
295
- }
296
-
297
- // ── the package that installed us ────────────────────────────────────────────
298
- //
299
- // Two of the three names on npm are this tarball republished; the third is not
300
- // this tarball at all. `ccdeck` is a stub that ships nothing but bin/ and
301
- // depends on `agents-deck`, so `npm i -g ccdeck` produces a layout no other
302
- // install does: npm stopped hoisting a global package's dependencies in v7, so
303
- // the deck lands at `<prefix>/lib/node_modules/ccdeck/node_modules/agents-deck`
304
- // and the process runs out of a directory owned by a package it is not named
305
- // after.
306
- //
307
- // #351 taught the stub to FIND the deck there. Nothing downstream learned about
308
- // it, so every self-update surface went on naming `agents-deck` — which in that
309
- // layout resolves to `<prefix>/lib/node_modules/agents-deck`, a directory this
310
- // process never reads and a `npm i -g ccdeck` user usually does not have at
311
- // all. "Update now" therefore installed a second, unrelated tree: the running
312
- // install never moved, so installedVersion stayed put, the restart notice never
313
- // came, and the banner offered the identical update forever while the upgrade
314
- // reported "done".
315
- //
316
- // So the name to install is not the name this build was PUBLISHED under, it is
317
- // the name the install was REACHED under — and under the stub layout that is
318
- // written on disk one directory up. Read from there rather than from argv[1]
319
- // (invoked-as.mjs answers the neighbouring question of which command the user
320
- // TYPED, and cannot answer this one): the stub spawns the deck by absolute
321
- // path, so argv[1] reads `agent-dag.js` under all three names, and on Windows
322
- // npm's .cmd/.ps1 shims never pass the typed name on at all. The directory npm
323
- // built is the one carrier that survives every platform.
324
- //
325
- // The other two names need no layout at all, and that is why the first fix
326
- // missed one of them. `agents-deck` and `agent-dag` are the same tarball
327
- // published twice with the manifest renamed between the two, so the deck IS the
328
- // whole package under both and sits directly under the global node_modules with
329
- // nothing above it — hostPackage correctly finds no host, and the answer falls
330
- // through to a default that is right for one of the two and wrong for the
331
- // other. What both of them do carry is their own package.json, which npm's
332
- // rename made authoritative: see installedName.
333
-
334
- /** Every name this deck is published under.
335
- *
336
- * `agents-deck` and `agent-dag` are one tarball published twice — see
337
- * .github/workflows/publish.yml, which renames it between the two — and since
338
- * #340 `ccdeck` is a third rename of the same tarball rather than a launcher
339
- * package in front of it. The same three strings as
340
- * invoked-as.mjs's COMMANDS, and deliberately not that list: this is the set
341
- * of npm PACKAGES a `npm i -g` may name, that is the set of bin commands a
342
- * user may type. They are equal only because the rename made them so, and a
343
- * test pins them against each other rather than either side assuming it. */
344
- export const ALIAS_PACKAGES = ["agents-deck", "agent-dag", "ccdeck"];
345
-
346
- /** Which of those three THIS build was published as, out of its own manifest.
347
- *
348
- * The other half of the same bug, and the easier half. `agent-dag` is not a
349
- * stub and is not nested inside anything — `npm i -g agent-dag` puts the deck
350
- * straight into `<prefix>/lib/node_modules/agent-dag` — so hostPackage finds no
351
- * host above it and there is no layout to read the answer out of. There does
352
- * not need to be: CI publishes that name by renaming the manifest in the
353
- * tarball (`npm pkg set name=agent-dag` in .github/workflows/publish.yml), so
354
- * the running copy's own package.json says `agent-dag` and has all along.
355
- *
356
- * It was never read. `name` is a parameter with a default in every exported
357
- * function here, and not one caller in the deck passes it — index.mjs and
358
- * bin/deck.js both call versionReport and startUpgrade with a pkgRoot and
359
- * nothing else — so the default WAS the answer, and the default is a guess
360
- * about which of three names this build carries. For `npm i -g agent-dag` it
361
- * guessed wrong: the user was told to run `npm i -g agents-deck@latest`, which
362
- * installs a second, unrelated global package while their `agent-dag` binary
363
- * stays exactly where it was, and the version check went on caching under a
364
- * package this install is not.
365
- *
366
- * Confined to the three names, for the reason hostNameFromMeta is: the answer
367
- * becomes an argument in the `npm i -g` this process spawns, and startUpgrade
368
- * promises that vector can only ever name this deck. A fork that republishes
369
- * under a fourth name adds it to the list above, which is one line and a
370
- * deliberate one — rather than having the deck hand npm a package name it has
371
- * never heard of because a directory on disk said so.
372
- *
373
- * `fallback` is what to answer when the manifest is missing, unreadable, or
374
- * names something that is not one of ours — the caller's own `name`, so every
375
- * shape that cannot prove which alias it is keeps the behaviour it had. */
376
- export function installedName(pkgRoot, fallback = "agents-deck") {
377
- const self = readManifest(pkgRoot)?.name;
378
- return typeof self === "string" && ALIAS_PACKAGES.includes(self) ? self : fallback;
379
- }
380
-
381
- /** The directory of the package this one is installed INSIDE, or null when it
382
- * is not inside one. Pure — path arithmetic only, so a Windows layout can be
383
- * tested on a POSIX box and both separators are split on, exactly as npxRoot
384
- * does it and for the same reason. */
385
- export function hostRoot(pkgRoot) {
386
- if (typeof pkgRoot !== "string") return null;
387
- const parts = pkgRoot.split(/[\\/]/);
388
- // `<host>/node_modules/<us>` and nothing else: the segment directly above us
389
- // has to be the node_modules npm nested us into, and the one above that is
390
- // the host. A scoped package would sit one level deeper, under `@scope`, and
391
- // is refused here rather than guessed at — none of the three names is scoped.
392
- if (parts.length < 3 || parts[parts.length - 2] !== "node_modules") return null;
393
- // Keep the separator the input used: a Windows path must come back as one.
394
- const sep = pkgRoot.includes("\\") && !pkgRoot.includes("/") ? "\\" : "/";
395
- return parts.slice(0, -2).join(sep) || null;
396
- }
397
-
398
- /** The host package's name, out of its own manifest — but only when it is one
399
- * of this deck's aliases AND it declares a dependency on us.
400
- *
401
- * The alias half is the load-bearing one. Any project that lists `agents-deck`
402
- * in its dependencies and runs it out of its own node_modules — a workspace, a
403
- * CI job, a tool that embeds the deck — is in exactly the same shape on disk
404
- * as the stub, and answering with THAT name would have the deck offering to
405
- * `npm i -g their-app@latest`: a package it has no business installing, and on
406
- * a private name one that does not exist. Confining the answer to the three
407
- * published names leaves every such install with the fallback it has today. */
408
- export function hostNameFromMeta(meta, name = "agents-deck") {
409
- const host = typeof meta?.name === "string" ? meta.name : null;
410
- if (!host || !ALIAS_PACKAGES.includes(host)) return null;
411
- return typeof meta?.dependencies?.[name] === "string" ? host : null;
412
- }
413
-
414
- /** The same, answered against the filesystem: `{ root, name }` for the alias
415
- * package this copy was installed as a dependency of, or null. */
416
- export function hostPackage(pkgRoot, name = "agents-deck") {
417
- const root = hostRoot(pkgRoot);
418
- if (!root) return null;
419
- const host = hostNameFromMeta(readManifest(root), name);
420
- return host ? { root, name: host } : null;
421
- }
422
-
423
- /**
424
- * The directory that holds this install's code AFTER an upgrade replaced it,
425
- * or null when nothing has been replaced.
426
- *
427
- * One layout produces this and it is a transitional one. Before #340, `npm i -g
428
- * ccdeck` installed a launcher package with the deck nested inside it:
429
- *
430
- * <prefix>/lib/node_modules/ccdeck/ the launcher
431
- * <prefix>/lib/node_modules/ccdeck/node_modules/agents-deck/ pkgRoot
432
- *
433
- * upgradeName reads the host's declared dependency and correctly answers
434
- * `ccdeck`, so the upgrade runs `npm i -g ccdeck@latest` — which since #340
435
- * installs the deck itself over the host directory and removes everything that
436
- * was under it, including pkgRoot. The process keeps running (POSIX keeps an
437
- * open inode alive, and the modules are already loaded) out of a directory that
438
- * no longer exists.
439
- *
440
- * Deliberately NOT hostPackage. That function recognises a host by the
441
- * dependency it declares on us, and the whole point here is that the host has
442
- * just stopped declaring one — it is no longer a launcher, it is the deck. What
443
- * identifies it instead is its name, confined to the three we publish, for the
444
- * same reason installedName confines its answer: this decides what a version
445
- * report says about the user's machine, and a directory that merely happens to
446
- * sit above us is not evidence.
447
- *
448
- * Guarded on our own manifest being unreadable, so nothing changes for an
449
- * install that is intact — including the ordinary nested layout before it is
450
- * upgraded, where pkgRoot answers for itself and this is never consulted.
451
- */
452
- export function successorRoot(pkgRoot) {
453
- if (readManifest(pkgRoot)) return null;
454
- const root = hostRoot(pkgRoot);
455
- if (!root) return null;
456
- const name = readManifest(root)?.name;
457
- return typeof name === "string" && ALIAS_PACKAGES.includes(name) ? root : null;
458
- }
459
-
460
- /** The package an upgrade would actually install here — the only package worth
461
- * asking npm about.
462
- *
463
- * The check used to ask about `agents-deck` no matter what the upgrade
464
- * command installed, so a deck started with `npx ccdeck` compared its version
465
- * against `agents-deck`'s dist-tag and then handed back `npx -y ccdeck@latest`.
466
- * Nothing tied the two together. CI publishes the three names one after
467
- * another, so between the first and the last publish they genuinely disagree,
468
- * and inside that window the deck offered a version the command could not
469
- * install — the ETARGET below, with a window measured in publishes rather than
470
- * in seconds of propagation.
471
- *
472
- * Deriving the name from what would actually be installed keeps the two halves
473
- * consistent by construction: whatever the command will install is what gets
474
- * asked about, and the per-name marker follows the same name. Each install
475
- * shape carries that answer somewhere different — npx in the spec it recorded,
476
- * a stub install in the layout npm built, a plain global install in the
477
- * manifest of the package it is. All three are read; none is assumed. */
478
- export function upgradeName(pkgRoot, name = "agents-deck") {
479
- // The published name this build actually carries, which outranks `name` in
480
- // every branch below because `name` is a default at every call site in the
481
- // deck and the manifest on disk is not a guess. It replaces the parameter
482
- // rather than sitting beside it: this one function is where all four of the
483
- // registry-shaped answers meet — the dist-tag that is fetched, the marker it
484
- // is cached in, the command the user is shown, and the argv npm is spawned
485
- // with — so resolving the name once here is what keeps those four naming one
486
- // package, which is the property the whole function exists to hold.
487
- const self = installedName(pkgRoot, name);
488
- // A checkout installs nothing at all, so the published name is the only
489
- // sensible subject for the version question — and the only one whose
490
- // dist-tag says anything about the branch the maintainer is sitting on.
491
- if (isGitCheckout(pkgRoot)) return self;
492
- // npx recorded the spec the user typed, which is a better answer than the
493
- // manifest: `npx ccdeck` unpacks a deck whose manifest says `agents-deck`,
494
- // and re-running the name they did not type would move them off the stub.
495
- // The manifest is what is left when that record cannot be read.
496
- if (isNpxInstall(pkgRoot)) return bareSpecName(npxRestartSpec(pkgRoot, self)) ?? self;
497
- // Left: a global install, where the answer is whichever package owns the
498
- // directory npm would rewrite. That is the stub for `npm i -g ccdeck`, where
499
- // the deck is nested one level down inside a package it is not named after,
500
- // and this build's own name for `npm i -g agents-deck` and `npm i -g
501
- // agent-dag`, where the deck IS the whole package and nothing is above it.
502
- //
503
- // successorRoot is the third case, and it is the second half of the same
504
- // question. Once that stub install HAS been upgraded, the host has stopped
505
- // declaring a dependency on us — which is the only thing hostPackage
506
- // recognises a host by — so this fell through to `self`, and `self` is the
507
- // fallback `agents-deck` because our own manifest went with the directory.
508
- // The user was then shown `npm i -g agents-deck@latest`: a different package,
509
- // a second global tree, and their `ccdeck` binary left exactly where it was.
510
- // That is #358 verbatim, arriving through the upgrade that was supposed to be
511
- // the end of it. The successor's own manifest names it, and that name is what
512
- // the next upgrade has to install.
513
- const host = hostPackage(pkgRoot, self)?.name;
514
- if (host) return host;
515
- const moved = successorRoot(pkgRoot);
516
- return moved ? installedName(moved, self) : self;
517
- }
518
-
519
- /** The exact line the user can paste, for the way THIS copy was installed. */
520
- export function upgradeCommand(pkgRoot, name = "agents-deck") {
521
- // A checkout is updated by pulling, and the bundle is built, not shipped —
522
- // so `npm run build` is part of the answer rather than an afterthought.
523
- if (isGitCheckout(pkgRoot)) return "git pull && npm run build";
524
- if (isNpxInstall(pkgRoot)) return `npx -y ${upgradeName(pkgRoot, name)}@latest`;
525
- // Through upgradeName for the same reason the npx line above it is: the
526
- // printed command is the user's escape hatch when the button fails or is not
527
- // offered, and one that names a package this install cannot be replaced by is
528
- // worse than none — it looks like it worked.
529
- return `npm i -g ${upgradeName(pkgRoot, name)}@latest`;
530
- }
531
-
532
- // ── what npm has ─────────────────────────────────────────────────────────────
533
-
534
- // The dist-tags endpoint answers with ~20 bytes ({"latest":"1.30.7"}); the full
535
- // packument is >2 KB and needs parsing we have no use for.
536
- //
537
- // Answers `{ ok, version }` rather than a bare string, because "npm says the
538
- // latest is X" and "npm did not answer" used to arrive here as the same null.
539
- // `ok` is true only when the registry handed back a usable version — a
540
- // timeout, a non-200 and a 200 with no `latest` in it are all failures, and
541
- // the caller has to be able to tell them from an up-to-date deck.
542
- async function fetchLatest(name) {
543
- try {
544
- const res = await fetch(`https://registry.npmjs.org/-/package/${name}/dist-tags`, {
545
- headers: { accept: "application/json", "user-agent": "agents-deck" },
546
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
547
- });
548
- if (!res.ok) return { ok: false, version: null };
549
- const v = (await res.json())?.latest;
550
- return typeof v === "string" ? { ok: true, version: v } : { ok: false, version: null };
551
- } catch {
552
- return { ok: false, version: null };
553
- }
554
- }
555
-
556
- // A dist-tag is a pointer, and being pointed at is not the same as being
557
- // installable.
558
- //
559
- // npm makes the moved tag visible before the version document has propagated to
560
- // the replica the installer reads, so for a window `{"latest":"1.33.28"}` and
561
- // `No matching version found for ccdeck@1.33.28` are both true at the same
562
- // moment. Reported live: the banner offered v1.33.28, the restart ran
563
- // `npx -y ccdeck@latest`, npm answered ETARGET, and the deck came back on the
564
- // version it started with after tearing itself down — under npx a restart is
565
- // not free, since the worker exits and hands the port over before anything is
566
- // fetched.
567
- //
568
- // So the tag is checked against the version document, which is the same
569
- // question `npm view <name>@<version> version` asks and the same document the
570
- // installer resolves against. 404 is the answer this exists for: published
571
- // tag, unpublished version, try again in five minutes. `ok` is false only when
572
- // the registry gave no usable answer at all — that is not a licence to
573
- // announce either, but it is not evidence of an unpublished version.
574
- async function isPublished(name, version) {
575
- try {
576
- const res = await fetch(`https://registry.npmjs.org/${name}/${version}`, {
577
- headers: { accept: "application/json", "user-agent": "agents-deck" },
578
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
579
- });
580
- if (res.status === 404) return { ok: true, published: false };
581
- if (!res.ok) return { ok: false, published: false };
582
- // The document has to be the one asked for: a registry that answers 200
583
- // with something else has not shown that this version is resolvable.
584
- const body = await res.json().catch(() => null);
585
- return { ok: true, published: body?.version === version };
586
- } catch {
587
- return { ok: false, published: false };
588
- }
589
- }
590
-
591
- /** Marker file name for a package: `ccdeck` → `.self-update-check-ccdeck`.
592
- *
593
- * Pure, and deliberately strict about what reaches the filesystem — a package
594
- * name may be scoped (`@scope/pkg`), and `/`, `\` and `:` are either a path
595
- * separator or outright illegal in a Windows file name. Everything outside
596
- * `[a-z0-9._-]` collapses to `-`, the scope's leading `@` is dropped so the
597
- * common case reads plainly, and the tail is trimmed so a pathological name
598
- * cannot produce a path the OS refuses. An unusable name falls back to the
599
- * default rather than to the shared file this fix exists to get rid of. */
600
- export function markerFileName(name = "agents-deck") {
601
- return `.self-update-check-${safeNamePart(name)}`;
602
- }
603
-
604
- /** The sanitised half, shared with the restart-failure note below so the two
605
- * files agree on what a package name becomes on disk. */
606
- function safeNamePart(name) {
607
- const raw = typeof name === "string" ? name.trim().toLowerCase() : "";
608
- const safe = raw
609
- .replace(/^@/, "")
610
- .replace(/[^a-z0-9._-]+/g, "-")
611
- .slice(0, 64)
612
- // Trimmed after the slice, and at both ends: Windows silently drops a
613
- // trailing dot from a file name, so a name that ends in one would write to
614
- // a path that is not the path we would later read.
615
- .replace(/^[-.]+|[-.]+$/g, "");
616
- return safe || "agents-deck";
617
- }
618
-
619
- function markerPath(name) {
620
- return join(MARKER_DIR, markerFileName(name));
621
- }
622
-
623
- // The marker carries the answer, not just the timestamp. The existing markers
624
- // store only an mtime, which means a restart inside the window forgets what npm
625
- // said and shows nothing until the window expires.
626
- //
627
- // It carries the outcome too. `at` is when npm last ANSWERED and `version` is
628
- // what it said; `failedAt` is when the last attempt failed, and is null the
629
- // moment one succeeds. Keeping them apart is what lets the deck say "checked
630
- // 3m ago" and "could not reach npm" as the different things they are, instead
631
- // of reporting a timeout as a fresh, up-to-date check.
632
- function readMarkerFile(path) {
633
- try {
634
- const m = JSON.parse(readFileSync(path, "utf8"));
635
- // Either half is enough to be worth keeping: a marker written by a first
636
- // attempt that failed has no `at` yet, and markers written before
637
- // `failedAt` existed have no `failedAt` at all.
638
- return (typeof m?.at === "number" || typeof m?.failedAt === "number") ? m : null;
639
- } catch {
640
- return null;
641
- }
642
- }
643
-
644
- // The unsuffixed marker every earlier deck wrote, read at most once per name
645
- // per process: upgrading to this version should not throw away an answer npm
646
- // already gave. It is consulted only while the per-name file is still missing,
647
- // so the shared file never goes back to being in charge.
648
- const _legacy = new Map();
649
- function legacyMarker(name) {
650
- const key = markerFileName(name);
651
- if (!_legacy.has(key)) _legacy.set(key, readMarkerFile(LEGACY_MARKER));
652
- return _legacy.get(key);
653
- }
654
-
655
- function readMarker(name) {
656
- return readMarkerFile(markerPath(name)) ?? legacyMarker(name);
657
- }
658
-
659
- /** The version npm last named as `latest` for this package, straight off the
660
- * marker and with no lookup of its own.
661
- *
662
- * This is what the supervisor calls a failed upgrade's TARGET. It has to be
663
- * answerable without the network — the supervisor asks it in the moment
664
- * between a click and a fetch, and the reason the fetch is about to fail may
665
- * well be that there is no network — and it has to be the same number the
666
- * banner offered, which is precisely what the marker holds. */
667
- export function lastKnownLatest(name = "agents-deck") {
668
- const v = readMarker(name)?.version;
669
- return typeof v === "string" && v ? v : null;
670
- }
671
-
672
- function writeMarker(name, marker) {
673
- const path = markerPath(name);
674
- try {
675
- mkdirSync(dirname(path), { recursive: true });
676
- writeFileSync(path, JSON.stringify(marker));
677
- } catch { /* a read-only home must not break the deck */ }
678
- }
679
-
680
- /** What the marker should hold after an attempt. Pure, because "a failed
681
- * lookup must not be recorded as a successful one" is precisely the rule this
682
- * file used to get wrong, and a rule worth a bug is worth a test.
683
- *
684
- * A success stamps the hour and clears the failure. A failure records only
685
- * itself, leaving the last real answer and the time it arrived untouched —
686
- * the deck keeps showing what it knew, and stops claiming it just confirmed
687
- * it.
688
- *
689
- * `installable` is the third outcome: npm answered, and what it named is not
690
- * yet a version anything can install. That is a real answer — `at` moves, the
691
- * registry was reached — but the version is held in `pending` instead of
692
- * `version`, so `latest` stays a number the upgrade command can resolve, and
693
- * `pendingAt` puts the next look on the short window rather than the hour. */
694
- export function nextMarker({ prev, now, ok, version, installable = true }) {
695
- if (!ok) {
696
- return {
697
- at: prev?.at ?? null,
698
- version: prev?.version ?? null,
699
- failedAt: now,
700
- pending: prev?.pending ?? null,
701
- pendingAt: prev?.pendingAt ?? null,
702
- };
703
- }
704
- if (!installable) {
705
- return { at: now, version: prev?.version ?? null, failedAt: null, pending: version ?? null, pendingAt: now };
706
- }
707
- return { at: now, version: version ?? null, failedAt: null, pending: null, pendingAt: null };
708
- }
709
-
710
- // Even a per-package marker is shared by every deck running that package, so
711
- // one deck's check still answers for the others inside the window. Asking once
712
- // per PROCESS lands the check exactly where the user expects the truth: a
713
- // freshly started deck — which for `npx ccdeck` is every single run. Keyed by
714
- // name, so a process that asked about one package has not asked about another.
715
- const _askedThisProcess = new Set();
716
-
717
- /**
718
- * Whether to ask npm, or reuse the answer on disk. Pure, because "why did no
719
- * banner appear" is the question this feature gets asked, and the rule behind
720
- * it should be readable in one place.
721
- */
722
- export function checkDue({ at, failedAt, pendingAt, now, first = false, force = false, ttlMs = CHECK_MS, retryMs = RETRY_MS }) {
723
- if (force || first) return true; // explicit ask, or this process's first
724
- // Two ways of not having an answer yet, and neither may spend the hour a real
725
- // answer buys: the last attempt failed, or npm named a version that cannot be
726
- // installed yet. Both take the short window instead — an unreachable registry
727
- // must not turn every poll into another request, and a release mid-publish is
728
- // resolvable minutes later, not an hour later. Answered before `at`, which
729
- // here is the older, settled check and would otherwise ask again immediately
730
- // (or, for a pending version, not for another hour).
731
- const unsettled = [failedAt, pendingAt].filter(t => typeof t === "number");
732
- if (unsettled.length) {
733
- const last = Math.max(...unsettled);
734
- if (last > now) return true; // clock moved; do not wait it out
735
- return now - last >= retryMs;
736
- }
737
- if (typeof at !== "number") return true; // never checked
738
- if (at > now) return true; // marker from the future: a moved clock
739
- return now - at >= ttlMs;
740
- }
741
-
742
- /** Last known npm `latest`, refreshed at most once per CHECK_MS. Returns the
743
- * cached answer immediately when the window has not elapsed.
744
- *
745
- * `force` skips the window: the first call in this process, and an explicit
746
- * "check now" from the UI. What it does not skip is FORCE_POLL_MS — see
747
- * mayAskNpm, and the note above it for what a forced call used to cost. */
748
- async function latestOnNpm(name, now, force = false) {
749
- const m = readMarker(name);
750
- const key = markerFileName(name);
751
- const first = !_askedThisProcess.has(key);
752
- _askedThisProcess.add(key);
753
- if (!checkDue({ at: m?.at, failedAt: m?.failedAt, pendingAt: m?.pendingAt, now, first, force })) {
754
- return m?.version ?? null;
755
- }
756
- // Offered before the floor: a check that has not answered yet is a lookup
757
- // newer than the marker, which is what refresh asked for, and joining it costs
758
- // nothing.
759
- const inflight = _inflight.get(key);
760
- if (inflight) return inflight;
761
- // The floor, under every rule above it. A refused check is answered with the
762
- // version we already hold — the same string an ordinary cached call returns,
763
- // carrying the marker's own `checkedAt` rather than the moment of the read
764
- // that was refused, so /api/version reports exactly what it reported a moment
765
- // ago and no surface learns a new failure mode from being asked twice.
766
- if (!mayAskNpm({ now, lastAskAt: _lastAskAt.get(key) ?? 0 })) return m?.version ?? null;
767
- _lastAskAt.set(key, now);
768
- const run = runCheck(name, m, now)
769
- // Record the outcome, not just the moment, and record it against THIS
770
- // package: only an answer stamps `at`; a failure takes the short retry
771
- // window instead of the hour, keeps the version we already knew rather
772
- // than erasing it, and lands in this name's marker rather than spending
773
- // another package's window on a lookup that was never about it.
774
- .then((marker) => {
775
- writeMarker(name, marker);
776
- return marker.version ?? null;
777
- })
778
- .catch(() => m?.version ?? null)
779
- .finally(() => { _inflight.delete(key); });
780
- _inflight.set(key, run);
781
- return run;
782
- }
783
-
784
- /** One check, as a marker: what npm's dist-tag says, and — only when that is a
785
- * version this deck has not already confirmed — whether it can be installed.
786
- *
787
- * The second request is what keeps the banner honest, and it is skipped in the
788
- * case that runs all day: a tag that has not moved was confirmed the first
789
- * time it was seen, so a deck sitting on the current release still costs one
790
- * ~20-byte GET per check. Confirming costs one more, once per release. */
791
- async function runCheck(name, prev, now) {
792
- const { ok, version } = await fetchLatest(name);
793
- if (!ok || version === prev?.version) return nextMarker({ prev, now, ok, version });
794
- const probe = await isPublished(name, version);
795
- return nextMarker({ prev, now, ok, version, installable: probe.ok && probe.published });
796
- }
797
-
798
- // ── the notice ───────────────────────────────────────────────────────────────
799
-
800
- /** Pure: turns three version strings into at most one thing worth saying.
801
- *
802
- * "restart" outranks "upgrade" because it is the free fix — the newer code is
803
- * already on the machine and a restart is all that stands between the user and
804
- * it. Once restarted, the next check surfaces the upgrade if one is still due. */
805
- export function pickNotice({ running, installed, latest }) {
806
- if (running && installed && isOlder(running, installed)) {
807
- return { kind: "restart", from: running, to: installed };
808
- }
809
- const have = installed ?? running;
810
- if (have && latest && isOlder(have, latest)) {
811
- return { kind: "upgrade", from: have, to: latest };
812
- }
813
- return null;
814
- }
815
-
816
- // ── installing ───────────────────────────────────────────────────────────────
817
-
818
- const INSTALL_TIMEOUT_MS = 300_000; // a cold global install on a slow line
819
-
820
- /**
821
- * What `spawn` gets for `npm install -g <target>@latest`.
822
- *
823
- * This was the last caller still spelling it the way #362 and #456 were written
824
- * to remove: `spawn("npm.cmd", args, { shell: true })`, with a comment claiming
825
- * it was "the same pair the ccusage installer uses". ccusage stopped using that
826
- * pair when #456 fixed it, and this one was never revisited — it does not go
827
- * through `run`/`runInteractive`/`runDetached`, so #457's sweep of their callers
828
- * could not see it and exec-shim-callers.test.ts never listed it.
829
- *
830
- * Both halves of the old spelling were wrong on Windows and only one of them
831
- * bites today.
832
- *
833
- * The one that bites: a `.cmd` shim locates its payload relative to `%~dp0`,
834
- * the drive and path of the command token cmd.exe was handed, and a BARE
835
- * `npm.cmd` carries no directory — so `%~dp0` came out as the deck's working
836
- * directory and npm's shim went looking for `node_modules\npm\bin\npm-cli.js`
837
- * underneath it. A user with a global install, deck started from
838
- * `C:\Users\vceban`, clicks Update now and npm dies with `Cannot find module
839
- * 'C:\Users\vceban\node_modules\npm\bin\npm-cli.js'` — on a machine whose npm
840
- * is perfectly healthy, and where typing the same command at the same prompt
841
- * works. shimPath is the answer, and `?? "npm.cmd"` keeps a layout it cannot see
842
- * exactly as well off as it was.
843
- *
844
- * The one that does not, yet: `shell: true` makes Node join file and args with
845
- * single spaces and no quoting. These arguments contain no spaces, so it has
846
- * never mattered here — but it is the #362 defect sitting one argument away, and
847
- * spawnSpec removes it by quoting every token into the cmd.exe line.
848
- *
849
- * POSIX is untouched: `npm` there is a real executable, isBatch is false, and
850
- * the vector goes to spawn exactly as it always has.
851
- *
852
- * Exported for tests: the platform is a parameter so the Windows command line
853
- * can be checked from any OS, and `deps` stands in for the Windows filesystem
854
- * the shim lookup asks about. The same shape ccusage.mjs's installSpec has.
855
- */
856
- export function upgradeSpec(target, platform = process.platform, deps) {
857
- const args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
858
- const file = platform === "win32" ? (shimPath("npm.cmd", deps) ?? "npm.cmd") : "npm";
859
- return { ...spawnSpec(file, args, platform), plain: args };
860
- }
861
-
862
- /**
863
- * Why an in-app upgrade would be wrong here, or null when it is fine.
864
- *
865
- * Pure so the policy can be read and tested on its own — it is the part that
866
- * decides whether we are allowed to write to the user's machine.
867
- */
868
- export function upgradeBlockedReason({ git, npx, writable, optedOut }) {
869
- if (optedOut) return "opted_out";
870
- // The maintainer's own tree. Its version leads npm's, and installing over it
871
- // would replace a working copy with a published tarball.
872
- if (git) return "git_checkout";
873
- // npx runs from a content-addressed cache directory that is never upgraded in
874
- // place — `npx agents-deck@latest` fetches a DIFFERENT directory, which this
875
- // process could not switch to even after restarting.
876
- if (npx) return "npx";
877
- // Almost always a root-owned global prefix. Failing inside npm with EACCES
878
- // tells the user less than declining up front does.
879
- if (!writable) return "not_writable";
880
- return null;
881
- }
882
-
883
- /**
884
- * How this copy can update itself, if it can at all.
885
- *
886
- * "install" — `npm i -g` here and restart into the new files.
887
- * "npx" — nothing to install: the supervisor re-runs `npx -y <spec>`,
888
- * which fetches a NEW cache directory and hands the port to it.
889
- * null — a checkout, an unwritable prefix, or an explicit opt-out; the
890
- * user gets the command and does it themselves.
891
- *
892
- * Pure, so the policy is one readable expression rather than three conditions
893
- * spread across the server and the UI.
894
- */
895
- export function upgradeMode(blockedReason) {
896
- if (blockedReason === null || blockedReason === undefined) return "install";
897
- return blockedReason === "npx" ? "npx" : null;
898
- }
899
-
900
- /**
901
- * Can this user write into `p`?
902
- *
903
- * ON WINDOWS THE QUESTION HAS TO BE ASKED BY WRITING (#795). libuv's
904
- * `fs__access` short-circuits to success for anything carrying
905
- * FILE_ATTRIBUTE_DIRECTORY — "Directories cannot be read-only on Windows" — and
906
- * never consults the ACL; Node documents this. Both arguments this is called
907
- * with are always directories, so `writable` was unconditionally true there,
908
- * `upgradeBlockedReason` could never answer `not_writable`, and the banner
909
- * offered "Update & restart" on the common nvm-windows layout where the global
910
- * prefix is C:\Program Files\nodejs and a non-elevated shell cannot write it.
911
- * npm then died with EPERM and the user got a truncated npm log tail in a
912
- * 46-character box — where a POSIX user in the same position gets "the install
913
- * directory is not writable by this user" and the command to paste. The comment
914
- * on upgradeBlockedReason states the intent the platform defeated: "Failing
915
- * inside npm with EACCES tells the user less than declining up front does."
916
- *
917
- * The probe is `createTemp`'s "wx" pattern, synchronously: a name nothing else
918
- * can be using, created exclusively and removed at once. POSIX keeps
919
- * `accessSync`, where it is correct and cheaper.
920
- */
921
- function dirWritable(p) {
922
- if (process.platform !== "win32") {
923
- try { accessSync(p, FS.W_OK); return true; } catch { return false; }
924
- }
925
- // `wx` fails if the name exists, so a collision reads as "not writable"
926
- // rather than clobbering somebody's file — hence pid and a random suffix.
927
- const probe = join(p, `.ccdeck-w-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
928
- try {
929
- writeFileSync(probe, "", { flag: "wx" });
930
- return true;
931
- } catch {
932
- return false;
933
- } finally {
934
- try { unlinkSync(probe); } catch { /* never created, or already gone */ }
935
- }
936
- }
937
-
938
- /** The same question, answered against the real filesystem and environment. */
939
- export function upgradeBlock(pkgRoot) {
940
- // Whatever `npm i -g` would rewrite is what has to be writable, and under the
941
- // stub layout that is not this directory: `npm i -g ccdeck` replaces
942
- // <prefix>/lib/node_modules/ccdeck, and the copy running out of its nested
943
- // node_modules goes with it. In practice both pairs answer the same, since
944
- // one npm install created them with one owner — but the question is about the
945
- // tree the command touches, and that tree is the host's.
946
- //
947
- // Asked with this build's own name rather than with the default, for the same
948
- // reason upgradeName is: the host is recognised by the dependency it declares
949
- // on us, and "us" is whatever the manifest here says — `agents-deck` under the
950
- // stub npm publishes today, but nothing in this rule should assume that.
951
- //
952
- // successorRoot covers the case after such an upgrade has run: pkgRoot is
953
- // gone, so hostPackage cannot recognise a host that no longer declares us,
954
- // and asking accessSync about a deleted directory answers ENOENT — which this
955
- // function reported as `not_writable`, telling the user their npm prefix was
956
- // read-only when it was not.
957
- const target = hostPackage(pkgRoot, installedName(pkgRoot))?.root
958
- ?? successorRoot(pkgRoot)
959
- ?? pkgRoot;
960
- return upgradeBlockedReason({
961
- git: isGitCheckout(pkgRoot),
962
- npx: isNpxInstall(pkgRoot),
963
- // npm -g rewrites the package directory and its parent (the global
964
- // node_modules), so both have to be ours to write.
965
- writable: dirWritable(target) && dirWritable(resolve(target, "..")),
966
- optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1",
967
- });
968
- }
969
-
970
- // ── the note a failed npx relaunch leaves behind ─────────────────────────────
971
- //
972
- // The npx upgrade is the one path whose failure the server cannot see. It runs
973
- // in the SUPERVISOR, after this process has already exited: the worker asks to
974
- // come back through `npx -y <spec>@latest`, npx fails, and the supervisor
975
- // relaunches the copy on disk. The new worker boots knowing nothing, so
976
- // /api/version kept answering `upgrade: {state:"idle"}` — the banner still
977
- // offered "Update & restart", the tab said nothing at all, and a user who
978
- // clicked the button without watching the terminal saw the deck blink and come
979
- // back unchanged. Every click then repeated the whole cycle identically.
980
- //
981
- // A file is the only channel between the two processes: the supervisor writes
982
- // one when the relaunch fails, and the worker it starts instead reads it here.
983
- // Same directory as the update markers, and named after the package for the
984
- // same reason they are.
985
- //
986
- // The package name alone was not enough, and one release was enough to show it.
987
- // Two `npx ccdeck` decks resolve into the same content-addressed _npx directory,
988
- // so they run the same package at the same version out of the same home: when
989
- // one user's upgrade failed, the other deck — which had never asked for
990
- // anything — read that note as its own, reported `upgrade: {state:"failed"}`
991
- // and labelled its first ever click "Retry update". The version-staleness rule
992
- // below cannot catch it, because both decks are the same version.
993
- //
994
- // So the name carries WHOSE failure it is as well as which package's: the pid
995
- // of the supervisor that wrote it. That is unique among the decks alive on the
996
- // machine, and it crosses the process boundary on its own — the worker that
997
- // reads the note is a child the supervisor spawns after the failure, and
998
- // inherits the pid through AGENTS_DECK_SUPERVISOR_PID. A worker with no
999
- // supervisor to answer for reads nothing rather than falling back to a shared
1000
- // file, which is the bug this whole naming exists to avoid.
1001
- const NOTE_PREFIX = ".restart-failed-";
1002
-
1003
- /** Which supervisor a note belongs to, as the file name may spell it. Digits
1004
- * and nothing else: legal on every filesystem, and readable back as the pid
1005
- * the sweep below asks about. Anything else is refused rather than scrubbed
1006
- * into digits, since two keys must never collapse into one file name. */
1007
- function safeOwner(key) {
1008
- const raw = String(key ?? "").trim();
1009
- return /^\d{1,12}$/.test(raw) && Number(raw) > 0 ? raw : null;
1010
- }
1011
-
1012
- /** This process's own key, set by the supervisor on itself and inherited by
1013
- * every worker it launches. */
1014
- export function restartFailureKey(env = process.env) {
1015
- return safeOwner(env?.AGENTS_DECK_SUPERVISOR_PID);
1016
- }
1017
-
1018
- /** Called once by the supervisor, on its own environment, which every worker it
1019
- * spawns then inherits. Assigned rather than defaulted: a deck launched by
1020
- * another deck's npx relaunch inherits that supervisor's key and must answer
1021
- * for itself, not for the parent whose upgrade it is the result of. */
1022
- export function claimRestartFailureKey(env = process.env, pid = process.pid) {
1023
- env.AGENTS_DECK_SUPERVISOR_PID = String(pid);
1024
- return safeOwner(pid);
1025
- }
1026
-
1027
- /** `ccdeck` under supervisor 4821 → `.restart-failed-ccdeck-4821`, or null when
1028
- * there is no supervisor, which is not a deck any note can be about. */
1029
- export function restartFailureFileName(name = "agents-deck", key = restartFailureKey()) {
1030
- const owner = safeOwner(key);
1031
- return owner ? `${NOTE_PREFIX}${safeNamePart(name)}-${owner}` : null;
1032
- }
1033
-
1034
- function restartFailurePath(name, key) {
1035
- const file = restartFailureFileName(name, key);
1036
- return file ? join(MARKER_DIR, file) : null;
1037
- }
1038
-
1039
- /** Called by the supervisor when an upgrade does not happen — because the fetch
1040
- * failed, because the fetched copy never served, or because this target has
1041
- * already failed here and is not being tried again. Best-effort: a read-only
1042
- * home costs the report, not the deck. */
1043
- export function recordRestartFailure({
1044
- name = "agents-deck", command = null, error = null, version = null,
1045
- target = null, attempts = 1, at = Date.now(), failedAt = at, key = restartFailureKey(),
1046
- } = {}) {
1047
- const file = restartFailureFileName(name, key);
1048
- if (!file) return;
1049
- const path = join(MARKER_DIR, file);
1050
- try {
1051
- mkdirSync(dirname(path), { recursive: true });
1052
- writeFileSync(path, JSON.stringify({
1053
- command,
1054
- error: error ? String(error).slice(0, 300) : null,
1055
- // The version that failed to leave — see restartFailureNotice.
1056
- version,
1057
- // The version that failed to ARRIVE, and how many attempts it has cost.
1058
- // Together they are the whole of what stops an unattended deck retrying
1059
- // the identical fetch forever; see upgradeAttempt in supervisor.mjs.
1060
- target,
1061
- attempts,
1062
- // When the fetch itself failed, which a refusal re-stating that failure
1063
- // carries forward unchanged. `at` moves on every write because the
1064
- // browser ends its attempt on a note it has not seen before — the
1065
- // cooldown must not be pushed out by the act of asking about it.
1066
- failedAt,
1067
- at,
1068
- }));
1069
- sweepOrphanedNotes(file);
1070
- } catch { /* ignore */ }
1071
- }
1072
-
1073
- /** Called before each attempt, so a retry is answered by its own outcome rather
1074
- * than by the last one's. */
1075
- export function clearRestartFailure(name = "agents-deck", key = restartFailureKey()) {
1076
- const path = restartFailurePath(name, key);
1077
- if (!path) return;
1078
- try { rmSync(path, { force: true }); } catch { /* ignore */ }
1079
- }
1080
-
1081
- export function readRestartFailure(name = "agents-deck", key = restartFailureKey()) {
1082
- const path = restartFailurePath(name, key);
1083
- if (!path) return null;
1084
- try {
1085
- const m = JSON.parse(readFileSync(path, "utf8"));
1086
- return m && typeof m === "object" ? m : null;
1087
- } catch {
1088
- return null;
1089
- }
1090
- }
1091
-
1092
- // A note is named after a process, so it outlives its deck whenever that
1093
- // supervisor is killed before anyone reads the tab, and nothing would ever
1094
- // delete it: the retry that clears one is exactly the thing that never happened.
1095
- // Swept from the only path that creates notes, and only where the owner is
1096
- // provably gone — another deck's note is another deck's to clear. Names without
1097
- // a pid are the single shared file of v1.33.82, which nothing reads any more.
1098
- function sweepOrphanedNotes(keep) {
1099
- let files;
1100
- try { files = readdirSync(MARKER_DIR); } catch { return; }
1101
- for (const f of files) {
1102
- if (f === keep || !f.startsWith(NOTE_PREFIX)) continue;
1103
- const owner = Number(f.slice(f.lastIndexOf("-") + 1));
1104
- if (Number.isInteger(owner) && owner > 0 && processAlive(owner)) continue;
1105
- try { rmSync(join(MARKER_DIR, f), { force: true }); } catch { /* ignore */ }
1106
- }
1107
- }
1108
-
1109
- // Signal 0 delivers nothing; it asks whether the pid could be signalled.
1110
- //
1111
- // BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
1112
- // answers EPERM for a process this account may not signal. On Windows
1113
- // `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
1114
- // maps that to EACCES — so a deck started from an elevated terminal, or under
1115
- // another account, read as DEAD to every probe in this repo. What followed was
1116
- // silent: the live deck's discovery file was unlinked on the next hook fire,
1117
- // rewritten five seconds later by keepDiscovery, and its banner went on
1118
- // claiming it was receiving events it had stopped receiving.
1119
- function processAlive(pid) {
1120
- try { process.kill(pid, 0); return true; }
1121
- catch (e) { return e?.code === "EPERM" || e?.code === "EACCES"; }
1122
- }
1123
-
1124
- /**
1125
- * The note as the version report should carry it, or null when it no longer
1126
- * describes this deck. Pure, because the staleness rule is the whole subtlety.
1127
- * WHOSE failure it is was settled by the file name; this decides only whether
1128
- * it is still current.
1129
- *
1130
- * The note names the version that was running when the upgrade failed. While
1131
- * that is still the version on disk, the failure is current: the deck really is
1132
- * stuck where it was. Once the files are a different version the upgrade
1133
- * happened some other way — a `npm i -g`, a fixed npm prefix, a manual npx —
1134
- * and a note about a deck that no longer exists must not keep claiming the
1135
- * update is broken.
1136
- */
1137
- export function restartFailureNotice(record, installed = null) {
1138
- if (!record || typeof record.error !== "string" || !record.error) return null;
1139
- if (record.version && installed && record.version !== installed) return null;
1140
- return {
1141
- state: "failed",
1142
- command: typeof record.command === "string" ? record.command : null,
1143
- error: record.error,
1144
- at: typeof record.at === "number" ? record.at : 0,
1145
- };
1146
- }
1147
-
1148
- // One install at a time, per process. State is deliberately coarse: the UI only
1149
- // needs to know whether to show a spinner, a version, or an error.
1150
- let _upgrade = { state: "idle", command: null, error: null, at: 0 };
1151
-
1152
- export function upgradeStatus() {
1153
- return { ..._upgrade };
1154
- }
1155
-
1156
- /**
1157
- * Start `npm i -g <name>@latest` in the background.
1158
- *
1159
- * Returns immediately with the accepted command, or a refusal. Never installs
1160
- * anything except this deck — under one of its own three published names, and
1161
- * never at a version the caller chose. The argument vector is fixed here, not
1162
- * assembled from request input: the only thing that varies is which alias, and
1163
- * that is read off the install on disk and confined to ALIAS_PACKAGES.
1164
- */
1165
- export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
1166
- if (_upgrade.state === "running") return { ok: true, already: true, command: _upgrade.command };
1167
- const blocked = upgradeBlock(pkgRoot);
1168
- if (blocked) return { ok: false, reason: blocked, command: upgradeCommand(pkgRoot, name) };
1169
-
1170
- // The package this install can actually be replaced by — the stub for a
1171
- // `npm i -g ccdeck`, the published name everywhere else. Installing `name`
1172
- // there wrote a tree this process never reads, so the version on disk never
1173
- // moved and the same update was offered forever.
1174
- const target = upgradeName(pkgRoot, name);
1175
- const spec = upgradeSpec(target);
1176
- // The LOGICAL vector, not the cmd.exe line: this string is shown to the user
1177
- // and is the one they can paste. `cmd /d /s /c "…"` is an implementation
1178
- // detail of how this platform reaches npm, and pasting it would be advice
1179
- // about the deck rather than about their install.
1180
- const command = `npm ${spec.plain.join(" ")}`;
1181
- _upgrade = { state: "running", command, error: null, at: Date.now() };
1182
-
1183
- let child;
1184
- try {
1185
- child = spawn(spec.file, spec.args, { ...spec.opts, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
1186
- } catch (err) {
1187
- _upgrade = { state: "failed", command, error: err?.message ?? String(err), at: Date.now() };
1188
- return { ok: false, reason: "spawn_failed", command };
1189
- }
1190
-
1191
- // Only the tail is kept: npm's failures put the useful line near the end, and
1192
- // a full buffer of an install log is not something the browser should hold.
1193
- let err = "";
1194
- const keepTail = (s) => { err = (err + s).slice(-4000); };
1195
- child.stdout.on("data", d => keepTail(String(d)));
1196
- child.stderr.on("data", d => keepTail(String(d)));
1197
-
1198
- // The deadline states the outcome itself, and only then kills.
1199
- //
1200
- // npm is a .cmd shim on Windows and is therefore spawned through cmd.exe, so
1201
- // `child` is cmd.exe and npm itself is a grandchild — a plain kill would
1202
- // report the install as timed out while it carried on writing to
1203
- // node_modules. killTree is what reaches it (taskkill /T there, the same
1204
- // plain signal everywhere else).
1205
- //
1206
- // Leaving the verdict to 'close' was the other half of the same bug. 'close'
1207
- // waits for the stdio pipes, which the grandchild inherited, so on Windows it
1208
- // could arrive minutes after the deadline — or never, if the tree kill could
1209
- // not run at all — and until it did, /api/version kept reporting
1210
- // `state: "running"` with the UI spinning on "installing…" and the guard at
1211
- // the top of this function refusing every retry. When it finally arrived it
1212
- // carried the killed wrapper's status, so a five-minute timeout was announced
1213
- // to the user as "npm exited null".
1214
- let timedOut = false;
1215
- const timer = setTimeout(() => {
1216
- timedOut = true;
1217
- _upgrade = {
1218
- state: "failed",
1219
- command,
1220
- error: `timed out after ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes`,
1221
- at: Date.now(),
1222
- };
1223
- killTree(child);
1224
- }, INSTALL_TIMEOUT_MS);
1225
- timer.unref?.();
1226
-
1227
- child.on("error", (e) => {
1228
- clearTimeout(timer);
1229
- // A kill can make the child emit one of these; whatever it says, the reason
1230
- // this install failed is the deadline that has already been reported.
1231
- if (timedOut) return;
1232
- _upgrade = { state: "failed", command, error: e?.message ?? String(e), at: Date.now() };
1233
- });
1234
- child.on("close", (code) => {
1235
- clearTimeout(timer);
1236
- if (code === 0) {
1237
- // A clean exit is a real install even if it lands after the deadline —
1238
- // npm finishing in the same breath as the timer is the one case where the
1239
- // files on disk disagree with the verdict above, and the files win.
1240
- //
1241
- // Deliberately does not restart anything. The new files on disk make
1242
- // installedVersion() disagree with the running one, and the ordinary
1243
- // drift path takes it from there — including its wait for an idle moment.
1244
- _upgrade = { state: "done", command, error: null, at: Date.now() };
1245
- } else if (!timedOut && _upgrade?.state !== "failed") {
1246
- // Not over a failure the 'error' handler already explained. A missing npm
1247
- // emits 'error' with ENOENT and THEN 'close' with a null code, and this
1248
- // branch used to replace "spawn npm ENOENT" with "npm exited -2" — the
1249
- // one message that says what is wrong, overwritten by the one that does
1250
- // not.
1251
- _upgrade = { state: "failed", command, error: lastMeaningfulLine(err) || `npm exited ${code}`, at: Date.now() };
1252
- }
1253
- });
1254
-
1255
- return { ok: true, command };
1256
- }
1257
-
1258
- // The furniture around a Node crash, none of which is the reason anything
1259
- // failed. npm's own log needs none of this — it is `npm ERR!` lines and a rule —
1260
- // but the two get mixed the moment the thing npm's shim tried to load is
1261
- // missing, which is what #535's bare `npm.cmd` produced on every attempt.
1262
- const CRASH_NOISE = [
1263
- /^\s*at\s/, // stack frames
1264
- /^\s*\^+\s*$/, // the caret under the throw
1265
- /^node:internal\//, // the frame node leads with
1266
- /^\s*throw\s/,
1267
- /^Node\.js v/, // the last line, and the one that was quoted
1268
- /^\s*[{}]\s*$/, // the error object's braces
1269
- /^\s*(code|errno|syscall|path|requireStack|stack):/,
1270
- /^Require stack:/,
1271
- /^-+$/,
1272
- /^A complete log/,
1273
- ];
1274
-
1275
- /** npm's real complaint, for the banner: the last line that is not furniture.
1276
- *
1277
- * Last rather than first, and that is the whole reason this is not npx.mjs's
1278
- * summariser under another name. The two read different documents. npm's log
1279
- * opens with its codes — `code EACCES`, `syscall mkdir` — and ends with the
1280
- * sentence a person can act on, so the last line wins. An npx failure is a Node
1281
- * crash dump, which opens with the sentence and ends with a stack, a brace and
1282
- * a version banner, so the first signal line wins there. Sharing one function
1283
- * would mean picking one of those and being wrong about the other half the
1284
- * time; sharing the NOISE list would be the copy this comment exists instead
1285
- * of.
1286
- *
1287
- * What was wrong was not the rule but its list. `lastMeaningfulLine` dropped
1288
- * `npm ERR!` prefixes, rules and "A complete log", and nothing else — so when
1289
- * #535's spawn failed with a MODULE_NOT_FOUND dump, the last surviving line was
1290
- * `Node.js v22.11.0`, and that string was the entire explanation the UI gave
1291
- * for a failed upgrade. */
1292
- export function lastMeaningfulLine(text) {
1293
- const lines = String(text ?? "").split(/\r?\n/)
1294
- .map(l => l.replace(/^npm (ERR!|WARN)\s*/, "").trim())
1295
- .filter(l => l && !CRASH_NOISE.some(re => re.test(l)));
1296
- if (!lines.length) return "";
1297
- // A line that names an error outranks a later line that does not, because
1298
- // what survives the filter after one is usually its context rather than its
1299
- // successor: `Require stack:` is furniture, but the paths listed under it are
1300
- // not shaped like furniture and would otherwise be the last thing standing.
1301
- // Still the LAST such line, not the first — npm's own log builds up to its
1302
- // sentence, and picking the first would answer `code EACCES` where the next
1303
- // line says which directory and why.
1304
- const named = lines.filter(l => /(^|\s)[A-Za-z]*Error:/.test(l));
1305
- const pick = named.length ? named[named.length - 1] : lines[lines.length - 1];
1306
- return pick.slice(0, 300);
1307
- }
1308
-
1309
- /** Full answer for GET /api/version. Never throws, and answers the local half
1310
- * even when the registry is unreachable.
1311
- *
1312
- * Network worst case is 2 x FETCH_TIMEOUT_MS, not one: the dist-tag lookup,
1313
- * plus — only when the tag has moved to a version this deck has not confirmed
1314
- * yet — the installability probe (see runCheck), each carrying its own
1315
- * AbortSignal timeout. That second request costs its timeout at most once per
1316
- * release; a deck sitting on the current release stays inside one. */
1317
- export async function versionReport({ running, pkgRoot, name = "agents-deck", now = Date.now(), force = false }) {
1318
- const installed = installedVersion(pkgRoot);
1319
- // Asked about the package the command installs, not about the one this build
1320
- // happens to be named after — see upgradeName. Everything registry-shaped in
1321
- // this report is about `target`: the version, the marker it is cached in, and
1322
- // the name the report gives for it.
1323
- const target = upgradeName(pkgRoot, name);
1324
- // Only an explicit opt-out silences the registry.
1325
- //
1326
- // A checkout used to be excluded here too, on the reasoning that its version
1327
- // leads npm's. That reasoning holds for the COMMAND — telling someone to
1328
- // `npm i -g` over their working copy is wrong — but not for the question.
1329
- // Knowing a release shipped is useful however you would install it, and
1330
- // suppressing the lookup meant `latest` was always null, so the upgrade
1331
- // notice could never appear and the "this is a checkout" explanation had
1332
- // nowhere to render. A checkout that is ahead of npm still says nothing:
1333
- // isOlder decides that, not this.
1334
- const skipRegistry =
1335
- process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
1336
- process.env.AGENTS_DECK_NO_INSTALL === "1";
1337
- const latest = skipRegistry ? null : await latestOnNpm(target, now, force);
1338
- const marker = skipRegistry ? null : readMarker(target);
1339
- const blocked = upgradeBlock(pkgRoot);
1340
- // An install started in THIS process outranks the note on disk: it is newer
1341
- // by construction, and a running one must not be reported as a past failure.
1342
- // The note is read whatever the registry is doing — it is a local event, not
1343
- // a lookup — so an offline deck still explains why its update did nothing.
1344
- // Only the note addressed to this deck's own supervisor is read: the decks
1345
- // sharing this home directory are usually the same package at the same
1346
- // version, and none of them may answer for another's failed upgrade.
1347
- const live = upgradeStatus();
1348
- const upgrade = live.state === "idle"
1349
- ? (restartFailureNotice(readRestartFailure(target), installed) ?? live)
1350
- : live;
1351
- return {
1352
- name: target,
1353
- running: running ?? null,
1354
- installed,
1355
- latest,
1356
- // When npm last ANSWERED, so the UI can say it rather than leaving the
1357
- // user to wonder whether the check runs at all. A lookup that failed does
1358
- // not move this: "checked 2 minutes ago" over an hour-old answer is the
1359
- // one thing this field must never say.
1360
- checkedAt: marker?.at ?? null,
1361
- // …and when it last failed, null once one succeeds. Without it, the single
1362
- // most common reason for a missing update button — a proxy, a flaky line,
1363
- // an offline machine — is indistinguishable from being up to date.
1364
- checkFailedAt: marker?.failedAt ?? null,
1365
- // A version npm's dist-tag names that the registry cannot serve yet — the
1366
- // one thing `latest` deliberately will not say, since saying it is what
1367
- // sent a deck into a restart that ended in ETARGET. Reported so the state
1368
- // is visible rather than looking like nothing was published at all.
1369
- latestPending: marker?.pending ?? null,
1370
- checkDisabled: skipRegistry,
1371
- notice: pickNotice({ running, installed, latest }),
1372
- command: upgradeCommand(pkgRoot, name),
1373
- // Why an in-app `npm i -g` is refused, when it is — so the UI can say so
1374
- // instead of leaving a gap the user has to guess about. "npx" is a refusal
1375
- // of the install, not of the update: upgradeMode says so.
1376
- upgradeBlocked: blocked,
1377
- upgradeMode: upgradeMode(blocked),
1378
- upgrade,
1379
- };
1380
- }