ccdeck 3.36.8 → 3.37.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 (76) 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/leaving.js +54 -0
  5. package/bin/cli/login-item.js +44 -20
  6. package/bin/cli/one-shot.js +15 -5
  7. package/bin/cli/screen.js +10 -5
  8. package/bin/cli/startup.js +6 -2
  9. package/bin/cli/uninstall.js +45 -0
  10. package/bin/deck.js +50 -8
  11. package/dist/web/assets/BrowserWatchModal-DCHV09J8.js +1 -0
  12. package/dist/web/assets/UsageHistoryModal-oDjpHw4z.js +1 -0
  13. package/dist/web/assets/index-B0SOa-XB.css +1 -0
  14. package/dist/web/assets/index-wCbqcRJd.js +306 -0
  15. package/dist/web/index.html +2 -2
  16. package/hook/hook.js +80 -9
  17. package/package.json +1 -1
  18. package/release-notes.json +3 -1
  19. package/src/server/account-origins.mjs +8 -1
  20. package/src/server/account-projects.mjs +34 -9
  21. package/src/server/activation.mjs +39 -10
  22. package/src/server/app-host.mjs +7 -2
  23. package/src/server/args.mjs +3 -0
  24. package/src/server/browser-watch-routes.mjs +23 -16
  25. package/src/server/browser-watch.mjs +9 -3
  26. package/src/server/claude-accounts.mjs +27 -7
  27. package/src/server/claude-verdicts.mjs +12 -1
  28. package/src/server/cli-versions.mjs +6 -4
  29. package/src/server/codex-auth.mjs +29 -0
  30. package/src/server/codex-quota.mjs +57 -7
  31. package/src/server/deck-home.mjs +23 -1
  32. package/src/server/deck-prefs.mjs +70 -7
  33. package/src/server/depth-facts.mjs +178 -0
  34. package/src/server/detach.mjs +61 -33
  35. package/src/server/event-log.mjs +15 -0
  36. package/src/server/event-pipeline.mjs +5 -2
  37. package/src/server/event-routes.mjs +22 -0
  38. package/src/server/feedback-form.mjs +36 -13
  39. package/src/server/index.mjs +6 -2
  40. package/src/server/install-layout.mjs +35 -0
  41. package/src/server/install-ref.mjs +39 -0
  42. package/src/server/installer.mjs +20 -2
  43. package/src/server/lan-beacon.mjs +18 -3
  44. package/src/server/lan-deck.mjs +7 -1
  45. package/src/server/lan-engine.mjs +10 -1
  46. package/src/server/lan-manifest.mjs +9 -6
  47. package/src/server/lan-requests.mjs +18 -2
  48. package/src/server/lan-serve.mjs +4 -2
  49. package/src/server/lan-sync.mjs +97 -6
  50. package/src/server/lifecycle.mjs +18 -2
  51. package/src/server/log-replay.mjs +9 -3
  52. package/src/server/login-service.mjs +95 -12
  53. package/src/server/npm-upgrade.mjs +12 -4
  54. package/src/server/pinned-build.mjs +3 -0
  55. package/src/server/prefs-routes.mjs +5 -8
  56. package/src/server/prefs-state.mjs +9 -1
  57. package/src/server/quota-oauth.mjs +88 -13
  58. package/src/server/quota-shape.mjs +28 -0
  59. package/src/server/quota.mjs +68 -17
  60. package/src/server/rating.mjs +87 -0
  61. package/src/server/reports-routes.mjs +21 -2
  62. package/src/server/reports.mjs +704 -63
  63. package/src/server/ring-bounds.mjs +11 -0
  64. package/src/server/running-deck.mjs +48 -0
  65. package/src/server/self-update.mjs +1 -1
  66. package/src/server/session-enrichment.mjs +14 -6
  67. package/src/server/stable-node.mjs +68 -0
  68. package/src/server/static-serve.mjs +5 -0
  69. package/src/server/supervisor.mjs +21 -15
  70. package/src/server/tailscale.mjs +8 -4
  71. package/src/server/transcript-scan.mjs +7 -4
  72. package/src/server/usage-day.mjs +76 -11
  73. package/dist/web/assets/BrowserWatchModal-CeVGLslQ.js +0 -1
  74. package/dist/web/assets/UsageHistoryModal-BcUzGS2-.js +0 -1
  75. package/dist/web/assets/index-4ecbC_Cy.js +0 -306
  76. package/dist/web/assets/index-BkXTN4dZ.css +0 -1
@@ -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,15 +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),
29
+ // - an "uninstall" event when somebody runs `ccdeck --uninstall`, with the
30
+ // reason they picked from a short list if they picked one (bin/cli/leaving.js),
20
31
  // - an "activated" event, once, when a new install's first session arrives:
21
32
  // how long after the install it came, as a bucket ("5m", "1h", "1d", "7d",
22
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),
23
39
  // - errors: a request handler that threw on this server, or an error the page
24
- // caught, with home folders, email addresses and key-shaped strings
25
- // 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),
26
44
  // - a "ping" heartbeat, every ten minutes or so while the deck runs, which
27
- // 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.
28
47
  // It writes no event and carries the install id alone — no facts, no
29
48
  // fingerprint.
30
49
  //
@@ -61,24 +80,38 @@
61
80
  //
62
81
  // NOTHING HERE THROWS OR WAITS FOR ANYBODY. A report that cannot be sent is
63
82
  // dropped: it is a nicety for the people who make ccdeck, and no part of the
64
- // 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.
65
87
 
66
88
  import { createHash, randomUUID } from "node:crypto";
89
+ import { chmod, mkdir, readFile, unlink } from "node:fs/promises";
67
90
  import { arch as osArch, cpus as osCpus, homedir, hostname, platform as osPlatform, totalmem } from "node:os";
68
- import { dirname, resolve } from "node:path";
69
- import { fileURLToPath } from "node:url";
91
+ import { basename, dirname, join, resolve } from "node:path";
92
+ import { fileURLToPath, pathToFileURL } from "node:url";
70
93
  import { inApp } from "./app-host.mjs";
94
+ import { stripBom, writeFileAtomic } from "./atomic-write.mjs";
95
+ import { deckDataDir } from "./deck-home.mjs";
71
96
  import { reportsVetoed } from "./deck-prefs.mjs";
72
- import { isGitCheckout } from "./install-layout.mjs";
73
- 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";
74
100
  import { RUNNING_VERSION } from "./running-version.mjs";
75
101
  import { FEATURES, usageDay, utcDay } from "./usage-day.mjs";
76
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";
77
108
 
78
109
  export const REPORTS_API = "https://api.ccdeck.dev";
79
110
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
80
111
  const TIMEOUT_MS = 6000;
81
- /** 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. */
82
115
  const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
83
116
  /** The heartbeat's beat: a lighter timer than the check-in that only moves the
84
117
  * install's "last seen", so the admin can count who is online now. Ten minutes,
@@ -86,15 +119,26 @@ const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
86
119
  * not all beat on the same tick. */
87
120
  const PING_EVERY_MS = 10 * 60 * 1000;
88
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;
89
128
  /** Errors per hour this deck may send, and how long one error is not repeated. */
90
129
  const ERRORS_PER_HOUR = 20;
91
130
  const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
92
131
  const MESSAGE_MAX = 500;
93
132
  const STACK_MAX = 4000;
94
133
 
134
+ /** Why somebody uninstalled, when they said: the answers `ccdeck --uninstall`
135
+ * offers (bin/cli/leaving.js), and nothing else ever leaves as a reason. */
136
+ export const UNINSTALL_REASONS = Object.freeze(["not-useful", "too-noisy", "broken", "other-tool", "privacy", "other"]);
137
+
95
138
  const EMPTY_REPORT = Object.freeze({
96
139
  installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
97
- installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
140
+ installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false, ref: "", selfUpdate: null,
141
+ rating: normaliseRating(null),
98
142
  });
99
143
 
100
144
  /** Is this deck sending reports right now? The switch, which is on unless
@@ -210,16 +254,38 @@ function intlLocale() {
210
254
  }
211
255
 
212
256
  /** The shell as a bare name — "zsh", "bash", "fish". `SHELL` names it on POSIX;
213
- * on Windows there is no `SHELL` (unless a Git-Bash-style one set it), so the
214
- * 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). */
215
259
  export function shellToken(env = process.env, platform = process.platform) {
216
260
  const sh = env?.SHELL;
217
261
  if (sh) return token(baseName(sh), TOKEN_CAPS.shell);
218
- if (platform === "win32") {
219
- if (env?.PSModulePath) return "pwsh";
220
- const comSpec = env?.ComSpec ?? env?.COMSPEC;
221
- return comSpec ? token(baseName(comSpec), TOKEN_CAPS.shell) : "cmd";
222
- }
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";
223
289
  return undefined;
224
290
  }
225
291
 
@@ -284,22 +350,132 @@ export function installFacts({
284
350
  return facts;
285
351
  }
286
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
+
287
434
  /**
288
435
  * Take out of an error what could say who someone is.
289
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
+ *
290
456
  * The catch-all for long opaque strings deliberately stops at a slash: with one
291
457
  * in it, the pattern swallowed every long file path, and a stack whose frames
292
458
  * read `at save (~/.<secret>.mjs:40:3)` tells nobody where the bug is.
293
459
  * The API scrubs again;
294
- * this is the pass that means it never has to: this user's home folder, anyone
295
- * 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.
296
462
  */
297
- export function scrub(text, home = homedir()) {
463
+ export function scrub(text, home = homedir(), root = PKG_ROOT) {
298
464
  let out = String(text ?? "");
299
- 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"), "~");
300
471
  return out
301
472
  .replace(/\/(?:home|Users)\/[^/\s:'"]+/g, "~")
302
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>")
303
479
  .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
304
480
  .replace(
305
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,
@@ -318,10 +494,16 @@ export function scrub(text, home = homedir()) {
318
494
  * @param {NodeJS.ProcessEnv} [deps.env]
319
495
  * @param {ReturnType<typeof installFacts>} [deps.facts]
320
496
  * @param {string} [deps.home]
497
+ * @param {string} [deps.root] the deck's own package, whose paths an error keeps (see scrub)
321
498
  * @param {Promise<unknown>} [deps.ready] what start() waits for: the prefs read at import
322
499
  * @param {() => { claudeVersion?: string, codexVersion?: string }} [deps.versions] the CLI
323
500
  * versions to fold into the facts, read fresh on every send so a background
324
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.
325
507
  * @param {ReturnType<typeof import("./usage-day.mjs").createUsageDay>} [deps.usage]
326
508
  * the day's tally: the "active" report carries the last finished day's totals,
327
509
  * read only when an "active" is about to be sent, and the tally is saved into
@@ -330,6 +512,27 @@ export function scrub(text, home = homedir()) {
330
512
  * boot set up, folded into install, update and active (activation.mjs).
331
513
  * @param {() => Promise<unknown>} [deps.setupKnown] what start() waits for besides the
332
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.
333
536
  */
334
537
  export function createReporter({
335
538
  fetchImpl = globalThis.fetch,
@@ -338,16 +541,28 @@ export function createReporter({
338
541
  env = process.env,
339
542
  facts = installFacts({ env }),
340
543
  home = homedir(),
544
+ root = PKG_ROOT,
341
545
  ready = prefsRead,
342
546
  versions = () => ({}),
547
+ probe = async () => {},
343
548
  usage = usageDay,
344
549
  setup = setupFacts,
345
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,
346
559
  } = {}) {
347
560
  const errorsSent = [];
348
561
  const lastSentAt = new Map();
349
562
  let timer = null;
350
563
  let pingTimer = null;
564
+ /** How long this run took to start, read when start() runs; null before. */
565
+ let launchMs = null;
351
566
 
352
567
  /** The CLI versions to add to the facts right now, tokenised and omitted when
353
568
  * not yet known — read on every send so the background probe's result appears
@@ -364,6 +579,41 @@ export function createReporter({
364
579
  }
365
580
  }
366
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
+
367
617
  /** The full facts an event carries: the fixed install facts, plus whatever CLI
368
618
  * versions are known this moment. */
369
619
  function factsNow() {
@@ -384,6 +634,25 @@ export function createReporter({
384
634
  }
385
635
  }
386
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
+
387
656
  /** The coarse usage the "active" report carries: the last finished day before
388
657
  * `today`, its date as `usageDay` and its counts beside it. Nothing at all
389
658
  * when there is no such day yet — a first launch has no yesterday — and only
@@ -400,12 +669,45 @@ export function createReporter({
400
669
  // The features used that day, as names off the fixed list — an empty list
401
670
  // is a real answer (the deck ran, nothing was opened), not a missing one.
402
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);
403
677
  return out;
404
678
  } catch {
405
679
  return {};
406
680
  }
407
681
  }
408
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
+
409
711
  /** The tally the last save wrote, so a save with nothing new writes nothing. */
410
712
  let usageSaved = -1;
411
713
 
@@ -435,6 +737,9 @@ export function createReporter({
435
737
  }
436
738
  }
437
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. */
438
743
  async function call(method, path, body) {
439
744
  try {
440
745
  const res = await fetchImpl(REPORTS_API + path, {
@@ -443,12 +748,94 @@ export function createReporter({
443
748
  body: body === undefined ? undefined : JSON.stringify(body),
444
749
  signal: AbortSignal.timeout(TIMEOUT_MS),
445
750
  });
446
- 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";
447
769
  } catch {
448
770
  return false;
449
771
  }
450
772
  }
451
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
+
452
839
  /** Change the reporter's state, but only while it still belongs to `installId`:
453
840
  * a switch-off that landed in between has the last word. */
454
841
  function remember(installId, change) {
@@ -457,14 +844,40 @@ export function createReporter({
457
844
  );
458
845
  }
459
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
+
460
851
  /** Send whatever is due: a pending deletion, then install or update, then
461
852
  * today's "active". Never throws: a floating rejection here would be an
462
853
  * unhandled one, and Node's answer to those is to end the process. */
463
854
  async function checkIn() {
855
+ checkingIn++;
464
856
  try {
465
857
  await sendWhatIsDue();
466
858
  } catch {
467
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;
468
881
  }
469
882
  }
470
883
 
@@ -474,45 +887,72 @@ export function createReporter({
474
887
  if (reportsVetoed(env)) return;
475
888
  const p = prefs.current();
476
889
  const { forget } = p.report ?? EMPTY_REPORT;
477
- 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") {
478
893
  await prefs.update(prev => (prev.report.forget === forget ? { report: { ...prev.report, forget: "" } } : undefined));
479
894
  }
480
895
  if (!reportsOn(prefs.current(), env)) return;
481
896
 
482
897
  // The id is made here, the first time there is something to send under it.
483
898
  if (!prefs.current().report.installId) {
899
+ const fresh = isFirstRun();
484
900
  await prefs.update(prev =>
485
901
  prev.reports === false || prev.report.installId
486
902
  ? undefined
487
- // When the id is made is when this install began: the clock the first
488
- // session is measured against. Kept here, never sent as a time.
489
- : { 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() : "" } },
490
909
  );
491
910
  }
492
911
  const { installId, lastVersion, lastActiveDay } = prefs.current().report;
493
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();
494
918
  if (!lastVersion) {
495
- if (await call("POST", "/v1/app/events", { installId, kind: "install", ...factsNow(), ...setupNow() })) {
496
- 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 });
497
930
  }
498
931
  } else if (lastVersion !== facts.version) {
499
- if (await call("POST", "/v1/app/events", { installId, kind: "update", fromVersion: lastVersion, ...factsNow(), ...setupNow() })) {
500
- 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 });
501
940
  }
502
941
  }
503
942
 
504
- const today = utcDay(now());
505
943
  if (lastActiveDay !== today) {
506
944
  // Usage rides on the "active" event alone, and is read only here — the one
507
945
  // report it belongs to — so a checkIn that sends nothing new never asks the
508
946
  // deck for its counts.
509
947
  const used = coarseUsage(today);
510
- 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 }))) {
511
950
  if (used.usageDay) usage.markSent(used.usageDay);
512
951
  await remember(installId, { lastActiveDay: today });
513
952
  }
514
953
  }
515
954
  await sendActivation();
955
+ await sendRating();
516
956
  await saveUsage();
517
957
  }
518
958
 
@@ -521,7 +961,8 @@ export function createReporter({
521
961
  * after the install it came, in a bucket, and which CLI it came from.
522
962
  *
523
963
  * Only for an install whose start this deck saw — `installedAt` is made with
524
- * 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
525
966
  * measured: its first session was long ago, and "now" would be a lie. The time
526
967
  * is taken from the first use this run heard, so a session that arrived before
527
968
  * the first check-in had an id is still dated when it happened. A send that
@@ -540,7 +981,140 @@ export function createReporter({
540
981
  const body = { installId: r.installId, kind: "activated", ...factsNow(), ...setupNow() };
541
982
  addIf(body, "firstSession", sinceInstallBucket(r.installedAt, r.firstSessionAt));
542
983
  addIf(body, "firstProvider", r.firstProvider === "codex" || r.firstProvider === "claude" ? r.firstProvider : undefined);
543
- 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
+ }
1091
+ }
1092
+
1093
+ /** Whether a report would go out right now: reports on, the machine not
1094
+ * vetoing them, and an install id to send under. `ccdeck --uninstall` asks
1095
+ * before it asks the person anything. Never throws. */
1096
+ async function willReport() {
1097
+ try {
1098
+ await Promise.resolve(ready).catch(() => {});
1099
+ const p = prefs.current();
1100
+ return reportsOn(p, env) && Boolean(p.report.installId);
1101
+ } catch {
1102
+ return false;
1103
+ }
1104
+ }
1105
+
1106
+ /** The install leaving: `ccdeck --uninstall`, with the reason picked off
1107
+ * UNINSTALL_REASONS if one was. The id stays: a deck run again later is the
1108
+ * same install coming back. Resolves to whether it got through; never throws. */
1109
+ async function reportUninstall(reason) {
1110
+ try {
1111
+ if (!(await willReport())) return false;
1112
+ const body = { installId: prefs.current().report.installId, kind: "uninstall", ...factsNow() };
1113
+ addIf(body, "reason", UNINSTALL_REASONS.includes(reason) ? reason : undefined);
1114
+ return (await call("POST", "/v1/app/events", body)) === "sent";
1115
+ } catch {
1116
+ return false;
1117
+ }
544
1118
  }
545
1119
 
546
1120
  /** The first session this run heard, sent the moment it lands rather than at
@@ -559,11 +1133,13 @@ export function createReporter({
559
1133
  * dropped if it cannot get through, and it never throws. No id yet means the
560
1134
  * first check-in has not run, so there is nothing to be online under and
561
1135
  * nothing is sent; the check-in that makes the id is what puts this deck
562
- * 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. */
563
1139
  async function ping() {
564
1140
  try {
565
1141
  const p = prefs.current();
566
- if (!reportsOn(p, env) || !p.report.installId) return;
1142
+ if (!reportsOn(p, env) || !p.report.installId || !askedWaitOver()) return;
567
1143
  await call("POST", "/v1/app/ping", { installId: p.report.installId });
568
1144
  } catch {
569
1145
  // The lightest of the niceties: a heartbeat that could not be sent is gone.
@@ -581,6 +1157,7 @@ export function createReporter({
581
1157
  reports: false,
582
1158
  report: { ...EMPTY_REPORT, forget: prev.report.installId || prev.report.forget },
583
1159
  }));
1160
+ await keepRating();
584
1161
  await checkIn();
585
1162
  }
586
1163
 
@@ -592,7 +1169,7 @@ export function createReporter({
592
1169
  async function reportError(where, error) {
593
1170
  const p = prefs.current();
594
1171
  if (!reportsOn(p, env) || !p.report.installId) return false;
595
- 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();
596
1173
  if (!message) return false;
597
1174
  const at = now().getTime();
598
1175
  while (errorsSent.length && at - errorsSent[0] > SAME_ERROR_QUIET_MS) errorsSent.shift();
@@ -600,11 +1177,23 @@ export function createReporter({
600
1177
  if (errorsSent.length >= ERRORS_PER_HOUR || at - (lastSentAt.get(key) ?? -Infinity) < SAME_ERROR_QUIET_MS) return false;
601
1178
  errorsSent.push(at);
602
1179
  lastSentAt.set(key, at);
603
- 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;
604
1181
  // Less the runtime, and less the deviceId — an error is not one of the events
605
1182
  // the fingerprint rides on, so the personal field never leaves on one.
606
1183
  const { runtime: _runtime, deviceId: _deviceId, ...fields } = factsNow();
607
- 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();
608
1197
  }
609
1198
 
610
1199
  /** The heartbeat timer, rescheduled from itself so each beat carries fresh
@@ -613,7 +1202,8 @@ export function createReporter({
613
1202
  * tally if it moved, so a deck that stops loses ten minutes of it at most. */
614
1203
  function scheduleNextPing() {
615
1204
  pingTimer = setTimeout(() => {
616
- void ping();
1205
+ samplePeak();
1206
+ void beat();
617
1207
  void saveUsage();
618
1208
  scheduleNextPing();
619
1209
  }, PING_EVERY_MS + Math.floor(Math.random() * PING_JITTER_MS));
@@ -621,16 +1211,34 @@ export function createReporter({
621
1211
  }
622
1212
 
623
1213
  /** Check in once the prefs are read, and every few hours after; and beat a
624
- * heartbeat on its own, lighter timer. Both timers never hold the process
625
- * open. The first check-in already puts a freshly launched deck online, so the
626
- * 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. */
627
1218
  function start() {
628
- 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(() => {})])
629
1236
  .then(restoreUsage)
1237
+ .then(restoreRating)
630
1238
  .then(checkIn)
631
1239
  .then(() => usage.onFirstUse?.(() => void onFirstSession()));
632
1240
  if (!timer) {
633
- timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
1241
+ timer = setInterval(() => { if (retryDue()) void checkIn(); }, CHECK_IN_EVERY_MS);
634
1242
  timer.unref?.();
635
1243
  }
636
1244
  if (!pingTimer) scheduleNextPing();
@@ -643,7 +1251,10 @@ export function createReporter({
643
1251
  pingTimer = null;
644
1252
  }
645
1253
 
646
- return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, sendActivation, start, stop };
1254
+ return {
1255
+ checkIn, ping, setReports, reportError, restoreUsage, restoreRating, saveUsage, sendActivation, willReport,
1256
+ reportUninstall, noteSelfUpdate, ratingAsk, rate, rateLater, sendRating, start, stop,
1257
+ };
647
1258
  }
648
1259
 
649
1260
  // ── the deck's own providers ─────────────────────────────────────────────────
@@ -654,22 +1265,52 @@ export function createReporter({
654
1265
  // `--version` that imports it for nothing. The day's usage tally is imported
655
1266
  // directly: usage-day.mjs imports nothing.
656
1267
 
657
- // The CLI versions, detected once in the background, off the boot path. The
658
- // first send starts the probe (past boot, and not while the machine has vetoed
659
- // reports) and reads back an empty answer; the version rides along on the
660
- // 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.
661
1273
  let cliVersionCache = {};
662
- let cliProbeStarted = false;
663
- function deckCliVersions() {
664
- if (!cliProbeStarted && !reportsVetoed(process.env)) {
665
- cliProbeStarted = true;
666
- import("./cli-versions.mjs")
667
- .then(m => m.detectCliVersions())
668
- .then(v => { cliVersionCache = { ...cliVersionCache, ...v }; })
669
- .catch(() => { /* a probe that could not run leaves the versions unsent */ });
670
- }
671
- 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
+ };
672
1310
  }
673
1311
 
674
1312
  /** The deck's own reporter. */
675
- 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
+ });