ccdeck 3.29.11 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,385 @@
1
+ // What npm says the newest version is, asked at most once an hour and never
2
+ // more often than the floor allows, with the answer kept on disk between runs.
3
+ //
4
+ // This lived in src/server/self-update.mjs, in two pieces: the check's windows
5
+ // and its floor at the top of the file, and the lookup, its marker file and the
6
+ // rules for when to ask again in the middle. They share the marker and the
7
+ // state that dedupes and rations the requests, and nothing else in the
8
+ // self-update code keeps any, so they moved here together. self-update.mjs
9
+ // reads the answer for its version report and re-exports the names it
10
+ // exported before; the restart-failure note shares the directory and the rule
11
+ // for turning a package name into a file name. The bodies are unchanged.
12
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { PUBLISHED_NAME } from "./install-layout.mjs";
16
+
17
+ // Once an hour, not once a day.
18
+ //
19
+ // The daily cadence was copied from the ccusage and cswap checks, where it is
20
+ // right: those answer "is a different tool out of date", and nobody is waiting
21
+ // on it. This one answers "is the thing you are looking at out of date", and a
22
+ // day is long enough that a deck started shortly before a release shows nothing
23
+ // at all until tomorrow — reported from a machine running `npx ccdeck`, which
24
+ // is the case where it bites hardest, since npx runs are short-lived and each
25
+ // one inherits the same stale marker. The request is ~20 bytes.
26
+ const CHECK_MS = 3600_000;
27
+ // A lookup that failed is not an answer, so it must not spend the hour an
28
+ // answer buys. It still has to spend something: the reason to rate-limit is
29
+ // gone, but a registry that is down would otherwise be asked again on every
30
+ // single poll. Five minutes is the compromise — short enough that a network
31
+ // coming back is noticed while the user is still looking at the deck, long
32
+ // enough that a full npm outage costs a dozen ~20-byte requests an hour.
33
+ const RETRY_MS = 300_000;
34
+ const FETCH_TIMEOUT_MS = 6_000;
35
+ // A third marker family: ccusage owns ~/.agents-deck/ccusage/.last-update-check
36
+ // and cswap owns ~/.agents-deck/.cswap-update-check. Sharing one would make the
37
+ // three features fight over a single daily slot.
38
+ export const MARKER_DIR = join(homedir(), ".agents-deck");
39
+ // The name this deck was published under is part of the marker's identity.
40
+ //
41
+ // A single ~/.agents-deck/.self-update-check was shared by every deck on the
42
+ // machine, so whichever one asked npm first pinned the answer for all of them
43
+ // until the window expired — including a freshly started `npx ccdeck` that had
44
+ // never written it, and including decks running a DIFFERENT package where the
45
+ // cached version means nothing. Reported with four decks alive at once: three
46
+ // served the same `checkedAt` to the millisecond and none of them showed the
47
+ // seven releases that had shipped meanwhile. One file per package name means
48
+ // `agents-deck`, `ccdeck` and `agent-dag` decks stop silencing each other; the
49
+ // old shared path stays here only to be inherited from once.
50
+ const LEGACY_MARKER = join(MARKER_DIR, ".self-update-check");
51
+
52
+ // Dedupe concurrent /api/version calls into one fetch, per package name — two
53
+ // names are two different questions and must not be answered with one answer.
54
+ const _inflight = new Map();
55
+
56
+ // ── what a forced check may cost ─────────────────────────────────────────────
57
+ //
58
+ // `_inflight` deduplicates callers that OVERLAP and nothing else. `checkDue`
59
+ // answers `true` on `force` before it asks anything else, so a caller that
60
+ // waited for one check to settle and then asked again got a fresh
61
+ // `https://registry.npmjs.org/…/dist-tags` every time. Reads on this server are
62
+ // deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
63
+ // that `isTrustedMutation` does, because a cross-site read of
64
+ // `http://127.0.0.1:4317` is an ordinary top-level navigation — so
65
+ //
66
+ // (async function spin() {
67
+ // for (;;) await fetch("http://127.0.0.1:4317/api/version?refresh=1",
68
+ // { mode: "no-cors" });
69
+ // })();
70
+ //
71
+ // from any page the user had open was one registry request per turn, as fast as
72
+ // the round trip allows, and every so often two of them: `runCheck` confirms a
73
+ // tag it has not seen before against the version document. The requests are
74
+ // small — the whole reason the dist-tags endpoint is used here rather than the
75
+ // packument — but they leave the user's address, carrying the user-agent this
76
+ // deck sets, and they are aimed at a third party rather than at the machine the
77
+ // loop is running on. That makes it #580's shape with the cost pointed
78
+ // outwards, which if anything is the worse direction.
79
+ //
80
+ // Nothing above this line was going to stop it. The hour is written to a marker
81
+ // FILE, and `force` walks past it; `first` walks past it too; and on a machine
82
+ // where `~/.agents-deck` cannot be written `writeMarker` swallows the failure,
83
+ // so `checkDue` sees "never checked" on every single call and the hour is not
84
+ // really there at all.
85
+ //
86
+ // So the floor sits under all of it, in this process's own memory, between the
87
+ // last rule that admitted a check and the request it admitted. It is quota.mjs's
88
+ // number and codex-quota.mjs's and codex-usage.mjs's — the five routes in this
89
+ // deck's router that accept `?refresh=1` and can pay for it have no business
90
+ // disagreeing about what it costs.
91
+ const FORCE_POLL_MS = 60_000;
92
+
93
+ // Stamped when a check STARTS rather than when npm answers, because what the
94
+ // floor rations is the request. Per package name, like `_inflight` and like the
95
+ // markers: two names are two different questions, and one of them being asked
96
+ // is not a reason to refuse the other. Deliberately in memory rather than on
97
+ // disk — the marker is shared by every deck running that package and this is
98
+ // about what THIS process is sending.
99
+ const _lastAskAt = new Map();
100
+
101
+ /**
102
+ * Whether we may spend a registry request right now.
103
+ *
104
+ * Exported for tests, for the same reason quota.mjs exports `maySelfPoll`,
105
+ * codex-quota.mjs `mayFetchQuota` and codex-usage.mjs `mayScanUsage`: this is
106
+ * the rule, it is pure, and it is worth pinning down away from the request it
107
+ * guards.
108
+ */
109
+ export function mayAskNpm({ now, lastAskAt }) {
110
+ // A stamp from the future is a clock that moved, not a check that just ran —
111
+ // the same case `checkDue` answers twice below with `> now`, and the same
112
+ // answer. Without it, a machine whose clock corrects backwards by an hour
113
+ // would go an hour without a version check.
114
+ if (lastAskAt > now) return true;
115
+ return now - lastAskAt >= FORCE_POLL_MS;
116
+ }
117
+
118
+ // ── what npm has ─────────────────────────────────────────────────────────────
119
+
120
+ /** One GET to the registry, asked the way both lookups below ask it: for JSON,
121
+ * under this deck's user-agent, and given up on after FETCH_TIMEOUT_MS. */
122
+ function askRegistry(path) {
123
+ return fetch(`https://registry.npmjs.org/${path}`, {
124
+ headers: { accept: "application/json", "user-agent": "agents-deck" },
125
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
126
+ });
127
+ }
128
+
129
+ // The dist-tags endpoint answers with ~20 bytes ({"latest":"1.30.7"}); the full
130
+ // packument is >2 KB and needs parsing we have no use for.
131
+ //
132
+ // Answers `{ ok, version }` rather than a bare string, because "npm says the
133
+ // latest is X" and "npm did not answer" used to arrive here as the same null.
134
+ // `ok` is true only when the registry handed back a usable version — a
135
+ // timeout, a non-200 and a 200 with no `latest` in it are all failures, and
136
+ // the caller has to be able to tell them from an up-to-date deck.
137
+ async function fetchLatest(name) {
138
+ try {
139
+ const res = await askRegistry(`-/package/${name}/dist-tags`);
140
+ if (!res.ok) return { ok: false, version: null };
141
+ const v = (await res.json())?.latest;
142
+ return typeof v === "string" ? { ok: true, version: v } : { ok: false, version: null };
143
+ } catch {
144
+ return { ok: false, version: null };
145
+ }
146
+ }
147
+
148
+ // A dist-tag is a pointer, and being pointed at is not the same as being
149
+ // installable.
150
+ //
151
+ // npm makes the moved tag visible before the version document has propagated to
152
+ // the replica the installer reads, so for a window `{"latest":"1.33.28"}` and
153
+ // `No matching version found for ccdeck@1.33.28` are both true at the same
154
+ // moment. Reported live: the banner offered v1.33.28, the restart ran
155
+ // `npx -y ccdeck@latest`, npm answered ETARGET, and the deck came back on the
156
+ // version it started with after tearing itself down — under npx a restart is
157
+ // not free, since the worker exits and hands the port over before anything is
158
+ // fetched.
159
+ //
160
+ // So the tag is checked against the version document, which is the same
161
+ // question `npm view <name>@<version> version` asks and the same document the
162
+ // installer resolves against. 404 is the answer this exists for: published
163
+ // tag, unpublished version, try again in five minutes. `ok` is false only when
164
+ // the registry gave no usable answer at all — that is not a licence to
165
+ // announce either, but it is not evidence of an unpublished version.
166
+ async function isPublished(name, version) {
167
+ try {
168
+ const res = await askRegistry(`${name}/${version}`);
169
+ if (res.status === 404) return { ok: true, published: false };
170
+ if (!res.ok) return { ok: false, published: false };
171
+ // The document has to be the one asked for: a registry that answers 200
172
+ // with something else has not shown that this version is resolvable.
173
+ const body = await res.json().catch(() => null);
174
+ return { ok: true, published: body?.version === version };
175
+ } catch {
176
+ return { ok: false, published: false };
177
+ }
178
+ }
179
+
180
+ /** Marker file name for a package: `ccdeck` → `.self-update-check-ccdeck`.
181
+ *
182
+ * Pure, and deliberately strict about what reaches the filesystem — a package
183
+ * name may be scoped (`@scope/pkg`), and `/`, `\` and `:` are either a path
184
+ * separator or outright illegal in a Windows file name. Everything outside
185
+ * `[a-z0-9._-]` collapses to `-`, the scope's leading `@` is dropped so the
186
+ * common case reads plainly, and the tail is trimmed so a pathological name
187
+ * cannot produce a path the OS refuses. An unusable name falls back to the
188
+ * default rather than to the shared file this fix exists to get rid of. */
189
+ export function markerFileName(name = PUBLISHED_NAME) {
190
+ return `.self-update-check-${safeNamePart(name)}`;
191
+ }
192
+
193
+ /** The sanitised half, shared with the restart-failure note so the two files
194
+ * agree on what a package name becomes on disk. */
195
+ export function safeNamePart(name) {
196
+ const raw = typeof name === "string" ? name.trim().toLowerCase() : "";
197
+ const safe = raw
198
+ .replace(/^@/, "")
199
+ .replace(/[^a-z0-9._-]+/g, "-")
200
+ .slice(0, 64)
201
+ // Trimmed after the slice, and at both ends: Windows silently drops a
202
+ // trailing dot from a file name, so a name that ends in one would write to
203
+ // a path that is not the path we would later read.
204
+ .replace(/^[-.]+|[-.]+$/g, "");
205
+ return safe || PUBLISHED_NAME;
206
+ }
207
+
208
+ function markerPath(name) {
209
+ return join(MARKER_DIR, markerFileName(name));
210
+ }
211
+
212
+ // The marker carries the answer, not just the timestamp. The existing markers
213
+ // store only an mtime, which means a restart inside the window forgets what npm
214
+ // said and shows nothing until the window expires.
215
+ //
216
+ // It carries the outcome too. `at` is when npm last ANSWERED and `version` is
217
+ // what it said; `failedAt` is when the last attempt failed, and is null the
218
+ // moment one succeeds. Keeping them apart is what lets the deck say "checked
219
+ // 3m ago" and "could not reach npm" as the different things they are, instead
220
+ // of reporting a timeout as a fresh, up-to-date check.
221
+ function readMarkerFile(path) {
222
+ try {
223
+ const m = JSON.parse(readFileSync(path, "utf8"));
224
+ // Either half is enough to be worth keeping: a marker written by a first
225
+ // attempt that failed has no `at` yet, and markers written before
226
+ // `failedAt` existed have no `failedAt` at all.
227
+ return (typeof m?.at === "number" || typeof m?.failedAt === "number") ? m : null;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ // The unsuffixed marker every earlier deck wrote, read at most once per name
234
+ // per process: upgrading to this version should not throw away an answer npm
235
+ // already gave. It is consulted only while the per-name file is still missing,
236
+ // so the shared file never goes back to being in charge.
237
+ const _legacy = new Map();
238
+ function legacyMarker(name) {
239
+ const key = markerFileName(name);
240
+ if (!_legacy.has(key)) _legacy.set(key, readMarkerFile(LEGACY_MARKER));
241
+ return _legacy.get(key);
242
+ }
243
+
244
+ export function readMarker(name) {
245
+ return readMarkerFile(markerPath(name)) ?? legacyMarker(name);
246
+ }
247
+
248
+ /** The version npm last named as `latest` for this package, straight off the
249
+ * marker and with no lookup of its own.
250
+ *
251
+ * This is what the supervisor calls a failed upgrade's TARGET. It has to be
252
+ * answerable without the network — the supervisor asks it in the moment
253
+ * between a click and a fetch, and the reason the fetch is about to fail may
254
+ * well be that there is no network — and it has to be the same number the
255
+ * banner offered, which is precisely what the marker holds. */
256
+ export function lastKnownLatest(name = PUBLISHED_NAME) {
257
+ const v = readMarker(name)?.version;
258
+ return typeof v === "string" && v ? v : null;
259
+ }
260
+
261
+ function writeMarker(name, marker) {
262
+ const path = markerPath(name);
263
+ try {
264
+ mkdirSync(dirname(path), { recursive: true });
265
+ writeFileSync(path, JSON.stringify(marker));
266
+ } catch { /* a read-only home must not break the deck */ }
267
+ }
268
+
269
+ /** What the marker should hold after an attempt. Pure, because "a failed
270
+ * lookup must not be recorded as a successful one" is precisely the rule this
271
+ * file used to get wrong, and a rule worth a bug is worth a test.
272
+ *
273
+ * A success stamps the hour and clears the failure. A failure records only
274
+ * itself, leaving the last real answer and the time it arrived untouched —
275
+ * the deck keeps showing what it knew, and stops claiming it just confirmed
276
+ * it.
277
+ *
278
+ * `installable` is the third outcome: npm answered, and what it named is not
279
+ * yet a version anything can install. That is a real answer — `at` moves, the
280
+ * registry was reached — but the version is held in `pending` instead of
281
+ * `version`, so `latest` stays a number the upgrade command can resolve, and
282
+ * `pendingAt` puts the next look on the short window rather than the hour. */
283
+ export function nextMarker({ prev, now, ok, version, installable = true }) {
284
+ if (!ok) {
285
+ return {
286
+ at: prev?.at ?? null,
287
+ version: prev?.version ?? null,
288
+ failedAt: now,
289
+ pending: prev?.pending ?? null,
290
+ pendingAt: prev?.pendingAt ?? null,
291
+ };
292
+ }
293
+ if (!installable) {
294
+ return { at: now, version: prev?.version ?? null, failedAt: null, pending: version ?? null, pendingAt: now };
295
+ }
296
+ return { at: now, version: version ?? null, failedAt: null, pending: null, pendingAt: null };
297
+ }
298
+
299
+ // Even a per-package marker is shared by every deck running that package, so
300
+ // one deck's check still answers for the others inside the window. Asking once
301
+ // per PROCESS lands the check exactly where the user expects the truth: a
302
+ // freshly started deck — which for `npx ccdeck` is every single run. Keyed by
303
+ // name, so a process that asked about one package has not asked about another.
304
+ const _askedThisProcess = new Set();
305
+
306
+ /**
307
+ * Whether to ask npm, or reuse the answer on disk. Pure, because "why did no
308
+ * banner appear" is the question this feature gets asked, and the rule behind
309
+ * it should be readable in one place.
310
+ */
311
+ export function checkDue({ at, failedAt, pendingAt, now, first = false, force = false, ttlMs = CHECK_MS, retryMs = RETRY_MS }) {
312
+ if (force || first) return true; // explicit ask, or this process's first
313
+ // Two ways of not having an answer yet, and neither may spend the hour a real
314
+ // answer buys: the last attempt failed, or npm named a version that cannot be
315
+ // installed yet. Both take the short window instead — an unreachable registry
316
+ // must not turn every poll into another request, and a release mid-publish is
317
+ // resolvable minutes later, not an hour later. Answered before `at`, which
318
+ // here is the older, settled check and would otherwise ask again immediately
319
+ // (or, for a pending version, not for another hour).
320
+ const unsettled = [failedAt, pendingAt].filter(t => typeof t === "number");
321
+ if (unsettled.length) {
322
+ const last = Math.max(...unsettled);
323
+ if (last > now) return true; // clock moved; do not wait it out
324
+ return now - last >= retryMs;
325
+ }
326
+ if (typeof at !== "number") return true; // never checked
327
+ if (at > now) return true; // marker from the future: a moved clock
328
+ return now - at >= ttlMs;
329
+ }
330
+
331
+ /** Last known npm `latest`, refreshed at most once per CHECK_MS. Returns the
332
+ * cached answer immediately when the window has not elapsed.
333
+ *
334
+ * `force` skips the window: the first call in this process, and an explicit
335
+ * "check now" from the UI. What it does not skip is FORCE_POLL_MS — see
336
+ * mayAskNpm, and the note above it for what a forced call used to cost. */
337
+ export async function latestOnNpm(name, now, force = false) {
338
+ const m = readMarker(name);
339
+ const key = markerFileName(name);
340
+ const first = !_askedThisProcess.has(key);
341
+ _askedThisProcess.add(key);
342
+ if (!checkDue({ at: m?.at, failedAt: m?.failedAt, pendingAt: m?.pendingAt, now, first, force })) {
343
+ return m?.version ?? null;
344
+ }
345
+ // Offered before the floor: a check that has not answered yet is a lookup
346
+ // newer than the marker, which is what refresh asked for, and joining it costs
347
+ // nothing.
348
+ const inflight = _inflight.get(key);
349
+ if (inflight) return inflight;
350
+ // The floor, under every rule above it. A refused check is answered with the
351
+ // version we already hold — the same string an ordinary cached call returns,
352
+ // carrying the marker's own `checkedAt` rather than the moment of the read
353
+ // that was refused, so /api/version reports exactly what it reported a moment
354
+ // ago and no surface learns a new failure mode from being asked twice.
355
+ if (!mayAskNpm({ now, lastAskAt: _lastAskAt.get(key) ?? 0 })) return m?.version ?? null;
356
+ _lastAskAt.set(key, now);
357
+ const run = runCheck(name, m, now)
358
+ // Record the outcome, not just the moment, and record it against THIS
359
+ // package: only an answer stamps `at`; a failure takes the short retry
360
+ // window instead of the hour, keeps the version we already knew rather
361
+ // than erasing it, and lands in this name's marker rather than spending
362
+ // another package's window on a lookup that was never about it.
363
+ .then((marker) => {
364
+ writeMarker(name, marker);
365
+ return marker.version ?? null;
366
+ })
367
+ .catch(() => m?.version ?? null)
368
+ .finally(() => { _inflight.delete(key); });
369
+ _inflight.set(key, run);
370
+ return run;
371
+ }
372
+
373
+ /** One check, as a marker: what npm's dist-tag says, and — only when that is a
374
+ * version this deck has not already confirmed — whether it can be installed.
375
+ *
376
+ * The second request is what keeps the banner honest, and it is skipped in the
377
+ * case that runs all day: a tag that has not moved was confirmed the first
378
+ * time it was seen, so a deck sitting on the current release still costs one
379
+ * ~20-byte GET per check. Confirming costs one more, once per release. */
380
+ async function runCheck(name, prev, now) {
381
+ const { ok, version } = await fetchLatest(name);
382
+ if (!ok || version === prev?.version) return nextMarker({ prev, now, ok, version });
383
+ const probe = await isPublished(name, version);
384
+ return nextMarker({ prev, now, ok, version, installable: probe.ok && probe.published });
385
+ }