ccdeck 3.36.7 → 3.36.9

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.
@@ -64,7 +64,7 @@
64
64
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
65
65
  })();
66
66
  </script>
67
- <script type="module" crossorigin src="/assets/index-B1ngNkKH.js"></script>
67
+ <script type="module" crossorigin src="/assets/index-tVBMXvDv.js"></script>
68
68
  <link rel="stylesheet" crossorigin href="/assets/index-BkXTN4dZ.css">
69
69
  </head>
70
70
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.36.7",
3
+ "version": "3.36.9",
4
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": {
@@ -66,7 +66,9 @@
66
66
  "3.36.4",
67
67
  "3.36.5",
68
68
  "3.36.6",
69
- "3.36.7"
69
+ "3.36.7",
70
+ "3.36.8",
71
+ "3.36.9"
70
72
  ],
71
73
  "3.36.3": [
72
74
  {
@@ -0,0 +1,42 @@
1
+ // Which features got used today, for the "active" report's `features` (see
2
+ // usage-day.mjs for the list and reports.mjs for what leaves).
3
+ //
4
+ // Two doors in. The page names what it saw opened — a panel shown, a dialog
5
+ // opened — through POST /api/feature, once per name per day (feature-use.ts).
6
+ // The server names what a request did: the routes below are actions somebody
7
+ // takes on purpose, never something a page polls, so the request is the use.
8
+ // Either way only a name off FEATURES is kept, and only as "used today".
9
+ import { readBody, send } from "./http-io.mjs";
10
+ import { usageDay } from "./usage-day.mjs";
11
+
12
+ /** The requests that are a feature being used. Only actions: a read the page
13
+ * makes on its own (the quota, the roster, a radio probe) says nothing about
14
+ * what somebody chose to do. */
15
+ const ROUTE_FEATURES = new Map([
16
+ ["POST /api/claude-accounts/switch", "account-switch"],
17
+ ["POST /api/claude-accounts/admin", "accounts-manage"],
18
+ ["POST /api/cswap-auto", "auto-switch"],
19
+ ["POST /api/lan/invite", "lan-pairing"],
20
+ ["POST /api/lan/peer", "lan-pairing"],
21
+ ["POST /api/feedback", "feedback-sent"],
22
+ ["POST /api/upgrade", "self-update"],
23
+ ["POST /api/clear", "clear-board"],
24
+ ]);
25
+
26
+ /** Note the feature a request is, if it is one. Called for requests that got
27
+ * past the deck's own gates. */
28
+ export function noteRouteFeature(method, pathname) {
29
+ const name = ROUTE_FEATURES.get(`${method} ${pathname}`);
30
+ if (name) usageDay.noteFeature(name);
31
+ }
32
+
33
+ /** POST /api/feature — the page saying it showed or opened something.
34
+ * `{ name }`; a name off the list is a 400, and nothing about it is kept. */
35
+ export async function handleFeature(req, res) {
36
+ const raw = await readBody(req).catch(() => null);
37
+ let body = null;
38
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
39
+ const name = typeof body?.name === "string" ? body.name : "";
40
+ if (!usageDay.noteFeature(name)) return send(res, 400, { ok: false, reason: "unknown_feature" });
41
+ return send(res, 200, { ok: true });
42
+ }
@@ -63,6 +63,8 @@ import { serveStatic } from "./static-serve.mjs";
63
63
  // transcript watch between hook events, and POST /api/forget. See
64
64
  // session-tracking.mjs.
65
65
  import { handleForget, startOutputWatch } from "./session-tracking.mjs";
66
+ // Which features were used today, for the usage reports — see feature-use.mjs.
67
+ import { handleFeature, noteRouteFeature } from "./feature-use.mjs";
66
68
  // Exported from this file before it moved, and still.
67
69
  export { HARD_TRACKED_SESSIONS } from "./session-tracking.mjs";
68
70
  // The desktop app's update as its window sees it — the state the app reports
@@ -285,6 +287,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
285
287
  return send(res, 401, { error: "unauthenticated" });
286
288
  }
287
289
 
290
+ // A request that is somebody using a feature on purpose — switching an
291
+ // account, pairing a deck — counts as that feature used today. Past the
292
+ // gates above, so only the deck's own callers count. See feature-use.mjs.
293
+ noteRouteFeature(req.method, url.pathname);
294
+
288
295
  // `?persist=0` — another deck was elected to write this event to the log
289
296
  // the two of them share. Absent, this deck writes it.
290
297
  if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);
@@ -387,6 +394,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
387
394
  // Above the 404 below, which is what every real route has to be.
388
395
  if (req.method === "POST" && url.pathname === "/api/forget") return guard(handleForget(req, res), res);
389
396
 
397
+ // POST /api/feature — the page saying it showed a panel or opened a dialog,
398
+ // once per name per day. A name, never what was in it. See feature-use.mjs.
399
+ if (req.method === "POST" && url.pathname === "/api/feature") return guard(handleFeature(req, res), res);
400
+
390
401
  // AN UNMATCHED /api/ PATH IS A 404, not the SPA shell.
391
402
  //
392
403
  // serveStatic falls back to index.html for a path it cannot find, which is
@@ -13,7 +13,12 @@
13
13
  // ccdeck gets counted without counting anything else about them, and which
14
14
  // alone also carries coarse counts of how much: how many sessions,
15
15
  // subagents and projects the deck heard from on the last day it was used,
16
- // and that day's date — numbers, never their names (see usage-day.mjs),
16
+ // and that day's date — numbers, never their names (see usage-day.mjs) —
17
+ // and which of the deck's features were used that day, as fixed names
18
+ // ("usage-history", "account-switch") and nothing about what was done
19
+ // with them (feature-use.mjs),
20
+ // - an "uninstall" event when somebody runs `ccdeck --uninstall`, with the
21
+ // reason they picked from a short list if they picked one (bin/cli/leaving.js),
17
22
  // - an "activated" event, once, when a new install's first session arrives:
18
23
  // how long after the install it came, as a bucket ("5m", "1h", "1d", "7d",
19
24
  // "later"), and whether it was a Claude or a Codex session (activation.mjs),
@@ -69,7 +74,7 @@ import { reportsVetoed } from "./deck-prefs.mjs";
69
74
  import { isGitCheckout } from "./install-layout.mjs";
70
75
  import { heldPrefs, prefsRead } from "./prefs-state.mjs";
71
76
  import { RUNNING_VERSION } from "./running-version.mjs";
72
- import { usageDay, utcDay } from "./usage-day.mjs";
77
+ import { FEATURES, usageDay, utcDay } from "./usage-day.mjs";
73
78
  import { setupFacts, sinceInstallBucket, whenSetupKnown } from "./activation.mjs";
74
79
 
75
80
  export const REPORTS_API = "https://api.ccdeck.dev";
@@ -89,6 +94,10 @@ const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
89
94
  const MESSAGE_MAX = 500;
90
95
  const STACK_MAX = 4000;
91
96
 
97
+ /** Why somebody uninstalled, when they said: the answers `ccdeck --uninstall`
98
+ * offers (bin/cli/leaving.js), and nothing else ever leaves as a reason. */
99
+ export const UNINSTALL_REASONS = Object.freeze(["not-useful", "too-noisy", "broken", "other-tool", "privacy", "other"]);
100
+
92
101
  const EMPTY_REPORT = Object.freeze({
93
102
  installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
94
103
  installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
@@ -394,6 +403,9 @@ export function createReporter({
394
403
  addIf(out, "sessions", count(raw.sessions));
395
404
  addIf(out, "subagents", count(raw.subagents));
396
405
  addIf(out, "projects", count(raw.projects));
406
+ // The features used that day, as names off the fixed list — an empty list
407
+ // is a real answer (the deck ran, nothing was opened), not a missing one.
408
+ out.features = Array.isArray(raw.features) ? raw.features.filter(f => FEATURES.includes(f)) : [];
397
409
  return out;
398
410
  } catch {
399
411
  return {};
@@ -537,6 +549,33 @@ export function createReporter({
537
549
  if (await call("POST", "/v1/app/events", body)) await remember(r.installId, { activationSent: true });
538
550
  }
539
551
 
552
+ /** Whether a report would go out right now: reports on, the machine not
553
+ * vetoing them, and an install id to send under. `ccdeck --uninstall` asks
554
+ * before it asks the person anything. Never throws. */
555
+ async function willReport() {
556
+ try {
557
+ await Promise.resolve(ready).catch(() => {});
558
+ const p = prefs.current();
559
+ return reportsOn(p, env) && Boolean(p.report.installId);
560
+ } catch {
561
+ return false;
562
+ }
563
+ }
564
+
565
+ /** The install leaving: `ccdeck --uninstall`, with the reason picked off
566
+ * UNINSTALL_REASONS if one was. The id stays: a deck run again later is the
567
+ * same install coming back. Resolves to whether it got through; never throws. */
568
+ async function reportUninstall(reason) {
569
+ try {
570
+ if (!(await willReport())) return false;
571
+ const body = { installId: prefs.current().report.installId, kind: "uninstall", ...factsNow() };
572
+ addIf(body, "reason", UNINSTALL_REASONS.includes(reason) ? reason : undefined);
573
+ return await call("POST", "/v1/app/events", body);
574
+ } catch {
575
+ return false;
576
+ }
577
+ }
578
+
540
579
  /** The first session this run heard, sent the moment it lands rather than at
541
580
  * the next check-in, hours later or on a next launch that may never come. */
542
581
  async function onFirstSession() {
@@ -637,7 +676,7 @@ export function createReporter({
637
676
  pingTimer = null;
638
677
  }
639
678
 
640
- return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, sendActivation, start, stop };
679
+ return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, sendActivation, willReport, reportUninstall, start, stop };
641
680
  }
642
681
 
643
682
  // ── the deck's own providers ─────────────────────────────────────────────────
@@ -12,7 +12,9 @@
12
12
  // WHAT IS KEPT. In memory, the session ids, the subagent ids and the folders the
13
13
  // transcripts sit in, so a session heard from a hundred times counts once. They
14
14
  // never leave this module: what is saved between runs (prefs.json, beside the
15
- // install id) and what is sent are three counts and the day they belong to.
15
+ // install id) and what is sent are three counts, the day they belong to, and
16
+ // the names of the features used that day — fixed tokens off FEATURES, a yes
17
+ // for each, never what was done with them.
16
18
  //
17
19
  // A RESTART MID-DAY. The saved counts come back as a floor and the new run
18
20
  // counts on top of them, so a session that was running across the restart is
@@ -24,6 +26,34 @@
24
26
  // watches (its first scan skips every file's history). Replayed lines and the
25
27
  // deck's own synthetic events are not use.
26
28
 
29
+ /**
30
+ * The features a day can say were used — fixed tokens, never anything a caller
31
+ * makes up. The page names the ones it sees opened (POST /api/feature, through
32
+ * feature-use.ts), the server the ones a route or a session shows. A name not on
33
+ * this list is dropped wherever it comes from.
34
+ */
35
+ export const FEATURES = Object.freeze([
36
+ // Panels the page showed that day (each can be closed, and the choice is kept).
37
+ "usage-panel", "machine-panel", "detail-panel", "accounts-panel",
38
+ // Dialogs somebody opened.
39
+ "tool-detail", "session-summary", "context-window", "usage-history", "browser-watch",
40
+ "feedback", "keyboard-help", "share-accounts", "account-projects", "add-account",
41
+ "process-list", "lan-setup", "claude-fm",
42
+ // What the server saw done.
43
+ "account-switch", "accounts-manage", "auto-switch", "lan-pairing", "feedback-sent",
44
+ "self-update", "clear-board",
45
+ // Which CLI the day's sessions came from.
46
+ "claude-sessions", "codex-sessions",
47
+ ]);
48
+ const FEATURE_SET = new Set(FEATURES);
49
+
50
+ /** Only known feature names, once each, in the list's order. */
51
+ function knownFeatures(list) {
52
+ if (!Array.isArray(list)) return [];
53
+ const have = new Set(list.filter(f => FEATURE_SET.has(f)));
54
+ return FEATURES.filter(f => have.has(f));
55
+ }
56
+
27
57
  /** The day as the reporter says it: the UTC date, "2026-10-01". */
28
58
  export function utcDay(date) {
29
59
  return date.toISOString().slice(0, 10);
@@ -36,10 +66,13 @@ function whole(n) {
36
66
  return Number.isInteger(n) && n >= 0 ? n : 0;
37
67
  }
38
68
 
39
- /** Saved totals, coerced: a day and three counts, or null for anything else. */
69
+ /** Saved totals, coerced: a day, three counts and the features used, or null for anything else. */
40
70
  function savedTotals(raw) {
41
71
  if (!raw || typeof raw !== "object" || typeof raw.day !== "string" || !DAY.test(raw.day)) return null;
42
- return { day: raw.day, sessions: whole(raw.sessions), subagents: whole(raw.subagents), projects: whole(raw.projects) };
72
+ return {
73
+ day: raw.day, sessions: whole(raw.sessions), subagents: whole(raw.subagents), projects: whole(raw.projects),
74
+ features: knownFeatures(raw.features),
75
+ };
43
76
  }
44
77
 
45
78
  /** What prefs.json keeps for this, coerced — deck-prefs.mjs normalises with it. */
@@ -79,10 +112,11 @@ export function createUsageDay({ now = () => new Date() } = {}) {
79
112
  function fresh(day, floor = null) {
80
113
  return {
81
114
  day,
82
- floor: floor ?? { sessions: 0, subagents: 0, projects: 0 },
115
+ floor: floor ?? { sessions: 0, subagents: 0, projects: 0, features: [] },
83
116
  sessions: new Set(),
84
117
  subagents: new Set(),
85
118
  projects: new Set(),
119
+ features: new Set(),
86
120
  };
87
121
  }
88
122
 
@@ -92,6 +126,7 @@ export function createUsageDay({ now = () => new Date() } = {}) {
92
126
  sessions: c.floor.sessions + c.sessions.size,
93
127
  subagents: c.floor.subagents + c.subagents.size,
94
128
  projects: c.floor.projects + c.projects.size,
129
+ features: knownFeatures([...c.floor.features, ...c.features]),
95
130
  };
96
131
  }
97
132
 
@@ -118,6 +153,7 @@ export function createUsageDay({ now = () => new Date() } = {}) {
118
153
  const c = today();
119
154
  add(c.sessions, sid);
120
155
  if (typeof raw.agent_id === "string" && raw.agent_id) add(c.subagents, `${sid}\u0000${raw.agent_id}`);
156
+ add(c.features, raw.provider === "codex" ? "codex-sessions" : "claude-sessions");
121
157
  if (!first) {
122
158
  first = { at: now().toISOString(), provider: raw.provider === "codex" ? "codex" : "claude" };
123
159
  for (const fn of firstListeners) {
@@ -133,6 +169,13 @@ export function createUsageDay({ now = () => new Date() } = {}) {
133
169
  add(today().projects, folderOf(path));
134
170
  }
135
171
 
172
+ /** A feature somebody used today — a name off FEATURES, or nothing. */
173
+ function noteFeature(name) {
174
+ if (!FEATURE_SET.has(name)) return false;
175
+ add(today().features, name);
176
+ return true;
177
+ }
178
+
136
179
  /** A Codex session's working directory, which is its project. */
137
180
  function noteFolder(dir) {
138
181
  if (!dir || typeof dir !== "string") return;
@@ -187,7 +230,7 @@ export function createUsageDay({ now = () => new Date() } = {}) {
187
230
  if (!t) continue;
188
231
  const c = today();
189
232
  if (t.day === c.day) {
190
- c.floor = { sessions: t.sessions, subagents: t.subagents, projects: t.projects };
233
+ c.floor = { sessions: t.sessions, subagents: t.subagents, projects: t.projects, features: t.features };
191
234
  } else if (t.day < c.day && (!done || t.day > done.day)) {
192
235
  done = t;
193
236
  }
@@ -200,7 +243,7 @@ export function createUsageDay({ now = () => new Date() } = {}) {
200
243
  return changes;
201
244
  }
202
245
 
203
- return { noteUse, noteProject, noteFolder, firstUse, onFirstUse, finished, markSent, saved, restore, version };
246
+ return { noteUse, noteProject, noteFolder, noteFeature, firstUse, onFirstUse, finished, markSent, saved, restore, version };
204
247
  }
205
248
 
206
249
  /** The deck's own tally, which the event pipeline feeds and the reporter reads. */