ccdeck 3.33.1 → 3.34.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.
@@ -62,8 +62,8 @@
62
62
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
63
63
  })();
64
64
  </script>
65
- <script type="module" crossorigin src="/assets/index-D7ddxIpC.js"></script>
66
- <link rel="stylesheet" crossorigin href="/assets/index-BIANlQ9A.css">
65
+ <script type="module" crossorigin src="/assets/index-CSDSEC1Q.js"></script>
66
+ <link rel="stylesheet" crossorigin href="/assets/index-DglpEGvW.css">
67
67
  </head>
68
68
  <body>
69
69
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.33.1",
4
- "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
3
+ "version": "3.34.1",
4
+ "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ccdeck": "bin/agent-dag.js"
@@ -44,16 +44,21 @@
44
44
  "dashboard",
45
45
  "monitor",
46
46
  "monitoring",
47
- "observability",
48
- "usage",
49
47
  "cost",
50
- "tokens",
51
48
  "quota",
52
49
  "hooks",
53
50
  "dag",
54
51
  "visualization",
55
- "cli",
56
- "deck"
52
+ "claude-code-monitor",
53
+ "claude-code-sessions",
54
+ "session-monitor",
55
+ "codex-dashboard",
56
+ "claude-code-subagents",
57
+ "parallel-sessions",
58
+ "desktop-app",
59
+ "ccusage",
60
+ "claude-swap",
61
+ "local-first"
57
62
  ],
58
63
  "author": "Bargan Constantin",
59
64
  "license": "AGPL-3.0-only",
@@ -60,6 +60,26 @@
60
60
  "3.29.9",
61
61
  "3.33.1"
62
62
  ],
63
+ "3.34.1": [
64
+ {
65
+ "title": "🗂️ The tray menu stays open while you read it",
66
+ "body": "On Linux the menu behind the tray icon could close by itself while agents were working, often before the row you pressed took the click, so it had to be opened and pressed again. It now changes only when something it says has changed, and stays open while you choose."
67
+ }
68
+ ],
69
+ "3.34.0": [
70
+ {
71
+ "title": "🗺️ The network map shows who you are connected to",
72
+ "body": "Paired decks that are on stand closest to this one, and a light runs along their wires. Paired decks that are away come next, in a colour of their own, with how long they have been gone, and machines that are not paired stand outside in grey. Decks reached over Tailscale sit together in their own shaded slice instead of on dashed wires. Pointing at a deck opens its details beside the map: whether it is there, what to do next if anything, its address and what it runs, and every login the two share, the ones that need a look first."
73
+ },
74
+ {
75
+ "title": "🔑 A login that expired everywhere says so",
76
+ "body": "When a shared login expired on this deck and every paired deck that is on held an expired copy too, the row said \"Login expired\" and waited for a repair that could never come. It now says \"Login expired on all online decks\". Claude retires a login's other copies each time one machine refreshes it, so shared copies expire together: sign in again on any one machine and the others repair from it on their next round."
77
+ },
78
+ {
79
+ "title": "💬 Send feedback from inside the deck",
80
+ "body": "Appearance settings has a \"Send feedback…\" button: pick what kind it is, write a title and a few lines, and add a way to reach you if you want an answer. It goes to the people who make ccdeck, who may open a public issue from it, never with your contact in it. If it cannot be sent, the form stays filled in and says why."
81
+ }
82
+ ],
63
83
  "3.33.0": [
64
84
  {
65
85
  "title": "🪝 Hooks you add in Claude Code are no longer removed",
@@ -78,6 +78,19 @@ export const DEFAULTS = Object.freeze({
78
78
  // `auto when idle`, which defaulted on as a localStorage key; it moved here so
79
79
  // the server can read it with no page open.
80
80
  autoUpdate: true,
81
+ // ANONYMOUS REPORTS (#1853), ON unless somebody turns them off — the owner's
82
+ // decision, 2026-09-30: nobody is asked, the README says what is sent, and
83
+ // Appearance holds the switch. See reports.mjs for what is sent, and
84
+ // reportsVetoed for the launch-time veto that wins over this.
85
+ reports: true,
86
+ // What reports.mjs keeps between runs, and the page never sees
87
+ // (publicPrefs). `installId` is the random id made at the first check-in and
88
+ // dropped when reports are switched off; `lastVersion` and `lastActiveDay`
89
+ // are what the install last said, so an update is told once and "active" at
90
+ // most once a day; `forget` is an id whose deletion has been asked for and
91
+ // not yet acknowledged, retried on the next start so switching off while
92
+ // offline still ends in a deletion.
93
+ report: Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "" }),
81
94
  // LAN sync, ON unless somebody turns it off (since 3.22.7; off before).
82
95
  // `passphrase` is the only secret this file has ever held, which is why the
83
96
  // write below names a mode. AGENTS_DECK_NO_LAN=1 keeps a deck off the network
@@ -266,10 +279,24 @@ export function normalise(raw) {
266
279
  notifications: flagOr(src.notifications, DEFAULTS.notifications),
267
280
  tourSeen: flagOr(src.tourSeen, DEFAULTS.tourSeen),
268
281
  autoUpdate: flagOr(src.autoUpdate, DEFAULTS.autoUpdate),
282
+ reports: flagOr(src.reports, DEFAULTS.reports),
283
+ report: normaliseReport(src.report),
269
284
  lan: normaliseLan(src.lan),
270
285
  };
271
286
  }
272
287
 
288
+ /** The reporter's own state, coerced: four strings, empty when absent. */
289
+ function normaliseReport(raw) {
290
+ const src = raw && typeof raw === "object" ? raw : {};
291
+ const text = v => (typeof v === "string" ? v : "");
292
+ return {
293
+ installId: text(src.installId),
294
+ lastVersion: text(src.lastVersion),
295
+ lastActiveDay: text(src.lastActiveDay),
296
+ forget: text(src.forget),
297
+ };
298
+ }
299
+
273
300
  // How a refusal to write is told — the error writePrefs throws, and the reason
274
301
  // and the detail the route hands the panel: prefs-refusal.mjs.
275
302
  export { prefsRefusalDetail, prefsWriteRefusal };
@@ -578,7 +605,9 @@ async function save(mutate, home, deps) {
578
605
  * page draws. What a page shows is a name and a fingerprint.
579
606
  */
580
607
  export function publicPrefs(prefs) {
581
- const p = normalise(prefs);
608
+ // The reporter's state is the server's alone: a page has no use for the
609
+ // install id, and one that could read it could send reports as this install.
610
+ const { report: _report, ...p } = normalise(prefs);
582
611
  // unpaired is engine-authored state too. No page draws or edits it; keeping
583
612
  // it out also means a future whole-prefs form cannot replay a stale unpair
584
613
  // list over a decision the engine made after the form loaded.
@@ -630,3 +659,10 @@ export function lanEnabled(prefs, env = process.env) {
630
659
  export function notificationsVetoed(env = process.env) {
631
660
  return env[OFF_ENV] === "1";
632
661
  }
662
+
663
+ /** Did the machine rule out anonymous reports (#1853), whatever the switch says?
664
+ * AGENTS_DECK_NO_REPORTS=1 does, and so does AGENTS_DECK_NO_INSTALL=1, which
665
+ * the README promises "turns off everything but the quota reads". */
666
+ export function reportsVetoed(env = process.env) {
667
+ return env.AGENTS_DECK_NO_REPORTS === "1" || env.AGENTS_DECK_NO_INSTALL === "1";
668
+ }
@@ -135,6 +135,10 @@ import { applyLanPrefs, resetLanLoaded } from "./lan-deck.mjs";
135
135
  import { handleLanInvite, handleLanPeer, handleLanStatus, handleLanSync } from "./lan-routes.mjs";
136
136
  // GET and POST /api/prefs — see prefs-routes.mjs.
137
137
  import { handlePrefsGiveBack, handlePrefsRead, handlePrefsWrite } from "./prefs-routes.mjs";
138
+ // Anonymous reports and the feedback dialog (#1853). The requests to
139
+ // api.ccdeck.dev are made in reports.mjs, never in this file.
140
+ import { handleClientError, handleFeedback, handleReportsWrite } from "./reports-routes.mjs";
141
+ import { reporter } from "./reports.mjs";
138
142
  import { MANIFEST_PATH, offerManifest } from "./app-manifest.mjs";
139
143
  import { historySnapshot, processesReply, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
140
144
  // How every route reads a body and answers, and the answer for one that threw
@@ -213,7 +217,14 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
213
217
  // default for an unhandled rejection is to kill the process, which would
214
218
  // take the whole deck down — SSE stream, hook ingest and all — because one
215
219
  // background quota poll hit a network error. Answer the request instead.
216
- const guard = (p, res) => Promise.resolve(p).catch(err => sendInternalError(res, err));
220
+ //
221
+ // The error also goes to reporter.reportError, which sends it (scrubbed)
222
+ // unless reports are switched off or vetoed, and never throws.
223
+ const failed = (res, err) => {
224
+ sendInternalError(res, err);
225
+ reporter.reportError("server", err).catch(() => {});
226
+ };
227
+ const guard = (p, res) => Promise.resolve(p).catch(err => failed(res, err));
217
228
 
218
229
  // Which of the eleven candidates below this deck ended up on, set the moment
219
230
  // one of them binds. Declared here rather than beside the loop that fills it
@@ -295,6 +306,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
295
306
  if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
296
307
  if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
297
308
  if (req.method === "POST" && url.pathname === "/api/prefs/give-back") return guard(handlePrefsGiveBack(req, res), res);
309
+ if (req.method === "POST" && url.pathname === "/api/reports") return guard(handleReportsWrite(req, res), res);
310
+ if (req.method === "POST" && url.pathname === "/api/feedback") return guard(handleFeedback(req, res), res);
311
+ if (req.method === "POST" && url.pathname === "/api/client-error") return guard(handleClientError(req, res), res);
298
312
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
299
313
  // On demand only — the process list costs a subprocess on every platform,
300
314
  // so it is fetched while the detail panel is open and never on the timer.
@@ -430,7 +444,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
430
444
  try {
431
445
  route(req, res);
432
446
  } catch (err) {
433
- sendInternalError(res, err);
447
+ failed(res, err);
434
448
  }
435
449
  });
436
450
 
@@ -467,6 +481,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
467
481
  // deck that had it on comes back with it on, and a launcher that only
468
482
  // asked the registry never binds a port it is about to walk away from.
469
483
  prefsRead.then(() => applyLanPrefs()).catch(() => {});
484
+ // Anonymous reports (#1853): what is due once the prefs are read, then a
485
+ // check every few hours on an unref'd timer. Switched off in Appearance,
486
+ // or with AGENTS_DECK_NO_REPORTS / AGENTS_DECK_NO_INSTALL, nothing is sent.
487
+ reporter.start();
470
488
  // Auto-switch resumes only if the user previously turned it on; the
471
489
  // module reads its own persisted flag and does nothing otherwise. Its
472
490
  // ticks wait for `cswapQuiet`, the launcher's word that claude-swap is not
@@ -7,7 +7,7 @@
7
7
  // engine had (lan-deck.mjs): what is left of them is a body read, a write
8
8
  // through the one, a push to the other, and an answer. The notifier stays in
9
9
  // index.mjs, beside the desktop-app connections it notifies through.
10
- import { notificationsOn, notificationsVetoed, prefsRefusalDetail, prefsWriteRefusal, publicPrefs } from "./deck-prefs.mjs";
10
+ import { notificationsOn, notificationsVetoed, prefsRefusalDetail, prefsWriteRefusal, publicPrefs, reportsVetoed } from "./deck-prefs.mjs";
11
11
  import { PRODUCT } from "./brand.mjs";
12
12
  import { heldPrefs } from "./prefs-state.mjs";
13
13
  import { applyLanPrefs, forgetReach, resetLanLoaded } from "./lan-deck.mjs";
@@ -38,6 +38,7 @@ function prefsPayload() {
38
38
  prefs: publicPrefs(prefs),
39
39
  notificationsAllowed: notificationsOn(prefs),
40
40
  notificationsVetoed: notificationsVetoed(),
41
+ reportsVetoed: reportsVetoed(),
41
42
  };
42
43
  }
43
44
 
@@ -51,8 +52,12 @@ export async function handlePrefsWrite(req, res) {
51
52
  let body = null;
52
53
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
53
54
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
55
+ // The reports switch has consequences a plain write would skip — a deletion
56
+ // asked for — so it is changed only through /api/reports, and its state never
57
+ // by the page at all (#1853).
58
+ const { reports: _reports, report: _report, ...patch } = body;
54
59
  try {
55
- await heldPrefs.write(body);
60
+ await heldPrefs.write(patch);
56
61
  } catch (err) {
57
62
  // A settings file or folder this user cannot reach is the machine's to fix,
58
63
  // not the deck's, and `guard`'s bare 500 left the panel with nothing to say
@@ -0,0 +1,75 @@
1
+ // The page's three ways into reports.mjs (#1853): the switch in Appearance, an
2
+ // error the page caught, and the feedback dialog. The page never
3
+ // talks to api.ccdeck.dev itself — this server does, which is also why the
4
+ // install id never has to reach the page.
5
+
6
+ import { reportsVetoed } from "./deck-prefs.mjs";
7
+ import { readBody, send } from "./http-io.mjs";
8
+ import { heldPrefs } from "./prefs-state.mjs";
9
+ import { REPORTS_API, installFacts, reporter } from "./reports.mjs";
10
+
11
+ async function readJson(req, res, limit) {
12
+ const raw = await readBody(req, res, limit).catch(() => null);
13
+ try {
14
+ const body = JSON.parse(raw ?? "");
15
+ return body && typeof body === "object" ? body : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ /** `POST /api/reports` `{ on }`: Appearance's "Send anonymous reports" switch. */
22
+ export async function handleReportsWrite(req, res, { report = reporter } = {}) {
23
+ const body = await readJson(req, res, 1_000);
24
+ if (typeof body?.on !== "boolean") return send(res, 400, { ok: false, reason: "bad_request" });
25
+ await report.setReports(body.on);
26
+ return send(res, 200, { ok: true, reports: heldPrefs.current().reports, reportsVetoed: reportsVetoed() });
27
+ }
28
+
29
+ /** `POST /api/client-error` `{ message, stack? }`: an error the page caught. Sent on only while reports are on. */
30
+ export async function handleClientError(req, res, { report = reporter } = {}) {
31
+ const body = await readJson(req, res, 16_000);
32
+ if (typeof body?.message !== "string") return send(res, 400, { ok: false, reason: "bad_request" });
33
+ const stack = typeof body.stack === "string" ? body.stack : undefined;
34
+ const sent = await report.reportError("web", { message: body.message, stack });
35
+ return send(res, 202, { ok: true, sent });
36
+ }
37
+
38
+ /**
39
+ * `POST /api/feedback` `{ kind, title, body, contact? }`: the feedback dialog.
40
+ *
41
+ * The API stores it, and the people who make ccdeck read it there. They may
42
+ * open a public issue from it, never with the contact; nothing becomes public
43
+ * by being sent, which is why the answer to the page is a plain "it arrived"
44
+ * and carries no link.
45
+ *
46
+ * Not gated on the reports switch: pressing Send is its own decision, for this
47
+ * one message. AGENTS_DECK_NO_INSTALL=1 still wins, because the README promises
48
+ * that it "turns off everything but the quota reads", and a launch script's
49
+ * word about the machine outranks a button on the page.
50
+ */
51
+ export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
52
+ if (env.AGENTS_DECK_NO_INSTALL === "1") return send(res, 403, { ok: false, reason: "vetoed" });
53
+ const body = await readJson(req, res, 32_000);
54
+ if (!body) return send(res, 400, { ok: false, reason: "bad_request" });
55
+ const facts = installFacts({ env });
56
+ const contact = typeof body.contact === "string" && body.contact.trim() ? body.contact.trim() : undefined;
57
+ try {
58
+ const upstream = await fetchImpl(`${REPORTS_API}/v1/feedback`, {
59
+ method: "POST",
60
+ headers: { "content-type": "application/json", "user-agent": `ccdeck/${facts.version}` },
61
+ body: JSON.stringify({
62
+ kind: body.kind, title: body.title, body: body.body, contact,
63
+ appVersion: facts.version, platform: `${facts.os}-${facts.arch}`,
64
+ }),
65
+ signal: AbortSignal.timeout(20_000),
66
+ });
67
+ const answer = await upstream.json().catch(() => null);
68
+ if (upstream.status === 202) return send(res, 200, { ok: true });
69
+ if (upstream.status === 400) return send(res, 400, { ok: false, reason: "invalid", errors: answer?.errors ?? {} });
70
+ if (upstream.status === 429) return send(res, 429, { ok: false, reason: "too_many" });
71
+ return send(res, 502, { ok: false, reason: "unavailable" });
72
+ } catch {
73
+ return send(res, 502, { ok: false, reason: "unavailable" });
74
+ }
75
+ }
@@ -0,0 +1,252 @@
1
+ // Anonymous reports, on unless the person switched them off (#1853).
2
+ //
3
+ // WHAT GOES OUT, to api.ccdeck.dev, while `prefs.reports` is true and the
4
+ // machine has not vetoed it (reportsVetoed):
5
+ //
6
+ // - an "install" event the first time,
7
+ // - an "update" event, with the version it came from, when the version moves,
8
+ // - an "active" event at most once a UTC day, which is how many people use
9
+ // ccdeck gets counted without counting anything else about them,
10
+ // - errors: a request handler that threw on this server, or an error the page
11
+ // caught, with home folders, email addresses and key-shaped strings
12
+ // scrubbed out before they leave.
13
+ //
14
+ // Each carries the install id, the version, the OS and CPU architecture, the
15
+ // channel (the desktop app, npm, or a source checkout) and the runtime. Nothing about a session, a
16
+ // project, a prompt, a path or a person: the install id is random, made at the
17
+ // first check-in, and tied to nothing on the machine.
18
+ //
19
+ // NOBODY IS ASKED, AND NOTHING IS HIDDEN. The owner chose on-by-default
20
+ // (2026-09-30): the README says what is sent, Appearance holds the switch, and
21
+ // AGENTS_DECK_NO_REPORTS=1 keeps it off from the first start.
22
+ //
23
+ // SWITCHING IT OFF DELETES WHAT WAS SENT. The id is forgotten here and the API
24
+ // is asked to drop every event and error it holds for it; if that request
25
+ // cannot get through, the id waits in `report.forget` and the ask is repeated
26
+ // on the next start, so switching off while offline still ends in a deletion.
27
+ //
28
+ // NOTHING HERE THROWS OR WAITS FOR ANYBODY. A report that cannot be sent is
29
+ // dropped: it is a nicety for the people who make ccdeck, and no part of the
30
+ // deck depends on it.
31
+
32
+ import { randomUUID } from "node:crypto";
33
+ import { homedir } from "node:os";
34
+ import { dirname, resolve } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { inApp } from "./app-host.mjs";
37
+ import { reportsVetoed } from "./deck-prefs.mjs";
38
+ import { isGitCheckout } from "./install-layout.mjs";
39
+ import { heldPrefs, prefsRead } from "./prefs-state.mjs";
40
+ import { RUNNING_VERSION } from "./running-version.mjs";
41
+
42
+ export const REPORTS_API = "https://api.ccdeck.dev";
43
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
44
+ const TIMEOUT_MS = 6000;
45
+ /** How often a long-running deck checks whether a new day wants its "active". */
46
+ const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
47
+ /** Errors per hour this deck may send, and how long one error is not repeated. */
48
+ const ERRORS_PER_HOUR = 20;
49
+ const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
50
+ const MESSAGE_MAX = 500;
51
+ const STACK_MAX = 4000;
52
+
53
+ const EMPTY_REPORT = Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "" });
54
+
55
+ /** Is this deck sending reports right now? The switch, which is on unless
56
+ * somebody turned it off, and the machine's consent. */
57
+ export function reportsOn(prefs, env = process.env) {
58
+ return !reportsVetoed(env) && prefs?.reports !== false;
59
+ }
60
+
61
+ /** What every report says about the install, and nothing else. */
62
+ export function installFacts({
63
+ version = RUNNING_VERSION,
64
+ platform = process.platform,
65
+ arch = process.arch,
66
+ versions = process.versions,
67
+ env = process.env,
68
+ checkout = isGitCheckout(PKG_ROOT),
69
+ } = {}) {
70
+ return {
71
+ version: String(version),
72
+ os: platform,
73
+ arch,
74
+ // A deck run out of a git checkout is somebody developing ccdeck, and it
75
+ // says so, so the people counting installs can leave it out.
76
+ channel: inApp(env) ? "desktop" : checkout ? "checkout" : "npm",
77
+ runtime: versions.electron ? `electron-${versions.electron}` : `node-${versions.node}`,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Take out of an error what could say who someone is.
83
+ *
84
+ * The catch-all for long opaque strings deliberately stops at a slash: with one
85
+ * in it, the pattern swallowed every long file path, and a stack whose frames
86
+ * read `at save (~/.<secret>.mjs:40:3)` tells nobody where the bug is.
87
+ * The API scrubs again;
88
+ * this is the pass that means it never has to: this user's home folder, anyone
89
+ * else's home folder, email addresses, and strings shaped like keys and tokens.
90
+ */
91
+ export function scrub(text, home = homedir()) {
92
+ let out = String(text ?? "");
93
+ if (home && home.length > 1) out = out.split(home).join("~");
94
+ return out
95
+ .replace(/\/(?:home|Users)\/[^/\s:'"]+/g, "~")
96
+ .replace(/[A-Za-z]:\\(?:Users|Documents and Settings)\\[^\\\r\n:'"]+/gi, "~")
97
+ .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
98
+ .replace(
99
+ /\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,
100
+ "<secret>",
101
+ );
102
+ }
103
+
104
+ /**
105
+ * The reporter, with everything it touches passed in so the suite can run it
106
+ * against a fake API, a fake clock and a prefs store in memory.
107
+ *
108
+ * @param {object} [deps]
109
+ * @param {typeof fetch} [deps.fetchImpl]
110
+ * @param {() => Date} [deps.now]
111
+ * @param {{ current: () => any, update: (mutate: (prev: any) => any) => Promise<any> }} [deps.prefs]
112
+ * @param {NodeJS.ProcessEnv} [deps.env]
113
+ * @param {ReturnType<typeof installFacts>} [deps.facts]
114
+ * @param {string} [deps.home]
115
+ * @param {Promise<unknown>} [deps.ready] what start() waits for: the prefs read at import
116
+ */
117
+ export function createReporter({
118
+ fetchImpl = globalThis.fetch,
119
+ now = () => new Date(),
120
+ prefs = heldPrefs,
121
+ env = process.env,
122
+ facts = installFacts({ env }),
123
+ home = homedir(),
124
+ ready = prefsRead,
125
+ } = {}) {
126
+ const errorsSent = [];
127
+ const lastSentAt = new Map();
128
+ let timer = null;
129
+
130
+ async function call(method, path, body) {
131
+ try {
132
+ const res = await fetchImpl(REPORTS_API + path, {
133
+ method,
134
+ headers: { "content-type": "application/json", "user-agent": `ccdeck/${facts.version}` },
135
+ body: body === undefined ? undefined : JSON.stringify(body),
136
+ signal: AbortSignal.timeout(TIMEOUT_MS),
137
+ });
138
+ return res.ok;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /** Change the reporter's state, but only while it still belongs to `installId`:
145
+ * a switch-off that landed in between has the last word. */
146
+ function remember(installId, change) {
147
+ return prefs.update(prev =>
148
+ prev.report.installId === installId ? { report: { ...prev.report, ...change } } : undefined,
149
+ );
150
+ }
151
+
152
+ /** Send whatever is due: a pending deletion, then install or update, then
153
+ * today's "active". Never throws: a floating rejection here would be an
154
+ * unhandled one, and Node's answer to those is to end the process. */
155
+ async function checkIn() {
156
+ try {
157
+ await sendWhatIsDue();
158
+ } catch {
159
+ // A settings file that cannot be written this minute; the next check-in tries again.
160
+ }
161
+ }
162
+
163
+ async function sendWhatIsDue() {
164
+ // The machine's veto covers the deletion too: a deck launched to stay off
165
+ // the network stays off it, and the deletion waits for a launch without it.
166
+ if (reportsVetoed(env)) return;
167
+ const p = prefs.current();
168
+ const { forget } = p.report ?? EMPTY_REPORT;
169
+ if (forget && (await call("DELETE", `/v1/app/installs/${encodeURIComponent(forget)}`))) {
170
+ await prefs.update(prev => (prev.report.forget === forget ? { report: { ...prev.report, forget: "" } } : undefined));
171
+ }
172
+ if (!reportsOn(prefs.current(), env)) return;
173
+
174
+ // The id is made here, the first time there is something to send under it.
175
+ if (!prefs.current().report.installId) {
176
+ await prefs.update(prev =>
177
+ prev.reports === false || prev.report.installId ? undefined : { report: { ...prev.report, installId: randomUUID() } },
178
+ );
179
+ }
180
+ const { installId, lastVersion, lastActiveDay } = prefs.current().report;
181
+ if (!installId) return;
182
+ if (!lastVersion) {
183
+ if (await call("POST", "/v1/app/events", { installId, kind: "install", ...facts })) {
184
+ await remember(installId, { lastVersion: facts.version });
185
+ }
186
+ } else if (lastVersion !== facts.version) {
187
+ if (await call("POST", "/v1/app/events", { installId, kind: "update", fromVersion: lastVersion, ...facts })) {
188
+ await remember(installId, { lastVersion: facts.version });
189
+ }
190
+ }
191
+
192
+ const today = now().toISOString().slice(0, 10);
193
+ if (lastActiveDay !== today && (await call("POST", "/v1/app/events", { installId, kind: "active", ...facts }))) {
194
+ await remember(installId, { lastActiveDay: today });
195
+ }
196
+ }
197
+
198
+ /** The switch. On checks in, which makes an id if there is none; off drops the id and asks for a deletion. */
199
+ async function setReports(on) {
200
+ if (on) {
201
+ await prefs.update(() => ({ reports: true }));
202
+ await checkIn();
203
+ return;
204
+ }
205
+ await prefs.update(prev => ({
206
+ reports: false,
207
+ report: { ...EMPTY_REPORT, forget: prev.report.installId || prev.report.forget },
208
+ }));
209
+ await checkIn();
210
+ }
211
+
212
+ /**
213
+ * Report an error, scrubbed. At most ERRORS_PER_HOUR an hour, and the same
214
+ * message is not repeated within SAME_ERROR_QUIET_MS: a handler that fails on
215
+ * every poll would otherwise send the same line all day.
216
+ */
217
+ async function reportError(where, error) {
218
+ const p = prefs.current();
219
+ if (!reportsOn(p, env) || !p.report.installId) return false;
220
+ const message = scrub(error?.message ?? error, home).slice(0, MESSAGE_MAX).trim();
221
+ if (!message) return false;
222
+ const at = now().getTime();
223
+ while (errorsSent.length && at - errorsSent[0] > SAME_ERROR_QUIET_MS) errorsSent.shift();
224
+ const key = `${where}\n${message}`;
225
+ if (errorsSent.length >= ERRORS_PER_HOUR || at - (lastSentAt.get(key) ?? -Infinity) < SAME_ERROR_QUIET_MS) return false;
226
+ errorsSent.push(at);
227
+ lastSentAt.set(key, at);
228
+ const stack = typeof error?.stack === "string" ? scrub(error.stack, home).slice(0, STACK_MAX) : undefined;
229
+ const { runtime: _runtime, ...fields } = facts;
230
+ return call("POST", "/v1/app/errors", { installId: p.report.installId, ...fields, where, message, stack });
231
+ }
232
+
233
+ /** Check in once the prefs are read, and every few hours after, on a timer
234
+ * that never holds the process open. */
235
+ function start() {
236
+ Promise.resolve(ready).catch(() => {}).then(checkIn);
237
+ if (!timer) {
238
+ timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
239
+ timer.unref?.();
240
+ }
241
+ }
242
+
243
+ function stop() {
244
+ if (timer) clearInterval(timer);
245
+ timer = null;
246
+ }
247
+
248
+ return { checkIn, setReports, reportError, start, stop };
249
+ }
250
+
251
+ /** The deck's own reporter. */
252
+ export const reporter = createReporter();