ccdeck 3.36.9 → 3.37.1

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 (75) hide show
  1. package/README.md +15 -7
  2. package/bin/agent-dag.js +19 -6
  3. package/bin/cli/help.js +1 -0
  4. package/bin/cli/login-item.js +44 -20
  5. package/bin/cli/one-shot.js +15 -5
  6. package/bin/cli/screen.js +10 -5
  7. package/bin/cli/startup.js +6 -2
  8. package/bin/cli/uninstall.js +41 -5
  9. package/bin/deck.js +50 -8
  10. package/dist/web/assets/BrowserWatchModal-njN0zg93.js +1 -0
  11. package/dist/web/assets/UsageHistoryModal-DYSZZZEz.js +1 -0
  12. package/dist/web/assets/index-B0SOa-XB.css +1 -0
  13. package/dist/web/assets/index-DDxnWaTa.js +306 -0
  14. package/dist/web/index.html +2 -2
  15. package/hook/hook.js +80 -9
  16. package/package.json +1 -1
  17. package/release-notes.json +8 -1
  18. package/src/server/account-origins.mjs +8 -1
  19. package/src/server/account-projects.mjs +34 -9
  20. package/src/server/activation.mjs +39 -10
  21. package/src/server/app-host.mjs +7 -2
  22. package/src/server/args.mjs +3 -0
  23. package/src/server/browser-watch-routes.mjs +23 -16
  24. package/src/server/browser-watch.mjs +9 -3
  25. package/src/server/claude-accounts.mjs +27 -7
  26. package/src/server/claude-verdicts.mjs +12 -1
  27. package/src/server/cli-versions.mjs +6 -4
  28. package/src/server/codex-auth.mjs +29 -0
  29. package/src/server/codex-quota.mjs +57 -7
  30. package/src/server/deck-home.mjs +23 -1
  31. package/src/server/deck-prefs.mjs +70 -7
  32. package/src/server/depth-facts.mjs +178 -0
  33. package/src/server/detach.mjs +61 -33
  34. package/src/server/event-log.mjs +15 -0
  35. package/src/server/event-pipeline.mjs +5 -2
  36. package/src/server/event-routes.mjs +22 -0
  37. package/src/server/feedback-form.mjs +36 -13
  38. package/src/server/index.mjs +6 -2
  39. package/src/server/install-layout.mjs +35 -0
  40. package/src/server/install-ref.mjs +39 -0
  41. package/src/server/installer.mjs +20 -2
  42. package/src/server/lan-beacon.mjs +18 -3
  43. package/src/server/lan-deck.mjs +7 -1
  44. package/src/server/lan-engine.mjs +40 -21
  45. package/src/server/lan-manifest.mjs +9 -6
  46. package/src/server/lan-requests.mjs +18 -2
  47. package/src/server/lan-serve.mjs +4 -2
  48. package/src/server/lan-sync.mjs +97 -6
  49. package/src/server/lifecycle.mjs +18 -2
  50. package/src/server/log-replay.mjs +9 -3
  51. package/src/server/login-service.mjs +95 -12
  52. package/src/server/npm-upgrade.mjs +12 -4
  53. package/src/server/pinned-build.mjs +3 -0
  54. package/src/server/prefs-routes.mjs +5 -8
  55. package/src/server/prefs-state.mjs +9 -1
  56. package/src/server/quota-oauth.mjs +88 -13
  57. package/src/server/quota-shape.mjs +28 -0
  58. package/src/server/quota.mjs +68 -17
  59. package/src/server/rating.mjs +87 -0
  60. package/src/server/reports-routes.mjs +21 -2
  61. package/src/server/reports.mjs +672 -64
  62. package/src/server/ring-bounds.mjs +11 -0
  63. package/src/server/running-deck.mjs +48 -0
  64. package/src/server/self-update.mjs +1 -1
  65. package/src/server/session-enrichment.mjs +14 -6
  66. package/src/server/stable-node.mjs +68 -0
  67. package/src/server/static-serve.mjs +5 -0
  68. package/src/server/supervisor.mjs +21 -15
  69. package/src/server/tailscale.mjs +8 -4
  70. package/src/server/transcript-scan.mjs +7 -4
  71. package/src/server/usage-day.mjs +76 -11
  72. package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
  73. package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
  74. package/dist/web/assets/index-BkXTN4dZ.css +0 -1
  75. package/dist/web/assets/index-tVBMXvDv.js +0 -306
@@ -7,8 +7,12 @@
7
7
  // WHAT GOES OUT, to api.ccdeck.dev, while `prefs.reports` is true and the
8
8
  // machine has not vetoed it (reportsVetoed):
9
9
  //
10
- // - an "install" event the first time,
10
+ // - an "install" event the first time, with the ccdeck.dev page its command
11
+ // was copied from when the command said (`--ref`, install-ref.mjs) — a
12
+ // page name, never anything about the person,
11
13
  // - an "update" event, with the version it came from, when the version moves,
14
+ // and how the new one arrived: the deck's own update, the desktop app's
15
+ // updater, npx, npm, or a source checkout,
12
16
  // - an "active" event at most once a UTC day, which is how many people use
13
17
  // ccdeck gets counted without counting anything else about them, and which
14
18
  // alone also carries coarse counts of how much: how many sessions,
@@ -16,17 +20,30 @@
16
20
  // and that day's date — numbers, never their names (see usage-day.mjs) —
17
21
  // and which of the deck's features were used that day, as fixed names
18
22
  // ("usage-history", "account-switch") and nothing about what was done
19
- // with them (feature-use.mjs),
23
+ // with them (feature-use.mjs), and how the deck held up and who runs it:
24
+ // how long this run took to start, that day's peak memory and how many
25
+ // events it took in, each as a bucket ("2s", "512m", "1k"), the Claude and
26
+ // Codex plan as its category ("max-20x", "plus") and nothing else from the
27
+ // credentials it is read off, and how many Claude accounts and paired
28
+ // machines the deck holds, as capped counts (depth-facts.mjs),
20
29
  // - an "uninstall" event when somebody runs `ccdeck --uninstall`, with the
21
30
  // reason they picked from a short list if they picked one (bin/cli/leaving.js),
22
31
  // - an "activated" event, once, when a new install's first session arrives:
23
32
  // how long after the install it came, as a bucket ("5m", "1h", "1d", "7d",
24
33
  // "later"), and whether it was a Claude or a Codex session (activation.mjs),
34
+ // - a "rated" event, once, if somebody answers the one question the deck asks
35
+ // about itself after about a week of use — "How useful is ccdeck to you?" —
36
+ // with the number picked, 0 to 10, and how many days the deck had been used
37
+ // as a range ("7-13", "30-89"); nothing for a question put off or left
38
+ // unanswered (rating.mjs),
25
39
  // - errors: a request handler that threw on this server, or an error the page
26
- // caught, with home folders, email addresses and key-shaped strings
27
- // scrubbed out before they leave,
40
+ // caught, with every path outside the deck's own package, project folder
41
+ // names, the host of every web address, every IP address and the machine
42
+ // name a network error carries, email addresses and key-shaped strings
43
+ // scrubbed out before they leave (see scrub),
28
44
  // - a "ping" heartbeat, every ten minutes or so while the deck runs, which
29
- // moves the install's "last seen" so the admin can show who is online now.
45
+ // moves the install's "last seen" so the admin can show who is online now,
46
+ // and is counted per day, which is how long the deck stays open.
30
47
  // It writes no event and carries the install id alone — no facts, no
31
48
  // fingerprint.
32
49
  //
@@ -63,24 +80,38 @@
63
80
  //
64
81
  // NOTHING HERE THROWS OR WAITS FOR ANYBODY. A report that cannot be sent is
65
82
  // dropped: it is a nicety for the people who make ccdeck, and no part of the
66
- // deck depends on it.
83
+ // deck depends on it. An event a check-in owes is tried again on the heartbeat,
84
+ // less often the longer the API stays out of reach; one the API itself refuses
85
+ // (its 400, 413 or 422 problem+json) is not sent again, since the same body would
86
+ // be refused the same way. A 4xx the edge in front of it answers with is tried again.
67
87
 
68
88
  import { createHash, randomUUID } from "node:crypto";
89
+ import { chmod, mkdir, readFile, unlink } from "node:fs/promises";
69
90
  import { arch as osArch, cpus as osCpus, homedir, hostname, platform as osPlatform, totalmem } from "node:os";
70
- import { dirname, resolve } from "node:path";
71
- import { fileURLToPath } from "node:url";
91
+ import { basename, dirname, join, resolve } from "node:path";
92
+ import { fileURLToPath, pathToFileURL } from "node:url";
72
93
  import { inApp } from "./app-host.mjs";
94
+ import { stripBom, writeFileAtomic } from "./atomic-write.mjs";
95
+ import { deckDataDir } from "./deck-home.mjs";
73
96
  import { reportsVetoed } from "./deck-prefs.mjs";
74
- import { isGitCheckout } from "./install-layout.mjs";
75
- import { heldPrefs, prefsRead } from "./prefs-state.mjs";
97
+ import { prefsDir } from "./prefs-path.mjs";
98
+ import { isGitCheckout, isNpxInstall } from "./install-layout.mjs";
99
+ import { bootFoundNoPrefs, heldPrefs, prefsRead } from "./prefs-state.mjs";
76
100
  import { RUNNING_VERSION } from "./running-version.mjs";
77
101
  import { FEATURES, usageDay, utcDay } from "./usage-day.mjs";
78
102
  import { setupFacts, sinceInstallBucket, whenSetupKnown } from "./activation.mjs";
103
+ import { notedRef, refSlug } from "./install-ref.mjs";
104
+ import {
105
+ CLAUDE_PLANS, CODEX_PLANS, cappedCount, deckDepth, eventsBucket, launchBucket, memoryBucket, updateVia,
106
+ } from "./depth-facts.mjs";
107
+ import { daysUsedBucket, normaliseRating, ratingDue, ratingStage, scoreOf } from "./rating.mjs";
79
108
 
80
109
  export const REPORTS_API = "https://api.ccdeck.dev";
81
110
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
82
111
  const TIMEOUT_MS = 6000;
83
- /** How often a long-running deck checks whether a new day wants its "active". */
112
+ /** How often a long-running deck checks in whatever the heartbeat found. The beat
113
+ * already sends what is due — a new day's "active", a check-in that failed —
114
+ * so this is the floor under it, not the schedule. */
84
115
  const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
85
116
  /** The heartbeat's beat: a lighter timer than the check-in that only moves the
86
117
  * install's "last seen", so the admin can count who is online now. Ten minutes,
@@ -88,6 +119,12 @@ const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
88
119
  * not all beat on the same tick. */
89
120
  const PING_EVERY_MS = 10 * 60 * 1000;
90
121
  const PING_JITTER_MS = 2 * 60 * 1000;
122
+ /** The longest a 429's or a 503's Retry-After is waited for: a header that asks
123
+ * for a week does not silence a deck for one. */
124
+ const RETRY_AFTER_MAX_MS = 24 * 60 * 60 * 1000;
125
+ /** How long a report waits for the CLI versions it is about to carry: a couple
126
+ * of `--version` answers (cli-versions.mjs). One not known by then is left out. */
127
+ const CLI_PROBE_WAIT_MS = 8_000;
91
128
  /** Errors per hour this deck may send, and how long one error is not repeated. */
92
129
  const ERRORS_PER_HOUR = 20;
93
130
  const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
@@ -100,7 +137,8 @@ export const UNINSTALL_REASONS = Object.freeze(["not-useful", "too-noisy", "brok
100
137
 
101
138
  const EMPTY_REPORT = Object.freeze({
102
139
  installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
103
- installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
140
+ installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false, ref: "", selfUpdate: null,
141
+ rating: normaliseRating(null),
104
142
  });
105
143
 
106
144
  /** Is this deck sending reports right now? The switch, which is on unless
@@ -216,16 +254,38 @@ function intlLocale() {
216
254
  }
217
255
 
218
256
  /** The shell as a bare name — "zsh", "bash", "fish". `SHELL` names it on POSIX;
219
- * on Windows there is no `SHELL` (unless a Git-Bash-style one set it), so the
220
- * hints are PowerShell's `PSModulePath` and cmd's `ComSpec`. */
257
+ * on Windows there is no `SHELL` (unless a Git-Bash-style one set it), so it is
258
+ * read off what each shell leaves for what it starts (windowsShell). */
221
259
  export function shellToken(env = process.env, platform = process.platform) {
222
260
  const sh = env?.SHELL;
223
261
  if (sh) return token(baseName(sh), TOKEN_CAPS.shell);
224
- if (platform === "win32") {
225
- if (env?.PSModulePath) return "pwsh";
226
- const comSpec = env?.ComSpec ?? env?.COMSPEC;
227
- return comSpec ? token(baseName(comSpec), TOKEN_CAPS.shell) : "cmd";
228
- }
262
+ if (platform === "win32") return windowsShell(env);
263
+ return undefined;
264
+ }
265
+
266
+ /**
267
+ * The Windows shell that started the deck — "pwsh", "powershell", "cmd" — or
268
+ * nothing when it cannot be told.
269
+ *
270
+ * Not `PSModulePath` being set, nor `ComSpec`: Windows sets both machine-wide,
271
+ * so every process has them — cmd.exe, Windows PowerShell, and the desktop app
272
+ * started from the Start menu — and every Windows deck used to say "pwsh".
273
+ * What PowerShell adds for what it starts is its user module folder, under the
274
+ * user's profile: Documents\PowerShell\Modules for PowerShell 7,
275
+ * Documents\WindowsPowerShell\Modules for Windows PowerShell. cmd.exe defines
276
+ * `PROMPT`. PowerShell is asked first, because npm's .cmd shim runs through
277
+ * cmd.exe even from a PowerShell prompt.
278
+ */
279
+ function windowsShell(env) {
280
+ const profile = String(env?.USERPROFILE ?? "").replace(/[\\/]+$/, "").toLowerCase();
281
+ const userModules = profile
282
+ ? String(env?.PSModulePath ?? "").split(";")
283
+ .map(p => p.trim().replace(/[\\/]+$/, "").toLowerCase())
284
+ .filter(p => p.startsWith(`${profile}\\`) || p.startsWith(`${profile}/`))
285
+ : [];
286
+ if (userModules.some(p => /[\\/]powershell[\\/]modules$/.test(p))) return "pwsh";
287
+ if (userModules.some(p => /[\\/]windowspowershell[\\/]modules$/.test(p))) return "powershell";
288
+ if (env?.PROMPT) return token(baseName(env?.ComSpec ?? env?.COMSPEC ?? "cmd"), TOKEN_CAPS.shell) ?? "cmd";
229
289
  return undefined;
230
290
  }
231
291
 
@@ -290,22 +350,132 @@ export function installFacts({
290
350
  return facts;
291
351
  }
292
352
 
353
+ // One folder or file name of a path. One in the middle may hold spaces ("Bob
354
+ // Smith", "Application Support"), because the separator after it says where it
355
+ // ends; the last stops at the first space, so the words after a path are kept.
356
+ const NAME = String.raw`[^\s\\/:'"\`<>|?*;,]`;
357
+ const MIDDLE = String.raw`${NAME}(?:[^\\/\r\n:'"\`<>|?*;,]*${NAME})?[\\/]`;
358
+ const LAST = String.raw`[^\s\\/:'"\`<>|?*;,()]+`;
359
+ const TAIL = String.raw`(?:${MIDDLE})*(?:${LAST})?`;
360
+ /** A path in any shape an error carries one: a file:// URL, a ~ path, a drive
361
+ * path, a UNC path, an absolute POSIX path. Not an address — `https://host/x`
362
+ * starts after a colon and its path after the host, or after the `<deck>` or
363
+ * `<host>` that host is sent as (hideOrigin) — and not `node:internal/…`.
364
+ * A `:42:7` after it is left where it is. */
365
+ export const PATH_PATTERN = String.raw`(?<![\w.~/\\\]-]|<(?:deck|host)>)(?:file://(?:/[A-Za-z]:)?[^\s:'"\`<>()]*|~[\\/]${TAIL}|[A-Za-z]:[\\/]${TAIL}|\\\\(?:[?.]\\)?(?:[A-Za-z]:\\)?${TAIL}|/(?=${NAME})${TAIL}|(?<!:)//(?=${NAME})${TAIL})`;
366
+ /** A folder name in Claude Code's encoding of a project's path, every character
367
+ * but letters and digits made a dash: `-home-alice-acme`, `C--Users-Bob-acme`. */
368
+ export const PROJECT_PATTERN = String.raw`(?<![\w-])(?:[A-Za-z]--|-[A-Za-z0-9][\w.]*-)[\w.-]*`;
369
+ /** The folders under home whose name is kept in front of a scrubbed path: the
370
+ * two CLIs the deck reads. They say which side an error came from, and name nobody. */
371
+ const KEPT_HOME_FOLDERS = new Set([".claude", ".codex"]);
372
+
373
+ /** What a path outside the deck is sent as: `<path>`, or `~/<path>` and
374
+ * `~/.claude/<path>` for one under the home folder. */
375
+ function hidePath(path) {
376
+ if (!path.startsWith("~")) return "<path>";
377
+ const sep = path[1];
378
+ const [top, ...rest] = path.slice(2).split(/[\\/]/);
379
+ if (!top) return path;
380
+ if (!KEPT_HOME_FOLDERS.has(top)) return `~${sep}<path>`;
381
+ return rest.join("") ? `~${sep}${top}${sep}<path>` : path;
382
+ }
383
+
384
+ /** The scheme and host of an address (with its port, and a user in front of the
385
+ * host): a deck opened from another machine is opened by that machine's name,
386
+ * and every frame of the page carries it. The second group is the bundle's
387
+ * `/assets/` after it, which only the deck's own page loads scripts from. */
388
+ export const ORIGIN_PATTERN = String.raw`\b(https?|wss?)://[^\s/?#\\'"\x60<>(),;]+(?=(/assets/)?)`;
389
+
390
+ /** What an address is sent as: `<deck>/assets/…` for the page's own scripts, so
391
+ * a frame still says where in the bundle; `https://<host>/…` for any other. */
392
+ function hideOrigin(_origin, scheme, assets) {
393
+ return assets ? "<deck>" : `${scheme}://<host>`;
394
+ }
395
+
396
+ const OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
397
+ const IPV4 = String.raw`${OCTET}(?:\.${OCTET}){3}`;
398
+ const HEX = String.raw`[0-9A-Fa-f]{1,4}`;
399
+ /** An IPv6 address: compressed with a `::` or all eight groups there, with a
400
+ * digit in it (`Face::Add` is code), an IPv4 address allowed at its end
401
+ * (`::ffff:192.168.1.5`), and a port Node glues on with no brackets
402
+ * (`fd7a:115c::5:4317`) taken along, since nothing tells it from the last group. */
403
+ const IPV6 = String.raw`(?=[0-9A-Fa-f:]*\d)(?=[0-9A-Fa-f:]*::|(?:${HEX}:){7})(?:(?:${HEX})?:)+(?:${IPV4}|${HEX})?`;
404
+ const ZONE = String.raw`%[\w-]+(?:\.[\w-]+)*`;
405
+ /** The loopback and the any-address, plain or mapped: they name nobody. */
406
+ const KEPT_IPV6 = String.raw`(?:::1?|::ffff:(?:127(?:\.\d{1,3}){3}|0\.0\.0\.0))`;
407
+
408
+ /** Every IPv4 and IPv6 address, bracketed or bare, but the loopback and the
409
+ * any-address, so `connect ECONNREFUSED 127.0.0.1:4317` stays readable. Not a
410
+ * version: an IPv4 address is four parts, never one after a slash
411
+ * (`Chrome/130.0.0.0`) or a dash (`node-22.18.0`). */
412
+ export const ADDRESS_PATTERN = String.raw`(?<![\w:.%-])(?:\[(?!${KEPT_IPV6}\])${IPV6}(?:${ZONE})?\]|(?!${KEPT_IPV6}(?::\d{1,5})?(?![\w:%]|\.\d))${IPV6}(?:${ZONE})?(?![\w%]|\.\d))|(?<![\w./-])(?!127\.|0\.0\.0\.0\b)${IPV4}(?![\w-]|\.\d)`;
413
+
414
+ /** A host name, with a letter in it somewhere: one of all digits is an IPv4 address. */
415
+ const HOST = String.raw`(?=[\w.-]*[A-Za-z])[A-Za-z0-9][\w-]*(?:\.[\w-]+)*`;
416
+ /** A host name where a network error puts one: after a DNS or connect failure
417
+ * (`getaddrinfo ENOTFOUND bobs-pc`), glued to a port (`bobs-pc:4317`), or
418
+ * ending in a local-network suffix (.local, .lan, .home, .internal, .ts.net).
419
+ * Not localhost, and not a file:line:col frame: a port has no second number
420
+ * after it. */
421
+ export const HOST_PATTERN = String.raw`(?<![\w.@/\\%-])(?!localhost(?![\w-]|\.\w))(?:(?<=\b(?:(?:getaddrinfo|connect) E[A-Z_]+|ENOTFOUND|EAI_AGAIN) )${HOST}|${HOST}(?=:\d{1,5}(?![\w:-]|\.\w))|${HOST}\.(?:local|lan|home|internal|ts\.net)(?![\w-]|\.\w))`;
422
+
423
+ const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
424
+
425
+ /** Every spelling of `dir` a stack or a message can use — the path, and the
426
+ * file:// URL an ES module's frames carry — as a pattern for `dir` and the
427
+ * separator after it, so `/x/ccdeck-fork` is not taken for `/x/ccdeck`. */
428
+ function spellings(dir) {
429
+ const windows = /^(?:[A-Za-z]:[\\/]|\\\\)/.test(dir);
430
+ const url = pathToFileURL(dir, { windows }).href.replace(/\/$/, "");
431
+ return new RegExp(`(?:${escapeRegExp(url)}|${escapeRegExp(dir)})(?=[\\\\/])`, windows ? "gi" : "g");
432
+ }
433
+
293
434
  /**
294
435
  * Take out of an error what could say who someone is.
295
436
  *
437
+ * No path outside the deck's own package leaves. A path inside it keeps its
438
+ * place in the package — `ccdeck/src/server/tail.mjs:42:7`, however the deck was
439
+ * installed — so a stack still says where the bug is and reads the same on every
440
+ * machine; so does a dependency npx put beside it (`node_modules/ws/…`). Every
441
+ * other path becomes `<path>` (`~/.claude/<path>` under the home folder, see
442
+ * hidePath), and a project folder in Claude Code's encoding `<project>`: either
443
+ * would name what somebody works on.
444
+ *
445
+ * No host leaves either. The page's frames name the machine the deck was opened
446
+ * by (`http://alices-macbook.local:4317/assets/…`); they become `<deck>/assets/…`,
447
+ * which still says where in the bundle and reads the same on every machine, and
448
+ * any other address keeps its scheme and path and loses its host (hideOrigin).
449
+ * An address with no scheme in front goes the same way: every IPv4 and IPv6
450
+ * address (ADDRESS_PATTERN), and a host name where a network error puts one
451
+ * (HOST_PATTERN: `getaddrinfo ENOTFOUND bobs-pc.local`, `bobs-pc:4317`), becomes
452
+ * `<host>`, a port kept apart from it left in place. The loopback and the
453
+ * any-address stay — `connect ECONNREFUSED 127.0.0.1:4317` names nobody, and
454
+ * says which port.
455
+ *
296
456
  * The catch-all for long opaque strings deliberately stops at a slash: with one
297
457
  * in it, the pattern swallowed every long file path, and a stack whose frames
298
458
  * read `at save (~/.<secret>.mjs:40:3)` tells nobody where the bug is.
299
459
  * The API scrubs again;
300
- * this is the pass that means it never has to: this user's home folder, anyone
301
- * else's home folder, email addresses, and strings shaped like keys and tokens.
460
+ * this is the pass that means it never has to: paths, project folders, hosts
461
+ * and addresses, email addresses, and strings shaped like keys and tokens.
302
462
  */
303
- export function scrub(text, home = homedir()) {
463
+ export function scrub(text, home = homedir(), root = PKG_ROOT) {
304
464
  let out = String(text ?? "");
305
- if (home && home.length > 1) out = out.split(home).join("~");
465
+ if (root && root.length > 1) {
466
+ out = out.replace(spellings(root), "ccdeck");
467
+ if (basename(dirname(root)) === "node_modules") out = out.replace(spellings(dirname(root)), "node_modules");
468
+ }
469
+ // Never half a name: a home of /home/al is not the start of /home/alice.
470
+ if (home && home.length > 1) out = out.replace(new RegExp(`${escapeRegExp(home)}(?![\\w.-])`, "g"), "~");
306
471
  return out
307
472
  .replace(/\/(?:home|Users)\/[^/\s:'"]+/g, "~")
308
473
  .replace(/[A-Za-z]:\\(?:Users|Documents and Settings)\\[^\\\r\n:'"]+/gi, "~")
474
+ .replace(new RegExp(PATH_PATTERN, "g"), hidePath)
475
+ .replace(new RegExp(PROJECT_PATTERN, "g"), "<project>")
476
+ .replace(new RegExp(ORIGIN_PATTERN, "g"), hideOrigin)
477
+ .replace(new RegExp(ADDRESS_PATTERN, "g"), "<host>")
478
+ .replace(new RegExp(HOST_PATTERN, "g"), "<host>")
309
479
  .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
310
480
  .replace(
311
481
  /\b(?:sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|[A-Fa-f0-9]{40,}|[A-Za-z0-9+_=-]{48,})/g,
@@ -324,10 +494,16 @@ export function scrub(text, home = homedir()) {
324
494
  * @param {NodeJS.ProcessEnv} [deps.env]
325
495
  * @param {ReturnType<typeof installFacts>} [deps.facts]
326
496
  * @param {string} [deps.home]
497
+ * @param {string} [deps.root] the deck's own package, whose paths an error keeps (see scrub)
327
498
  * @param {Promise<unknown>} [deps.ready] what start() waits for: the prefs read at import
328
499
  * @param {() => { claudeVersion?: string, codexVersion?: string }} [deps.versions] the CLI
329
500
  * versions to fold into the facts, read fresh on every send so a background
330
501
  * probe's answer rides along the moment it arrives. Default: none.
502
+ * @param {() => Promise<unknown>} [deps.probe] asks the CLIs their versions again,
503
+ * for `versions` to answer with: started by start() once the prefs say reports
504
+ * are on, again on each new UTC day, and waited for — never past
505
+ * CLI_PROBE_WAIT_MS — before an install, update or "active" goes out.
506
+ * Default: none.
331
507
  * @param {ReturnType<typeof import("./usage-day.mjs").createUsageDay>} [deps.usage]
332
508
  * the day's tally: the "active" report carries the last finished day's totals,
333
509
  * read only when an "active" is about to be sent, and the tally is saved into
@@ -336,6 +512,27 @@ export function scrub(text, home = homedir()) {
336
512
  * boot set up, folded into install, update and active (activation.mjs).
337
513
  * @param {() => Promise<unknown>} [deps.setupKnown] what start() waits for besides the
338
514
  * prefs, so the first install event carries the setup. Bounded.
515
+ * @param {() => string | undefined} [deps.reference] the ref this run's command
516
+ * carried (install-ref.mjs), for the install event alone.
517
+ * @param {(ctx: { setup: object, prefs: object }) => Promise<object>} [deps.depth] the
518
+ * plans, accounts and paired machines the "active" carries (depth-facts.mjs),
519
+ * read only when one is about to go. Default: none — the deck's own reporter
520
+ * passes deckDepth, so no test reads a real credentials file.
521
+ * @param {() => number} [deps.launchedIn] ms from this process starting to the
522
+ * deck listening, read once by start(), which runs as the deck starts listening.
523
+ * @param {() => number} [deps.memory] the process's resident memory in bytes,
524
+ * sampled on the heartbeat's beat for the usage day's peak.
525
+ * @param {boolean} [deps.npx] this copy runs out of an npx cache, which is how an
526
+ * npm install that changed version under it updated.
527
+ * @param {() => number} [deps.uptime] how long this deck has been up, in ms: the
528
+ * question about the deck is never asked in the minutes after a launch.
529
+ * @param {() => boolean} [deps.firstRun] this run's boot found no settings file:
530
+ * the deck had never run here, so an install id made now is a real install and
531
+ * its first session is timed. Default: the boot read's own answer.
532
+ * @param {{ read: () => Promise<unknown>, write: (kept: object | null) => Promise<void> }} [deps.ratingKept]
533
+ * where the question's outcome is kept a second time, out of reach of an
534
+ * older deck's write of prefs.json (ratingFile). Default: none — the deck's
535
+ * own reporter passes ratingFile(), so no test writes beside a real prefs.json.
339
536
  */
340
537
  export function createReporter({
341
538
  fetchImpl = globalThis.fetch,
@@ -344,16 +541,28 @@ export function createReporter({
344
541
  env = process.env,
345
542
  facts = installFacts({ env }),
346
543
  home = homedir(),
544
+ root = PKG_ROOT,
347
545
  ready = prefsRead,
348
546
  versions = () => ({}),
547
+ probe = async () => {},
349
548
  usage = usageDay,
350
549
  setup = setupFacts,
351
550
  setupKnown = () => whenSetupKnown(),
551
+ reference = notedRef,
552
+ depth = async () => ({}),
553
+ launchedIn = () => process.uptime() * 1000,
554
+ memory = () => process.memoryUsage.rss(),
555
+ npx = false,
556
+ uptime = () => process.uptime() * 1000,
557
+ firstRun = bootFoundNoPrefs,
558
+ ratingKept = null,
352
559
  } = {}) {
353
560
  const errorsSent = [];
354
561
  const lastSentAt = new Map();
355
562
  let timer = null;
356
563
  let pingTimer = null;
564
+ /** How long this run took to start, read when start() runs; null before. */
565
+ let launchMs = null;
357
566
 
358
567
  /** The CLI versions to add to the facts right now, tokenised and omitted when
359
568
  * not yet known — read on every send so the background probe's result appears
@@ -370,6 +579,41 @@ export function createReporter({
370
579
  }
371
580
  }
372
581
 
582
+ /** The UTC day the CLIs were last asked their versions, and the asking under way. */
583
+ let probedDay = "";
584
+ let probing = null;
585
+
586
+ /** Ask the CLIs their versions, at most once a UTC day — so a deck left running
587
+ * for a week says the Claude Code it has now, not the one it booted with — and
588
+ * resolve once the answer is in. Never rejects. */
589
+ function probeVersions() {
590
+ try {
591
+ const today = utcDay(now());
592
+ if (!probing && probedDay !== today) {
593
+ probedDay = today;
594
+ probing = Promise.resolve()
595
+ .then(() => probe())
596
+ .catch(() => { /* a probe that could not run leaves the versions as they were */ })
597
+ .finally(() => { probing = null; });
598
+ }
599
+ } catch {
600
+ // A clock that cannot say the day; the report goes with what is known.
601
+ }
602
+ return probing ?? Promise.resolve();
603
+ }
604
+
605
+ /** The versions fresh for a report about to carry them: the day's probe, waited
606
+ * for, but never past CLI_PROBE_WAIT_MS — a version not known by then is left
607
+ * out, and rides on a later report. */
608
+ async function versionsReady() {
609
+ let timer;
610
+ await Promise.race([
611
+ probeVersions(),
612
+ new Promise(r => { timer = setTimeout(r, CLI_PROBE_WAIT_MS); timer.unref?.(); }),
613
+ ]);
614
+ clearTimeout(timer);
615
+ }
616
+
373
617
  /** The full facts an event carries: the fixed install facts, plus whatever CLI
374
618
  * versions are known this moment. */
375
619
  function factsNow() {
@@ -390,6 +634,25 @@ export function createReporter({
390
634
  }
391
635
  }
392
636
 
637
+ /** Whether this is the deck's first run here. Never throws: a run it cannot
638
+ * tell is not one. */
639
+ function isFirstRun() {
640
+ try {
641
+ return firstRun() === true;
642
+ } catch {
643
+ return false;
644
+ }
645
+ }
646
+
647
+ /** This run's ref, if it is one. Never throws. */
648
+ function refNow() {
649
+ try {
650
+ return refSlug(reference());
651
+ } catch {
652
+ return undefined;
653
+ }
654
+ }
655
+
393
656
  /** The coarse usage the "active" report carries: the last finished day before
394
657
  * `today`, its date as `usageDay` and its counts beside it. Nothing at all
395
658
  * when there is no such day yet — a first launch has no yesterday — and only
@@ -406,12 +669,45 @@ export function createReporter({
406
669
  // The features used that day, as names off the fixed list — an empty list
407
670
  // is a real answer (the deck ran, nothing was opened), not a missing one.
408
671
  out.features = Array.isArray(raw.features) ? raw.features.filter(f => FEATURES.includes(f)) : [];
672
+ // How hard the deck worked that day, as buckets (depth-facts.mjs). A peak
673
+ // of zero is a day no beat sampled, and null events a day this deck did
674
+ // not count in full (usage-day.mjs): neither says anything.
675
+ addIf(out, "events", eventsBucket(raw.events));
676
+ addIf(out, "deckMemory", raw.peakMb > 0 ? memoryBucket(raw.peakMb) : undefined);
409
677
  return out;
410
678
  } catch {
411
679
  return {};
412
680
  }
413
681
  }
414
682
 
683
+ /** What the "active" says about the deck beyond its day: how long this run
684
+ * took to start, as a bucket, and the plans, accounts and paired machines
685
+ * the depth provider reads — each only as a word off its list or a capped
686
+ * count (depth-facts.mjs). A reading that throws costs the report nothing. */
687
+ async function depthNow() {
688
+ const out = {};
689
+ addIf(out, "launch", launchMs == null ? undefined : launchBucket(launchMs));
690
+ try {
691
+ const d = (await depth({ setup: setupNow(), prefs: prefs.current() })) ?? {};
692
+ addIf(out, "claudePlan", CLAUDE_PLANS.includes(d.claudePlan) ? d.claudePlan : undefined);
693
+ addIf(out, "codexPlan", CODEX_PLANS.includes(d.codexPlan) ? d.codexPlan : undefined);
694
+ addIf(out, "accounts", cappedCount(d.accounts));
695
+ addIf(out, "machines", cappedCount(d.machines));
696
+ } catch {
697
+ // A plan or a count that could not be read is left out.
698
+ }
699
+ return out;
700
+ }
701
+
702
+ /** The deck's memory into the day's tally, which keeps the day's highest. */
703
+ function samplePeak() {
704
+ try {
705
+ usage.notePeak?.(memory() / (1024 * 1024));
706
+ } catch {
707
+ // A reading that failed this beat; the next one tries again.
708
+ }
709
+ }
710
+
415
711
  /** The tally the last save wrote, so a save with nothing new writes nothing. */
416
712
  let usageSaved = -1;
417
713
 
@@ -441,6 +737,9 @@ export function createReporter({
441
737
  }
442
738
  }
443
739
 
740
+ /** What became of a send: "sent"; "refused" — the API saying no to this body,
741
+ * which it will say again to the same body, so it is final; or "failed" —
742
+ * anything else, worth another try. */
444
743
  async function call(method, path, body) {
445
744
  try {
446
745
  const res = await fetchImpl(REPORTS_API + path, {
@@ -449,12 +748,94 @@ export function createReporter({
449
748
  body: body === undefined ? undefined : JSON.stringify(body),
450
749
  signal: AbortSignal.timeout(TIMEOUT_MS),
451
750
  });
452
- return res.ok;
751
+ if (res.ok) return "sent";
752
+ if (res.status === 429 || res.status === 503) waitAsAsked(res);
753
+ return refusedByApi(res) ? "refused" : "failed";
754
+ } catch {
755
+ return "failed";
756
+ }
757
+ }
758
+
759
+ /** Whether an answer is the API's own verdict on the body: a 400, 413 or 422
760
+ * it wrote as application/problem+json, the only way it turns a body down.
761
+ * Any other 4xx — a 403 or 404 Cloudflare answers with itself (a challenge,
762
+ * a rule, a tunnel being moved), a proxy's 401 — says nothing about the body,
763
+ * and a later try can get through, so it is tried again like a 5xx. */
764
+ function refusedByApi(res) {
765
+ try {
766
+ if (res.status !== 400 && res.status !== 413 && res.status !== 422) return false;
767
+ const type = String(res.headers?.get?.("content-type") ?? "").toLowerCase();
768
+ return type.split(";")[0].trim() === "application/problem+json";
453
769
  } catch {
454
770
  return false;
455
771
  }
456
772
  }
457
773
 
774
+ /** Delivered, or refused for good: either way nothing more to send. */
775
+ const settled = outcome => outcome !== "failed";
776
+
777
+ /** Check-ins in a row that ended still owing something, and the earliest the
778
+ * timers may check in again, by `now` — so a laptop that slept through the
779
+ * wait tries on its first beat awake. */
780
+ let failedInARow = 0;
781
+ let retryAt = 0;
782
+ /** The earliest the timers may send anything at all — a check-in or the
783
+ * heartbeat — because the API asked for the wait (a Retry-After), whichever
784
+ * request it answered. Kept apart from the backoff, which is the check-ins'
785
+ * own: a check-in that owes nothing more clears that, and not this. */
786
+ let askedWaitUntil = 0;
787
+
788
+ /** A Retry-After, in seconds or as a date, moves the next try out to it. */
789
+ function waitAsAsked(res) {
790
+ try {
791
+ const raw = String(res.headers?.get?.("retry-after") ?? "").trim();
792
+ if (!raw) return;
793
+ const at = now().getTime();
794
+ const ms = /^\d+$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw) - at;
795
+ if (Number.isFinite(ms) && ms > 0) askedWaitUntil = Math.max(askedWaitUntil, at + Math.min(ms, RETRY_AFTER_MAX_MS));
796
+ } catch {
797
+ // A header that will not read: the backoff alone decides.
798
+ }
799
+ }
800
+
801
+ /** Whether the API's own Retry-After has passed. Never throws. */
802
+ function askedWaitOver() {
803
+ try {
804
+ return now().getTime() >= askedWaitUntil;
805
+ } catch {
806
+ return true;
807
+ }
808
+ }
809
+
810
+ /** After a check-in: one that still owes something waits half a beat before
811
+ * the next try — so the first is the next beat, however long this one took
812
+ * to fail — then twice that each time, up to the six-hour check-in; one that
813
+ * owes nothing clears the wait. Never throws. */
814
+ function afterCheckIn() {
815
+ try {
816
+ if (!checkInDue()) {
817
+ failedInARow = 0;
818
+ retryAt = 0;
819
+ return;
820
+ }
821
+ failedInARow++;
822
+ const wait = Math.min((PING_EVERY_MS / 2) * 2 ** (failedInARow - 1), CHECK_IN_EVERY_MS);
823
+ retryAt = Math.max(retryAt, now().getTime() + wait);
824
+ } catch {
825
+ // A clock that cannot say: the next beat tries again.
826
+ }
827
+ }
828
+
829
+ /** Whether the timers may check in now: the backoff, and any wait the API
830
+ * asked for, both over. Never throws. */
831
+ function retryDue() {
832
+ try {
833
+ return now().getTime() >= retryAt && askedWaitOver();
834
+ } catch {
835
+ return true;
836
+ }
837
+ }
838
+
458
839
  /** Change the reporter's state, but only while it still belongs to `installId`:
459
840
  * a switch-off that landed in between has the last word. */
460
841
  function remember(installId, change) {
@@ -463,14 +844,40 @@ export function createReporter({
463
844
  );
464
845
  }
465
846
 
847
+ /** Check-ins under way, so a beat that lands during a slow one does not start
848
+ * a second beside it and send the same install twice. */
849
+ let checkingIn = 0;
850
+
466
851
  /** Send whatever is due: a pending deletion, then install or update, then
467
852
  * today's "active". Never throws: a floating rejection here would be an
468
853
  * unhandled one, and Node's answer to those is to end the process. */
469
854
  async function checkIn() {
855
+ checkingIn++;
470
856
  try {
471
857
  await sendWhatIsDue();
472
858
  } catch {
473
859
  // A settings file that cannot be written this minute; the next check-in tries again.
860
+ } finally {
861
+ checkingIn--;
862
+ afterCheckIn();
863
+ }
864
+ }
865
+
866
+ /** Whether a check-in has something to send: the conditions sendWhatIsDue
867
+ * acts on, read off the prefs and the clock alone — no network — so the
868
+ * heartbeat can ask on every beat. Never throws. */
869
+ function checkInDue() {
870
+ try {
871
+ if (reportsVetoed(env)) return false;
872
+ const p = prefs.current();
873
+ const r = p.report;
874
+ if (r.forget) return true;
875
+ if (!reportsOn(p, env)) return false;
876
+ if (!r.installId || r.lastVersion !== facts.version || r.lastActiveDay !== utcDay(now())) return true;
877
+ if (r.installedAt && !r.activationSent && (r.firstSessionAt || usage.firstUse?.())) return true;
878
+ return r.rating.score !== null && !r.rating.sent;
879
+ } catch {
880
+ return false;
474
881
  }
475
882
  }
476
883
 
@@ -480,45 +887,72 @@ export function createReporter({
480
887
  if (reportsVetoed(env)) return;
481
888
  const p = prefs.current();
482
889
  const { forget } = p.report ?? EMPTY_REPORT;
483
- if (forget && (await call("DELETE", `/v1/app/installs/${encodeURIComponent(forget)}`))) {
890
+ // Only a deletion that landed is done with: one refused stays owed, and is
891
+ // asked again as a failed one is, rather than dropped.
892
+ if (forget && (await call("DELETE", `/v1/app/installs/${encodeURIComponent(forget)}`)) === "sent") {
484
893
  await prefs.update(prev => (prev.report.forget === forget ? { report: { ...prev.report, forget: "" } } : undefined));
485
894
  }
486
895
  if (!reportsOn(prefs.current(), env)) return;
487
896
 
488
897
  // The id is made here, the first time there is something to send under it.
489
898
  if (!prefs.current().report.installId) {
899
+ const fresh = isFirstRun();
490
900
  await prefs.update(prev =>
491
901
  prev.reports === false || prev.report.installId
492
902
  ? undefined
493
- // When the id is made is when this install began: the clock the first
494
- // session is measured against. Kept here, never sent as a time.
495
- : { report: { ...prev.report, installId: randomUUID(), installedAt: now().toISOString() } },
903
+ // On a first run, when the id is made is when this install began: the
904
+ // clock the first session is measured against. Kept here, never sent as
905
+ // a time. A deck that ran here before — one upgraded from a version
906
+ // with no reports, which had no id to keep — gets none, and is never
907
+ // timed: its first session was long ago, and "now" would be a lie.
908
+ : { report: { ...prev.report, installId: randomUUID(), installedAt: fresh ? now().toISOString() : "" } },
496
909
  );
497
910
  }
498
911
  const { installId, lastVersion, lastActiveDay } = prefs.current().report;
499
912
  if (!installId) return;
913
+ const today = utcDay(now());
914
+ // The CLI versions ride on install, update and "active", and those go out at a
915
+ // boot more often than not: so they are asked for first, and on a new day
916
+ // asked again.
917
+ if (lastVersion !== facts.version || lastActiveDay !== today) await versionsReady();
500
918
  if (!lastVersion) {
501
- if (await call("POST", "/v1/app/events", { installId, kind: "install", ...factsNow(), ...setupNow() })) {
502
- await remember(installId, { lastVersion: facts.version });
919
+ // The site page the command came from: one an earlier try of this install
920
+ // could not send, else this run's. Kept until the install gets out, then
921
+ // never again — only a new install has a page to come from.
922
+ const ref = refSlug(prefs.current().report.ref) ?? refNow();
923
+ const body = { installId, kind: "install", ...factsNow(), ...setupNow() };
924
+ addIf(body, "ref", ref);
925
+ // Refused by the API is as done as sent: the same body would be refused again.
926
+ if (settled(await call("POST", "/v1/app/events", body))) {
927
+ await remember(installId, { lastVersion: facts.version, ref: "" });
928
+ } else if (ref) {
929
+ await remember(installId, { ref });
503
930
  }
504
931
  } else if (lastVersion !== facts.version) {
505
- if (await call("POST", "/v1/app/events", { installId, kind: "update", fromVersion: lastVersion, ...factsNow(), ...setupNow() })) {
506
- await remember(installId, { lastVersion: facts.version });
932
+ const body = { installId, kind: "update", fromVersion: lastVersion, ...factsNow(), ...setupNow() };
933
+ // How it arrived: the deck's own update if it wrote that it started one
934
+ // from this version, else what the channel says (depth-facts.mjs).
935
+ addIf(body, "via", updateVia({
936
+ marker: prefs.current().report.selfUpdate, lastVersion, channel: facts.channel, npx, now: now(),
937
+ }));
938
+ if (settled(await call("POST", "/v1/app/events", body))) {
939
+ await remember(installId, { lastVersion: facts.version, selfUpdate: null });
507
940
  }
508
941
  }
509
942
 
510
- const today = utcDay(now());
511
943
  if (lastActiveDay !== today) {
512
944
  // Usage rides on the "active" event alone, and is read only here — the one
513
945
  // report it belongs to — so a checkIn that sends nothing new never asks the
514
946
  // deck for its counts.
515
947
  const used = coarseUsage(today);
516
- if (await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...setupNow(), ...used })) {
948
+ const held = await depthNow();
949
+ if (settled(await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...setupNow(), ...used, ...held }))) {
517
950
  if (used.usageDay) usage.markSent(used.usageDay);
518
951
  await remember(installId, { lastActiveDay: today });
519
952
  }
520
953
  }
521
954
  await sendActivation();
955
+ await sendRating();
522
956
  await saveUsage();
523
957
  }
524
958
 
@@ -527,7 +961,8 @@ export function createReporter({
527
961
  * after the install it came, in a bucket, and which CLI it came from.
528
962
  *
529
963
  * Only for an install whose start this deck saw — `installedAt` is made with
530
- * the id, so an install from before this version has none and is never
964
+ * the id, on a first run only, so an install from before this version, or one
965
+ * whose id was made over an older deck's settings, has none and is never
531
966
  * measured: its first session was long ago, and "now" would be a lie. The time
532
967
  * is taken from the first use this run heard, so a session that arrived before
533
968
  * the first check-in had an id is still dated when it happened. A send that
@@ -546,7 +981,113 @@ export function createReporter({
546
981
  const body = { installId: r.installId, kind: "activated", ...factsNow(), ...setupNow() };
547
982
  addIf(body, "firstSession", sinceInstallBucket(r.installedAt, r.firstSessionAt));
548
983
  addIf(body, "firstProvider", r.firstProvider === "codex" || r.firstProvider === "claude" ? r.firstProvider : undefined);
549
- if (await call("POST", "/v1/app/events", body)) await remember(r.installId, { activationSent: true });
984
+ if (settled(await call("POST", "/v1/app/events", body))) await remember(r.installId, { activationSent: true });
985
+ }
986
+
987
+ /** The deck starting an update of its own — the Upgrade press, the npx
988
+ * relaunch onto the latest, or the update it runs while nobody is looking
989
+ * (lifecycle.mjs) — written down so the "update" the next version sends can
990
+ * say so. Only the version it started from and when; never throws. */
991
+ async function noteSelfUpdate() {
992
+ try {
993
+ const p = prefs.current();
994
+ if (!reportsOn(p, env) || !p.report.installId) return;
995
+ await remember(p.report.installId, { selfUpdate: { from: facts.version, at: now().toISOString() } });
996
+ } catch {
997
+ // Unsaid, the update is credited to its channel instead.
998
+ }
999
+ }
1000
+
1001
+ /** The answer to the deck's one question, said once — tried again at the next
1002
+ * check-in when it cannot get through. The score and how long the deck had
1003
+ * been used, as a range; nothing else about the question ever leaves. */
1004
+ async function sendRating() {
1005
+ const p = prefs.current();
1006
+ const r = p.report;
1007
+ if (!reportsOn(p, env) || !r.installId || r.rating.score === null || r.rating.sent) return;
1008
+ const days = usage.daysUsed?.() ?? 0;
1009
+ const body = { installId: r.installId, kind: "rated", ...factsNow(), score: r.rating.score, daysUsed: daysUsedBucket(days) };
1010
+ if (settled(await call("POST", "/v1/app/events", body))) {
1011
+ await remember(r.installId, { rating: { ...prefs.current().report.rating, sent: true } });
1012
+ await keepRating();
1013
+ }
1014
+ }
1015
+
1016
+ /** Should the page ask "How useful is ccdeck to you?" now? Only while reports
1017
+ * are on and there is an install to send the answer under (rating.mjs says
1018
+ * the rest). Never throws. */
1019
+ function ratingAsk() {
1020
+ try {
1021
+ const p = prefs.current();
1022
+ if (!reportsOn(p, env) || !p.report.installId) return false;
1023
+ return ratingDue(p.report.rating, usage.daysUsed?.() ?? 0, now(), uptime());
1024
+ } catch {
1025
+ return false;
1026
+ }
1027
+ }
1028
+
1029
+ /** The question answered: kept, then sent. A second answer, from a second tab,
1030
+ * changes nothing. Resolves to whether it was kept; never throws. */
1031
+ async function rate(score) {
1032
+ try {
1033
+ const picked = scoreOf(score);
1034
+ const p = prefs.current();
1035
+ if (picked === null || !reportsOn(p, env) || !p.report.installId || p.report.rating.score !== null) return false;
1036
+ await remember(p.report.installId, { rating: { ...p.report.rating, score: picked, sent: false } });
1037
+ await keepRating();
1038
+ await sendRating();
1039
+ return prefs.current().report.rating.score === picked;
1040
+ } catch {
1041
+ return false;
1042
+ }
1043
+ }
1044
+
1045
+ /** "Not now": the question waits a month, and after the second time it stops.
1046
+ * Resolves to whether it was kept; never throws. */
1047
+ async function rateLater() {
1048
+ try {
1049
+ const p = prefs.current();
1050
+ const r = p.report.rating;
1051
+ if (!reportsOn(p, env) || !p.report.installId || r.score !== null) return false;
1052
+ await remember(p.report.installId, { rating: { ...r, later: r.later + 1, laterAt: now().toISOString() } });
1053
+ await keepRating();
1054
+ return true;
1055
+ } catch {
1056
+ return false;
1057
+ }
1058
+ }
1059
+
1060
+ /** The question's outcome, kept a second time beside prefs.json (ratingFile)
1061
+ * under the install it belongs to — or taken off the disk once there is no
1062
+ * install to keep it for, so switching reports off forgets the id there too.
1063
+ * Never throws. */
1064
+ async function keepRating() {
1065
+ try {
1066
+ if (!ratingKept) return;
1067
+ const p = prefs.current();
1068
+ const { installId, rating } = p.report;
1069
+ await ratingKept.write(reportsOn(p, env) && installId ? { installId, rating } : null);
1070
+ } catch {
1071
+ // Unkept, the outcome still lives in prefs.json, as it always did.
1072
+ }
1073
+ }
1074
+
1075
+ /** The kept outcome, back into the prefs when it is further along than what
1076
+ * prefs.json says: an older deck wrote that file in between, and its write
1077
+ * drops every key it does not know, the rating among them — so the question
1078
+ * would be asked again, and a second answer sent. Only under the install it
1079
+ * was kept for. Never throws. */
1080
+ async function restoreRating() {
1081
+ try {
1082
+ if (!ratingKept) return;
1083
+ const kept = await ratingKept.read();
1084
+ const p = prefs.current();
1085
+ if (!reportsOn(p, env) || !p.report.installId || kept?.installId !== p.report.installId) return;
1086
+ const rating = normaliseRating(kept.rating);
1087
+ if (ratingStage(rating) > ratingStage(p.report.rating)) await remember(p.report.installId, { rating });
1088
+ } catch {
1089
+ // A kept file that will not read: what prefs.json says stands.
1090
+ }
550
1091
  }
551
1092
 
552
1093
  /** Whether a report would go out right now: reports on, the machine not
@@ -570,7 +1111,7 @@ export function createReporter({
570
1111
  if (!(await willReport())) return false;
571
1112
  const body = { installId: prefs.current().report.installId, kind: "uninstall", ...factsNow() };
572
1113
  addIf(body, "reason", UNINSTALL_REASONS.includes(reason) ? reason : undefined);
573
- return await call("POST", "/v1/app/events", body);
1114
+ return (await call("POST", "/v1/app/events", body)) === "sent";
574
1115
  } catch {
575
1116
  return false;
576
1117
  }
@@ -592,11 +1133,13 @@ export function createReporter({
592
1133
  * dropped if it cannot get through, and it never throws. No id yet means the
593
1134
  * first check-in has not run, so there is nothing to be online under and
594
1135
  * nothing is sent; the check-in that makes the id is what puts this deck
595
- * online, and the ping only keeps that fresh. */
1136
+ * online, and the ping only keeps that fresh. Not while a Retry-After the
1137
+ * API sent — on a ping or a check-in — is still running: it asked to be left
1138
+ * alone that long, and the heartbeat is the request sent most often. */
596
1139
  async function ping() {
597
1140
  try {
598
1141
  const p = prefs.current();
599
- if (!reportsOn(p, env) || !p.report.installId) return;
1142
+ if (!reportsOn(p, env) || !p.report.installId || !askedWaitOver()) return;
600
1143
  await call("POST", "/v1/app/ping", { installId: p.report.installId });
601
1144
  } catch {
602
1145
  // The lightest of the niceties: a heartbeat that could not be sent is gone.
@@ -614,6 +1157,7 @@ export function createReporter({
614
1157
  reports: false,
615
1158
  report: { ...EMPTY_REPORT, forget: prev.report.installId || prev.report.forget },
616
1159
  }));
1160
+ await keepRating();
617
1161
  await checkIn();
618
1162
  }
619
1163
 
@@ -625,7 +1169,7 @@ export function createReporter({
625
1169
  async function reportError(where, error) {
626
1170
  const p = prefs.current();
627
1171
  if (!reportsOn(p, env) || !p.report.installId) return false;
628
- const message = scrub(error?.message ?? error, home).slice(0, MESSAGE_MAX).trim();
1172
+ const message = scrub(error?.message ?? error, home, root).slice(0, MESSAGE_MAX).trim();
629
1173
  if (!message) return false;
630
1174
  const at = now().getTime();
631
1175
  while (errorsSent.length && at - errorsSent[0] > SAME_ERROR_QUIET_MS) errorsSent.shift();
@@ -633,11 +1177,23 @@ export function createReporter({
633
1177
  if (errorsSent.length >= ERRORS_PER_HOUR || at - (lastSentAt.get(key) ?? -Infinity) < SAME_ERROR_QUIET_MS) return false;
634
1178
  errorsSent.push(at);
635
1179
  lastSentAt.set(key, at);
636
- const stack = typeof error?.stack === "string" ? scrub(error.stack, home).slice(0, STACK_MAX) : undefined;
1180
+ const stack = typeof error?.stack === "string" ? scrub(error.stack, home, root).slice(0, STACK_MAX) : undefined;
637
1181
  // Less the runtime, and less the deviceId — an error is not one of the events
638
1182
  // the fingerprint rides on, so the personal field never leaves on one.
639
1183
  const { runtime: _runtime, deviceId: _deviceId, ...fields } = factsNow();
640
- return call("POST", "/v1/app/errors", { installId: p.report.installId, ...fields, where, message, stack });
1184
+ return (await call("POST", "/v1/app/errors", { installId: p.report.installId, ...fields, where, message, stack })) === "sent";
1185
+ }
1186
+
1187
+ /** One beat. Whatever a check-in still owes goes first: a boot check-in that
1188
+ * could not get through (a login item started before the Wi-Fi), or a UTC day
1189
+ * with no "active" yet. Left to the six-hour timer, that was hours of awake
1190
+ * time — Node's timers stand still while the machine sleeps, so on a laptop it
1191
+ * could be days, and a day the deck was used could end unsaid. Not before a
1192
+ * check-in that could not get through has waited its turn (afterCheckIn), so
1193
+ * an API that is down is not asked again every ten minutes. Then the ping. */
1194
+ async function beat() {
1195
+ if (!checkingIn && checkInDue() && retryDue()) await checkIn();
1196
+ await ping();
641
1197
  }
642
1198
 
643
1199
  /** The heartbeat timer, rescheduled from itself so each beat carries fresh
@@ -646,7 +1202,8 @@ export function createReporter({
646
1202
  * tally if it moved, so a deck that stops loses ten minutes of it at most. */
647
1203
  function scheduleNextPing() {
648
1204
  pingTimer = setTimeout(() => {
649
- void ping();
1205
+ samplePeak();
1206
+ void beat();
650
1207
  void saveUsage();
651
1208
  scheduleNextPing();
652
1209
  }, PING_EVERY_MS + Math.floor(Math.random() * PING_JITTER_MS));
@@ -654,16 +1211,34 @@ export function createReporter({
654
1211
  }
655
1212
 
656
1213
  /** Check in once the prefs are read, and every few hours after; and beat a
657
- * heartbeat on its own, lighter timer. Both timers never hold the process
658
- * open. The first check-in already puts a freshly launched deck online, so the
659
- * first ping is a full interval out — nothing pings at boot. */
1214
+ * heartbeat on its own, lighter timer, which also sends whatever a check-in
1215
+ * still owes (beat). Both timers never hold the process open. The first
1216
+ * check-in already puts a freshly launched deck online, so the first ping is
1217
+ * a full interval out — nothing pings at boot. */
660
1218
  function start() {
661
- Promise.all([Promise.resolve(ready).catch(() => {}), Promise.resolve(setupKnown()).catch(() => {})])
1219
+ // start() runs as the deck starts listening (index.mjs), so this is the
1220
+ // launch: process start to ready. A second start() keeps the first answer.
1221
+ if (launchMs == null) {
1222
+ try {
1223
+ const ms = launchedIn();
1224
+ launchMs = Number.isFinite(ms) && ms >= 0 ? ms : null;
1225
+ } catch {
1226
+ launchMs = null;
1227
+ }
1228
+ }
1229
+ samplePeak();
1230
+ const prefsIn = Promise.resolve(ready).catch(() => {});
1231
+ // The CLIs are asked their versions as soon as the prefs say reports are on —
1232
+ // past boot, beside the wait for the setup — so the first check-in, which
1233
+ // waits for the answer, is held up by little or nothing.
1234
+ prefsIn.then(() => { if (reportsOn(prefs.current(), env)) void probeVersions(); }).catch(() => {});
1235
+ Promise.all([prefsIn, Promise.resolve(setupKnown()).catch(() => {})])
662
1236
  .then(restoreUsage)
1237
+ .then(restoreRating)
663
1238
  .then(checkIn)
664
1239
  .then(() => usage.onFirstUse?.(() => void onFirstSession()));
665
1240
  if (!timer) {
666
- timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
1241
+ timer = setInterval(() => { if (retryDue()) void checkIn(); }, CHECK_IN_EVERY_MS);
667
1242
  timer.unref?.();
668
1243
  }
669
1244
  if (!pingTimer) scheduleNextPing();
@@ -676,7 +1251,10 @@ export function createReporter({
676
1251
  pingTimer = null;
677
1252
  }
678
1253
 
679
- return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, sendActivation, willReport, reportUninstall, start, stop };
1254
+ return {
1255
+ checkIn, ping, setReports, reportError, restoreUsage, restoreRating, saveUsage, sendActivation, willReport,
1256
+ reportUninstall, noteSelfUpdate, ratingAsk, rate, rateLater, sendRating, start, stop,
1257
+ };
680
1258
  }
681
1259
 
682
1260
  // ── the deck's own providers ─────────────────────────────────────────────────
@@ -687,22 +1265,52 @@ export function createReporter({
687
1265
  // `--version` that imports it for nothing. The day's usage tally is imported
688
1266
  // directly: usage-day.mjs imports nothing.
689
1267
 
690
- // The CLI versions, detected once in the background, off the boot path. The
691
- // first send starts the probe (past boot, and not while the machine has vetoed
692
- // reports) and reads back an empty answer; the version rides along on the
693
- // reports that follow, once the probe has filled this cache.
1268
+ // The CLI versions, detected in the background, off the boot path: the reporter
1269
+ // runs this once the prefs say reports are on (never under the machine's veto),
1270
+ // and again on each new UTC day (probeVersions). The last answer is kept until
1271
+ // the next one lands, and a probe that comes back with less — a wedged shim, a
1272
+ // timeout — does not take a version it knew away.
694
1273
  let cliVersionCache = {};
695
- let cliProbeStarted = false;
696
- function deckCliVersions() {
697
- if (!cliProbeStarted && !reportsVetoed(process.env)) {
698
- cliProbeStarted = true;
699
- import("./cli-versions.mjs")
700
- .then(m => m.detectCliVersions())
701
- .then(v => { cliVersionCache = { ...cliVersionCache, ...v }; })
702
- .catch(() => { /* a probe that could not run leaves the versions unsent */ });
703
- }
704
- return cliVersionCache;
1274
+ async function probeCliVersions() {
1275
+ const { detectCliVersions } = await import("./cli-versions.mjs");
1276
+ cliVersionCache = { ...cliVersionCache, ...(await detectCliVersions()) };
1277
+ }
1278
+
1279
+ /**
1280
+ * Where the question's outcome is kept a second time: rating.json, beside
1281
+ * prefs.json. A 3.36.x deck shares that folder — the desktop app before it
1282
+ * updates, an `npx ccdeck@3.36.9` — and its normalise drops every key it does
1283
+ * not know the first time it writes prefs.json, report.rating among them. It
1284
+ * never writes this file. `dir` is the deck's data dir, read when it is used.
1285
+ * Only the install id and the question's state go in it; `write(null)` takes
1286
+ * it off the disk.
1287
+ */
1288
+ export function ratingFile(dir) {
1289
+ const path = () => join(prefsDir(dir ?? deckDataDir()), "rating.json");
1290
+ return {
1291
+ async read() {
1292
+ try {
1293
+ return JSON.parse(stripBom(await readFile(path(), "utf8")));
1294
+ } catch {
1295
+ return null;
1296
+ }
1297
+ },
1298
+ async write(kept) {
1299
+ const at = path();
1300
+ if (!kept) {
1301
+ await unlink(at).catch(err => { if (err?.code !== "ENOENT") throw err; });
1302
+ return;
1303
+ }
1304
+ await mkdir(dirname(at), { recursive: true, mode: 0o700 });
1305
+ await writeFileAtomic(at, JSON.stringify(kept) + "\n");
1306
+ // The install id is kept as close as prefs.json keeps it.
1307
+ await chmod(at, 0o600).catch(() => {});
1308
+ },
1309
+ };
705
1310
  }
706
1311
 
707
1312
  /** The deck's own reporter. */
708
- export const reporter = createReporter({ versions: deckCliVersions });
1313
+ export const reporter = createReporter({
1314
+ versions: () => cliVersionCache, probe: probeCliVersions, depth: deckDepth, npx: isNpxInstall(PKG_ROOT),
1315
+ ratingKept: ratingFile(),
1316
+ });