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.
- package/README.md +44 -14
- package/dist/web/assets/{BrowserWatchModal-BTNOQdp5.js → BrowserWatchModal-C42m7ewG.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-CIDpgHTW.js → UsageHistoryModal-N-p7OGkW.js} +1 -1
- package/dist/web/assets/index-CSDSEC1Q.js +301 -0
- package/dist/web/assets/index-DglpEGvW.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +12 -7
- package/release-notes.json +20 -0
- package/src/server/deck-prefs.mjs +37 -1
- package/src/server/index.mjs +20 -2
- package/src/server/prefs-routes.mjs +7 -2
- package/src/server/reports-routes.mjs +75 -0
- package/src/server/reports.mjs +252 -0
- package/dist/web/assets/index-BIANlQ9A.css +0 -1
- package/dist/web/assets/index-D7ddxIpC.js +0 -299
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
66
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
56
|
-
"
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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();
|