ccdeck 3.36.6 → 3.36.8

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-B2C4N7Bl.js"></script>
67
+ <script type="module" crossorigin src="/assets/index-4ecbC_Cy.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.6",
3
+ "version": "3.36.8",
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": {
@@ -65,7 +65,9 @@
65
65
  "3.36.1",
66
66
  "3.36.4",
67
67
  "3.36.5",
68
- "3.36.6"
68
+ "3.36.6",
69
+ "3.36.7",
70
+ "3.36.8"
69
71
  ],
70
72
  "3.36.3": [
71
73
  {
@@ -0,0 +1,76 @@
1
+ // Whether this deck got set up, and how long its first session took to arrive —
2
+ // the "does a new install actually work" half of the usage reports
3
+ // (reports.mjs).
4
+ //
5
+ // WHAT IT ANSWERS. An "install" event says somebody ran ccdeck once. It does not
6
+ // say whether the Claude hooks went in, whether Codex is being watched, or
7
+ // whether a session ever reached the deck — and an install that never sees a
8
+ // session is somebody who tried ccdeck and left. So two facts ride on the
9
+ // reports that carry facts:
10
+ //
11
+ // - `claudeHooks`: "ok" when this boot installed the hooks, "failed" when it
12
+ // tried and could not (a settings.json it refuses to rewrite), "off" when it
13
+ // did not try (no Claude Code found, or --no-claude);
14
+ // - `codexWatch`: "on" when the Codex rollout watcher runs, else "off".
15
+ //
16
+ // and one "activated" event goes out per install, when its first session
17
+ // arrives, saying how long after the install that was — in coarse buckets,
18
+ // never a time.
19
+ //
20
+ // The boot (bin/deck.js) is what knows the setup, and it reaches this module
21
+ // through noteSetup. This file imports nothing, so the boot can load it early.
22
+
23
+ /** Not known yet, until the boot says. */
24
+ let setup = null;
25
+ let settle;
26
+ const known = new Promise(r => { settle = r; });
27
+
28
+ /**
29
+ * What the boot set up. `claude` is the hook install job — a promise of null
30
+ * (not attempted), `{ ok: true }` or `{ ok: false }` — or null when Claude is
31
+ * not wanted; `codex` is whether the rollout watcher runs. Never throws, and a
32
+ * job that rejects reads as "failed".
33
+ */
34
+ export function noteSetup({ claude = null, codex = false } = {}) {
35
+ Promise.resolve(claude)
36
+ .then(
37
+ job => (job == null ? "off" : job.ok ? "ok" : "failed"),
38
+ () => "failed",
39
+ )
40
+ .then(claudeHooks => {
41
+ setup = { claudeHooks, codexWatch: codex ? "on" : "off" };
42
+ settle();
43
+ });
44
+ }
45
+
46
+ /** The setup facts, or none while the boot has not said. */
47
+ export function setupFacts() {
48
+ return setup ? { ...setup } : {};
49
+ }
50
+
51
+ /** Resolves once the boot has said, or after `ms`, whichever is first — so a
52
+ * report never waits on a boot that will not say (a test, a respawn). */
53
+ export function whenSetupKnown(ms = 10_000) {
54
+ let timer;
55
+ return Promise.race([
56
+ known,
57
+ new Promise(r => { timer = setTimeout(r, ms); timer.unref?.(); }),
58
+ ]).finally(() => clearTimeout(timer));
59
+ }
60
+
61
+ const MINUTE = 60_000;
62
+ const HOUR = 60 * MINUTE;
63
+ const DAY = 24 * HOUR;
64
+
65
+ /** How long after the install the first session came, as a bucket: "5m" (under
66
+ * five minutes), "1h", "1d", "7d", or "later". A clock that went backwards
67
+ * reads as the shortest. */
68
+ export function sinceInstallBucket(installedAt, firstAt) {
69
+ const ms = Date.parse(firstAt) - Date.parse(installedAt);
70
+ if (!Number.isFinite(ms)) return undefined;
71
+ if (ms < 5 * MINUTE) return "5m";
72
+ if (ms < HOUR) return "1h";
73
+ if (ms < DAY) return "1d";
74
+ if (ms < 7 * DAY) return "7d";
75
+ return "later";
76
+ }
@@ -93,8 +93,14 @@ export const DEFAULTS = Object.freeze({
93
93
  // not yet acknowledged, retried on the next start so switching off while
94
94
  // offline still ends in a deletion; `usage` is the day's tally of sessions,
95
95
  // subagents and projects, counts and days only, so a restart carries it on
96
- // (usage-day.mjs).
97
- report: Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null }),
96
+ // (usage-day.mjs); `installedAt` is when the id was made, and
97
+ // `firstSessionAt`/`firstProvider`/`activationSent` the install's first
98
+ // session and whether the "activated" event saying so got through — the times
99
+ // stay here, only a bucket leaves (activation.mjs).
100
+ report: Object.freeze({
101
+ installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
102
+ installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
103
+ }),
98
104
  // The accounts this deck signed in itself, and the re-sign-in prompts
99
105
  // somebody put off (#1893) — keyed by account identity, written by the deck
100
106
  // and never by a page. See account-origins.mjs.
@@ -294,8 +300,8 @@ export function normalise(raw) {
294
300
  };
295
301
  }
296
302
 
297
- /** The reporter's own state, coerced: four strings, empty when absent, and the
298
- * day's tally, null until there is one. */
303
+ /** The reporter's own state, coerced: strings empty when absent, the day's
304
+ * tally null until there is one, and one flag. */
299
305
  function normaliseReport(raw) {
300
306
  const src = raw && typeof raw === "object" ? raw : {};
301
307
  const text = v => (typeof v === "string" ? v : "");
@@ -305,6 +311,10 @@ function normaliseReport(raw) {
305
311
  lastActiveDay: text(src.lastActiveDay),
306
312
  forget: text(src.forget),
307
313
  usage: src.usage && typeof src.usage === "object" ? normaliseUsage(src.usage) : null,
314
+ installedAt: text(src.installedAt),
315
+ firstSessionAt: text(src.firstSessionAt),
316
+ firstProvider: text(src.firstProvider),
317
+ activationSent: src.activationSent === true,
308
318
  };
309
319
  }
310
320
 
@@ -239,6 +239,11 @@ export function pushEvent(raw, source, opts = {}) {
239
239
  } else {
240
240
  noteRefusedTranscript(raw.transcript_path);
241
241
  }
242
+ } else if (source === "codex" && !opts.replay) {
243
+ // Codex has no hooks: its sessions arrive from the rollout watcher, which
244
+ // emits only what a rollout gains while the deck watches. That is use too.
245
+ usageDay.noteUse(raw);
246
+ usageDay.noteFolder(raw?.cwd);
242
247
  }
243
248
 
244
249
  return evt;
@@ -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,13 @@
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 "activated" event, once, when a new install's first session arrives:
21
+ // how long after the install it came, as a bucket ("5m", "1h", "1d", "7d",
22
+ // "later"), and whether it was a Claude or a Codex session (activation.mjs),
17
23
  // - errors: a request handler that threw on this server, or an error the page
18
24
  // caught, with home folders, email addresses and key-shaped strings
19
25
  // scrubbed out before they leave,
@@ -26,11 +32,14 @@
26
32
  // the channel (the desktop app, npm, or a source checkout) and the runtime, plus
27
33
  // a coarse sketch of the environment: the logical CPU count, the RAM in MB, the
28
34
  // locale, the shell and terminal names, and the Claude Code and Codex CLI
29
- // versions. Every one of those is a small fixed token or a number, safe by
30
- // construction — no path, no hostname, no user name, no project name, no prompt,
31
- // no free text ever reaches any field, so there is nothing in them to scrub. A
32
- // field that cannot be told is left out rather than guessed. The install id is
33
- // random, made at the first check-in, and tied to nothing on the machine.
35
+ // versions — and, on install, update, active and activated, what the boot set
36
+ // up: whether the Claude hooks went in ("ok", "failed", "off") and whether
37
+ // Codex is watched ("on", "off"). Every one of those is a small fixed token or
38
+ // a number, safe by construction — no path, no hostname, no user name, no
39
+ // project name, no prompt, no free text ever reaches any field, so there is
40
+ // nothing in them to scrub. A field that cannot be told is left out rather than
41
+ // guessed. The install id is random, made at the first check-in, and tied to
42
+ // nothing on the machine.
34
43
  //
35
44
  // The one field that IS derived from the machine is `deviceId`: a stable, hashed
36
45
  // device id sent with EVERY report — install, update and active — while reports
@@ -63,7 +72,8 @@ import { reportsVetoed } from "./deck-prefs.mjs";
63
72
  import { isGitCheckout } from "./install-layout.mjs";
64
73
  import { heldPrefs, prefsRead } from "./prefs-state.mjs";
65
74
  import { RUNNING_VERSION } from "./running-version.mjs";
66
- import { usageDay, utcDay } from "./usage-day.mjs";
75
+ import { FEATURES, usageDay, utcDay } from "./usage-day.mjs";
76
+ import { setupFacts, sinceInstallBucket, whenSetupKnown } from "./activation.mjs";
67
77
 
68
78
  export const REPORTS_API = "https://api.ccdeck.dev";
69
79
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -82,7 +92,10 @@ const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
82
92
  const MESSAGE_MAX = 500;
83
93
  const STACK_MAX = 4000;
84
94
 
85
- const EMPTY_REPORT = Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null });
95
+ const EMPTY_REPORT = Object.freeze({
96
+ installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
97
+ installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
98
+ });
86
99
 
87
100
  /** Is this deck sending reports right now? The switch, which is on unless
88
101
  * somebody turned it off, and the machine's consent. */
@@ -313,6 +326,10 @@ export function scrub(text, home = homedir()) {
313
326
  * the day's tally: the "active" report carries the last finished day's totals,
314
327
  * read only when an "active" is about to be sent, and the tally is saved into
315
328
  * the prefs so a restart does not lose the day. Default: the deck's own.
329
+ * @param {() => { claudeHooks?: string, codexWatch?: string }} [deps.setup] what the
330
+ * boot set up, folded into install, update and active (activation.mjs).
331
+ * @param {() => Promise<unknown>} [deps.setupKnown] what start() waits for besides the
332
+ * prefs, so the first install event carries the setup. Bounded.
316
333
  */
317
334
  export function createReporter({
318
335
  fetchImpl = globalThis.fetch,
@@ -324,6 +341,8 @@ export function createReporter({
324
341
  ready = prefsRead,
325
342
  versions = () => ({}),
326
343
  usage = usageDay,
344
+ setup = setupFacts,
345
+ setupKnown = () => whenSetupKnown(),
327
346
  } = {}) {
328
347
  const errorsSent = [];
329
348
  const lastSentAt = new Map();
@@ -351,6 +370,20 @@ export function createReporter({
351
370
  return { ...facts, ...versionFacts() };
352
371
  }
353
372
 
373
+ /** What the boot set up — the Claude hooks and the Codex watcher — as the two
374
+ * fixed tokens activation.mjs names, or nothing while it has not said. */
375
+ function setupNow() {
376
+ try {
377
+ const s = setup() ?? {};
378
+ const out = {};
379
+ addIf(out, "claudeHooks", ["ok", "failed", "off"].includes(s.claudeHooks) ? s.claudeHooks : undefined);
380
+ addIf(out, "codexWatch", ["on", "off"].includes(s.codexWatch) ? s.codexWatch : undefined);
381
+ return out;
382
+ } catch {
383
+ return {};
384
+ }
385
+ }
386
+
354
387
  /** The coarse usage the "active" report carries: the last finished day before
355
388
  * `today`, its date as `usageDay` and its counts beside it. Nothing at all
356
389
  * when there is no such day yet — a first launch has no yesterday — and only
@@ -364,6 +397,9 @@ export function createReporter({
364
397
  addIf(out, "sessions", count(raw.sessions));
365
398
  addIf(out, "subagents", count(raw.subagents));
366
399
  addIf(out, "projects", count(raw.projects));
400
+ // The features used that day, as names off the fixed list — an empty list
401
+ // is a real answer (the deck ran, nothing was opened), not a missing one.
402
+ out.features = Array.isArray(raw.features) ? raw.features.filter(f => FEATURES.includes(f)) : [];
367
403
  return out;
368
404
  } catch {
369
405
  return {};
@@ -446,17 +482,21 @@ export function createReporter({
446
482
  // The id is made here, the first time there is something to send under it.
447
483
  if (!prefs.current().report.installId) {
448
484
  await prefs.update(prev =>
449
- prev.reports === false || prev.report.installId ? undefined : { report: { ...prev.report, installId: randomUUID() } },
485
+ prev.reports === false || prev.report.installId
486
+ ? 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() } },
450
490
  );
451
491
  }
452
492
  const { installId, lastVersion, lastActiveDay } = prefs.current().report;
453
493
  if (!installId) return;
454
494
  if (!lastVersion) {
455
- if (await call("POST", "/v1/app/events", { installId, kind: "install", ...factsNow() })) {
495
+ if (await call("POST", "/v1/app/events", { installId, kind: "install", ...factsNow(), ...setupNow() })) {
456
496
  await remember(installId, { lastVersion: facts.version });
457
497
  }
458
498
  } else if (lastVersion !== facts.version) {
459
- if (await call("POST", "/v1/app/events", { installId, kind: "update", fromVersion: lastVersion, ...factsNow() })) {
499
+ if (await call("POST", "/v1/app/events", { installId, kind: "update", fromVersion: lastVersion, ...factsNow(), ...setupNow() })) {
460
500
  await remember(installId, { lastVersion: facts.version });
461
501
  }
462
502
  }
@@ -467,14 +507,52 @@ export function createReporter({
467
507
  // report it belongs to — so a checkIn that sends nothing new never asks the
468
508
  // deck for its counts.
469
509
  const used = coarseUsage(today);
470
- if (await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...used })) {
510
+ if (await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...setupNow(), ...used })) {
471
511
  if (used.usageDay) usage.markSent(used.usageDay);
472
512
  await remember(installId, { lastActiveDay: today });
473
513
  }
474
514
  }
515
+ await sendActivation();
475
516
  await saveUsage();
476
517
  }
477
518
 
519
+ /**
520
+ * The install's first session, said once: an "activated" event with how long
521
+ * after the install it came, in a bucket, and which CLI it came from.
522
+ *
523
+ * 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
525
+ * measured: its first session was long ago, and "now" would be a lie. The time
526
+ * is taken from the first use this run heard, so a session that arrived before
527
+ * the first check-in had an id is still dated when it happened. A send that
528
+ * cannot get through is tried again at the next check-in.
529
+ */
530
+ async function sendActivation() {
531
+ const first = usage.firstUse?.();
532
+ let r = prefs.current().report;
533
+ if (!reportsOn(prefs.current(), env) || !r.installId || !r.installedAt || r.activationSent) return;
534
+ if (!r.firstSessionAt) {
535
+ if (!first) return;
536
+ await remember(r.installId, { firstSessionAt: first.at, firstProvider: first.provider });
537
+ r = prefs.current().report;
538
+ if (!r.firstSessionAt) return;
539
+ }
540
+ const body = { installId: r.installId, kind: "activated", ...factsNow(), ...setupNow() };
541
+ addIf(body, "firstSession", sinceInstallBucket(r.installedAt, r.firstSessionAt));
542
+ 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 });
544
+ }
545
+
546
+ /** The first session this run heard, sent the moment it lands rather than at
547
+ * the next check-in, hours later or on a next launch that may never come. */
548
+ async function onFirstSession() {
549
+ try {
550
+ await sendActivation();
551
+ } catch {
552
+ // A settings file that cannot be written this minute; the next check-in tries again.
553
+ }
554
+ }
555
+
478
556
  /** The heartbeat. While reports are on and an id exists, tell the API this deck
479
557
  * is still here — it moves the install's "last seen" and writes no event, so it
480
558
  * carries the id and nothing else: no facts, no deviceId. Like every send it is
@@ -547,7 +625,10 @@ export function createReporter({
547
625
  * open. The first check-in already puts a freshly launched deck online, so the
548
626
  * first ping is a full interval out — nothing pings at boot. */
549
627
  function start() {
550
- Promise.resolve(ready).catch(() => {}).then(restoreUsage).then(checkIn);
628
+ Promise.all([Promise.resolve(ready).catch(() => {}), Promise.resolve(setupKnown()).catch(() => {})])
629
+ .then(restoreUsage)
630
+ .then(checkIn)
631
+ .then(() => usage.onFirstUse?.(() => void onFirstSession()));
551
632
  if (!timer) {
552
633
  timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
553
634
  timer.unref?.();
@@ -562,7 +643,7 @@ export function createReporter({
562
643
  pingTimer = null;
563
644
  }
564
645
 
565
- return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, start, stop };
646
+ return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, sendActivation, start, stop };
566
647
  }
567
648
 
568
649
  // ── the deck's own providers ─────────────────────────────────────────────────
@@ -12,16 +12,47 @@
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
19
21
  // counted twice. That is the price of keeping no ids on disk, and a restart is
20
22
  // rare next to the day's sessions.
21
23
  //
22
- // Only what a hook said counts: a hook fires because somebody is running the
23
- // CLI. Replayed lines, a rollout read off disk and the deck's own synthetic
24
- // events are not use.
24
+ // Only live use counts: a Claude hook fires because somebody is running the
25
+ // CLI, and the Codex watcher emits only what a rollout gains while the deck
26
+ // watches (its first scan skips every file's history). Replayed lines and the
27
+ // deck's own synthetic events are not use.
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
+ }
25
56
 
26
57
  /** The day as the reporter says it: the UTC date, "2026-10-01". */
27
58
  export function utcDay(date) {
@@ -35,10 +66,13 @@ function whole(n) {
35
66
  return Number.isInteger(n) && n >= 0 ? n : 0;
36
67
  }
37
68
 
38
- /** 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. */
39
70
  function savedTotals(raw) {
40
71
  if (!raw || typeof raw !== "object" || typeof raw.day !== "string" || !DAY.test(raw.day)) return null;
41
- 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
+ };
42
76
  }
43
77
 
44
78
  /** What prefs.json keeps for this, coerced — deck-prefs.mjs normalises with it. */
@@ -71,14 +105,18 @@ export function createUsageDay({ now = () => new Date() } = {}) {
71
105
  let sent = "";
72
106
  /** Moves on every change, so the reporter saves only when there is something new. */
73
107
  let changes = 0;
108
+ /** The first use this run heard, `{ at, provider }`, for activation.mjs. */
109
+ let first = null;
110
+ const firstListeners = [];
74
111
 
75
112
  function fresh(day, floor = null) {
76
113
  return {
77
114
  day,
78
- floor: floor ?? { sessions: 0, subagents: 0, projects: 0 },
115
+ floor: floor ?? { sessions: 0, subagents: 0, projects: 0, features: [] },
79
116
  sessions: new Set(),
80
117
  subagents: new Set(),
81
118
  projects: new Set(),
119
+ features: new Set(),
82
120
  };
83
121
  }
84
122
 
@@ -88,6 +126,7 @@ export function createUsageDay({ now = () => new Date() } = {}) {
88
126
  sessions: c.floor.sessions + c.sessions.size,
89
127
  subagents: c.floor.subagents + c.subagents.size,
90
128
  projects: c.floor.projects + c.projects.size,
129
+ features: knownFeatures([...c.floor.features, ...c.features]),
91
130
  };
92
131
  }
93
132
 
@@ -107,21 +146,56 @@ export function createUsageDay({ now = () => new Date() } = {}) {
107
146
  changes++;
108
147
  }
109
148
 
110
- /** A live hook payload: its session, and the subagent it came from if any. */
149
+ /** A live payload: its session, and the subagent it came from if any. */
111
150
  function noteUse(raw) {
112
151
  const sid = raw?.session_id;
113
152
  if (!sid || typeof sid !== "string") return;
114
153
  const c = today();
115
154
  add(c.sessions, sid);
116
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");
157
+ if (!first) {
158
+ first = { at: now().toISOString(), provider: raw.provider === "codex" ? "codex" : "claude" };
159
+ for (const fn of firstListeners) {
160
+ try { fn(first); } catch { /* a listener's failure is its own */ }
161
+ }
162
+ }
117
163
  }
118
164
 
119
- /** A transcript path a hook carried, already accepted as Claude's. */
165
+ /** A transcript path a Claude hook carried, already accepted as Claude's:
166
+ * the project is the folder it sits in. */
120
167
  function noteProject(path) {
121
168
  if (!path || typeof path !== "string") return;
122
169
  add(today().projects, folderOf(path));
123
170
  }
124
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
+
179
+ /** A Codex session's working directory, which is its project. */
180
+ function noteFolder(dir) {
181
+ if (!dir || typeof dir !== "string") return;
182
+ add(today().projects, dir);
183
+ }
184
+
185
+ /** The first use this run heard, or null. */
186
+ function firstUse() {
187
+ return first ? { ...first } : null;
188
+ }
189
+
190
+ /** Call `fn` with the first use, once, when it happens — or now, if it has. */
191
+ function onFirstUse(fn) {
192
+ if (first) {
193
+ try { fn(first); } catch { /* as above */ }
194
+ return;
195
+ }
196
+ firstListeners.push(fn);
197
+ }
198
+
125
199
  /** The totals of the last finished day before `day`, if they have not gone
126
200
  * out yet; null when there is none. Rolls over first, so a deck that ran past
127
201
  * midnight with nothing said since still has yesterday finished. */
@@ -156,7 +230,7 @@ export function createUsageDay({ now = () => new Date() } = {}) {
156
230
  if (!t) continue;
157
231
  const c = today();
158
232
  if (t.day === c.day) {
159
- 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 };
160
234
  } else if (t.day < c.day && (!done || t.day > done.day)) {
161
235
  done = t;
162
236
  }
@@ -169,7 +243,7 @@ export function createUsageDay({ now = () => new Date() } = {}) {
169
243
  return changes;
170
244
  }
171
245
 
172
- return { noteUse, noteProject, finished, markSent, saved, restore, version };
246
+ return { noteUse, noteProject, noteFolder, noteFeature, firstUse, onFirstUse, finished, markSent, saved, restore, version };
173
247
  }
174
248
 
175
249
  /** The deck's own tally, which the event pipeline feeds and the reporter reads. */