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.
- package/README.md +1 -1
- package/bin/deck.js +5 -0
- package/dist/web/assets/{BrowserWatchModal-LYnUxuAL.js → BrowserWatchModal-Fzl8O2oN.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-B0qdsys5.js → UsageHistoryModal-BEWKpjC2.js} +1 -1
- package/dist/web/assets/{index-B18vwZ9I.js → index-B1ngNkKH.js} +13 -13
- 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 +18 -3
- package/src/server/event-pipeline.mjs +13 -1
- package/src/server/output-watch.mjs +1 -14
- package/src/server/reports.mjs +143 -49
- package/src/server/session-enrichment.mjs +0 -15
- package/src/server/session-tracking.mjs +1 -18
- package/src/server/usage-day.mjs +207 -0
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-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.
|
|
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": {
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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:
|
|
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)
|
|
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,
|
|
273
|
+
return { note, forget, clear, size, poll };
|
|
287
274
|
}
|
package/src/server/reports.mjs
CHANGED
|
@@ -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
|
|
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
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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({
|
|
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 {
|
|
311
|
-
* the
|
|
312
|
-
* about to be sent
|
|
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
|
-
/**
|
|
352
|
-
*
|
|
353
|
-
|
|
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
|
|
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
|
|
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()
|
|
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 =
|
|
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(() => {}).
|
|
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
|
|
534
|
-
//
|
|
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
|
|
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
|
|
242
|
+
export { handleForget, outputWatch, startOutputWatch, touchSession };
|