ccdeck 3.29.11 → 3.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +22 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  175. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  176. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,506 @@
1
+ // Which install this running copy is, and what would update it.
2
+ //
3
+ // These lived in src/server/self-update.mjs, between the version comparison and
4
+ // the registry check: the three sections that ask a package directory who it is
5
+ // — its own manifest, the npx cache it may sit in, the package it may be nested
6
+ // inside — and the three answers built on them: the package an upgrade installs,
7
+ // the one npm is asked about, and the line the user can paste. None of it talks
8
+ // to the network or keeps any state, so it moved to a leaf of its own.
9
+ // self-update.mjs imports what it calls and re-exports every name, so
10
+ // bin/agent-dag.js, invoked-as.mjs and the login item still import them from
11
+ // there. The code is unchanged.
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ // ── what is on disk ──────────────────────────────────────────────────────────
16
+
17
+ /** A package directory's own manifest, or null when there is not one worth
18
+ * reading there.
19
+ *
20
+ * Shared by everything below that asks a directory who it is — the npx cache
21
+ * directory's record of the spec included — so its callers cannot drift on
22
+ * what a missing, truncated or non-object package.json means. A JSON document
23
+ * is not necessarily an object — `null`, `"ccdeck"` and `[]` all parse — and a
24
+ * manifest that is not an object has no fields to read, so it is refused here
25
+ * once rather than guarded against at every caller. */
26
+ function readManifest(dir) {
27
+ try {
28
+ const meta = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
29
+ // An array is an object to `typeof`, so it is refused by name.
30
+ return meta && typeof meta === "object" && !Array.isArray(meta) ? meta : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** Version currently written in the package's own package.json. Deliberately
37
+ * read fresh on every call: that is the whole point — it changes under a
38
+ * running process when npm replaces the install.
39
+ *
40
+ * And sometimes the directory changes with it. A `npm i -g ccdeck` performed
41
+ * before #340 left the deck nested inside a launcher package; upgrading such an
42
+ * install now writes the flat tarball over that launcher, and npm's reify takes
43
+ * the nested copy — the one this process is running out of — with it. Reading
44
+ * our own manifest then answers null, which pickNotice reads as "nothing is
45
+ * installed" and turns into the same upgrade offered forever, with the restart
46
+ * notice this module exists for never firing at all.
47
+ *
48
+ * The version is not lost, it moved one directory up. successorRoot is where
49
+ * to, and it answers null in every layout where nothing moved — so the normal
50
+ * case still reads exactly one manifest. */
51
+ export function installedVersion(pkgRoot) {
52
+ const own = readManifest(pkgRoot);
53
+ if (typeof own?.version === "string") return own.version;
54
+ const moved = successorRoot(pkgRoot);
55
+ const v = moved ? readManifest(moved)?.version : null;
56
+ return typeof v === "string" ? v : null;
57
+ }
58
+
59
+ /** npx keeps each package in its own content-addressed cache directory, so
60
+ * `npm i -g` is the wrong advice there — nothing global exists to upgrade. */
61
+ export function isNpxInstall(pkgRoot) {
62
+ // Split on both separators, not `path.sep`: Windows paths reach here with
63
+ // backslashes but a POSIX-style path is still a valid input there, and a
64
+ // separator-agnostic test is what keeps this identical on all three
65
+ // platforms. Segment-wise, so a directory merely named "my_npx-tools" is not
66
+ // mistaken for the npx cache.
67
+ return typeof pkgRoot === "string" && pkgRoot.split(/[\\/]/).includes("_npx");
68
+ }
69
+
70
+ /** A git checkout is the maintainer's own tree. Its version routinely sits
71
+ * ahead of npm, and telling someone to `npm i -g` over their working copy is
72
+ * actively wrong, so the registry side of the check is skipped there.
73
+ *
74
+ * Exported for one caller outside the self-update modules: the login item,
75
+ * which must not be installed from a checkout for the same reason it must not
76
+ * be installed from an npx run — the path is not one anybody promised to keep.
77
+ * Everything else it gates stays internal to them, and each of those is already
78
+ * asserted against a directory with a real `.git` in it — `upgradeCommand`
79
+ * returning "git pull && npm run build", `upgradeName` refusing to move a
80
+ * checkout onto a published alias, and `startUpgrade` refusing with
81
+ * `git_checkout`. A test importing the predicate would restate what those three
82
+ * already prove, one level further from the behaviour a user can see.
83
+ *
84
+ * `existsSync` and not a directory test, deliberately (#587). Git writes `.git`
85
+ * as a directory only for an ordinary clone; a linked worktree and a submodule
86
+ * each get a FILE whose whole content is one `gitdir:` line, and all three are
87
+ * checkouts nobody may install over. Nothing here reads that line, which is
88
+ * also what keeps the rule identical on Windows, where the path inside it
89
+ * carries a drive letter and backslashes. Both shapes are covered in
90
+ * worktree-git-file-587.test.ts and beside every checkout fixture in the
91
+ * suite — narrowing this to `.isDirectory()` fails them. */
92
+ export function isGitCheckout(pkgRoot) {
93
+ try { return existsSync(join(pkgRoot, ".git")); } catch { return false; }
94
+ }
95
+
96
+ // ── npx ──────────────────────────────────────────────────────────────────────
97
+ //
98
+ // An npx run lives in ~/.npm/_npx/<hash>/node_modules/<pkg>. The hash is over
99
+ // the SPEC the user typed, so upgrading means fetching a different directory —
100
+ // there is nothing to install over. What there IS, is the spec itself: npm
101
+ // writes it into <hash>/package.json as `_npx.packages`, which is the only
102
+ // record of whether the user typed `ccdeck`, `agent-dag` or `agents-deck`.
103
+ // Re-running the wrong one would work but would leave them on a package they
104
+ // never asked for, so it is worth reading rather than guessing.
105
+
106
+ /** The separator to join a path's segments back with: the one the input used,
107
+ * because a Windows path must come back as one. Backslashes only when the
108
+ * path has no forward slash at all — POSIX-style input is valid on Windows
109
+ * too. Shared by npxRoot and hostRoot, the two that cut a path apart and
110
+ * hand a piece of it back. */
111
+ function sepOf(path) {
112
+ return path.includes("\\") && !path.includes("/") ? "\\" : "/";
113
+ }
114
+
115
+ /** The `_npx/<hash>` directory this package was unpacked into, or null. Pure —
116
+ * path arithmetic only, so both platforms' separators can be tested. */
117
+ export function npxRoot(pkgRoot) {
118
+ if (typeof pkgRoot !== "string") return null;
119
+ const parts = pkgRoot.split(/[\\/]/);
120
+ const i = parts.lastIndexOf("_npx");
121
+ if (i === -1 || i + 1 >= parts.length) return null;
122
+ return parts.slice(0, i + 2).join(sepOf(pkgRoot));
123
+ }
124
+
125
+ /** Package name out of an npm spec, scope intact: `ccdeck@1.2.3` → `ccdeck`,
126
+ * `@scope/pkg` → `@scope/pkg`. Null for anything that is not a plain name —
127
+ * a tarball URL or a git spec is not something to re-run with `@latest`. */
128
+ export function bareSpecName(spec) {
129
+ if (typeof spec !== "string") return null;
130
+ const s = spec.trim();
131
+ if (!s) return null;
132
+ const at = s.lastIndexOf("@");
133
+ const name = at > 0 ? s.slice(0, at) : s;
134
+ return /^@?[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)?$/i.test(name) ? name : null;
135
+ }
136
+
137
+ /** What to hand `npx -y`, read from the cache directory's own metadata.
138
+ *
139
+ * `fallback` is what to answer when that metadata names nothing usable — the
140
+ * package name, for the caller whose job is to re-run SOMETHING. Pass null and
141
+ * the answer is null instead, which is what invoked-as.mjs needs: it asks which
142
+ * name the user typed, and there the package name is not a lesser answer, it is
143
+ * a wrong one. */
144
+ export function npxSpecFromMeta(meta, fallback = PUBLISHED_NAME) {
145
+ const list = meta && meta._npx && Array.isArray(meta._npx.packages) ? meta._npx.packages : [];
146
+ for (const entry of list) {
147
+ const name = bareSpecName(entry);
148
+ if (name) return `${name}@latest`;
149
+ }
150
+ return fallback ? `${fallback}@latest` : null;
151
+ }
152
+
153
+ /** The same, answered against the filesystem. Null when this is not an npx run,
154
+ * and — for a caller that passed no fallback — when the metadata cannot be
155
+ * read. */
156
+ export function npxRestartSpec(pkgRoot, name = PUBLISHED_NAME) {
157
+ const root = npxRoot(pkgRoot);
158
+ if (!root) return null;
159
+ return npxSpecFromMeta(readManifest(root), name);
160
+ }
161
+
162
+ // ── the package that installed us ────────────────────────────────────────────
163
+ //
164
+ // Two of the three names on npm are this tarball republished; the third is not
165
+ // this tarball at all. `ccdeck` is a stub that ships nothing but bin/ and
166
+ // depends on `agents-deck`, so `npm i -g ccdeck` produces a layout no other
167
+ // install does: npm stopped hoisting a global package's dependencies in v7, so
168
+ // the deck lands at `<prefix>/lib/node_modules/ccdeck/node_modules/agents-deck`
169
+ // and the process runs out of a directory owned by a package it is not named
170
+ // after.
171
+ //
172
+ // #351 taught the stub to FIND the deck there. Nothing downstream learned about
173
+ // it, so every self-update surface went on naming `agents-deck` — which in that
174
+ // layout resolves to `<prefix>/lib/node_modules/agents-deck`, a directory this
175
+ // process never reads and a `npm i -g ccdeck` user usually does not have at
176
+ // all. "Update now" therefore installed a second, unrelated tree: the running
177
+ // install never moved, so installedVersion stayed put, the restart notice never
178
+ // came, and the banner offered the identical update forever while the upgrade
179
+ // reported "done".
180
+ //
181
+ // So the name to install is not the name this build was PUBLISHED under, it is
182
+ // the name the install was REACHED under — and under the stub layout that is
183
+ // written on disk one directory up. Read from there rather than from argv[1]
184
+ // (invoked-as.mjs answers the neighbouring question of which command the user
185
+ // TYPED, and cannot answer this one): the stub spawns the deck by absolute
186
+ // path, so argv[1] reads `agent-dag.js` under all three names, and on Windows
187
+ // npm's .cmd/.ps1 shims never pass the typed name on at all. The directory npm
188
+ // built is the one carrier that survives every platform.
189
+ //
190
+ // The other two names need no layout at all, and that is why the first fix
191
+ // missed one of them. `agents-deck` and `agent-dag` are the same tarball
192
+ // published twice with the manifest renamed between the two, so the deck IS the
193
+ // whole package under both and sits directly under the global node_modules with
194
+ // nothing above it — hostPackage correctly finds no host, and the answer falls
195
+ // through to a default that is right for one of the two and wrong for the
196
+ // other. What both of them do carry is their own package.json, which npm's
197
+ // rename made authoritative: see installedName.
198
+
199
+ /** Every name an install of this deck can have been REACHED under.
200
+ *
201
+ * Only `ccdeck` is published now. `agents-deck` and `agent-dag` were this
202
+ * tarball published twice more, then (from 3.22.3) small packages that
203
+ * depended on ccdeck, and both stay on the registry at their last version — so
204
+ * a deck can still be running inside one of them, or out of an npx run of one
205
+ * of them, and this list is what lets it recognise that. It is not the list of
206
+ * names to ask npm about: see PUBLISHED_NAME. The same three strings as
207
+ * invoked-as.mjs's COMMANDS, and deliberately not that list: this is the set
208
+ * of npm PACKAGES an install may belong to, that is the set of commands a user
209
+ * may have typed. A test pins them against each other rather than either side
210
+ * assuming it. */
211
+ export const ALIAS_PACKAGES = ["agents-deck", "agent-dag", "ccdeck"];
212
+
213
+ /** The one name the deck is published under, and so the only package whose
214
+ * dist-tag still moves. */
215
+ export const PUBLISHED_NAME = "ccdeck";
216
+
217
+ /** The names it is no longer published under. */
218
+ export const RETIRED_NAMES = ["agents-deck", "agent-dag"];
219
+
220
+ /** The published name in place of a retired one; any other name unchanged. */
221
+ export function currentName(name) {
222
+ return RETIRED_NAMES.includes(name) ? PUBLISHED_NAME : name;
223
+ }
224
+
225
+ /** Which of those three THIS build was published as, out of its own manifest.
226
+ *
227
+ * The other half of the same bug, and the easier half. `agent-dag` is not a
228
+ * stub and is not nested inside anything — `npm i -g agent-dag` puts the deck
229
+ * straight into `<prefix>/lib/node_modules/agent-dag` — so hostPackage finds no
230
+ * host above it and there is no layout to read the answer out of. There does
231
+ * not need to be: CI publishes that name by renaming the manifest in the
232
+ * tarball (`npm pkg set name=agent-dag` in .github/workflows/publish.yml), so
233
+ * the running copy's own package.json says `agent-dag` and has all along.
234
+ *
235
+ * It was never read. `name` is a parameter with a default in every exported
236
+ * function here, and not one caller in the deck passes it — index.mjs and
237
+ * bin/deck.js both call versionReport and startUpgrade with a pkgRoot and
238
+ * nothing else — so the default WAS the answer, and the default is a guess
239
+ * about which of three names this build carries. For `npm i -g agent-dag` it
240
+ * guessed wrong: the user was told to run `npm i -g agents-deck@latest`, which
241
+ * installs a second, unrelated global package while their `agent-dag` binary
242
+ * stays exactly where it was, and the version check went on caching under a
243
+ * package this install is not.
244
+ *
245
+ * Confined to the three names, for the reason hostNameFromMeta is: the answer
246
+ * becomes an argument in the `npm i -g` this process spawns, and startUpgrade
247
+ * promises that vector can only ever name this deck. A fork that republishes
248
+ * under a fourth name adds it to the list above, which is one line and a
249
+ * deliberate one — rather than having the deck hand npm a package name it has
250
+ * never heard of because a directory on disk said so.
251
+ *
252
+ * `fallback` is what to answer when the manifest is missing, unreadable, or
253
+ * names something that is not one of ours — the caller's own `name`, so every
254
+ * shape that cannot prove which alias it is keeps the behaviour it had. */
255
+ export function installedName(pkgRoot, fallback = PUBLISHED_NAME) {
256
+ const self = readManifest(pkgRoot)?.name;
257
+ return typeof self === "string" && ALIAS_PACKAGES.includes(self) ? self : fallback;
258
+ }
259
+
260
+ /** The directory of the package this one is installed INSIDE, or null when it
261
+ * is not inside one. Pure — path arithmetic only, so a Windows layout can be
262
+ * tested on a POSIX box and both separators are split on, exactly as npxRoot
263
+ * does it and for the same reason. */
264
+ export function hostRoot(pkgRoot) {
265
+ if (typeof pkgRoot !== "string") return null;
266
+ const parts = pkgRoot.split(/[\\/]/);
267
+ // `<host>/node_modules/<us>` and nothing else: the segment directly above us
268
+ // has to be the node_modules npm nested us into, and the one above that is
269
+ // the host. A scoped package would sit one level deeper, under `@scope`, and
270
+ // is refused here rather than guessed at — none of the three names is scoped.
271
+ if (parts.length < 3 || parts[parts.length - 2] !== "node_modules") return null;
272
+ return parts.slice(0, -2).join(sepOf(pkgRoot)) || null;
273
+ }
274
+
275
+ /** The host package's name, out of its own manifest — but only when it is one
276
+ * of this deck's aliases AND it declares a dependency on us.
277
+ *
278
+ * The alias half is the load-bearing one. Any project that lists `agents-deck`
279
+ * in its dependencies and runs it out of its own node_modules — a workspace, a
280
+ * CI job, a tool that embeds the deck — is in exactly the same shape on disk
281
+ * as the stub, and answering with THAT name would have the deck offering to
282
+ * `npm i -g their-app@latest`: a package it has no business installing, and on
283
+ * a private name one that does not exist. Confining the answer to the three
284
+ * published names leaves every such install with the fallback it has today. */
285
+ export function hostNameFromMeta(meta, name = PUBLISHED_NAME) {
286
+ const host = typeof meta?.name === "string" ? meta.name : null;
287
+ if (!host || !ALIAS_PACKAGES.includes(host)) return null;
288
+ return typeof meta?.dependencies?.[name] === "string" ? host : null;
289
+ }
290
+
291
+ /** The same, answered against the filesystem: `{ root, name }` for the alias
292
+ * package this copy was installed as a dependency of, or null. */
293
+ export function hostPackage(pkgRoot, name = PUBLISHED_NAME) {
294
+ const root = hostRoot(pkgRoot);
295
+ if (!root) return null;
296
+ const host = hostNameFromMeta(readManifest(root), name);
297
+ return host ? { root, name: host } : null;
298
+ }
299
+
300
+ /**
301
+ * The directory that holds this install's code AFTER an upgrade replaced it,
302
+ * or null when nothing has been replaced.
303
+ *
304
+ * One layout produces this and it is a transitional one. Before #340, `npm i -g
305
+ * ccdeck` installed a launcher package with the deck nested inside it:
306
+ *
307
+ * <prefix>/lib/node_modules/ccdeck/ the launcher
308
+ * <prefix>/lib/node_modules/ccdeck/node_modules/agents-deck/ pkgRoot
309
+ *
310
+ * upgradeName reads the host's declared dependency and correctly answers
311
+ * `ccdeck`, so the upgrade runs `npm i -g ccdeck@latest` — which since #340
312
+ * installs the deck itself over the host directory and removes everything that
313
+ * was under it, including pkgRoot. The process keeps running (POSIX keeps an
314
+ * open inode alive, and the modules are already loaded) out of a directory that
315
+ * no longer exists.
316
+ *
317
+ * Deliberately NOT hostPackage. That function recognises a host by the
318
+ * dependency it declares on us, and the whole point here is that the host has
319
+ * just stopped declaring one — it is no longer a launcher, it is the deck. What
320
+ * identifies it instead is its name, confined to the three we publish, for the
321
+ * same reason installedName confines its answer: this decides what a version
322
+ * report says about the user's machine, and a directory that merely happens to
323
+ * sit above us is not evidence.
324
+ *
325
+ * Guarded on our own manifest being unreadable, so nothing changes for an
326
+ * install that is intact — except for the one replacement that leaves a
327
+ * readable manifest behind, which is nestedDeckRoot below.
328
+ */
329
+ export function successorRoot(pkgRoot) {
330
+ if (readManifest(pkgRoot)) return nestedDeckRoot(pkgRoot);
331
+ const root = hostRoot(pkgRoot);
332
+ if (!root) return null;
333
+ const name = readManifest(root)?.name;
334
+ return typeof name === "string" && ALIAS_PACKAGES.includes(name) ? root : null;
335
+ }
336
+
337
+ /**
338
+ * The other direction the same replacement can go: the deck moved DOWN, into a
339
+ * node_modules underneath the directory it used to be.
340
+ *
341
+ * `npm i -g agents-deck` before 3.22.3 installed the deck flat — that directory
342
+ * WAS the deck, bin/ and src/ and hook/ and all. What the registry serves for
343
+ * that name now is a 5 KB pointer package: a shim.js, a manifest, and a
344
+ * dependency on `ccdeck`. So reinstalling it replaces the deck with the pointer
345
+ * and puts the real deck one level down, in `<pkgRoot>/node_modules/ccdeck`.
346
+ *
347
+ * The guard above cannot see that, and that is the whole of #975. npm's reify
348
+ * deleted bin/ and src/ but WROTE a package.json in their place, so
349
+ * `readManifest(pkgRoot)` answers — with the shim's manifest — and every reader
350
+ * downstream reads "intact". successorRoot returned null, replacedNote was
351
+ * handed `moved: null` and stayed silent, and the supervisor went on spawning a
352
+ * bin/deck.js npm had just removed: five crash restarts in ten minutes and then
353
+ * a deck that stops for good, with nothing on screen about an upgrade.
354
+ *
355
+ * Three facts together, because none of them alone is evidence. Our name is one
356
+ * of the retired ones; our manifest declares a dependency on the published name,
357
+ * which a deck's own manifest never does; and the package that dependency names
358
+ * is really sitting under us. A deck that merely vendors something, or a
359
+ * retired-name package from before the pointer, matches none of them.
360
+ */
361
+ function nestedDeckRoot(pkgRoot) {
362
+ const self = readManifest(pkgRoot);
363
+ if (!RETIRED_NAMES.includes(self?.name)) return null;
364
+ if (typeof self?.dependencies?.[PUBLISHED_NAME] !== "string") return null;
365
+ const root = join(pkgRoot, "node_modules", PUBLISHED_NAME);
366
+ return readManifest(root)?.name === PUBLISHED_NAME ? root : null;
367
+ }
368
+
369
+ /**
370
+ * The retired package this install IS, when reinstalling that package would
371
+ * replace a working deck with the pointer npm now serves for it — or null,
372
+ * which is every other install shape on the planet.
373
+ *
374
+ * `npm i -g agents-deck` and `npm i -g agent-dag` at 3.22.1 or earlier put the
375
+ * deck straight into `<prefix>/lib/node_modules/<name>`: no host above it, no
376
+ * nested copy under it, that directory is the deck. registryName's exception
377
+ * asks npm about `ccdeck` for such an install and is right to — a retired name's
378
+ * dist-tag stopped moving — so the deck correctly learns that a newer version
379
+ * exists. What it then offers is `npm i -g agents-deck@latest`, and since 3.22.8
380
+ * that command fetches 5 KB of shim and deletes the deck to make room for it.
381
+ *
382
+ * The nested layout is the case registryName's comment describes and it is
383
+ * unaffected: there the retired package is the host, the deck is its dependency,
384
+ * and reinstalling the host re-resolves `ccdeck@^3` to the newest deck. The
385
+ * difference is entirely whether the retired name is the wrapper or the thing
386
+ * inside it, and a wrapper is a package — so the directory test below answers
387
+ * that too, before it is asked whose name is on it.
388
+ *
389
+ * Confined to the global layout — `<prefix>/lib/node_modules/<us>` on POSIX,
390
+ * `<prefix>/node_modules/<us>` on Windows — because that is the only tree
391
+ * `npm i -g` rewrites. The test for it is that the directory holding our
392
+ * node_modules is not a package: a global prefix's `lib` has no manifest, while
393
+ * a workspace, a CI job or any tool that vendors the deck as a dependency does.
394
+ * Reinstalling a retired name from inside one of THOSE writes a global tree the
395
+ * project never reads and leaves its copy exactly where it is — a different
396
+ * complaint, and not one to answer by telling somebody to uninstall.
397
+ *
398
+ * A checkout and an npx run are refused before this in every caller, and are
399
+ * refused here too, so the predicate is true on its own terms rather than only
400
+ * in the order it happens to be asked.
401
+ */
402
+ export function frozenNameInstall(pkgRoot, name = PUBLISHED_NAME) {
403
+ if (isGitCheckout(pkgRoot) || isNpxInstall(pkgRoot)) return null;
404
+ const above = hostRoot(pkgRoot);
405
+ if (!above || readManifest(above)) return null;
406
+ const self = installedName(pkgRoot, name);
407
+ return RETIRED_NAMES.includes(self) ? self : null;
408
+ }
409
+
410
+ /** The package an upgrade would actually install here — and, but for a retired
411
+ * name, the one worth asking npm about (see registryName).
412
+ *
413
+ * The check used to ask about `agents-deck` no matter what the upgrade
414
+ * command installed, so a deck started with `npx ccdeck` compared its version
415
+ * against `agents-deck`'s dist-tag and then handed back `npx -y ccdeck@latest`.
416
+ * Nothing tied the two together. CI publishes the three names one after
417
+ * another, so between the first and the last publish they genuinely disagree,
418
+ * and inside that window the deck offered a version the command could not
419
+ * install — the ETARGET isPublished exists for, with a window measured in
420
+ * publishes rather than in seconds of propagation.
421
+ *
422
+ * Deriving the name from what would actually be installed keeps the two halves
423
+ * consistent by construction: whatever the command will install is what gets
424
+ * asked about, and the per-name marker follows the same name. Each install
425
+ * shape carries that answer somewhere different — npx in the spec it recorded,
426
+ * a stub install in the layout npm built, a plain global install in the
427
+ * manifest of the package it is. All three are read; none is assumed. */
428
+ export function upgradeName(pkgRoot, name = PUBLISHED_NAME) {
429
+ // The published name this build actually carries, which outranks `name` in
430
+ // every branch below because `name` is a default at every call site in the
431
+ // deck and the manifest on disk is not a guess. It replaces the parameter
432
+ // rather than sitting beside it: this one function is where all four of the
433
+ // registry-shaped answers meet — the dist-tag that is fetched, the marker it
434
+ // is cached in, the command the user is shown, and the argv npm is spawned
435
+ // with — so resolving the name once here is what keeps those four naming one
436
+ // package. registryName is the one exception, for a retired name, and it
437
+ // starts from this answer.
438
+ const self = installedName(pkgRoot, name);
439
+ // A checkout installs nothing at all, so the published name is the only
440
+ // sensible subject for the version question — and the only one whose
441
+ // dist-tag says anything about the branch the maintainer is sitting on.
442
+ if (isGitCheckout(pkgRoot)) return self;
443
+ // npx recorded the spec the user typed, which is a better answer than the
444
+ // manifest — except that a retired name answers `ccdeck`. npx reinstalls only
445
+ // when a spec resolves to a different version, and `agents-deck@latest`
446
+ // resolves to the same last-published package forever, so re-running it
447
+ // would reuse the cached copy, and the ccdeck inside it, and never move. The
448
+ // manifest is what is left when the record cannot be read.
449
+ if (isNpxInstall(pkgRoot)) return currentName(bareSpecName(npxRestartSpec(pkgRoot, self)) ?? self);
450
+ // Left: a global install, where the answer is whichever package owns the
451
+ // directory npm would rewrite. That is the stub for `npm i -g ccdeck`, where
452
+ // the deck is nested one level down inside a package it is not named after,
453
+ // and this build's own name for `npm i -g agents-deck` and `npm i -g
454
+ // agent-dag`, where the deck IS the whole package and nothing is above it.
455
+ //
456
+ // successorRoot is the third case, and it is the second half of the same
457
+ // question. Once that stub install HAS been upgraded, the host has stopped
458
+ // declaring a dependency on us — which is the only thing hostPackage
459
+ // recognises a host by — so this fell through to `self`, and `self` is the
460
+ // fallback `agents-deck` because our own manifest went with the directory.
461
+ // The user was then shown `npm i -g agents-deck@latest`: a different package,
462
+ // a second global tree, and their `ccdeck` binary left exactly where it was.
463
+ // That is #358 verbatim, arriving through the upgrade that was supposed to be
464
+ // the end of it. The successor's own manifest names it, and that name is what
465
+ // the next upgrade has to install.
466
+ const host = hostPackage(pkgRoot, self)?.name;
467
+ if (host) return host;
468
+ const moved = successorRoot(pkgRoot);
469
+ return moved ? installedName(moved, self) : self;
470
+ }
471
+
472
+ /** The exact line the user can paste, for the way THIS copy was installed. */
473
+ export function upgradeCommand(pkgRoot, name = PUBLISHED_NAME) {
474
+ // A checkout is updated by pulling, and the bundle is built, not shipped —
475
+ // so `npm run build` is part of the answer rather than an afterthought.
476
+ if (isGitCheckout(pkgRoot)) return "git pull && npm run build";
477
+ if (isNpxInstall(pkgRoot)) return `npx -y ${upgradeName(pkgRoot, name)}@latest`;
478
+ // A flat install of a retired name cannot be updated by reinstalling itself —
479
+ // that fetches the pointer package and deletes the deck (frozenNameInstall).
480
+ // The command has to move them onto the published name, and the removal is
481
+ // not optional: the old package owns `<prefix>/bin/ccdeck` as well as its own
482
+ // command, so installing ccdeck alongside it is npm linking a bin over a file
483
+ // another package still claims.
484
+ const frozen = frozenNameInstall(pkgRoot, name);
485
+ if (frozen) return `npm rm -g ${frozen} && npm i -g ${PUBLISHED_NAME}`;
486
+ // Through upgradeName for the same reason the npx line above it is: the
487
+ // printed command is the user's escape hatch when the button fails or is not
488
+ // offered, and one that names a package this install cannot be replaced by is
489
+ // worse than none — it looks like it worked.
490
+ return `npm i -g ${upgradeName(pkgRoot, name)}@latest`;
491
+ }
492
+
493
+ /** The package to ask npm about: the one an upgrade installs, except that a
494
+ * retired name is asked about as `ccdeck`.
495
+ *
496
+ * One install reaches that exception. A deck sitting inside the old
497
+ * `agents-deck` or `agent-dag` package upgrades by reinstalling THAT package —
498
+ * installing `ccdeck` would write a tree this process never reads, #358 again —
499
+ * but that package's dist-tag stopped moving when it stopped being published.
500
+ * The reinstall is still the right act: the old package depends on
501
+ * `ccdeck@^3`, and npm resolves that to the newest ccdeck on every reinstall,
502
+ * even of the same version. So the version asked about is ccdeck's and the
503
+ * command names the old package — the one place the two differ, on purpose. */
504
+ export function registryName(pkgRoot, name = PUBLISHED_NAME) {
505
+ return currentName(upgradeName(pkgRoot, name));
506
+ }