ccdeck 3.34.1 → 3.35.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 +1 -1
- package/dist/web/assets/{BrowserWatchModal-C42m7ewG.js → BrowserWatchModal-BtyfOjW7.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-N-p7OGkW.js → UsageHistoryModal-6XA5g4lT.js} +1 -1
- package/dist/web/assets/{index-DglpEGvW.css → index-BJjwlbi1.css} +1 -1
- package/dist/web/assets/{index-CSDSEC1Q.js → index-Buo4BLjv.js} +47 -47
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +2 -1
- package/src/server/cli-versions.mjs +88 -0
- package/src/server/pinned-build.mjs +5 -0
- package/src/server/reports.mjs +219 -13
- package/src/server/session-enrichment.mjs +15 -0
- package/src/server/session-tracking.mjs +10 -1
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-Buo4BLjv.js"></script>
|
|
66
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BJjwlbi1.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.35.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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The versions of the Claude Code and Codex CLIs this machine has, for the
|
|
2
|
+
// anonymous "which CLIs, at which version" count the reporter sends (reports.mjs).
|
|
3
|
+
//
|
|
4
|
+
// SPAWNED, BUT NEVER ON THE BOOT PATH. claude-dir.mjs measured a Claude Code
|
|
5
|
+
// child at ~3.0s and says out loud that boot is the wrong place to spend one, so
|
|
6
|
+
// this is run once, lazily, in the background — reports.mjs kicks it from the
|
|
7
|
+
// first check-in, which is already past boot — and its result rides along on the
|
|
8
|
+
// reports that come after. A version that is not known yet is simply not sent;
|
|
9
|
+
// the field is optional and the API records nothing for an absent one.
|
|
10
|
+
//
|
|
11
|
+
// WHAT LEAVES IS A VERSION AND NOTHING ELSE. parseVersion keeps only a
|
|
12
|
+
// dotted-number token (with the `^[0-9A-Za-z.+_-]+$` shape the API enforces, and
|
|
13
|
+
// its 32-char cap), so a `claude --version` line that also printed a path, a
|
|
14
|
+
// build host or an install directory contributes only the number. Presence is
|
|
15
|
+
// asked first, through the deck's existing checks, so a machine without the CLI
|
|
16
|
+
// never spawns anything looking for it.
|
|
17
|
+
//
|
|
18
|
+
// NOTHING HERE THROWS. run() already answers a missing tool with { ok: false }
|
|
19
|
+
// rather than an exception, and every call is wrapped besides — a floating
|
|
20
|
+
// rejection out of a background probe would be an unhandled one.
|
|
21
|
+
|
|
22
|
+
import { claudeCliCandidates, hasClaudeInstalled } from "./claude-dir.mjs";
|
|
23
|
+
import { run } from "./exec.mjs";
|
|
24
|
+
import { hasCodexInstalled } from "./installer.mjs";
|
|
25
|
+
|
|
26
|
+
/** A `--version` probe is fast when the tool is present (it prints and exits)
|
|
27
|
+
* and fast when it is absent (ENOENT), so a short deadline is only a guard
|
|
28
|
+
* against a wedged shim, not the common path. */
|
|
29
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
/** The first dotted-number run in the output — "1.2.3", "1.2.3-beta.1",
|
|
32
|
+
* "1.2.3+build" — reduced to the API's token shape and length. Everything else
|
|
33
|
+
* on the line (a leading "claude-code ", a trailing " (Claude Code)", a path)
|
|
34
|
+
* is dropped. */
|
|
35
|
+
export function parseVersion(text) {
|
|
36
|
+
const m = /(\d+\.\d+(?:\.\d+)?(?:[.+-][0-9A-Za-z.+-]*)?)/.exec(String(text ?? ""));
|
|
37
|
+
if (!m) return undefined;
|
|
38
|
+
const token = m[1].replace(/[^0-9A-Za-z.+_-]/g, "");
|
|
39
|
+
return token && token.length <= 32 ? token : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function probe(runImpl, cand) {
|
|
43
|
+
try {
|
|
44
|
+
const r = await runImpl(cand, ["--version"], { timeout: PROBE_TIMEOUT_MS });
|
|
45
|
+
if (r?.ok) return parseVersion(`${r.stdout ?? ""}\n${r.stderr ?? ""}`);
|
|
46
|
+
} catch { /* run() does not throw, but a stubbed one might */ }
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ask = (fn) => { try { return Boolean(fn()); } catch { return false; } };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `{ claudeVersion?, codexVersion? }` — each present only when its CLI is on the
|
|
54
|
+
* machine and answered with a version. Every dependency is injected so the whole
|
|
55
|
+
* of this is exercised in a test with no real child process: `runImpl` for the
|
|
56
|
+
* spawn, and the two presence gates and the candidate list for what it is asked
|
|
57
|
+
* to spawn.
|
|
58
|
+
*/
|
|
59
|
+
export async function detectCliVersions({
|
|
60
|
+
runImpl = run,
|
|
61
|
+
claudeCandidates = claudeCliCandidates,
|
|
62
|
+
claudePresent = hasClaudeInstalled,
|
|
63
|
+
codexPresent = hasCodexInstalled,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const out = {};
|
|
66
|
+
try {
|
|
67
|
+
if (ask(claudePresent)) {
|
|
68
|
+
// The candidate list is claude-dir.mjs's answer to "where does `claude`
|
|
69
|
+
// live", so a version is found on the same installs the deck already
|
|
70
|
+
// recognises — not only the one on the launcher's PATH. First hit wins; a
|
|
71
|
+
// machine with the CLI answers on its first or second candidate.
|
|
72
|
+
for (const cand of new Set(claudeCandidates())) {
|
|
73
|
+
const v = await probe(runImpl, cand);
|
|
74
|
+
if (v) { out.claudeVersion = v; break; }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch { /* leave claudeVersion unset */ }
|
|
78
|
+
try {
|
|
79
|
+
// No candidate list exists for Codex — the deck reads its sessions off disk
|
|
80
|
+
// and never spawns the CLI — so the bare name goes through run()'s own PATH
|
|
81
|
+
// and shim resolution, which is the same resolution every other spawn uses.
|
|
82
|
+
if (ask(codexPresent)) {
|
|
83
|
+
const v = await probe(runImpl, "codex");
|
|
84
|
+
if (v) out.codexVersion = v;
|
|
85
|
+
}
|
|
86
|
+
} catch { /* leave codexVersion unset */ }
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
@@ -84,6 +84,11 @@ const PINNED_MODULES = [
|
|
|
84
84
|
"lan-sync.mjs",
|
|
85
85
|
// The providers' status pages (#1311), reached only from their route.
|
|
86
86
|
"provider-status.mjs",
|
|
87
|
+
// The environment and usage the reporter adds to a report, all reached only
|
|
88
|
+
// through import() from reports.mjs so they stay off the fast paths.
|
|
89
|
+
"cli-versions.mjs",
|
|
90
|
+
"session-tracking.mjs",
|
|
91
|
+
"session-enrichment.mjs",
|
|
87
92
|
];
|
|
88
93
|
|
|
89
94
|
let _pinned = null;
|
package/src/server/reports.mjs
CHANGED
|
@@ -6,15 +6,22 @@
|
|
|
6
6
|
// - an "install" event the first time,
|
|
7
7
|
// - an "update" event, with the version it came from, when the version moves,
|
|
8
8
|
// - an "active" event at most once a UTC day, which is how many people use
|
|
9
|
-
// ccdeck gets counted without counting anything else about them,
|
|
9
|
+
// ccdeck gets counted without counting anything else about them, and which
|
|
10
|
+
// alone also carries coarse counts of how much: how many sessions,
|
|
11
|
+
// subagents and projects — numbers, never their names,
|
|
10
12
|
// - errors: a request handler that threw on this server, or an error the page
|
|
11
13
|
// caught, with home folders, email addresses and key-shaped strings
|
|
12
14
|
// scrubbed out before they leave.
|
|
13
15
|
//
|
|
14
16
|
// Each carries the install id, the version, the OS and CPU architecture, the
|
|
15
|
-
// channel (the desktop app, npm, or a source checkout) and the runtime
|
|
16
|
-
//
|
|
17
|
-
//
|
|
17
|
+
// channel (the desktop app, npm, or a source checkout) and the runtime, plus a
|
|
18
|
+
// coarse sketch of the environment: the logical CPU count, the RAM in MB, the
|
|
19
|
+
// locale, the shell and terminal names, and the Claude Code and Codex CLI
|
|
20
|
+
// versions. Every one of those is a small fixed token or a number, safe by
|
|
21
|
+
// construction — no path, no hostname, no user name, no project name, no prompt,
|
|
22
|
+
// no free text ever reaches any field, so there is nothing in them to scrub. A
|
|
23
|
+
// field that cannot be told is left out rather than guessed. The install id is
|
|
24
|
+
// random, made at the first check-in, and tied to nothing on the machine.
|
|
18
25
|
//
|
|
19
26
|
// NOBODY IS ASKED, AND NOTHING IS HIDDEN. The owner chose on-by-default
|
|
20
27
|
// (2026-09-30): the README says what is sent, Appearance holds the switch, and
|
|
@@ -30,7 +37,7 @@
|
|
|
30
37
|
// deck depends on it.
|
|
31
38
|
|
|
32
39
|
import { randomUUID } from "node:crypto";
|
|
33
|
-
import { homedir } from "node:os";
|
|
40
|
+
import { cpus as osCpus, homedir, totalmem } from "node:os";
|
|
34
41
|
import { dirname, resolve } from "node:path";
|
|
35
42
|
import { fileURLToPath } from "node:url";
|
|
36
43
|
import { inApp } from "./app-host.mjs";
|
|
@@ -58,7 +65,97 @@ export function reportsOn(prefs, env = process.env) {
|
|
|
58
65
|
return !reportsVetoed(env) && prefs?.reports !== false;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
// ── the coarse environment facts ─────────────────────────────────────────────
|
|
69
|
+
//
|
|
70
|
+
// Everything added to an install's facts here is a count or a small fixed token,
|
|
71
|
+
// 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. Each is optional — a field
|
|
73
|
+
// that cannot be told is left undefined and so never sent, and none of these can
|
|
74
|
+
// throw, because a report is a nicety and no part of the deck may hang on it.
|
|
75
|
+
|
|
76
|
+
// The keys the API caps, so a value that would be rejected for length is not
|
|
77
|
+
// sent: it is trimmed to the cap here instead. Only ever tokens the shape of a
|
|
78
|
+
// 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 };
|
|
80
|
+
|
|
81
|
+
/** A value reduced to the API's token shape `^[0-9A-Za-z.+_-]+$`: spaces become
|
|
82
|
+
* "_" (so "Apple Terminal" rides as "Apple_Terminal"), every other disallowed
|
|
83
|
+
* character is dropped, and the result is trimmed to `max`. Empty or nothing
|
|
84
|
+
* in → nothing out, so the caller omits the field. */
|
|
85
|
+
function token(value, max) {
|
|
86
|
+
if (value == null) return undefined;
|
|
87
|
+
const t = String(value).trim().replace(/\s+/g, "_").replace(/[^0-9A-Za-z.+_-]/g, "");
|
|
88
|
+
if (!t) return undefined;
|
|
89
|
+
return t.length > max ? t.slice(0, max) : t;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A whole count of zero or more, or nothing. */
|
|
93
|
+
function count(n) {
|
|
94
|
+
return Number.isInteger(n) && n >= 0 ? n : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Logical CPUs, or nothing when the platform will not say. */
|
|
98
|
+
function cpuCount() {
|
|
99
|
+
try { return count(osCpus().length); } catch { return undefined; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Total RAM in whole MB, or nothing. */
|
|
103
|
+
function totalMemMb() {
|
|
104
|
+
try { return count(Math.round(totalmem() / 1024 / 1024)); } catch { return undefined; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The UI locale as a token — "en-US", "de-DE". The runtime's own resolved
|
|
109
|
+
* locale first, then `LANG`/`LC_ALL` with any `.UTF-8` encoding stripped. The
|
|
110
|
+
* resolved locale is a parameter so a test can drive both halves without
|
|
111
|
+
* standing up a second Intl.
|
|
112
|
+
*/
|
|
113
|
+
export function localeToken(env = process.env, resolved = intlLocale()) {
|
|
114
|
+
const raw = resolved || String(env?.LANG ?? env?.LC_ALL ?? env?.LC_MESSAGES ?? "").split(".")[0];
|
|
115
|
+
return token(raw, TOKEN_CAPS.locale);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function intlLocale() {
|
|
119
|
+
try { return Intl.DateTimeFormat().resolvedOptions().locale || ""; } catch { return ""; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The shell as a bare name — "zsh", "bash", "fish". `SHELL` names it on POSIX;
|
|
123
|
+
* on Windows there is no `SHELL` (unless a Git-Bash-style one set it), so the
|
|
124
|
+
* hints are PowerShell's `PSModulePath` and cmd's `ComSpec`. */
|
|
125
|
+
export function shellToken(env = process.env, platform = process.platform) {
|
|
126
|
+
const sh = env?.SHELL;
|
|
127
|
+
if (sh) return token(baseName(sh), TOKEN_CAPS.shell);
|
|
128
|
+
if (platform === "win32") {
|
|
129
|
+
if (env?.PSModulePath) return "pwsh";
|
|
130
|
+
const comSpec = env?.ComSpec ?? env?.COMSPEC;
|
|
131
|
+
return comSpec ? token(baseName(comSpec), TOKEN_CAPS.shell) : "cmd";
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The terminal program as a token — "iTerm.app", "Apple_Terminal", "vscode",
|
|
137
|
+
* "WezTerm", "Windows_Terminal", else whatever `TERM` says ("xterm-256color"). */
|
|
138
|
+
export function termToken(env = process.env) {
|
|
139
|
+
const prog = env?.TERM_PROGRAM;
|
|
140
|
+
if (prog) return token(prog, TOKEN_CAPS.term);
|
|
141
|
+
if (env?.WT_SESSION) return "Windows_Terminal";
|
|
142
|
+
return token(env?.TERM, TOKEN_CAPS.term);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function baseName(p) {
|
|
146
|
+
return String(p).split(/[\\/]/).pop().replace(/\.exe$/i, "");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function addIf(target, key, value) {
|
|
150
|
+
if (value !== undefined) target[key] = value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** What every report says about the install, and nothing else — the version, the
|
|
154
|
+
* system, the channel and the runtime, plus the coarse environment tokens and
|
|
155
|
+
* counts the API records as optional. `claudeVersion`/`codexVersion` are
|
|
156
|
+
* supplied by the reporter once its background probe has them (cli-versions.mjs)
|
|
157
|
+
* and omitted until then; everything else is read here from the OS and the
|
|
158
|
+
* environment. */
|
|
62
159
|
export function installFacts({
|
|
63
160
|
version = RUNNING_VERSION,
|
|
64
161
|
platform = process.platform,
|
|
@@ -66,8 +163,15 @@ export function installFacts({
|
|
|
66
163
|
versions = process.versions,
|
|
67
164
|
env = process.env,
|
|
68
165
|
checkout = isGitCheckout(PKG_ROOT),
|
|
166
|
+
cpus = cpuCount(),
|
|
167
|
+
memMb = totalMemMb(),
|
|
168
|
+
locale = localeToken(env),
|
|
169
|
+
shell = shellToken(env, platform),
|
|
170
|
+
term = termToken(env),
|
|
171
|
+
claudeVersion,
|
|
172
|
+
codexVersion,
|
|
69
173
|
} = {}) {
|
|
70
|
-
|
|
174
|
+
const facts = {
|
|
71
175
|
version: String(version),
|
|
72
176
|
os: platform,
|
|
73
177
|
arch,
|
|
@@ -76,6 +180,14 @@ export function installFacts({
|
|
|
76
180
|
channel: inApp(env) ? "desktop" : checkout ? "checkout" : "npm",
|
|
77
181
|
runtime: versions.electron ? `electron-${versions.electron}` : `node-${versions.node}`,
|
|
78
182
|
};
|
|
183
|
+
addIf(facts, "cpus", count(cpus));
|
|
184
|
+
addIf(facts, "memMb", count(memMb));
|
|
185
|
+
addIf(facts, "locale", token(locale, TOKEN_CAPS.locale));
|
|
186
|
+
addIf(facts, "shell", token(shell, TOKEN_CAPS.shell));
|
|
187
|
+
addIf(facts, "term", token(term, TOKEN_CAPS.term));
|
|
188
|
+
addIf(facts, "claudeVersion", token(claudeVersion, TOKEN_CAPS.claudeVersion));
|
|
189
|
+
addIf(facts, "codexVersion", token(codexVersion, TOKEN_CAPS.codexVersion));
|
|
190
|
+
return facts;
|
|
79
191
|
}
|
|
80
192
|
|
|
81
193
|
/**
|
|
@@ -113,6 +225,12 @@ export function scrub(text, home = homedir()) {
|
|
|
113
225
|
* @param {ReturnType<typeof installFacts>} [deps.facts]
|
|
114
226
|
* @param {string} [deps.home]
|
|
115
227
|
* @param {Promise<unknown>} [deps.ready] what start() waits for: the prefs read at import
|
|
228
|
+
* @param {() => { claudeVersion?: string, codexVersion?: string }} [deps.versions] the CLI
|
|
229
|
+
* versions to fold into the facts, read fresh on every send so a background
|
|
230
|
+
* probe's answer rides along the moment it arrives. Default: none.
|
|
231
|
+
* @param {() => ({ sessions?: number, subagents?: number, projects?: number } | Promise<...>)} [deps.usage]
|
|
232
|
+
* the coarse counts the "active" report carries, read only when an "active" is
|
|
233
|
+
* about to be sent. Default: none.
|
|
116
234
|
*/
|
|
117
235
|
export function createReporter({
|
|
118
236
|
fetchImpl = globalThis.fetch,
|
|
@@ -122,11 +240,50 @@ export function createReporter({
|
|
|
122
240
|
facts = installFacts({ env }),
|
|
123
241
|
home = homedir(),
|
|
124
242
|
ready = prefsRead,
|
|
243
|
+
versions = () => ({}),
|
|
244
|
+
usage = () => ({}),
|
|
125
245
|
} = {}) {
|
|
126
246
|
const errorsSent = [];
|
|
127
247
|
const lastSentAt = new Map();
|
|
128
248
|
let timer = null;
|
|
129
249
|
|
|
250
|
+
/** The CLI versions to add to the facts right now, tokenised and omitted when
|
|
251
|
+
* not yet known — read on every send so the background probe's result appears
|
|
252
|
+
* on the first report after it lands, not only on the next restart. */
|
|
253
|
+
function versionFacts() {
|
|
254
|
+
try {
|
|
255
|
+
const v = versions() ?? {};
|
|
256
|
+
const out = {};
|
|
257
|
+
addIf(out, "claudeVersion", token(v.claudeVersion, TOKEN_CAPS.claudeVersion));
|
|
258
|
+
addIf(out, "codexVersion", token(v.codexVersion, TOKEN_CAPS.codexVersion));
|
|
259
|
+
return out;
|
|
260
|
+
} catch {
|
|
261
|
+
return {};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The full facts an event carries: the fixed install facts, plus whatever CLI
|
|
266
|
+
* versions are known this moment. */
|
|
267
|
+
function factsNow() {
|
|
268
|
+
return { ...facts, ...versionFacts() };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The coarse usage the "active" report carries: only whole counts of zero or
|
|
272
|
+
* more survive, and a provider that throws or is slow costs the report
|
|
273
|
+
* nothing. */
|
|
274
|
+
async function coarseUsage() {
|
|
275
|
+
try {
|
|
276
|
+
const raw = (await usage()) ?? {};
|
|
277
|
+
const out = {};
|
|
278
|
+
addIf(out, "sessions", count(raw.sessions));
|
|
279
|
+
addIf(out, "subagents", count(raw.subagents));
|
|
280
|
+
addIf(out, "projects", count(raw.projects));
|
|
281
|
+
return out;
|
|
282
|
+
} catch {
|
|
283
|
+
return {};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
130
287
|
async function call(method, path, body) {
|
|
131
288
|
try {
|
|
132
289
|
const res = await fetchImpl(REPORTS_API + path, {
|
|
@@ -180,18 +337,24 @@ export function createReporter({
|
|
|
180
337
|
const { installId, lastVersion, lastActiveDay } = prefs.current().report;
|
|
181
338
|
if (!installId) return;
|
|
182
339
|
if (!lastVersion) {
|
|
183
|
-
if (await call("POST", "/v1/app/events", { installId, kind: "install", ...
|
|
340
|
+
if (await call("POST", "/v1/app/events", { installId, kind: "install", ...factsNow() })) {
|
|
184
341
|
await remember(installId, { lastVersion: facts.version });
|
|
185
342
|
}
|
|
186
343
|
} else if (lastVersion !== facts.version) {
|
|
187
|
-
if (await call("POST", "/v1/app/events", { installId, kind: "update", fromVersion: lastVersion, ...
|
|
344
|
+
if (await call("POST", "/v1/app/events", { installId, kind: "update", fromVersion: lastVersion, ...factsNow() })) {
|
|
188
345
|
await remember(installId, { lastVersion: facts.version });
|
|
189
346
|
}
|
|
190
347
|
}
|
|
191
348
|
|
|
192
349
|
const today = now().toISOString().slice(0, 10);
|
|
193
|
-
if (lastActiveDay !== today
|
|
194
|
-
|
|
350
|
+
if (lastActiveDay !== today) {
|
|
351
|
+
// Usage rides on the "active" event alone, and is read only here — the one
|
|
352
|
+
// report it belongs to — so a checkIn that sends nothing new never asks the
|
|
353
|
+
// deck for its counts.
|
|
354
|
+
const used = await coarseUsage();
|
|
355
|
+
if (await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...used })) {
|
|
356
|
+
await remember(installId, { lastActiveDay: today });
|
|
357
|
+
}
|
|
195
358
|
}
|
|
196
359
|
}
|
|
197
360
|
|
|
@@ -226,7 +389,7 @@ export function createReporter({
|
|
|
226
389
|
errorsSent.push(at);
|
|
227
390
|
lastSentAt.set(key, at);
|
|
228
391
|
const stack = typeof error?.stack === "string" ? scrub(error.stack, home).slice(0, STACK_MAX) : undefined;
|
|
229
|
-
const { runtime: _runtime, ...fields } =
|
|
392
|
+
const { runtime: _runtime, ...fields } = factsNow();
|
|
230
393
|
return call("POST", "/v1/app/errors", { installId: p.report.installId, ...fields, where, message, stack });
|
|
231
394
|
}
|
|
232
395
|
|
|
@@ -248,5 +411,48 @@ export function createReporter({
|
|
|
248
411
|
return { checkIn, setReports, reportError, start, stop };
|
|
249
412
|
}
|
|
250
413
|
|
|
414
|
+
// ── the deck's own providers ─────────────────────────────────────────────────
|
|
415
|
+
//
|
|
416
|
+
// Kept here rather than in the modules they read, so reports.mjs stays the one
|
|
417
|
+
// place that decides what leaves — and reached by lazy import, so the reporter's
|
|
418
|
+
// own module graph does not pull the session tracker, the enrichment cache or
|
|
419
|
+
// the CLI probe into a fast path like `--version` that imports it for nothing.
|
|
420
|
+
|
|
421
|
+
/** The coarse counts the "active" report carries, read from the state the deck
|
|
422
|
+
* already keeps — the LRU of tracked sessions and the per-session subagent
|
|
423
|
+
* signatures. Never new tracking, and never anything but a number: no session
|
|
424
|
+
* id, name or path. `projects` is left out for now — its only source is the
|
|
425
|
+
* disk-backed, per-account rollup (account-projects.mjs), which has no cheap
|
|
426
|
+
* synchronous count — so the field is simply not sent. */
|
|
427
|
+
async function deckUsage() {
|
|
428
|
+
const out = {};
|
|
429
|
+
try {
|
|
430
|
+
const { trackedSessionCount } = await import("./session-tracking.mjs");
|
|
431
|
+
out.sessions = trackedSessionCount();
|
|
432
|
+
} catch { /* count unavailable: omitted */ }
|
|
433
|
+
try {
|
|
434
|
+
const { trackedSubagentCount } = await import("./session-enrichment.mjs");
|
|
435
|
+
out.subagents = trackedSubagentCount();
|
|
436
|
+
} catch { /* count unavailable: omitted */ }
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The CLI versions, detected once in the background, off the boot path. The
|
|
441
|
+
// first send starts the probe (past boot, and not while the machine has vetoed
|
|
442
|
+
// reports) and reads back an empty answer; the version rides along on the
|
|
443
|
+
// reports that follow, once the probe has filled this cache.
|
|
444
|
+
let cliVersionCache = {};
|
|
445
|
+
let cliProbeStarted = false;
|
|
446
|
+
function deckCliVersions() {
|
|
447
|
+
if (!cliProbeStarted && !reportsVetoed(process.env)) {
|
|
448
|
+
cliProbeStarted = true;
|
|
449
|
+
import("./cli-versions.mjs")
|
|
450
|
+
.then(m => m.detectCliVersions())
|
|
451
|
+
.then(v => { cliVersionCache = { ...cliVersionCache, ...v }; })
|
|
452
|
+
.catch(() => { /* a probe that could not run leaves the versions unsent */ });
|
|
453
|
+
}
|
|
454
|
+
return cliVersionCache;
|
|
455
|
+
}
|
|
456
|
+
|
|
251
457
|
/** The deck's own reporter. */
|
|
252
|
-
export const reporter = createReporter();
|
|
458
|
+
export const reporter = createReporter({ versions: deckCliVersions, usage: deckUsage });
|
|
@@ -77,6 +77,21 @@ 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 anonymous "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
|
+
|
|
80
95
|
/** Read the main session JSONL. Returns the root model and any
|
|
81
96
|
* legacy-schema subagent models (older CC versions kept subagent blocks
|
|
82
97
|
* inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
|
|
@@ -147,6 +147,15 @@ 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 anonymous "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
|
+
|
|
150
159
|
function touchSession(sid) {
|
|
151
160
|
if (!sid || typeof sid !== "string") return;
|
|
152
161
|
// Re-insert so the Map's own insertion order *is* the LRU order and eviction
|
|
@@ -239,4 +248,4 @@ async function handleForget(req, res) {
|
|
|
239
248
|
// What index.mjs calls besides HARD_TRACKED_SESSIONS. Listed rather than
|
|
240
249
|
// marked at each declaration, so the declarations read as they did where they
|
|
241
250
|
// came from.
|
|
242
|
-
export { handleForget, outputWatch, startOutputWatch, touchSession };
|
|
251
|
+
export { handleForget, outputWatch, startOutputWatch, touchSession, trackedSessionCount };
|