ccdeck 3.36.4 → 3.36.6
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/dist/web/assets/{BrowserWatchModal-BqkA9n0I.js → BrowserWatchModal-Bpz7yVfY.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-5Ypz2NsB.js → UsageHistoryModal-kH3rzR0R.js} +1 -1
- package/dist/web/assets/{index-Cb31GACQ.js → index-B2C4N7Bl.js} +3 -3
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/deck-prefs.mjs +8 -3
- package/src/server/event-pipeline.mjs +8 -1
- package/src/server/reports.mjs +59 -38
- package/src/server/session-enrichment.mjs +0 -15
- package/src/server/session-tracking.mjs +1 -10
- package/src/server/usage-day.mjs +176 -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-B2C4N7Bl.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.6",
|
|
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
|
@@ -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,10 @@ 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).
|
|
97
|
+
report: Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null }),
|
|
95
98
|
// The accounts this deck signed in itself, and the re-sign-in prompts
|
|
96
99
|
// somebody put off (#1893) — keyed by account identity, written by the deck
|
|
97
100
|
// and never by a page. See account-origins.mjs.
|
|
@@ -291,7 +294,8 @@ export function normalise(raw) {
|
|
|
291
294
|
};
|
|
292
295
|
}
|
|
293
296
|
|
|
294
|
-
/** The reporter's own state, coerced: four strings, empty when absent
|
|
297
|
+
/** The reporter's own state, coerced: four strings, empty when absent, and the
|
|
298
|
+
* day's tally, null until there is one. */
|
|
295
299
|
function normaliseReport(raw) {
|
|
296
300
|
const src = raw && typeof raw === "object" ? raw : {};
|
|
297
301
|
const text = v => (typeof v === "string" ? v : "");
|
|
@@ -300,6 +304,7 @@ function normaliseReport(raw) {
|
|
|
300
304
|
lastVersion: text(src.lastVersion),
|
|
301
305
|
lastActiveDay: text(src.lastActiveDay),
|
|
302
306
|
forget: text(src.forget),
|
|
307
|
+
usage: src.usage && typeof src.usage === "object" ? normaliseUsage(src.usage) : null,
|
|
303
308
|
};
|
|
304
309
|
}
|
|
305
310
|
|
|
@@ -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,7 +232,10 @@ 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
|
}
|
package/src/server/reports.mjs
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
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),
|
|
16
17
|
// - errors: a request handler that threw on this server, or an error the page
|
|
17
18
|
// caught, with home folders, email addresses and key-shaped strings
|
|
18
19
|
// scrubbed out before they leave,
|
|
@@ -62,6 +63,7 @@ import { reportsVetoed } from "./deck-prefs.mjs";
|
|
|
62
63
|
import { isGitCheckout } from "./install-layout.mjs";
|
|
63
64
|
import { heldPrefs, prefsRead } from "./prefs-state.mjs";
|
|
64
65
|
import { RUNNING_VERSION } from "./running-version.mjs";
|
|
66
|
+
import { usageDay, utcDay } from "./usage-day.mjs";
|
|
65
67
|
|
|
66
68
|
export const REPORTS_API = "https://api.ccdeck.dev";
|
|
67
69
|
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
@@ -80,7 +82,7 @@ const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
|
|
|
80
82
|
const MESSAGE_MAX = 500;
|
|
81
83
|
const STACK_MAX = 4000;
|
|
82
84
|
|
|
83
|
-
const EMPTY_REPORT = Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "" });
|
|
85
|
+
const EMPTY_REPORT = Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null });
|
|
84
86
|
|
|
85
87
|
/** Is this deck sending reports right now? The switch, which is on unless
|
|
86
88
|
* somebody turned it off, and the machine's consent. */
|
|
@@ -307,9 +309,10 @@ export function scrub(text, home = homedir()) {
|
|
|
307
309
|
* @param {() => { claudeVersion?: string, codexVersion?: string }} [deps.versions] the CLI
|
|
308
310
|
* versions to fold into the facts, read fresh on every send so a background
|
|
309
311
|
* probe's answer rides along the moment it arrives. Default: none.
|
|
310
|
-
* @param {
|
|
311
|
-
* the
|
|
312
|
-
* about to be sent
|
|
312
|
+
* @param {ReturnType<typeof import("./usage-day.mjs").createUsageDay>} [deps.usage]
|
|
313
|
+
* the day's tally: the "active" report carries the last finished day's totals,
|
|
314
|
+
* read only when an "active" is about to be sent, and the tally is saved into
|
|
315
|
+
* the prefs so a restart does not lose the day. Default: the deck's own.
|
|
313
316
|
*/
|
|
314
317
|
export function createReporter({
|
|
315
318
|
fetchImpl = globalThis.fetch,
|
|
@@ -320,7 +323,7 @@ export function createReporter({
|
|
|
320
323
|
home = homedir(),
|
|
321
324
|
ready = prefsRead,
|
|
322
325
|
versions = () => ({}),
|
|
323
|
-
usage =
|
|
326
|
+
usage = usageDay,
|
|
324
327
|
} = {}) {
|
|
325
328
|
const errorsSent = [];
|
|
326
329
|
const lastSentAt = new Map();
|
|
@@ -348,13 +351,16 @@ export function createReporter({
|
|
|
348
351
|
return { ...facts, ...versionFacts() };
|
|
349
352
|
}
|
|
350
353
|
|
|
351
|
-
/** The coarse usage the "active" report carries:
|
|
352
|
-
*
|
|
354
|
+
/** The coarse usage the "active" report carries: the last finished day before
|
|
355
|
+
* `today`, its date as `usageDay` and its counts beside it. Nothing at all
|
|
356
|
+
* when there is no such day yet — a first launch has no yesterday — and only
|
|
357
|
+
* whole counts of zero or more survive. A tally that throws costs the report
|
|
353
358
|
* nothing. */
|
|
354
|
-
|
|
359
|
+
function coarseUsage(today) {
|
|
355
360
|
try {
|
|
356
|
-
const raw =
|
|
357
|
-
|
|
361
|
+
const raw = usage.finished(today);
|
|
362
|
+
if (!raw || typeof raw.day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(raw.day) || raw.day >= today) return {};
|
|
363
|
+
const out = { usageDay: raw.day };
|
|
358
364
|
addIf(out, "sessions", count(raw.sessions));
|
|
359
365
|
addIf(out, "subagents", count(raw.subagents));
|
|
360
366
|
addIf(out, "projects", count(raw.projects));
|
|
@@ -364,6 +370,35 @@ export function createReporter({
|
|
|
364
370
|
}
|
|
365
371
|
}
|
|
366
372
|
|
|
373
|
+
/** The tally the last save wrote, so a save with nothing new writes nothing. */
|
|
374
|
+
let usageSaved = -1;
|
|
375
|
+
|
|
376
|
+
/** Keep the day's tally in the prefs, beside the install id, so a restart
|
|
377
|
+
* carries it on. Only while reports are on and under the id it belongs to:
|
|
378
|
+
* switching off drops it with the id. Never throws. */
|
|
379
|
+
async function saveUsage() {
|
|
380
|
+
try {
|
|
381
|
+
const at = usage.version();
|
|
382
|
+
if (at === usageSaved) return;
|
|
383
|
+
const p = prefs.current();
|
|
384
|
+
if (!reportsOn(p, env) || !p.report.installId) return;
|
|
385
|
+
await remember(p.report.installId, { usage: usage.saved() });
|
|
386
|
+
usageSaved = at;
|
|
387
|
+
} catch {
|
|
388
|
+
// A settings file that cannot be written this minute; the next save tries again.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The tally an earlier run saved, merged in once the prefs are read. */
|
|
393
|
+
function restoreUsage() {
|
|
394
|
+
try {
|
|
395
|
+
const saved = prefs.current().report?.usage;
|
|
396
|
+
if (saved) usage.restore(saved);
|
|
397
|
+
} catch {
|
|
398
|
+
// A saved tally that will not read: this run's counting goes on without it.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
367
402
|
async function call(method, path, body) {
|
|
368
403
|
try {
|
|
369
404
|
const res = await fetchImpl(REPORTS_API + path, {
|
|
@@ -426,16 +461,18 @@ export function createReporter({
|
|
|
426
461
|
}
|
|
427
462
|
}
|
|
428
463
|
|
|
429
|
-
const today = now()
|
|
464
|
+
const today = utcDay(now());
|
|
430
465
|
if (lastActiveDay !== today) {
|
|
431
466
|
// Usage rides on the "active" event alone, and is read only here — the one
|
|
432
467
|
// report it belongs to — so a checkIn that sends nothing new never asks the
|
|
433
468
|
// deck for its counts.
|
|
434
|
-
const used =
|
|
469
|
+
const used = coarseUsage(today);
|
|
435
470
|
if (await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...used })) {
|
|
471
|
+
if (used.usageDay) usage.markSent(used.usageDay);
|
|
436
472
|
await remember(installId, { lastActiveDay: today });
|
|
437
473
|
}
|
|
438
474
|
}
|
|
475
|
+
await saveUsage();
|
|
439
476
|
}
|
|
440
477
|
|
|
441
478
|
/** The heartbeat. While reports are on and an id exists, tell the API this deck
|
|
@@ -494,10 +531,12 @@ export function createReporter({
|
|
|
494
531
|
|
|
495
532
|
/** The heartbeat timer, rescheduled from itself so each beat carries fresh
|
|
496
533
|
* 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.
|
|
534
|
+
* Unref'd, so it never holds the process open. Each beat also saves the day's
|
|
535
|
+
* tally if it moved, so a deck that stops loses ten minutes of it at most. */
|
|
498
536
|
function scheduleNextPing() {
|
|
499
537
|
pingTimer = setTimeout(() => {
|
|
500
538
|
void ping();
|
|
539
|
+
void saveUsage();
|
|
501
540
|
scheduleNextPing();
|
|
502
541
|
}, PING_EVERY_MS + Math.floor(Math.random() * PING_JITTER_MS));
|
|
503
542
|
pingTimer.unref?.();
|
|
@@ -508,7 +547,7 @@ export function createReporter({
|
|
|
508
547
|
* open. The first check-in already puts a freshly launched deck online, so the
|
|
509
548
|
* first ping is a full interval out — nothing pings at boot. */
|
|
510
549
|
function start() {
|
|
511
|
-
Promise.resolve(ready).catch(() => {}).then(checkIn);
|
|
550
|
+
Promise.resolve(ready).catch(() => {}).then(restoreUsage).then(checkIn);
|
|
512
551
|
if (!timer) {
|
|
513
552
|
timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
|
|
514
553
|
timer.unref?.();
|
|
@@ -523,34 +562,16 @@ export function createReporter({
|
|
|
523
562
|
pingTimer = null;
|
|
524
563
|
}
|
|
525
564
|
|
|
526
|
-
return { checkIn, ping, setReports, reportError, start, stop };
|
|
565
|
+
return { checkIn, ping, setReports, reportError, restoreUsage, saveUsage, start, stop };
|
|
527
566
|
}
|
|
528
567
|
|
|
529
568
|
// ── the deck's own providers ─────────────────────────────────────────────────
|
|
530
569
|
//
|
|
531
570
|
// Kept here rather than in the modules they read, so reports.mjs stays the one
|
|
532
571
|
// 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 and the per-session subagent
|
|
538
|
-
* signatures. Never new tracking, and never anything but a number: no session
|
|
539
|
-
* id, name or path. `projects` is left out for now — its only source is the
|
|
540
|
-
* disk-backed, per-account rollup (account-projects.mjs), which has no cheap
|
|
541
|
-
* synchronous count — so the field is simply not sent. */
|
|
542
|
-
async function deckUsage() {
|
|
543
|
-
const out = {};
|
|
544
|
-
try {
|
|
545
|
-
const { trackedSessionCount } = await import("./session-tracking.mjs");
|
|
546
|
-
out.sessions = trackedSessionCount();
|
|
547
|
-
} catch { /* count unavailable: omitted */ }
|
|
548
|
-
try {
|
|
549
|
-
const { trackedSubagentCount } = await import("./session-enrichment.mjs");
|
|
550
|
-
out.subagents = trackedSubagentCount();
|
|
551
|
-
} catch { /* count unavailable: omitted */ }
|
|
552
|
-
return out;
|
|
553
|
-
}
|
|
572
|
+
// own module graph does not pull the CLI probe into a fast path like
|
|
573
|
+
// `--version` that imports it for nothing. The day's usage tally is imported
|
|
574
|
+
// directly: usage-day.mjs imports nothing.
|
|
554
575
|
|
|
555
576
|
// The CLI versions, detected once in the background, off the boot path. The
|
|
556
577
|
// first send starts the probe (past boot, and not while the machine has vetoed
|
|
@@ -570,4 +591,4 @@ function deckCliVersions() {
|
|
|
570
591
|
}
|
|
571
592
|
|
|
572
593
|
/** The deck's own reporter. */
|
|
573
|
-
export const reporter = createReporter({ versions: deckCliVersions
|
|
594
|
+
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,15 +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
150
|
function touchSession(sid) {
|
|
160
151
|
if (!sid || typeof sid !== "string") return;
|
|
161
152
|
// Re-insert so the Map's own insertion order *is* the LRU order and eviction
|
|
@@ -248,4 +239,4 @@ async function handleForget(req, res) {
|
|
|
248
239
|
// What index.mjs calls besides HARD_TRACKED_SESSIONS. Listed rather than
|
|
249
240
|
// marked at each declaration, so the declarations read as they did where they
|
|
250
241
|
// came from.
|
|
251
|
-
export { handleForget, outputWatch, startOutputWatch, touchSession
|
|
242
|
+
export { handleForget, outputWatch, startOutputWatch, touchSession };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// How much this deck was used on one UTC day: the distinct sessions, subagents
|
|
2
|
+
// and projects its hooks heard from. The "active" report carries the totals of
|
|
3
|
+
// the last finished day (reports.mjs).
|
|
4
|
+
//
|
|
5
|
+
// WHY A DAY AND NOT A SNAPSHOT. The counts used to be read off the live state at
|
|
6
|
+
// the moment the "active" went out — and that moment is the first check-in of
|
|
7
|
+
// the day, which runs at launch, before anybody has done anything. Production
|
|
8
|
+
// on 2026-10-01: of 33 "active" reports carrying counts, the median session
|
|
9
|
+
// count was 0, and 24 said zero sessions. A day's totals, sent the next day,
|
|
10
|
+
// are the number the snapshot was trying to be.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IS KEPT. In memory, the session ids, the subagent ids and the folders the
|
|
13
|
+
// transcripts sit in, so a session heard from a hundred times counts once. They
|
|
14
|
+
// never leave this module: what is saved between runs (prefs.json, beside the
|
|
15
|
+
// install id) and what is sent are three counts and the day they belong to.
|
|
16
|
+
//
|
|
17
|
+
// A RESTART MID-DAY. The saved counts come back as a floor and the new run
|
|
18
|
+
// counts on top of them, so a session that was running across the restart is
|
|
19
|
+
// counted twice. That is the price of keeping no ids on disk, and a restart is
|
|
20
|
+
// rare next to the day's sessions.
|
|
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.
|
|
25
|
+
|
|
26
|
+
/** The day as the reporter says it: the UTC date, "2026-10-01". */
|
|
27
|
+
export function utcDay(date) {
|
|
28
|
+
return date.toISOString().slice(0, 10);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
32
|
+
|
|
33
|
+
/** A whole count of zero or more, else 0. */
|
|
34
|
+
function whole(n) {
|
|
35
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Saved totals, coerced: a day and three counts, or null for anything else. */
|
|
39
|
+
function savedTotals(raw) {
|
|
40
|
+
if (!raw || typeof raw !== "object" || typeof raw.day !== "string" || !DAY.test(raw.day)) return null;
|
|
41
|
+
return { day: raw.day, sessions: whole(raw.sessions), subagents: whole(raw.subagents), projects: whole(raw.projects) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What prefs.json keeps for this, coerced — deck-prefs.mjs normalises with it. */
|
|
45
|
+
export function normaliseUsage(raw) {
|
|
46
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
47
|
+
return {
|
|
48
|
+
current: savedTotals(src.current),
|
|
49
|
+
done: savedTotals(src.done),
|
|
50
|
+
sent: typeof src.sent === "string" && DAY.test(src.sent) ? src.sent : "",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The folder a transcript sits in, which is the project it belongs to:
|
|
55
|
+
* `~/.claude/projects/<project>/<session>.jsonl`. */
|
|
56
|
+
function folderOf(path) {
|
|
57
|
+
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
58
|
+
return cut > 0 ? path.slice(0, cut) : path;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The tally. `now` is injectable so the suite can walk it across midnight.
|
|
63
|
+
*/
|
|
64
|
+
export function createUsageDay({ now = () => new Date() } = {}) {
|
|
65
|
+
/** The day being counted: saved counts from an earlier run as a floor, and
|
|
66
|
+
* this run's distinct ids on top. */
|
|
67
|
+
let current = null;
|
|
68
|
+
/** The last finished day whose totals have not gone out yet. */
|
|
69
|
+
let done = null;
|
|
70
|
+
/** The last day whose totals did go out. */
|
|
71
|
+
let sent = "";
|
|
72
|
+
/** Moves on every change, so the reporter saves only when there is something new. */
|
|
73
|
+
let changes = 0;
|
|
74
|
+
|
|
75
|
+
function fresh(day, floor = null) {
|
|
76
|
+
return {
|
|
77
|
+
day,
|
|
78
|
+
floor: floor ?? { sessions: 0, subagents: 0, projects: 0 },
|
|
79
|
+
sessions: new Set(),
|
|
80
|
+
subagents: new Set(),
|
|
81
|
+
projects: new Set(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function totals(c) {
|
|
86
|
+
return {
|
|
87
|
+
day: c.day,
|
|
88
|
+
sessions: c.floor.sessions + c.sessions.size,
|
|
89
|
+
subagents: c.floor.subagents + c.subagents.size,
|
|
90
|
+
projects: c.floor.projects + c.projects.size,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The day being counted, rolled over first if the clock has passed it. */
|
|
95
|
+
function today() {
|
|
96
|
+
const day = utcDay(now());
|
|
97
|
+
if (current?.day === day) return current;
|
|
98
|
+
if (current && current.day < day) done = totals(current);
|
|
99
|
+
current = fresh(day);
|
|
100
|
+
changes++;
|
|
101
|
+
return current;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function add(set, key) {
|
|
105
|
+
if (set.has(key)) return;
|
|
106
|
+
set.add(key);
|
|
107
|
+
changes++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A live hook payload: its session, and the subagent it came from if any. */
|
|
111
|
+
function noteUse(raw) {
|
|
112
|
+
const sid = raw?.session_id;
|
|
113
|
+
if (!sid || typeof sid !== "string") return;
|
|
114
|
+
const c = today();
|
|
115
|
+
add(c.sessions, sid);
|
|
116
|
+
if (typeof raw.agent_id === "string" && raw.agent_id) add(c.subagents, `${sid}\u0000${raw.agent_id}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A transcript path a hook carried, already accepted as Claude's. */
|
|
120
|
+
function noteProject(path) {
|
|
121
|
+
if (!path || typeof path !== "string") return;
|
|
122
|
+
add(today().projects, folderOf(path));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The totals of the last finished day before `day`, if they have not gone
|
|
126
|
+
* out yet; null when there is none. Rolls over first, so a deck that ran past
|
|
127
|
+
* midnight with nothing said since still has yesterday finished. */
|
|
128
|
+
function finished(day) {
|
|
129
|
+
today();
|
|
130
|
+
const c = current && current.day < day ? totals(current) : done;
|
|
131
|
+
return c && c.day < day && c.day > sent ? c : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Those totals went out: never send them again. */
|
|
135
|
+
function markSent(day) {
|
|
136
|
+
if (day > sent) sent = day;
|
|
137
|
+
if (done && done.day <= sent) done = null;
|
|
138
|
+
changes++;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What survives a restart: counts and days, never an id or a path. */
|
|
142
|
+
function saved() {
|
|
143
|
+
return { current: current ? totals(current) : null, done, sent };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* An earlier run's `saved()`, merged into this one. This run may already have
|
|
148
|
+
* heard from sessions — the prefs are read after the first hooks can land —
|
|
149
|
+
* so nothing it counted is dropped: the saved day becomes the floor of the
|
|
150
|
+
* same day, or the finished day of an earlier one.
|
|
151
|
+
*/
|
|
152
|
+
function restore(raw) {
|
|
153
|
+
const s = normaliseUsage(raw);
|
|
154
|
+
if (s.sent > sent) sent = s.sent;
|
|
155
|
+
for (const t of [s.done, s.current]) {
|
|
156
|
+
if (!t) continue;
|
|
157
|
+
const c = today();
|
|
158
|
+
if (t.day === c.day) {
|
|
159
|
+
c.floor = { sessions: t.sessions, subagents: t.subagents, projects: t.projects };
|
|
160
|
+
} else if (t.day < c.day && (!done || t.day > done.day)) {
|
|
161
|
+
done = t;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (done && done.day <= sent) done = null;
|
|
165
|
+
changes++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function version() {
|
|
169
|
+
return changes;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { noteUse, noteProject, finished, markSent, saved, restore, version };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The deck's own tally, which the event pipeline feeds and the reporter reads. */
|
|
176
|
+
export const usageDay = createUsageDay();
|