ccdeck 3.36.6 → 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-B2C4N7Bl.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.6",
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": {
@@ -65,7 +65,8 @@
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"
69
70
  ],
70
71
  "3.36.3": [
71
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
+ }
@@ -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;
@@ -14,6 +14,9 @@
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
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),
17
20
  // - errors: a request handler that threw on this server, or an error the page
18
21
  // caught, with home folders, email addresses and key-shaped strings
19
22
  // scrubbed out before they leave,
@@ -26,11 +29,14 @@
26
29
  // the channel (the desktop app, npm, or a source checkout) and the runtime, plus
27
30
  // a coarse sketch of the environment: the logical CPU count, the RAM in MB, the
28
31
  // 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.
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.
34
40
  //
35
41
  // The one field that IS derived from the machine is `deviceId`: a stable, hashed
36
42
  // device id sent with EVERY report — install, update and active — while reports
@@ -64,6 +70,7 @@ import { isGitCheckout } from "./install-layout.mjs";
64
70
  import { heldPrefs, prefsRead } from "./prefs-state.mjs";
65
71
  import { RUNNING_VERSION } from "./running-version.mjs";
66
72
  import { usageDay, utcDay } from "./usage-day.mjs";
73
+ import { setupFacts, sinceInstallBucket, whenSetupKnown } from "./activation.mjs";
67
74
 
68
75
  export const REPORTS_API = "https://api.ccdeck.dev";
69
76
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -82,7 +89,10 @@ const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
82
89
  const MESSAGE_MAX = 500;
83
90
  const STACK_MAX = 4000;
84
91
 
85
- const EMPTY_REPORT = Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null });
92
+ const EMPTY_REPORT = Object.freeze({
93
+ installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
94
+ installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
95
+ });
86
96
 
87
97
  /** Is this deck sending reports right now? The switch, which is on unless
88
98
  * somebody turned it off, and the machine's consent. */
@@ -313,6 +323,10 @@ export function scrub(text, home = homedir()) {
313
323
  * the day's tally: the "active" report carries the last finished day's totals,
314
324
  * read only when an "active" is about to be sent, and the tally is saved into
315
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.
316
330
  */
317
331
  export function createReporter({
318
332
  fetchImpl = globalThis.fetch,
@@ -324,6 +338,8 @@ export function createReporter({
324
338
  ready = prefsRead,
325
339
  versions = () => ({}),
326
340
  usage = usageDay,
341
+ setup = setupFacts,
342
+ setupKnown = () => whenSetupKnown(),
327
343
  } = {}) {
328
344
  const errorsSent = [];
329
345
  const lastSentAt = new Map();
@@ -351,6 +367,20 @@ export function createReporter({
351
367
  return { ...facts, ...versionFacts() };
352
368
  }
353
369
 
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() {
373
+ try {
374
+ const s = setup() ?? {};
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
+
354
384
  /** The coarse usage the "active" report carries: the last finished day before
355
385
  * `today`, its date as `usageDay` and its counts beside it. Nothing at all
356
386
  * when there is no such day yet — a first launch has no yesterday — and only
@@ -446,17 +476,21 @@ export function createReporter({
446
476
  // The id is made here, the first time there is something to send under it.
447
477
  if (!prefs.current().report.installId) {
448
478
  await prefs.update(prev =>
449
- 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() } },
450
484
  );
451
485
  }
452
486
  const { installId, lastVersion, lastActiveDay } = prefs.current().report;
453
487
  if (!installId) return;
454
488
  if (!lastVersion) {
455
- if (await call("POST", "/v1/app/events", { installId, kind: "install", ...factsNow() })) {
489
+ if (await call("POST", "/v1/app/events", { installId, kind: "install", ...factsNow(), ...setupNow() })) {
456
490
  await remember(installId, { lastVersion: facts.version });
457
491
  }
458
492
  } else if (lastVersion !== facts.version) {
459
- 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() })) {
460
494
  await remember(installId, { lastVersion: facts.version });
461
495
  }
462
496
  }
@@ -467,14 +501,52 @@ export function createReporter({
467
501
  // report it belongs to — so a checkIn that sends nothing new never asks the
468
502
  // deck for its counts.
469
503
  const used = coarseUsage(today);
470
- if (await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...used })) {
504
+ if (await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...setupNow(), ...used })) {
471
505
  if (used.usageDay) usage.markSent(used.usageDay);
472
506
  await remember(installId, { lastActiveDay: today });
473
507
  }
474
508
  }
509
+ await sendActivation();
475
510
  await saveUsage();
476
511
  }
477
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
+ }
548
+ }
549
+
478
550
  /** The heartbeat. While reports are on and an id exists, tell the API this deck
479
551
  * is still here — it moves the install's "last seen" and writes no event, so it
480
552
  * carries the id and nothing else: no facts, no deviceId. Like every send it is
@@ -547,7 +619,10 @@ export function createReporter({
547
619
  * open. The first check-in already puts a freshly launched deck online, so the
548
620
  * first ping is a full interval out — nothing pings at boot. */
549
621
  function start() {
550
- Promise.resolve(ready).catch(() => {}).then(restoreUsage).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()));
551
626
  if (!timer) {
552
627
  timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
553
628
  timer.unref?.();
@@ -562,7 +637,7 @@ export function createReporter({
562
637
  pingTimer = null;
563
638
  }
564
639
 
565
- return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, start, stop };
640
+ return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, sendActivation, start, stop };
566
641
  }
567
642
 
568
643
  // ── the deck's own providers ─────────────────────────────────────────────────
@@ -19,9 +19,10 @@
19
19
  // counted twice. That is the price of keeping no ids on disk, and a restart is
20
20
  // rare next to the day's sessions.
21
21
  //
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.
22
+ // Only live use counts: a Claude hook fires because somebody is running the
23
+ // CLI, and the Codex watcher emits only what a rollout gains while the deck
24
+ // watches (its first scan skips every file's history). Replayed lines and the
25
+ // deck's own synthetic events are not use.
25
26
 
26
27
  /** The day as the reporter says it: the UTC date, "2026-10-01". */
27
28
  export function utcDay(date) {
@@ -71,6 +72,9 @@ export function createUsageDay({ now = () => new Date() } = {}) {
71
72
  let sent = "";
72
73
  /** Moves on every change, so the reporter saves only when there is something new. */
73
74
  let changes = 0;
75
+ /** The first use this run heard, `{ at, provider }`, for activation.mjs. */
76
+ let first = null;
77
+ const firstListeners = [];
74
78
 
75
79
  function fresh(day, floor = null) {
76
80
  return {
@@ -107,21 +111,48 @@ export function createUsageDay({ now = () => new Date() } = {}) {
107
111
  changes++;
108
112
  }
109
113
 
110
- /** A live hook payload: its session, and the subagent it came from if any. */
114
+ /** A live payload: its session, and the subagent it came from if any. */
111
115
  function noteUse(raw) {
112
116
  const sid = raw?.session_id;
113
117
  if (!sid || typeof sid !== "string") return;
114
118
  const c = today();
115
119
  add(c.sessions, sid);
116
120
  if (typeof raw.agent_id === "string" && raw.agent_id) add(c.subagents, `${sid}\u0000${raw.agent_id}`);
121
+ if (!first) {
122
+ first = { at: now().toISOString(), provider: raw.provider === "codex" ? "codex" : "claude" };
123
+ for (const fn of firstListeners) {
124
+ try { fn(first); } catch { /* a listener's failure is its own */ }
125
+ }
126
+ }
117
127
  }
118
128
 
119
- /** A transcript path a hook carried, already accepted as Claude's. */
129
+ /** A transcript path a Claude hook carried, already accepted as Claude's:
130
+ * the project is the folder it sits in. */
120
131
  function noteProject(path) {
121
132
  if (!path || typeof path !== "string") return;
122
133
  add(today().projects, folderOf(path));
123
134
  }
124
135
 
136
+ /** A Codex session's working directory, which is its project. */
137
+ function noteFolder(dir) {
138
+ if (!dir || typeof dir !== "string") return;
139
+ add(today().projects, dir);
140
+ }
141
+
142
+ /** The first use this run heard, or null. */
143
+ function firstUse() {
144
+ return first ? { ...first } : null;
145
+ }
146
+
147
+ /** Call `fn` with the first use, once, when it happens — or now, if it has. */
148
+ function onFirstUse(fn) {
149
+ if (first) {
150
+ try { fn(first); } catch { /* as above */ }
151
+ return;
152
+ }
153
+ firstListeners.push(fn);
154
+ }
155
+
125
156
  /** The totals of the last finished day before `day`, if they have not gone
126
157
  * out yet; null when there is none. Rolls over first, so a deck that ran past
127
158
  * midnight with nothing said since still has yesterday finished. */
@@ -169,7 +200,7 @@ export function createUsageDay({ now = () => new Date() } = {}) {
169
200
  return changes;
170
201
  }
171
202
 
172
- return { noteUse, noteProject, finished, markSent, saved, restore, version };
203
+ return { noteUse, noteProject, noteFolder, firstUse, onFirstUse, finished, markSent, saved, restore, version };
173
204
  }
174
205
 
175
206
  /** The deck's own tally, which the event pipeline feeds and the reporter reads. */