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.
- package/README.md +1 -1
- package/bin/deck.js +5 -0
- package/dist/web/assets/{BrowserWatchModal-Bpz7yVfY.js → BrowserWatchModal-CeVGLslQ.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-kH3rzR0R.js → UsageHistoryModal-BcUzGS2-.js} +1 -1
- package/dist/web/assets/index-4ecbC_Cy.js +306 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/activation.mjs +76 -0
- package/src/server/deck-prefs.mjs +14 -4
- package/src/server/event-pipeline.mjs +5 -0
- package/src/server/feature-use.mjs +42 -0
- package/src/server/index.mjs +11 -0
- package/src/server/reports.mjs +95 -14
- package/src/server/usage-day.mjs +85 -11
- package/dist/web/assets/index-B2C4N7Bl.js +0 -306
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
298
|
-
*
|
|
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
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
package/src/server/reports.mjs
CHANGED
|
@@ -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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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({
|
|
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
|
|
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(() => {}).
|
|
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 ─────────────────────────────────────────────────
|
package/src/server/usage-day.mjs
CHANGED
|
@@ -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
|
|
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
|
|
23
|
-
// CLI
|
|
24
|
-
//
|
|
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
|
|
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 {
|
|
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
|
|
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. */
|