ccdeck 3.35.1 → 3.36.0
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 +8 -6
- package/dist/web/assets/{BrowserWatchModal-B7wC6HUM.js → BrowserWatchModal-BnNwmF9s.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-DgfzdQhw.js → UsageHistoryModal-CAsrCgeA.js} +1 -1
- package/dist/web/assets/index-CuVOi3gc.css +1 -0
- package/dist/web/assets/index-MjcQ231B.js +305 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +2 -1
- package/src/server/cli-versions.mjs +1 -1
- package/src/server/deck-prefs.mjs +2 -2
- package/src/server/index.mjs +5 -4
- package/src/server/reports-routes.mjs +1 -1
- package/src/server/reports.mjs +129 -15
- package/src/server/session-enrichment.mjs +3 -3
- package/src/server/session-tracking.mjs +1 -1
- package/src/server/transcript-scan.mjs +86 -7
- package/dist/web/assets/index-8Q-n811d.css +0 -1
- package/dist/web/assets/index-CR_USWmE.js +0 -304
package/dist/web/index.html
CHANGED
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
63
63
|
})();
|
|
64
64
|
</script>
|
|
65
|
-
<script type="module" crossorigin src="/assets/index-
|
|
66
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
65
|
+
<script type="module" crossorigin src="/assets/index-MjcQ231B.js"></script>
|
|
66
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CuVOi3gc.css">
|
|
67
67
|
</head>
|
|
68
68
|
<body>
|
|
69
69
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.36.0",
|
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The versions of the Claude Code and Codex CLIs this machine has, for the
|
|
2
|
-
//
|
|
2
|
+
// "which CLIs, at which version" count the reporter sends (reports.mjs).
|
|
3
3
|
//
|
|
4
4
|
// SPAWNED, BUT NEVER ON THE BOOT PATH. claude-dir.mjs measured a Claude Code
|
|
5
5
|
// child at ~3.0s and says out loud that boot is the wrong place to spend one, so
|
|
@@ -78,7 +78,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
78
78
|
// `auto when idle`, which defaulted on as a localStorage key; it moved here so
|
|
79
79
|
// the server can read it with no page open.
|
|
80
80
|
autoUpdate: true,
|
|
81
|
-
//
|
|
81
|
+
// USAGE REPORTS (#1853), ON unless somebody turns them off — the owner's
|
|
82
82
|
// decision, 2026-09-30: nobody is asked, the README says what is sent, and
|
|
83
83
|
// Appearance holds the switch. See reports.mjs for what is sent, and
|
|
84
84
|
// reportsVetoed for the launch-time veto that wins over this.
|
|
@@ -660,7 +660,7 @@ export function notificationsVetoed(env = process.env) {
|
|
|
660
660
|
return env[OFF_ENV] === "1";
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
-
/** Did the machine rule out
|
|
663
|
+
/** Did the machine rule out usage reports (#1853), whatever the switch says?
|
|
664
664
|
* AGENTS_DECK_NO_REPORTS=1 does, and so does AGENTS_DECK_NO_INSTALL=1, which
|
|
665
665
|
* the README promises "turns off everything but the quota reads". */
|
|
666
666
|
export function reportsVetoed(env = process.env) {
|
package/src/server/index.mjs
CHANGED
|
@@ -135,7 +135,7 @@ import { applyLanPrefs, resetLanLoaded } from "./lan-deck.mjs";
|
|
|
135
135
|
import { handleLanInvite, handleLanPeer, handleLanStatus, handleLanSync } from "./lan-routes.mjs";
|
|
136
136
|
// GET and POST /api/prefs — see prefs-routes.mjs.
|
|
137
137
|
import { handlePrefsGiveBack, handlePrefsRead, handlePrefsWrite } from "./prefs-routes.mjs";
|
|
138
|
-
//
|
|
138
|
+
// Usage reports and the feedback dialog (#1853). The requests to
|
|
139
139
|
// api.ccdeck.dev are made in reports.mjs, never in this file.
|
|
140
140
|
import { handleClientError, handleFeedback, handleReportsWrite } from "./reports-routes.mjs";
|
|
141
141
|
import { reporter } from "./reports.mjs";
|
|
@@ -481,9 +481,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
481
481
|
// deck that had it on comes back with it on, and a launcher that only
|
|
482
482
|
// asked the registry never binds a port it is about to walk away from.
|
|
483
483
|
prefsRead.then(() => applyLanPrefs()).catch(() => {});
|
|
484
|
-
//
|
|
485
|
-
// check every few hours
|
|
486
|
-
//
|
|
484
|
+
// Usage reports (#1853): what is due once the prefs are read, then a
|
|
485
|
+
// check every few hours plus a lighter ~10-minute heartbeat, both on
|
|
486
|
+
// unref'd timers. Switched off in Appearance, or with
|
|
487
|
+
// AGENTS_DECK_NO_REPORTS / AGENTS_DECK_NO_INSTALL, nothing is sent.
|
|
487
488
|
reporter.start();
|
|
488
489
|
// Auto-switch resumes only if the user previously turned it on; the
|
|
489
490
|
// module reads its own persisted flag and does nothing otherwise. Its
|
|
@@ -18,7 +18,7 @@ async function readJson(req, res, limit) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** `POST /api/reports` `{ on }`: Appearance's "Send
|
|
21
|
+
/** `POST /api/reports` `{ on }`: Appearance's "Send usage reports" switch. */
|
|
22
22
|
export async function handleReportsWrite(req, res, { report = reporter } = {}) {
|
|
23
23
|
const body = await readJson(req, res, 1_000);
|
|
24
24
|
if (typeof body?.on !== "boolean") return send(res, 400, { ok: false, reason: "bad_request" });
|
package/src/server/reports.mjs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Usage reports, on unless the person switched them off (#1853). They are NOT
|
|
2
|
+
// anonymous: with reports on, every one carries a stable, hashed device id — an
|
|
3
|
+
// identifier for this machine — and the server records the IP the report arrives
|
|
4
|
+
// from. What never leaves is the person's sessions, prompts, files, project
|
|
5
|
+
// names and paths.
|
|
2
6
|
//
|
|
3
7
|
// WHAT GOES OUT, to api.ccdeck.dev, while `prefs.reports` is true and the
|
|
4
8
|
// machine has not vetoed it (reportsVetoed):
|
|
@@ -11,11 +15,15 @@
|
|
|
11
15
|
// subagents and projects — numbers, never their names,
|
|
12
16
|
// - errors: a request handler that threw on this server, or an error the page
|
|
13
17
|
// caught, with home folders, email addresses and key-shaped strings
|
|
14
|
-
// scrubbed out before they leave
|
|
18
|
+
// scrubbed out before they leave,
|
|
19
|
+
// - a "ping" heartbeat, every ten minutes or so while the deck runs, which
|
|
20
|
+
// moves the install's "last seen" so the admin can show who is online now.
|
|
21
|
+
// It writes no event and carries the install id alone — no facts, no
|
|
22
|
+
// fingerprint.
|
|
15
23
|
//
|
|
16
|
-
// Each carries the install id, the version, the OS and CPU architecture,
|
|
17
|
-
// channel (the desktop app, npm, or a source checkout) and the runtime, plus
|
|
18
|
-
// coarse sketch of the environment: the logical CPU count, the RAM in MB, the
|
|
24
|
+
// Each event carries the install id, the version, the OS and CPU architecture,
|
|
25
|
+
// the channel (the desktop app, npm, or a source checkout) and the runtime, plus
|
|
26
|
+
// a coarse sketch of the environment: the logical CPU count, the RAM in MB, the
|
|
19
27
|
// locale, the shell and terminal names, and the Claude Code and Codex CLI
|
|
20
28
|
// versions. Every one of those is a small fixed token or a number, safe by
|
|
21
29
|
// construction — no path, no hostname, no user name, no project name, no prompt,
|
|
@@ -23,6 +31,14 @@
|
|
|
23
31
|
// field that cannot be told is left out rather than guessed. The install id is
|
|
24
32
|
// random, made at the first check-in, and tied to nothing on the machine.
|
|
25
33
|
//
|
|
34
|
+
// The one field that IS derived from the machine is `deviceId`: a stable, hashed
|
|
35
|
+
// device id sent with EVERY report — install, update and active — while reports
|
|
36
|
+
// are on, never on the ping and never on errors. It is an identifier: it follows
|
|
37
|
+
// one machine across runs, which is why the reports are no longer anonymous. It
|
|
38
|
+
// is personal data, so it leaves only as a one-way hash of stable machine traits,
|
|
39
|
+
// never those traits in the clear (see deviceIdToken). It rides with the facts,
|
|
40
|
+
// so it stops entirely the moment reports are switched off or vetoed.
|
|
41
|
+
//
|
|
26
42
|
// NOBODY IS ASKED, AND NOTHING IS HIDDEN. The owner chose on-by-default
|
|
27
43
|
// (2026-09-30): the README says what is sent, Appearance holds the switch, and
|
|
28
44
|
// AGENTS_DECK_NO_REPORTS=1 keeps it off from the first start.
|
|
@@ -36,8 +52,8 @@
|
|
|
36
52
|
// dropped: it is a nicety for the people who make ccdeck, and no part of the
|
|
37
53
|
// deck depends on it.
|
|
38
54
|
|
|
39
|
-
import { randomUUID } from "node:crypto";
|
|
40
|
-
import { cpus as osCpus, homedir, totalmem } from "node:os";
|
|
55
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
56
|
+
import { arch as osArch, cpus as osCpus, homedir, hostname, platform as osPlatform, totalmem } from "node:os";
|
|
41
57
|
import { dirname, resolve } from "node:path";
|
|
42
58
|
import { fileURLToPath } from "node:url";
|
|
43
59
|
import { inApp } from "./app-host.mjs";
|
|
@@ -51,6 +67,12 @@ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
51
67
|
const TIMEOUT_MS = 6000;
|
|
52
68
|
/** How often a long-running deck checks whether a new day wants its "active". */
|
|
53
69
|
const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
70
|
+
/** The heartbeat's beat: a lighter timer than the check-in that only moves the
|
|
71
|
+
* install's "last seen", so the admin can count who is online now. Ten minutes,
|
|
72
|
+
* plus up to `PING_JITTER_MS` of random spread, so a fleet started together does
|
|
73
|
+
* not all beat on the same tick. */
|
|
74
|
+
const PING_EVERY_MS = 10 * 60 * 1000;
|
|
75
|
+
const PING_JITTER_MS = 2 * 60 * 1000;
|
|
54
76
|
/** Errors per hour this deck may send, and how long one error is not repeated. */
|
|
55
77
|
const ERRORS_PER_HOUR = 20;
|
|
56
78
|
const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
|
|
@@ -69,14 +91,18 @@ export function reportsOn(prefs, env = process.env) {
|
|
|
69
91
|
//
|
|
70
92
|
// Everything added to an install's facts here is a count or a small fixed token,
|
|
71
93
|
// safe by construction: no path, no hostname, no user name, no free text ever
|
|
72
|
-
// reaches any of them, so there is nothing to scrub.
|
|
73
|
-
//
|
|
74
|
-
//
|
|
94
|
+
// reaches any of them, so there is nothing to scrub. The ONE exception is
|
|
95
|
+
// `deviceId` below — it is derived from identifying machine traits (the hostname
|
|
96
|
+
// among them), so it is personal data, which is exactly why it leaves only as a
|
|
97
|
+
// one-way hash and never as the traits themselves (see deviceIdToken). Each is
|
|
98
|
+
// optional — a field that cannot be told is left undefined and so never sent, and
|
|
99
|
+
// none of these can throw, because a report is a nicety and no part of the deck
|
|
100
|
+
// may hang on it.
|
|
75
101
|
|
|
76
102
|
// The keys the API caps, so a value that would be rejected for length is not
|
|
77
103
|
// sent: it is trimmed to the cap here instead. Only ever tokens the shape of a
|
|
78
104
|
// terminal name, a shell name, a locale or a version — never anything free.
|
|
79
|
-
const TOKEN_CAPS = { locale: 16, shell: 16, term: 32, claudeVersion: 32, codexVersion: 32 };
|
|
105
|
+
const TOKEN_CAPS = { locale: 16, shell: 16, term: 32, claudeVersion: 32, codexVersion: 32, deviceId: 64 };
|
|
80
106
|
|
|
81
107
|
/** A value reduced to the API's token shape `^[0-9A-Za-z.+_-]+$`: spaces become
|
|
82
108
|
* "_" (so "Apple Terminal" rides as "Apple_Terminal"), every other disallowed
|
|
@@ -104,6 +130,54 @@ function totalMemMb() {
|
|
|
104
130
|
try { return count(Math.round(totalmem() / 1024 / 1024)); } catch { return undefined; }
|
|
105
131
|
}
|
|
106
132
|
|
|
133
|
+
/**
|
|
134
|
+
* A stable, non-reversible fingerprint of the machine, as an opaque token.
|
|
135
|
+
*
|
|
136
|
+
* PRIVACY — READ THIS. Unlike every other facts field, this is PERSONAL DATA: a
|
|
137
|
+
* stable id that follows one machine across runs, and one of its ingredients
|
|
138
|
+
* (`os.hostname()`) can name a person. That is exactly why it leaves only as a
|
|
139
|
+
* one-way hash, never as the traits in the clear — the traits are concatenated,
|
|
140
|
+
* run through SHA-256, and only the first 16 hex characters ship, so the machine
|
|
141
|
+
* details cannot be read back out of what leaves. It is identifying all the same:
|
|
142
|
+
* it is stable per machine, so it is what makes the reports no longer anonymous,
|
|
143
|
+
* and it ships with every report while reports are on. The token is hex, so it
|
|
144
|
+
* matches the API's `^[0-9A-Za-z.+_-]+$` shape and its 16 characters sit well
|
|
145
|
+
* under the 64 cap.
|
|
146
|
+
* Built from traits that do not change between restarts — platform, arch, CPU
|
|
147
|
+
* model and count, total RAM, and the hostname folded into the hash ONLY — and it
|
|
148
|
+
* never throws: a trait that cannot be read just yields a different, still stable,
|
|
149
|
+
* token. The traits are a parameter so a test can hash a fixed machine.
|
|
150
|
+
*/
|
|
151
|
+
export function deviceIdToken(traits = machineTraits()) {
|
|
152
|
+
try {
|
|
153
|
+
const seed = [traits.platform, traits.arch, traits.cpuModel, traits.cpuCount, traits.memBytes, traits.hostname]
|
|
154
|
+
.map(part => String(part ?? ""))
|
|
155
|
+
.join("\u0000");
|
|
156
|
+
return createHash("sha256").update(seed).digest("hex").slice(0, 16);
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The stable machine traits the fingerprint hashes — never sent themselves,
|
|
163
|
+
* only the hash of them. `hostname()` is identifying and is read here solely to
|
|
164
|
+
* be folded into that hash, never returned to a path that sends facts. */
|
|
165
|
+
function machineTraits() {
|
|
166
|
+
try {
|
|
167
|
+
const list = osCpus();
|
|
168
|
+
return {
|
|
169
|
+
platform: osPlatform(),
|
|
170
|
+
arch: osArch(),
|
|
171
|
+
cpuModel: list?.[0]?.model,
|
|
172
|
+
cpuCount: list?.length,
|
|
173
|
+
memBytes: totalmem(),
|
|
174
|
+
hostname: hostname(),
|
|
175
|
+
};
|
|
176
|
+
} catch {
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
107
181
|
/**
|
|
108
182
|
* The UI locale as a token — "en-US", "de-DE". The runtime's own resolved
|
|
109
183
|
* locale first, then `LANG`/`LC_ALL` with any `.UTF-8` encoding stripped. The
|
|
@@ -168,6 +242,7 @@ export function installFacts({
|
|
|
168
242
|
locale = localeToken(env),
|
|
169
243
|
shell = shellToken(env, platform),
|
|
170
244
|
term = termToken(env),
|
|
245
|
+
deviceId = deviceIdToken(),
|
|
171
246
|
claudeVersion,
|
|
172
247
|
codexVersion,
|
|
173
248
|
} = {}) {
|
|
@@ -185,6 +260,9 @@ export function installFacts({
|
|
|
185
260
|
addIf(facts, "locale", token(locale, TOKEN_CAPS.locale));
|
|
186
261
|
addIf(facts, "shell", token(shell, TOKEN_CAPS.shell));
|
|
187
262
|
addIf(facts, "term", token(term, TOKEN_CAPS.term));
|
|
263
|
+
// The one derived, hashed, personal field. Present on the events that carry
|
|
264
|
+
// facts; stripped from errors, and never on the heartbeat (see below).
|
|
265
|
+
addIf(facts, "deviceId", token(deviceId, TOKEN_CAPS.deviceId));
|
|
188
266
|
addIf(facts, "claudeVersion", token(claudeVersion, TOKEN_CAPS.claudeVersion));
|
|
189
267
|
addIf(facts, "codexVersion", token(codexVersion, TOKEN_CAPS.codexVersion));
|
|
190
268
|
return facts;
|
|
@@ -246,6 +324,7 @@ export function createReporter({
|
|
|
246
324
|
const errorsSent = [];
|
|
247
325
|
const lastSentAt = new Map();
|
|
248
326
|
let timer = null;
|
|
327
|
+
let pingTimer = null;
|
|
249
328
|
|
|
250
329
|
/** The CLI versions to add to the facts right now, tokenised and omitted when
|
|
251
330
|
* not yet known — read on every send so the background probe's result appears
|
|
@@ -358,6 +437,23 @@ export function createReporter({
|
|
|
358
437
|
}
|
|
359
438
|
}
|
|
360
439
|
|
|
440
|
+
/** The heartbeat. While reports are on and an id exists, tell the API this deck
|
|
441
|
+
* is still here — it moves the install's "last seen" and writes no event, so it
|
|
442
|
+
* carries the id and nothing else: no facts, no deviceId. Like every send it is
|
|
443
|
+
* dropped if it cannot get through, and it never throws. No id yet means the
|
|
444
|
+
* first check-in has not run, so there is nothing to be online under and
|
|
445
|
+
* nothing is sent; the check-in that makes the id is what puts this deck
|
|
446
|
+
* online, and the ping only keeps that fresh. */
|
|
447
|
+
async function ping() {
|
|
448
|
+
try {
|
|
449
|
+
const p = prefs.current();
|
|
450
|
+
if (!reportsOn(p, env) || !p.report.installId) return;
|
|
451
|
+
await call("POST", "/v1/app/ping", { installId: p.report.installId });
|
|
452
|
+
} catch {
|
|
453
|
+
// The lightest of the niceties: a heartbeat that could not be sent is gone.
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
361
457
|
/** The switch. On checks in, which makes an id if there is none; off drops the id and asks for a deletion. */
|
|
362
458
|
async function setReports(on) {
|
|
363
459
|
if (on) {
|
|
@@ -389,26 +485,44 @@ export function createReporter({
|
|
|
389
485
|
errorsSent.push(at);
|
|
390
486
|
lastSentAt.set(key, at);
|
|
391
487
|
const stack = typeof error?.stack === "string" ? scrub(error.stack, home).slice(0, STACK_MAX) : undefined;
|
|
392
|
-
|
|
488
|
+
// Less the runtime, and less the deviceId — an error is not one of the events
|
|
489
|
+
// the fingerprint rides on, so the personal field never leaves on one.
|
|
490
|
+
const { runtime: _runtime, deviceId: _deviceId, ...fields } = factsNow();
|
|
393
491
|
return call("POST", "/v1/app/errors", { installId: p.report.installId, ...fields, where, message, stack });
|
|
394
492
|
}
|
|
395
493
|
|
|
396
|
-
/**
|
|
397
|
-
* that
|
|
494
|
+
/** The heartbeat timer, rescheduled from itself so each beat carries fresh
|
|
495
|
+
* jitter — a fixed interval would let a fleet that started as one beat as one.
|
|
496
|
+
* Unref'd, so it never holds the process open. */
|
|
497
|
+
function scheduleNextPing() {
|
|
498
|
+
pingTimer = setTimeout(() => {
|
|
499
|
+
void ping();
|
|
500
|
+
scheduleNextPing();
|
|
501
|
+
}, PING_EVERY_MS + Math.floor(Math.random() * PING_JITTER_MS));
|
|
502
|
+
pingTimer.unref?.();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Check in once the prefs are read, and every few hours after; and beat a
|
|
506
|
+
* heartbeat on its own, lighter timer. Both timers never hold the process
|
|
507
|
+
* open. The first check-in already puts a freshly launched deck online, so the
|
|
508
|
+
* first ping is a full interval out — nothing pings at boot. */
|
|
398
509
|
function start() {
|
|
399
510
|
Promise.resolve(ready).catch(() => {}).then(checkIn);
|
|
400
511
|
if (!timer) {
|
|
401
512
|
timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
|
|
402
513
|
timer.unref?.();
|
|
403
514
|
}
|
|
515
|
+
if (!pingTimer) scheduleNextPing();
|
|
404
516
|
}
|
|
405
517
|
|
|
406
518
|
function stop() {
|
|
407
519
|
if (timer) clearInterval(timer);
|
|
408
520
|
timer = null;
|
|
521
|
+
if (pingTimer) clearTimeout(pingTimer);
|
|
522
|
+
pingTimer = null;
|
|
409
523
|
}
|
|
410
524
|
|
|
411
|
-
return { checkIn, setReports, reportError, start, stop };
|
|
525
|
+
return { checkIn, ping, setReports, reportError, start, stop };
|
|
412
526
|
}
|
|
413
527
|
|
|
414
528
|
// ── the deck's own providers ─────────────────────────────────────────────────
|
|
@@ -20,7 +20,7 @@ import { scanAgentsMdFiles, scanClaudeMdFiles } from "./memory-files.mjs";
|
|
|
20
20
|
import { RECAP_MARK } from "./session-recap.mjs";
|
|
21
21
|
// The shared transcript cursor every pass below reads through — see
|
|
22
22
|
// transcript-scan.mjs.
|
|
23
|
-
import { hasSpend, mergeUsageByModel, newUsageTotals, scanTranscript } from "./transcript-scan.mjs";
|
|
23
|
+
import { copyUsageBucket, hasSpend, mergeUsageByModel, newUsageTotals, scanTranscript } from "./transcript-scan.mjs";
|
|
24
24
|
// event-pipeline.mjs's pushEvent, reached without importing it — see
|
|
25
25
|
// event-sink.mjs.
|
|
26
26
|
import { pushEvent } from "./event-sink.mjs";
|
|
@@ -78,7 +78,7 @@ function knownModelId(sid) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** How many subagents the deck has seen across the sessions it still tracks —
|
|
81
|
-
* the coarse "subagents" count the
|
|
81
|
+
* the coarse "subagents" count the "active" report carries
|
|
82
82
|
* (reports.mjs). Reused, not new state: `subsSig` is already the per-session
|
|
83
83
|
* signature of the subagent models resolved for it, so its keys are the
|
|
84
84
|
* subagents. A signature that will not parse is skipped rather than guessed at.
|
|
@@ -314,7 +314,7 @@ export async function readUsageByModelFromTranscript(path) {
|
|
|
314
314
|
const out = {};
|
|
315
315
|
for (const [model, u] of Object.entries(state.usageByModel)) {
|
|
316
316
|
if (!hasSpend(u)) continue;
|
|
317
|
-
out[model] =
|
|
317
|
+
out[model] = copyUsageBucket(u);
|
|
318
318
|
}
|
|
319
319
|
return Object.keys(out).length ? out : null;
|
|
320
320
|
}
|
|
@@ -148,7 +148,7 @@ function forgetSession(sid) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
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
|
|
151
|
+
* polls, which is the coarse "how many sessions" the "active" report
|
|
152
152
|
* counts (reports.mjs). Reused, not new state: the Map is already the deck's
|
|
153
153
|
* own answer to which sessions it still holds anything for. Never a session id,
|
|
154
154
|
* a name or a path — only the count. */
|
|
@@ -104,6 +104,19 @@ const USAGE_FIELD_RE = {
|
|
|
104
104
|
// `server_tool_use`, several fields earlier. Match the sub-object on the raw
|
|
105
105
|
// line instead of on the extracted blob.
|
|
106
106
|
const CACHE_CREATION_BLOCK_RE = /"cache_creation"\s*:\s*\{([^}]*)\}/g;
|
|
107
|
+
// Claude Code writes an `iterations` array into the usage block, one entry per
|
|
108
|
+
// model iteration of the request, and every entry carries a `cache_creation`
|
|
109
|
+
// sub-object of its own. The request's split is the top-level one; the entries
|
|
110
|
+
// restate it. Matching every `cache_creation` on the line counted the split once
|
|
111
|
+
// per copy — 141,066 of 192,917 assistant lines on one machine carry the array,
|
|
112
|
+
// and on each of them the 1-hour and 5-minute tokens summed to twice the flat
|
|
113
|
+
// `cache_creation_input_tokens`, so every cache write was billed twice. The
|
|
114
|
+
// array is cut out before the split is read. Its entries hold only numbers and
|
|
115
|
+
// sub-objects, never a bracket inside a string, so one level of nesting is the
|
|
116
|
+
// whole of what the pattern has to span; a key quoted inside a message reaches
|
|
117
|
+
// the line as `\"iterations\"` and is not matched, for the reason
|
|
118
|
+
// TOOL_USE_RESULT_KEY_RE gives below.
|
|
119
|
+
const ITERATIONS_ARRAY_RE = /"iterations"\s*:\s*\[(?:[^[\]]|\[[^[\]]*\])*\]/g;
|
|
107
120
|
// A finished `Task`/`Agent` call is written into the PARENT's transcript as a
|
|
108
121
|
// top-level `toolUseResult`, and that object carries a `usage` block of its own
|
|
109
122
|
// — the subagent's LAST API turn, restated on the parent's line. Those same
|
|
@@ -149,6 +162,50 @@ function grabUsageField(blob, key) {
|
|
|
149
162
|
// model — the behaviour the whole deck had before #686.
|
|
150
163
|
const MAX_TRANSCRIPT_USAGE_MODELS = 32;
|
|
151
164
|
|
|
165
|
+
// A request's `usage.speed` (#754). Claude Code's fast mode runs the same model
|
|
166
|
+
// id at `speed: "fast"`, and Anthropic bills it at a premium, so the speed is
|
|
167
|
+
// part of which rate card a token is billed against, the way the model is. The
|
|
168
|
+
// API documents the value as "fast" or "standard", and Claude Code writes the
|
|
169
|
+
// usage object into the transcript verbatim — `speed` is on every final
|
|
170
|
+
// assistant line it writes today, "standard" on all 140,532 of them on one
|
|
171
|
+
// machine. Absent, null and "standard" are all the standard speed and add
|
|
172
|
+
// nothing here, so a session that never ran fast has buckets of exactly the
|
|
173
|
+
// shape it always had.
|
|
174
|
+
//
|
|
175
|
+
// Anything else is kept as a share of its model's bucket, under `speeds`, and
|
|
176
|
+
// the client prices each share at its own speed's rates or not at all. The
|
|
177
|
+
// value is bytes this process was handed, so it is held to a short token and a
|
|
178
|
+
// few distinct values per model, the same reason MAX_TRANSCRIPT_USAGE_MODELS
|
|
179
|
+
// caps the models; whatever fails either test is kept under
|
|
180
|
+
// UNRECOGNISED_SPEED, which no rate table names, so it is counted and left
|
|
181
|
+
// unpriced rather than priced at the standard rate.
|
|
182
|
+
const STANDARD_SPEED = "standard";
|
|
183
|
+
const UNRECOGNISED_SPEED = "unrecognised";
|
|
184
|
+
const SPEED_VALUE_RE = /^[a-z][a-z0-9_-]{0,31}$/i;
|
|
185
|
+
const MAX_SPEEDS_PER_MODEL = 4;
|
|
186
|
+
|
|
187
|
+
/** The speed a line's request was billed at, or null for the standard one.
|
|
188
|
+
* `record` is the line parsed, or null when it names no model — and a line
|
|
189
|
+
* that names no model is billed at the standard speed, as every line was. */
|
|
190
|
+
function billedSpeed(record) {
|
|
191
|
+
const speed = record?.message?.usage?.speed;
|
|
192
|
+
if (speed === undefined || speed === null || speed === STANDARD_SPEED) return null;
|
|
193
|
+
return typeof speed === "string" && SPEED_VALUE_RE.test(speed) ? speed : UNRECOGNISED_SPEED;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The share of `bucket` billed at `speed`, created on first sight. Null for
|
|
197
|
+
* the standard speed or a bucket we could not attribute. `Object.hasOwn`
|
|
198
|
+
* rather than a plain read, because `constructor` passes SPEED_VALUE_RE and
|
|
199
|
+
* every object already answers it. */
|
|
200
|
+
function speedBucketFor(bucket, speed) {
|
|
201
|
+
if (!bucket || !speed) return null;
|
|
202
|
+
const speeds = bucket.speeds ?? (bucket.speeds = {});
|
|
203
|
+
if (Object.hasOwn(speeds, speed)) return speeds[speed];
|
|
204
|
+
const key = Object.keys(speeds).length < MAX_SPEEDS_PER_MODEL ? speed : UNRECOGNISED_SPEED;
|
|
205
|
+
if (!Object.hasOwn(speeds, key)) speeds[key] = newUsageTotals();
|
|
206
|
+
return speeds[key];
|
|
207
|
+
}
|
|
208
|
+
|
|
152
209
|
/** The per-model usage bucket for `model`, created on first sight. Null for a
|
|
153
210
|
* line whose tokens we cannot attribute — no model seen yet in this file, or
|
|
154
211
|
* the cap above already reached. */
|
|
@@ -166,19 +223,40 @@ function usageBucketFor(state, model) {
|
|
|
166
223
|
* `cache_creation` sub-object splits cache writes into by TTL. */
|
|
167
224
|
const USAGE_BLOCK_FIELDS = ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
|
|
168
225
|
const CACHE_SPLIT_FIELDS = ["ephemeral_1h_input_tokens", "ephemeral_5m_input_tokens"];
|
|
226
|
+
const USAGE_TOTAL_FIELDS = [...USAGE_BLOCK_FIELDS, ...CACHE_SPLIT_FIELDS];
|
|
169
227
|
|
|
170
228
|
/** Charge `fields` of one usage blob to the file's flat totals and, when the
|
|
171
229
|
* tokens can be attributed, to their model's bucket too — the same number
|
|
172
230
|
* into both, read once, so the split and the total it splits go on summing
|
|
173
|
-
* to each other.
|
|
174
|
-
|
|
231
|
+
* to each other. A request billed at a speed other than standard is charged
|
|
232
|
+
* to that speed's share of the bucket as well, which is a subset of the
|
|
233
|
+
* bucket and not an addition to it. */
|
|
234
|
+
function chargeUsage(state, bucket, blob, fields, speedBucket) {
|
|
175
235
|
for (const k of fields) {
|
|
176
236
|
const n = grabUsageField(blob, k);
|
|
177
237
|
state.usage[k] += n;
|
|
178
238
|
if (bucket) bucket[k] += n;
|
|
239
|
+
if (speedBucket) speedBucket[k] += n;
|
|
179
240
|
}
|
|
180
241
|
}
|
|
181
242
|
|
|
243
|
+
/** Add one bucket's counters, and its speed shares, into `dst`. */
|
|
244
|
+
function addUsageBucket(dst, src) {
|
|
245
|
+
for (const k of USAGE_TOTAL_FIELDS) dst[k] += src[k] ?? 0;
|
|
246
|
+
if (!src.speeds) return dst;
|
|
247
|
+
for (const [speed, share] of Object.entries(src.speeds)) {
|
|
248
|
+
const into = speedBucketFor(dst, speed);
|
|
249
|
+
for (const k of USAGE_TOTAL_FIELDS) into[k] += share[k] ?? 0;
|
|
250
|
+
}
|
|
251
|
+
return dst;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A bucket's counters and speed shares, sharing no object with the scan state
|
|
255
|
+
* it was read from. */
|
|
256
|
+
function copyUsageBucket(u) {
|
|
257
|
+
return addUsageBucket(newUsageTotals(), u);
|
|
258
|
+
}
|
|
259
|
+
|
|
182
260
|
/** Add `src`'s per-model buckets into `dst`, key for key, and return `dst`.
|
|
183
261
|
* Under the same cap as the per-file map, so a session that delegates to
|
|
184
262
|
* hundreds of files cannot assemble an unbounded one out of bounded parts. */
|
|
@@ -191,7 +269,7 @@ function mergeUsageByModel(dst, src) {
|
|
|
191
269
|
bucket = newUsageTotals();
|
|
192
270
|
dst[model] = bucket;
|
|
193
271
|
}
|
|
194
|
-
|
|
272
|
+
addUsageBucket(bucket, u);
|
|
195
273
|
}
|
|
196
274
|
return dst;
|
|
197
275
|
}
|
|
@@ -394,11 +472,12 @@ function foldUsageLine(state, line, record) {
|
|
|
394
472
|
// and the two would stop summing to each other.
|
|
395
473
|
const billed = billedUsageText(line);
|
|
396
474
|
const bucket = usageBucketFor(state, state.lastModel);
|
|
475
|
+
const speedBucket = speedBucketFor(bucket, billedSpeed(record));
|
|
397
476
|
for (const m of billed.matchAll(USAGE_BLOCK_RE)) {
|
|
398
|
-
chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS);
|
|
477
|
+
chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS, speedBucket);
|
|
399
478
|
}
|
|
400
|
-
for (const m of billed.matchAll(CACHE_CREATION_BLOCK_RE)) {
|
|
401
|
-
chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS);
|
|
479
|
+
for (const m of billed.replace(ITERATIONS_ARRAY_RE, "").matchAll(CACHE_CREATION_BLOCK_RE)) {
|
|
480
|
+
chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS, speedBucket);
|
|
402
481
|
}
|
|
403
482
|
}
|
|
404
483
|
|
|
@@ -580,5 +659,5 @@ function scanTranscript(path) {
|
|
|
580
659
|
// rather than marked at each declaration so that every declaration above reads
|
|
581
660
|
// exactly as it did where it came from.
|
|
582
661
|
export {
|
|
583
|
-
scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, repeatsRequestUsage,
|
|
662
|
+
scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, copyUsageBucket, repeatsRequestUsage,
|
|
584
663
|
};
|