ccdeck 3.36.5 → 3.36.7

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-B18vwZ9I.js"></script>
67
+ <script type="module" crossorigin src="/assets/index-B1ngNkKH.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.5",
3
+ "version": "3.36.7",
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": {
@@ -64,7 +64,9 @@
64
64
  "3.36.0",
65
65
  "3.36.1",
66
66
  "3.36.4",
67
- "3.36.5"
67
+ "3.36.5",
68
+ "3.36.6",
69
+ "3.36.7"
68
70
  ],
69
71
  "3.36.3": [
70
72
  {
@@ -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
+ }
@@ -46,6 +46,7 @@ import { DENIED, foreignPath, quarantinePath, setAsideForeign } from "./prefs-se
46
46
  import { deckDataDir } from "./deck-home.mjs";
47
47
  import { PRODUCT } from "./brand.mjs";
48
48
  import { normaliseOrigins } from "./account-origins.mjs";
49
+ import { normaliseUsage } from "./usage-day.mjs";
49
50
 
50
51
  /** Set to "1" to keep the deck off the desktop whatever the stored preference
51
52
  * says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
@@ -90,8 +91,16 @@ export const DEFAULTS = Object.freeze({
90
91
  // are what the install last said, so an update is told once and "active" at
91
92
  // most once a day; `forget` is an id whose deletion has been asked for and
92
93
  // not yet acknowledged, retried on the next start so switching off while
93
- // offline still ends in a deletion.
94
- report: Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "" }),
94
+ // offline still ends in a deletion; `usage` is the day's tally of sessions,
95
+ // subagents and projects, counts and days only, so a restart carries it on
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
+ }),
95
104
  // The accounts this deck signed in itself, and the re-sign-in prompts
96
105
  // somebody put off (#1893) — keyed by account identity, written by the deck
97
106
  // and never by a page. See account-origins.mjs.
@@ -291,7 +300,8 @@ export function normalise(raw) {
291
300
  };
292
301
  }
293
302
 
294
- /** The reporter's own state, coerced: four strings, empty when absent. */
303
+ /** The reporter's own state, coerced: strings empty when absent, the day's
304
+ * tally null until there is one, and one flag. */
295
305
  function normaliseReport(raw) {
296
306
  const src = raw && typeof raw === "object" ? raw : {};
297
307
  const text = v => (typeof v === "string" ? v : "");
@@ -300,6 +310,11 @@ function normaliseReport(raw) {
300
310
  lastVersion: text(src.lastVersion),
301
311
  lastActiveDay: text(src.lastActiveDay),
302
312
  forget: text(src.forget),
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,
303
318
  };
304
319
  }
305
320
 
@@ -30,6 +30,8 @@ import { appendLogLine } from "./log-writer.mjs";
30
30
  import { activity } from "./lifecycle.mjs";
31
31
  // The session LRU and the transcript watch — see session-tracking.mjs.
32
32
  import { outputWatch, touchSession } from "./session-tracking.mjs";
33
+ // How much the deck was used today, for the "active" report — see usage-day.mjs.
34
+ import { usageDay } from "./usage-day.mjs";
33
35
  // What the deck learns about a session that its hooks never say, read off the
34
36
  // transcript and sent back through pushEvent — see session-enrichment.mjs.
35
37
  import { knownModelId, maybeResolveContext, maybeResolveModel, maybeResolveSessionName, maybeResolveUsage } from "./session-enrichment.mjs";
@@ -212,6 +214,8 @@ export function pushEvent(raw, source, opts = {}) {
212
214
  // rollout JSONL under ~/.codex/sessions/. The Claude scanners short-circuit
213
215
  // when transcript_path is absent (always the case for Codex hooks).
214
216
  if (source === "hook" && !opts.replay) {
217
+ // A hook is somebody running the CLI, which is what the day's usage counts.
218
+ usageDay.noteUse(raw);
215
219
  if (raw && raw.provider === "codex") {
216
220
  maybeResolveCodex(raw);
217
221
  } else if (!raw?.transcript_path || isClaudeTranscriptPath(raw.transcript_path)) {
@@ -228,10 +232,18 @@ export function pushEvent(raw, source, opts = {}) {
228
232
  // Where the transcript IS, learned from the one place it is free. The
229
233
  // four scanners above read it on this event; the watch reads it between
230
234
  // events, which is the whole of what it adds.
231
- if (raw?.transcript_path) outputWatch.note(raw.session_id, raw.transcript_path);
235
+ if (raw?.transcript_path) {
236
+ outputWatch.note(raw.session_id, raw.transcript_path);
237
+ usageDay.noteProject(raw.transcript_path);
238
+ }
232
239
  } else {
233
240
  noteRefusedTranscript(raw.transcript_path);
234
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);
235
247
  }
236
248
 
237
249
  return evt;
@@ -147,19 +147,6 @@ export function createOutputWatch(io = {}) {
147
147
  function clear() { seen.clear(); }
148
148
  function size() { return seen.size; }
149
149
 
150
- /** How many distinct projects the live sessions sit in, read off the paths
151
- * already held — no new state. Claude keeps one transcript folder per project
152
- * under ~/.claude/projects, so distinct folders are distinct projects. Only
153
- * the count is ever read; no path or project name leaves. */
154
- function projectCount() {
155
- const projects = new Set();
156
- for (const { path } of seen.values()) {
157
- const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
158
- projects.add(cut > 0 ? path.slice(0, cut) : path);
159
- }
160
- return projects.size;
161
- }
162
-
163
150
  /**
164
151
  * One tick. Stats each named session's file, reads only what is new, and
165
152
  * answers with EVERY block that landed in it, oldest first — see blocksIn
@@ -283,5 +270,5 @@ export function createOutputWatch(io = {}) {
283
270
  }
284
271
  }
285
272
 
286
- return { note, forget, clear, size, projectCount, poll };
273
+ return { note, forget, clear, size, poll };
287
274
  }
@@ -12,7 +12,11 @@
12
12
  // - an "active" event at most once a UTC day, which is how many people use
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
- // subagents and projects — numbers, never their names,
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),
17
+ // - an "activated" event, once, when a new install's first session arrives:
18
+ // how long after the install it came, as a bucket ("5m", "1h", "1d", "7d",
19
+ // "later"), and whether it was a Claude or a Codex session (activation.mjs),
16
20
  // - errors: a request handler that threw on this server, or an error the page
17
21
  // caught, with home folders, email addresses and key-shaped strings
18
22
  // scrubbed out before they leave,
@@ -25,11 +29,14 @@
25
29
  // the channel (the desktop app, npm, or a source checkout) and the runtime, plus
26
30
  // a coarse sketch of the environment: the logical CPU count, the RAM in MB, the
27
31
  // locale, the shell and terminal names, and the Claude Code and Codex CLI
28
- // versions. Every one of those is a small fixed token or a number, safe by
29
- // construction — no path, no hostname, no user name, no project name, no prompt,
30
- // no free text ever reaches any field, so there is nothing in them to scrub. A
31
- // field that cannot be told is left out rather than guessed. The install id is
32
- // random, made at the first check-in, and tied to nothing on the machine.
32
+ // versions — and, on install, update, active and activated, what the boot set
33
+ // up: whether the Claude hooks went in ("ok", "failed", "off") and whether
34
+ // Codex is watched ("on", "off"). Every one of those is a small fixed token or
35
+ // a number, safe by construction — no path, no hostname, no user name, no
36
+ // project name, no prompt, no free text ever reaches any field, so there is
37
+ // nothing in them to scrub. A field that cannot be told is left out rather than
38
+ // guessed. The install id is random, made at the first check-in, and tied to
39
+ // nothing on the machine.
33
40
  //
34
41
  // The one field that IS derived from the machine is `deviceId`: a stable, hashed
35
42
  // device id sent with EVERY report — install, update and active — while reports
@@ -62,6 +69,8 @@ import { reportsVetoed } from "./deck-prefs.mjs";
62
69
  import { isGitCheckout } from "./install-layout.mjs";
63
70
  import { heldPrefs, prefsRead } from "./prefs-state.mjs";
64
71
  import { RUNNING_VERSION } from "./running-version.mjs";
72
+ import { usageDay, utcDay } from "./usage-day.mjs";
73
+ import { setupFacts, sinceInstallBucket, whenSetupKnown } from "./activation.mjs";
65
74
 
66
75
  export const REPORTS_API = "https://api.ccdeck.dev";
67
76
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -80,7 +89,10 @@ const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
80
89
  const MESSAGE_MAX = 500;
81
90
  const STACK_MAX = 4000;
82
91
 
83
- const EMPTY_REPORT = Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "" });
92
+ const EMPTY_REPORT = Object.freeze({
93
+ installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
94
+ installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
95
+ });
84
96
 
85
97
  /** Is this deck sending reports right now? The switch, which is on unless
86
98
  * somebody turned it off, and the machine's consent. */
@@ -307,9 +319,14 @@ export function scrub(text, home = homedir()) {
307
319
  * @param {() => { claudeVersion?: string, codexVersion?: string }} [deps.versions] the CLI
308
320
  * versions to fold into the facts, read fresh on every send so a background
309
321
  * probe's answer rides along the moment it arrives. Default: none.
310
- * @param {() => ({ sessions?: number, subagents?: number, projects?: number } | Promise<...>)} [deps.usage]
311
- * the coarse counts the "active" report carries, read only when an "active" is
312
- * about to be sent. Default: none.
322
+ * @param {ReturnType<typeof import("./usage-day.mjs").createUsageDay>} [deps.usage]
323
+ * the day's tally: the "active" report carries the last finished day's totals,
324
+ * read only when an "active" is about to be sent, and the tally is saved into
325
+ * the prefs so a restart does not lose the day. Default: the deck's own.
326
+ * @param {() => { claudeHooks?: string, codexWatch?: string }} [deps.setup] what the
327
+ * boot set up, folded into install, update and active (activation.mjs).
328
+ * @param {() => Promise<unknown>} [deps.setupKnown] what start() waits for besides the
329
+ * prefs, so the first install event carries the setup. Bounded.
313
330
  */
314
331
  export function createReporter({
315
332
  fetchImpl = globalThis.fetch,
@@ -320,7 +337,9 @@ export function createReporter({
320
337
  home = homedir(),
321
338
  ready = prefsRead,
322
339
  versions = () => ({}),
323
- usage = () => ({}),
340
+ usage = usageDay,
341
+ setup = setupFacts,
342
+ setupKnown = () => whenSetupKnown(),
324
343
  } = {}) {
325
344
  const errorsSent = [];
326
345
  const lastSentAt = new Map();
@@ -348,13 +367,30 @@ export function createReporter({
348
367
  return { ...facts, ...versionFacts() };
349
368
  }
350
369
 
351
- /** The coarse usage the "active" report carries: only whole counts of zero or
352
- * more survive, and a provider that throws or is slow costs the report
353
- * nothing. */
354
- async function coarseUsage() {
370
+ /** What the boot set up — the Claude hooks and the Codex watcher — as the two
371
+ * fixed tokens activation.mjs names, or nothing while it has not said. */
372
+ function setupNow() {
355
373
  try {
356
- const raw = (await usage()) ?? {};
374
+ const s = setup() ?? {};
357
375
  const out = {};
376
+ addIf(out, "claudeHooks", ["ok", "failed", "off"].includes(s.claudeHooks) ? s.claudeHooks : undefined);
377
+ addIf(out, "codexWatch", ["on", "off"].includes(s.codexWatch) ? s.codexWatch : undefined);
378
+ return out;
379
+ } catch {
380
+ return {};
381
+ }
382
+ }
383
+
384
+ /** The coarse usage the "active" report carries: the last finished day before
385
+ * `today`, its date as `usageDay` and its counts beside it. Nothing at all
386
+ * when there is no such day yet — a first launch has no yesterday — and only
387
+ * whole counts of zero or more survive. A tally that throws costs the report
388
+ * nothing. */
389
+ function coarseUsage(today) {
390
+ try {
391
+ const raw = usage.finished(today);
392
+ if (!raw || typeof raw.day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(raw.day) || raw.day >= today) return {};
393
+ const out = { usageDay: raw.day };
358
394
  addIf(out, "sessions", count(raw.sessions));
359
395
  addIf(out, "subagents", count(raw.subagents));
360
396
  addIf(out, "projects", count(raw.projects));
@@ -364,6 +400,35 @@ export function createReporter({
364
400
  }
365
401
  }
366
402
 
403
+ /** The tally the last save wrote, so a save with nothing new writes nothing. */
404
+ let usageSaved = -1;
405
+
406
+ /** Keep the day's tally in the prefs, beside the install id, so a restart
407
+ * carries it on. Only while reports are on and under the id it belongs to:
408
+ * switching off drops it with the id. Never throws. */
409
+ async function saveUsage() {
410
+ try {
411
+ const at = usage.version();
412
+ if (at === usageSaved) return;
413
+ const p = prefs.current();
414
+ if (!reportsOn(p, env) || !p.report.installId) return;
415
+ await remember(p.report.installId, { usage: usage.saved() });
416
+ usageSaved = at;
417
+ } catch {
418
+ // A settings file that cannot be written this minute; the next save tries again.
419
+ }
420
+ }
421
+
422
+ /** The tally an earlier run saved, merged in once the prefs are read. */
423
+ function restoreUsage() {
424
+ try {
425
+ const saved = prefs.current().report?.usage;
426
+ if (saved) usage.restore(saved);
427
+ } catch {
428
+ // A saved tally that will not read: this run's counting goes on without it.
429
+ }
430
+ }
431
+
367
432
  async function call(method, path, body) {
368
433
  try {
369
434
  const res = await fetchImpl(REPORTS_API + path, {
@@ -411,31 +476,75 @@ export function createReporter({
411
476
  // The id is made here, the first time there is something to send under it.
412
477
  if (!prefs.current().report.installId) {
413
478
  await prefs.update(prev =>
414
- prev.reports === false || prev.report.installId ? undefined : { report: { ...prev.report, installId: randomUUID() } },
479
+ prev.reports === false || prev.report.installId
480
+ ? undefined
481
+ // When the id is made is when this install began: the clock the first
482
+ // session is measured against. Kept here, never sent as a time.
483
+ : { report: { ...prev.report, installId: randomUUID(), installedAt: now().toISOString() } },
415
484
  );
416
485
  }
417
486
  const { installId, lastVersion, lastActiveDay } = prefs.current().report;
418
487
  if (!installId) return;
419
488
  if (!lastVersion) {
420
- if (await call("POST", "/v1/app/events", { installId, kind: "install", ...factsNow() })) {
489
+ if (await call("POST", "/v1/app/events", { installId, kind: "install", ...factsNow(), ...setupNow() })) {
421
490
  await remember(installId, { lastVersion: facts.version });
422
491
  }
423
492
  } else if (lastVersion !== facts.version) {
424
- if (await call("POST", "/v1/app/events", { installId, kind: "update", fromVersion: lastVersion, ...factsNow() })) {
493
+ if (await call("POST", "/v1/app/events", { installId, kind: "update", fromVersion: lastVersion, ...factsNow(), ...setupNow() })) {
425
494
  await remember(installId, { lastVersion: facts.version });
426
495
  }
427
496
  }
428
497
 
429
- const today = now().toISOString().slice(0, 10);
498
+ const today = utcDay(now());
430
499
  if (lastActiveDay !== today) {
431
500
  // Usage rides on the "active" event alone, and is read only here — the one
432
501
  // report it belongs to — so a checkIn that sends nothing new never asks the
433
502
  // deck for its counts.
434
- const used = await coarseUsage();
435
- if (await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...used })) {
503
+ const used = coarseUsage(today);
504
+ if (await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...setupNow(), ...used })) {
505
+ if (used.usageDay) usage.markSent(used.usageDay);
436
506
  await remember(installId, { lastActiveDay: today });
437
507
  }
438
508
  }
509
+ await sendActivation();
510
+ await saveUsage();
511
+ }
512
+
513
+ /**
514
+ * The install's first session, said once: an "activated" event with how long
515
+ * after the install it came, in a bucket, and which CLI it came from.
516
+ *
517
+ * Only for an install whose start this deck saw — `installedAt` is made with
518
+ * the id, so an install from before this version has none and is never
519
+ * measured: its first session was long ago, and "now" would be a lie. The time
520
+ * is taken from the first use this run heard, so a session that arrived before
521
+ * the first check-in had an id is still dated when it happened. A send that
522
+ * cannot get through is tried again at the next check-in.
523
+ */
524
+ async function sendActivation() {
525
+ const first = usage.firstUse?.();
526
+ let r = prefs.current().report;
527
+ if (!reportsOn(prefs.current(), env) || !r.installId || !r.installedAt || r.activationSent) return;
528
+ if (!r.firstSessionAt) {
529
+ if (!first) return;
530
+ await remember(r.installId, { firstSessionAt: first.at, firstProvider: first.provider });
531
+ r = prefs.current().report;
532
+ if (!r.firstSessionAt) return;
533
+ }
534
+ const body = { installId: r.installId, kind: "activated", ...factsNow(), ...setupNow() };
535
+ addIf(body, "firstSession", sinceInstallBucket(r.installedAt, r.firstSessionAt));
536
+ addIf(body, "firstProvider", r.firstProvider === "codex" || r.firstProvider === "claude" ? r.firstProvider : undefined);
537
+ if (await call("POST", "/v1/app/events", body)) await remember(r.installId, { activationSent: true });
538
+ }
539
+
540
+ /** The first session this run heard, sent the moment it lands rather than at
541
+ * the next check-in, hours later or on a next launch that may never come. */
542
+ async function onFirstSession() {
543
+ try {
544
+ await sendActivation();
545
+ } catch {
546
+ // A settings file that cannot be written this minute; the next check-in tries again.
547
+ }
439
548
  }
440
549
 
441
550
  /** The heartbeat. While reports are on and an id exists, tell the API this deck
@@ -494,10 +603,12 @@ export function createReporter({
494
603
 
495
604
  /** The heartbeat timer, rescheduled from itself so each beat carries fresh
496
605
  * jitter — a fixed interval would let a fleet that started as one beat as one.
497
- * Unref'd, so it never holds the process open. */
606
+ * Unref'd, so it never holds the process open. Each beat also saves the day's
607
+ * tally if it moved, so a deck that stops loses ten minutes of it at most. */
498
608
  function scheduleNextPing() {
499
609
  pingTimer = setTimeout(() => {
500
610
  void ping();
611
+ void saveUsage();
501
612
  scheduleNextPing();
502
613
  }, PING_EVERY_MS + Math.floor(Math.random() * PING_JITTER_MS));
503
614
  pingTimer.unref?.();
@@ -508,7 +619,10 @@ export function createReporter({
508
619
  * open. The first check-in already puts a freshly launched deck online, so the
509
620
  * first ping is a full interval out — nothing pings at boot. */
510
621
  function start() {
511
- Promise.resolve(ready).catch(() => {}).then(checkIn);
622
+ Promise.all([Promise.resolve(ready).catch(() => {}), Promise.resolve(setupKnown()).catch(() => {})])
623
+ .then(restoreUsage)
624
+ .then(checkIn)
625
+ .then(() => usage.onFirstUse?.(() => void onFirstSession()));
512
626
  if (!timer) {
513
627
  timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
514
628
  timer.unref?.();
@@ -523,36 +637,16 @@ export function createReporter({
523
637
  pingTimer = null;
524
638
  }
525
639
 
526
- return { checkIn, ping, setReports, reportError, start, stop };
640
+ return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, sendActivation, start, stop };
527
641
  }
528
642
 
529
643
  // ── the deck's own providers ─────────────────────────────────────────────────
530
644
  //
531
645
  // Kept here rather than in the modules they read, so reports.mjs stays the one
532
646
  // place that decides what leaves — and reached by lazy import, so the reporter's
533
- // own module graph does not pull the session tracker, the enrichment cache or
534
- // the CLI probe into a fast path like `--version` that imports it for nothing.
535
-
536
- /** The coarse counts the "active" report carries, read from the state the deck
537
- * already keeps — the LRU of tracked sessions, the per-session subagent
538
- * signatures, and the folders those sessions' transcripts sit in. Never new
539
- * tracking, and never anything but a number: no session id, project name or
540
- * path, only how many. `projects` is the distinct transcript folders among the
541
- * live sessions, so it counts Claude projects; a Codex-only deck sends no
542
- * transcript path and so reports no projects. */
543
- async function deckUsage() {
544
- const out = {};
545
- try {
546
- const { trackedSessionCount, trackedProjectCount } = await import("./session-tracking.mjs");
547
- out.sessions = trackedSessionCount();
548
- out.projects = trackedProjectCount();
549
- } catch { /* counts unavailable: omitted */ }
550
- try {
551
- const { trackedSubagentCount } = await import("./session-enrichment.mjs");
552
- out.subagents = trackedSubagentCount();
553
- } catch { /* count unavailable: omitted */ }
554
- return out;
555
- }
647
+ // own module graph does not pull the CLI probe into a fast path like
648
+ // `--version` that imports it for nothing. The day's usage tally is imported
649
+ // directly: usage-day.mjs imports nothing.
556
650
 
557
651
  // The CLI versions, detected once in the background, off the boot path. The
558
652
  // first send starts the probe (past boot, and not while the machine has vetoed
@@ -572,4 +666,4 @@ function deckCliVersions() {
572
666
  }
573
667
 
574
668
  /** The deck's own reporter. */
575
- export const reporter = createReporter({ versions: deckCliVersions, usage: deckUsage });
669
+ export const reporter = createReporter({ versions: deckCliVersions });
@@ -77,21 +77,6 @@ function knownModelId(sid) {
77
77
  return cachedModelId(modelBySession.get(sid));
78
78
  }
79
79
 
80
- /** How many subagents the deck has seen across the sessions it still tracks —
81
- * the coarse "subagents" count the "active" report carries
82
- * (reports.mjs). Reused, not new state: `subsSig` is already the per-session
83
- * signature of the subagent models resolved for it, so its keys are the
84
- * subagents. A signature that will not parse is skipped rather than guessed at.
85
- * Only ever a number leaves — never an id, a model, a name or a path. */
86
- export function trackedSubagentCount() {
87
- let n = 0;
88
- for (const cached of modelBySession.values()) {
89
- if (!cached?.subsSig) continue;
90
- try { n += Object.keys(JSON.parse(cached.subsSig)).length; } catch { /* unparseable signature: not counted */ }
91
- }
92
- return n;
93
- }
94
-
95
80
  /** Read the main session JSONL. Returns the root model and any
96
81
  * legacy-schema subagent models (older CC versions kept subagent blocks
97
82
  * inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
@@ -147,23 +147,6 @@ function forgetSession(sid) {
147
147
  forgetCodexSession(sid);
148
148
  }
149
149
 
150
- /** How many sessions the deck is still tracking — the size of the LRU the watch
151
- * polls, which is the coarse "how many sessions" the "active" report
152
- * counts (reports.mjs). Reused, not new state: the Map is already the deck's
153
- * own answer to which sessions it still holds anything for. Never a session id,
154
- * a name or a path — only the count. */
155
- function trackedSessionCount() {
156
- return sessionTouchedAt.size;
157
- }
158
-
159
- /** How many distinct projects those live sessions sit in — the same live set,
160
- * counted by the folder each session's transcript is in. Reused, not new state:
161
- * the watch already holds every live session's transcript path. Never a project
162
- * name or path — only the count. */
163
- function trackedProjectCount() {
164
- return outputWatch.projectCount();
165
- }
166
-
167
150
  function touchSession(sid) {
168
151
  if (!sid || typeof sid !== "string") return;
169
152
  // Re-insert so the Map's own insertion order *is* the LRU order and eviction
@@ -256,4 +239,4 @@ async function handleForget(req, res) {
256
239
  // What index.mjs calls besides HARD_TRACKED_SESSIONS. Listed rather than
257
240
  // marked at each declaration, so the declarations read as they did where they
258
241
  // came from.
259
- export { handleForget, outputWatch, startOutputWatch, touchSession, trackedProjectCount, trackedSessionCount };
242
+ export { handleForget, outputWatch, startOutputWatch, touchSession };