@ipv9/tokentracker-cli 0.39.43 → 0.39.45

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.
Files changed (43) hide show
  1. package/README.md +18 -10
  2. package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-2-TQg7P7.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-yb9ss9uE.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BOc6XtOK.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-C2tIb68P.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-BsuLQ9co.js} +1 -1
  7. package/dashboard/dist/assets/LocalOnlyNotice-C8R9KLef.js +1 -0
  8. package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-BpfseoI7.js} +1 -1
  9. package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-Ctk8zze5.js} +1 -1
  10. package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-BKZlFyFs.js} +1 -1
  11. package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-CQXM8qGU.js} +1 -1
  12. package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-4EMm0eBX.js} +1 -1
  13. package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C2sdX5g6.js} +1 -1
  14. package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-CLiuEcQZ.js} +1 -1
  15. package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-BDkp93DX.js} +1 -1
  16. package/dashboard/dist/assets/{download-CTwO-YeA.js → download-DZ6SoCSn.js} +1 -1
  17. package/dashboard/dist/assets/{format-4chvNBjF.js → format-CaW9kvsA.js} +1 -1
  18. package/dashboard/dist/assets/limitDisplay-DNU_w4O7.js +1 -0
  19. package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-DgyymGht.js} +16 -3
  20. package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
  21. package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-D6C7Fba3.js} +1 -1
  22. package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-B7cHBa7Y.js} +1 -1
  23. package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-BKAzGuxw.js} +1 -1
  24. package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-BVr6Ajuu.js} +1 -1
  25. package/dashboard/dist/index.html +2 -2
  26. package/package.json +5 -3
  27. package/src/commands/doctor.js +8 -0
  28. package/src/commands/init.js +1 -1
  29. package/src/commands/sync.js +67 -0
  30. package/src/lib/doctor.js +227 -1
  31. package/src/lib/local-api.js +385 -113
  32. package/src/lib/pricing/seed-snapshot.json +1 -1
  33. package/src/lib/process-list.js +91 -0
  34. package/src/lib/queue-compact.js +220 -0
  35. package/src/lib/rollout.js +81 -14
  36. package/src/lib/single-flight.js +59 -0
  37. package/src/lib/skills-manager.js +2 -2
  38. package/src/lib/transcript-suppression.js +133 -0
  39. package/src/lib/usage-limits.js +99 -22
  40. package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
  41. package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
  42. package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
  43. package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
package/src/lib/doctor.js CHANGED
@@ -1,9 +1,15 @@
1
1
  const fs = require("node:fs/promises");
2
+ const { findRowViolations } = require("./queue-compact");
2
3
  const { constants } = require("node:fs");
3
4
  const path = require("node:path");
4
5
 
5
6
  const { readJsonStrict } = require("./fs");
6
7
 
8
+ // Stands in for a check whose `id` is missing or not a usable string, so that such
9
+ // a check can still be counted rather than silently dropped. See rule 2 in
10
+ // `listDegradedChecks`.
11
+ const UNNAMED_CHECK_ID = "(unnamed)";
12
+
7
13
  async function buildDoctorReport({
8
14
  runtime = {},
9
15
  diagnostics = null,
@@ -11,6 +17,7 @@ async function buildDoctorReport({
11
17
  now = () => new Date(),
12
18
  paths = {},
13
19
  system = null,
20
+ ingest = null,
14
21
  } = {}) {
15
22
  const checks = [];
16
23
 
@@ -28,26 +35,140 @@ async function buildDoctorReport({
28
35
  if (paths.cliPath) {
29
36
  checks.push(await checkCliEntrypoint(paths.cliPath));
30
37
  }
38
+ if (paths.queuePath) {
39
+ checks.push(await checkQueueRows(paths.queuePath));
40
+ }
31
41
 
32
42
  // No cloud reachability check: TokenTracker is local-only, so there is no
33
43
  // remote endpoint whose availability could affect anything here.
34
44
 
45
+ if (ingest) {
46
+ const suppression = buildTranscriptSuppressionCheck(ingest.transcriptSuppression);
47
+ if (suppression) checks.push(suppression);
48
+ }
49
+
35
50
  if (diagnostics) {
36
51
  checks.push(...buildDiagnosticsChecks(diagnostics));
37
52
  }
38
53
 
39
54
  const summary = summarizeChecks(checks);
55
+ const degradedChecks = listDegradedChecks(checks);
40
56
 
41
57
  return {
42
58
  version: 1,
43
59
  generated_at: now().toISOString(),
44
60
  ok: summary.critical === 0,
61
+ // `ok` answers "should this exit non-zero", and only `critical` moves it.
62
+ // A warning therefore leaves an entirely green-looking report, which is
63
+ // exactly how a source going unrecorded stayed invisible. `degraded` is the
64
+ // machine-readable half of that distinction: automation can alert on it
65
+ // without any existing caller's exit code changing.
66
+ //
67
+ // It counts every warn and fail except a warn whose check marked itself
68
+ // `advisory`. A first version counted every warn, which made it useless on the
69
+ // one machine it was written for: that box carries a standing
70
+ // `queue.row_invariant` warn about two parseable invariant violations, so
71
+ // `degraded` read true
72
+ // on a perfectly healthy day and an alert wired to it could never clear. An
73
+ // always-on alert and an alert that never fires fail the same way.
74
+ // `listDegradedChecks` holds the exact rule including its two fail-closed
75
+ // clauses. Advisory is assigned at the individual warning return site: a
76
+ // standing condition can opt out without muting an actionable warning from
77
+ // the same check id.
78
+ degraded: degradedChecks.length > 0,
79
+ // Which checks put it there. Without this, `degraded: true` is unactionable —
80
+ // a consumer has to re-derive the reason by walking `checks` itself, and a
81
+ // human reading the JSON cannot tell a new problem from the standing one.
82
+ degraded_checks: degradedChecks,
45
83
  summary,
46
84
  checks,
47
85
  diagnostics,
48
86
  };
49
87
  }
50
88
 
89
+ // A check is advisory when its warn describes a standing condition the operator
90
+ // cannot act on in the moment. Such a check still reports `warn` and still appears
91
+ // in `summary.warn`: the report does not become quieter, only the alert signal
92
+ // becomes specific. Anything that does not opt in counts, so a new check is
93
+ // alert-worthy by default and has to argue its way out.
94
+ //
95
+ // Two rules here are deliberately fail-CLOSED, because the failure this field
96
+ // exists to prevent is a real problem reading as silence, and both were live
97
+ // holes in the first version of this function:
98
+ //
99
+ // 1. `advisory` suppresses a `warn` and NOTHING ELSE. The rationale for the
100
+ // flag is about standing warnings; nothing argues for muting a `fail` on the
101
+ // same id, so a `fail` degrades the report whatever the flag says.
102
+ // 2. A check with a missing or malformed `id` is still counted, under
103
+ // UNNAMED_CHECK_ID. The earlier version mapped to `check.id` and then
104
+ // dropped non-strings, so an id typo silently removed a genuine warn from
105
+ // `degraded` altogether — it rendered as `[WARN] unknown` to a human and as
106
+ // nothing at all to automation. A placeholder keeps `degraded` and
107
+ // `degraded_checks` honest and in agreement.
108
+ function listDegradedChecks(checks = []) {
109
+ return checks
110
+ .filter((check) => check && (check.status === "warn" || check.status === "fail"))
111
+ // A malformed id overrides advisory suppression. Advisory is an explicit
112
+ // classification made at a known warning call site; if that identity is
113
+ // lost, fail closed under the placeholder rather than silently dropping it.
114
+ .filter((check) => !(
115
+ check.status === "warn"
116
+ && check.advisory === true
117
+ && typeof check.id === "string"
118
+ && check.id.trim().length > 0
119
+ ))
120
+ .map((check) =>
121
+ typeof check.id === "string" && check.id.trim().length > 0 ? check.id : UNNAMED_CHECK_ID,
122
+ )
123
+ .sort();
124
+ }
125
+
126
+ // Reports Claude CLI processes that were started with `--no-session-persistence`.
127
+ // Those sessions write no transcript, and transcripts are the only thing the
128
+ // Claude parser can read, so their tokens are unobservable to TokenTracker.
129
+ //
130
+ // Returns null when the platform cannot answer the question at all. A check that
131
+ // was never run must not be printed as `[OK]` — absence is honest, a green line
132
+ // would not be.
133
+ function buildTranscriptSuppressionCheck(detection) {
134
+ if (!detection || detection.supported === false) return null;
135
+
136
+ const id = "ingest.transcript_suppressed";
137
+
138
+ if (!detection.checked) {
139
+ return {
140
+ id,
141
+ status: "warn",
142
+ detail: "Could not read the process list, so transcript-suppressed sessions were not checked",
143
+ critical: false,
144
+ meta: { checked: false, reason: detection.reason || "process_list_failed" },
145
+ };
146
+ }
147
+
148
+ const count = Number(detection.count) || 0;
149
+ if (count === 0) {
150
+ return {
151
+ id,
152
+ status: "ok",
153
+ detail: "No Claude CLI process is running with --no-session-persistence",
154
+ critical: false,
155
+ meta: { checked: true, count: 0, models: [] },
156
+ };
157
+ }
158
+
159
+ const models = Array.isArray(detection.models) ? detection.models : [];
160
+ const modelSuffix = models.length ? ` (${models.join(", ")})` : "";
161
+ return {
162
+ id,
163
+ status: "warn",
164
+ detail:
165
+ `${count} Claude CLI process${count === 1 ? "" : "es"} running with --no-session-persistence${modelSuffix}`
166
+ + " - these sessions write no transcript, so their token usage cannot be recorded",
167
+ critical: false,
168
+ meta: { checked: true, count, models },
169
+ };
170
+ }
171
+
51
172
  async function buildSystemChecks({
52
173
  nodeVersion = process.version,
53
174
  platform = process.platform,
@@ -78,11 +199,14 @@ function buildNodeVersionCheck(nodeVersion) {
78
199
  async function buildBrowserOpenerCheck({ platform = process.platform, env = process.env, commandExists }) {
79
200
  const headless = isHeadlessEnvironment({ platform, env });
80
201
  if (headless) {
202
+ // Standing environment property: neither --no-open nor opening the printed
203
+ // URL manually can make a headless session acquire a browser opener.
81
204
  return {
82
205
  id: "browser.opener",
83
206
  status: "warn",
84
207
  detail: "headless/session environment detected; use --no-open or open the printed URL manually",
85
208
  critical: false,
209
+ advisory: true,
86
210
  meta: { platform, command: null, headless: true },
87
211
  };
88
212
  }
@@ -328,14 +452,21 @@ function buildDiagnosticsChecks(diagnostics) {
328
452
  notify.claude_hook_configured ||
329
453
  notify.gemini_hook_configured ||
330
454
  notify.opencode_plugin_configured ||
331
- notify.openclaw_hook_configured,
455
+ notify.openclaw_hook_configured ||
456
+ notify.openclaw_session_plugin_configured ||
457
+ notify.grok_hook_configured,
332
458
  );
333
459
 
460
+ // This aggregate describes an optional integration preference, not whether
461
+ // passive log ingestion works. `init` also skips hooks for providers whose
462
+ // config is absent, so "none configured" can be a stable, intentional state.
463
+ // Keep only that warn advisory; this check currently has no fail path.
334
464
  checks.push({
335
465
  id: "notify.configured",
336
466
  status: notifyConfigured ? "ok" : "warn",
337
467
  detail: notifyConfigured ? "notify configured" : "notify not configured",
338
468
  critical: false,
469
+ ...(notifyConfigured ? {} : { advisory: true }),
339
470
  meta: { configured: notifyConfigured },
340
471
  });
341
472
 
@@ -358,8 +489,103 @@ function summarizeChecks(checks = []) {
358
489
  return summary;
359
490
  }
360
491
 
492
+ // CLAUDE.md states the column invariant in prose:
493
+ //
494
+ // total = input + output + cache_creation + cache_read + reasoning
495
+ //
496
+ // Nothing enforced it at runtime, so a miswritten or corrupt row was aggregated
497
+ // and rendered rather than flagged — and a parser bug of exactly that shape is
498
+ // the class CLAUDE.md records at 1.6-7x magnitude. Same conversion as the
499
+ // curated-expiry and version-lockstep checks: a rule that lived in a document
500
+ // starts running.
501
+ //
502
+ // A warn rather than a fail: the rows are already on disk and already being
503
+ // rendered, so failing the whole health check would be reporting a crisis the
504
+ // user cannot act on in the moment. What they can act on is knowing which rows,
505
+ // and how many.
506
+ const QUEUE_VIOLATIONS_SHOWN = 5;
507
+
508
+ // `advisory: true` keeps a warn out of `degraded` and `degraded_checks` while
509
+ // leaving it a full `warn` in `checks` and in `summary.warn`. The flag is decided
510
+ // PER CALL SITE, not once for this check id, because the two warns this check can
511
+ // emit are not the same kind of thing:
512
+ //
513
+ // - a parseable row-invariant violation IS advisory: the row is already written
514
+ // and rendered, so there is nothing the operator can do at report time.
515
+ // - an unparseable line or unreadable queue is NOT. Those conditions omit usage
516
+ // or can stop ingestion and are actionable (corruption, permissions, disk).
517
+ //
518
+ // An earlier version of this function stamped `advisory: true` on everything it
519
+ // returned, which silenced the unreadable case: `warn` in `checks`,
520
+ // `degraded: false` on the wire. Default is NOT advisory, so a new call site has
521
+ // to argue its way out rather than inherit silence.
522
+ function queueCheck(status, detail, meta, { advisory = false } = {}) {
523
+ const check = { id: "queue.row_invariant", status, detail, critical: false, meta };
524
+ return advisory ? { ...check, advisory: true } : check;
525
+ }
526
+
527
+ async function readQueueRowsForDoctor(queuePath) {
528
+ const raw = await fs.readFile(queuePath, "utf8");
529
+ const rows = [];
530
+ let malformed = 0;
531
+ for (const line of raw.split("\n")) {
532
+ if (!line.trim()) continue;
533
+ try {
534
+ rows.push(JSON.parse(line));
535
+ } catch {
536
+ malformed += 1;
537
+ }
538
+ }
539
+ return { rows, malformed };
540
+ }
541
+
542
+ async function checkQueueRows(queuePath) {
543
+ let rows;
544
+ let malformed;
545
+ try {
546
+ ({ rows, malformed } = await readQueueRowsForDoctor(queuePath));
547
+ } catch (err) {
548
+ if (err && err.code === "ENOENT") {
549
+ return queueCheck("ok", "no queue yet", { path: queuePath });
550
+ }
551
+ return queueCheck("warn", `queue unreadable: ${err?.message || err}`, { path: queuePath });
552
+ }
553
+
554
+ const violations = findRowViolations(rows);
555
+ if (violations.length === 0 && malformed === 0) {
556
+ return queueCheck("ok", `${rows.length} rows satisfy the column invariant`, {
557
+ path: queuePath,
558
+ rows: rows.length,
559
+ });
560
+ }
561
+
562
+ const parts = [];
563
+ if (violations.length > 0) parts.push(`${violations.length} row problem(s)`);
564
+ if (malformed > 0) parts.push(`${malformed} unparseable line(s)`);
565
+ // Parseable invariant violations are already on disk and already aggregated
566
+ // into what the dashboard renders, so they are advisory. Malformed lines are
567
+ // skipped by local-api readers and their usage is absent; corruption or a
568
+ // partial write is actionable and must degrade the report.
569
+ return queueCheck(
570
+ "warn",
571
+ `${parts.join(", ")} in ${rows.length + malformed} line(s)`,
572
+ {
573
+ path: queuePath,
574
+ rows: rows.length,
575
+ malformed,
576
+ violations: violations.length,
577
+ examples: violations.slice(0, QUEUE_VIOLATIONS_SHOWN),
578
+ },
579
+ { advisory: malformed === 0 },
580
+ );
581
+ }
582
+
361
583
  module.exports = {
362
584
  buildDoctorReport,
585
+ buildTranscriptSuppressionCheck,
586
+ listDegradedChecks,
587
+ UNNAMED_CHECK_ID,
588
+ checkQueueRows,
363
589
  buildBrowserOpenerCheck,
364
590
  buildNodeVersionCheck,
365
591
  isHeadlessEnvironment,