ccdeck 3.34.0 → 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.
@@ -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-CgHJ_aHk.js"></script>
66
- <link rel="stylesheet" crossorigin href="/assets/index-DglpEGvW.css">
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,7 +1,7 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.34.0",
4
- "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
3
+ "version": "3.35.0",
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": {
7
7
  "ccdeck": "bin/agent-dag.js"
@@ -44,16 +44,21 @@
44
44
  "dashboard",
45
45
  "monitor",
46
46
  "monitoring",
47
- "observability",
48
- "usage",
49
47
  "cost",
50
- "tokens",
51
48
  "quota",
52
49
  "hooks",
53
50
  "dag",
54
51
  "visualization",
55
- "cli",
56
- "deck"
52
+ "claude-code-monitor",
53
+ "claude-code-sessions",
54
+ "session-monitor",
55
+ "codex-dashboard",
56
+ "claude-code-subagents",
57
+ "parallel-sessions",
58
+ "desktop-app",
59
+ "ccusage",
60
+ "claude-swap",
61
+ "local-first"
57
62
  ],
58
63
  "author": "Bargan Constantin",
59
64
  "license": "AGPL-3.0-only",
@@ -58,7 +58,14 @@
58
58
  "3.29.6",
59
59
  "3.29.7",
60
60
  "3.29.9",
61
- "3.33.1"
61
+ "3.33.1",
62
+ "3.35.0"
63
+ ],
64
+ "3.34.1": [
65
+ {
66
+ "title": "🗂️ The tray menu stays open while you read it",
67
+ "body": "On Linux the menu behind the tray icon could close by itself while agents were working, often before the row you pressed took the click, so it had to be opened and pressed again. It now changes only when something it says has changed, and stays open while you choose."
68
+ }
62
69
  ],
63
70
  "3.34.0": [
64
71
  {
@@ -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;
@@ -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. Nothing about a session, a
16
- // project, a prompt, a path or a person: the install id is random, made at the
17
- // first check-in, and tied to nothing on the machine.
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
- /** What every report says about the install, and nothing else. */
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
- return {
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", ...facts })) {
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, ...facts })) {
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 && (await call("POST", "/v1/app/events", { installId, kind: "active", ...facts }))) {
194
- await remember(installId, { lastActiveDay: today });
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 } = facts;
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 };