@ipv9/tokentracker-cli 0.39.43 → 0.39.44

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 (40) hide show
  1. package/README.md +18 -10
  2. package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-C_H8B1rI.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-DOa3N76u.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BE8I9B4-.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-DQ9gR343.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-Balfhw3-.js} +1 -1
  7. package/dashboard/dist/assets/LocalOnlyNotice-hxi161Wl.js +1 -0
  8. package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-B4zbh-jA.js} +1 -1
  9. package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-DLYT4NCO.js} +1 -1
  10. package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-DUyZrDnS.js} +1 -1
  11. package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-BySJoulk.js} +1 -1
  12. package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-DpiMLTPo.js} +1 -1
  13. package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C6je8upG.js} +1 -1
  14. package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-ByarX-vY.js} +1 -1
  15. package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-C-WAJGZ8.js} +1 -1
  16. package/dashboard/dist/assets/{download-CTwO-YeA.js → download-BhOg_qb4.js} +1 -1
  17. package/dashboard/dist/assets/{format-4chvNBjF.js → format-Co2okzG-.js} +1 -1
  18. package/dashboard/dist/assets/limitDisplay-CPMQA7lm.js +1 -0
  19. package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-q9UoIq7C.js} +12 -3
  20. package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
  21. package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-0XiGBeUV.js} +1 -1
  22. package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-DmEIJPtg.js} +1 -1
  23. package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-QLM3Sd2C.js} +1 -1
  24. package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-Dtize6Tx.js} +1 -1
  25. package/dashboard/dist/index.html +2 -2
  26. package/package.json +5 -3
  27. package/src/commands/doctor.js +2 -0
  28. package/src/commands/init.js +1 -1
  29. package/src/commands/sync.js +67 -0
  30. package/src/lib/doctor.js +72 -0
  31. package/src/lib/local-api.js +306 -93
  32. package/src/lib/pricing/seed-snapshot.json +1 -1
  33. package/src/lib/queue-compact.js +220 -0
  34. package/src/lib/rollout.js +76 -12
  35. package/src/lib/skills-manager.js +2 -2
  36. package/src/lib/usage-limits.js +20 -3
  37. package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
  38. package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
  39. package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
  40. package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+
3
+ // Compaction for the append-only queue, and the row invariant that lived in
4
+ // prose.
5
+ //
6
+ // Measured on a real install: 34,492 lines, 5,595 unique keys, 28,897
7
+ // superseded (83.8%), 11 MB. Five sixths of the file is dead weight, and
8
+ // `readQueueData` in local-api.js re-reads and re-dedups ALL of it on every
9
+ // endpoint call — a dashboard refresh hits 6-8 endpoints, auto-refresh defaults
10
+ // to 30s. Nothing ever reclaims it; the only rewrite in the codebase is a
11
+ // one-off migration.
12
+ //
13
+ // The design risk here is close to zero for one reason: THE READERS ALREADY
14
+ // DEFINE THE OUTPUT. `readQueueData` keeps the last row per
15
+ // `source|model|hour_start`. Compaction only has to produce what every reader
16
+ // already computes, so it keeps the last RAW LINE per key — not a re-serialised
17
+ // row. Byte-identical API responses then follow by construction rather than by
18
+ // luck, because the surviving bytes are the exact bytes the reader would have
19
+ // kept.
20
+
21
+ const fs = require("node:fs");
22
+ const path = require("node:path");
23
+
24
+ // Must match readQueueData in src/lib/local-api.js. If that key ever changes,
25
+ // this one has to change with it — a test asserts they agree on real rows.
26
+ function queueRowKey(row) {
27
+ return `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
28
+ }
29
+
30
+ const TOKEN_COLUMNS = [
31
+ "input_tokens",
32
+ "output_tokens",
33
+ "cache_creation_input_tokens",
34
+ "cached_input_tokens",
35
+ "reasoning_output_tokens",
36
+ ];
37
+
38
+ const THIRTY_MINUTES_MS = 30 * 60 * 1000;
39
+
40
+ // Codex and every-code report reasoning tokens that are ALREADY COUNTED inside
41
+ // output_tokens, so their total_tokens correctly excludes the reasoning column —
42
+ // adding it would count those tokens twice.
43
+ //
44
+ // This is not a special case invented here. `computeRowCost` in
45
+ // src/lib/pricing/index.js:309 makes exactly the same distinction, and charges
46
+ // reasoning at zero for these two sources for the same reason.
47
+ //
48
+ // Found by running this check against a real 34,922-row queue: 8,236 rows
49
+ // "violated" the invariant, every one of them source=codex, and in every case
50
+ // the difference was exactly reasoning_output_tokens. The rows were right, the
51
+ // check was wrong, and so was the prose it came from — CLAUDE.md said "sum of
52
+ // all columns" with no exception. Corrected there too.
53
+ const REASONING_FOLDED_INTO_OUTPUT = new Set(["codex", "every-code"]);
54
+
55
+ function expectedTotal(row) {
56
+ const folded = REASONING_FOLDED_INTO_OUTPUT.has(String(row.source || "").toLowerCase());
57
+ return TOKEN_COLUMNS.reduce((acc, column) => {
58
+ if (folded && column === "reasoning_output_tokens") return acc;
59
+ const value = Number(row[column] ?? 0);
60
+ return acc + (Number.isFinite(value) ? value : 0);
61
+ }, 0);
62
+ }
63
+
64
+ // Decides which lines survive, without touching the disk. Split out so the
65
+ // decision is testable on its own and so `analyze` and `compact` cannot drift.
66
+ //
67
+ // Malformed lines are KEPT. They are invisible to every reader already, so
68
+ // dropping them would not change a single API response — but it would destroy
69
+ // bytes nobody has looked at, and a partial write worth investigating is
70
+ // exactly the kind of thing that should survive a routine maintenance command.
71
+ function planCompaction(raw) {
72
+ const lines = raw.split("\n");
73
+ const keep = new Set();
74
+ const lastForKey = new Map();
75
+ let parseable = 0;
76
+ let malformed = 0;
77
+
78
+ lines.forEach((line, index) => {
79
+ if (!line.trim()) return;
80
+ let row;
81
+ try {
82
+ row = JSON.parse(line);
83
+ } catch {
84
+ malformed += 1;
85
+ keep.add(index);
86
+ return;
87
+ }
88
+ parseable += 1;
89
+ lastForKey.set(queueRowKey(row), index);
90
+ });
91
+
92
+ for (const index of lastForKey.values()) keep.add(index);
93
+
94
+ const kept = [...keep].sort((a, b) => a - b);
95
+ return {
96
+ lines: kept.map((index) => lines[index]),
97
+ stats: {
98
+ totalLines: parseable + malformed,
99
+ parseable,
100
+ malformed,
101
+ uniqueKeys: lastForKey.size,
102
+ superseded: parseable - lastForKey.size,
103
+ keptLines: kept.length,
104
+ },
105
+ };
106
+ }
107
+
108
+ function analyzeQueue(queuePath) {
109
+ let raw;
110
+ try {
111
+ raw = fs.readFileSync(queuePath, "utf8");
112
+ } catch (e) {
113
+ if (e?.code === "ENOENT") {
114
+ return { totalLines: 0, parseable: 0, malformed: 0, uniqueKeys: 0, superseded: 0, keptLines: 0, ratio: 0, bytes: 0 };
115
+ }
116
+ throw e;
117
+ }
118
+ const { stats } = planCompaction(raw);
119
+ return {
120
+ ...stats,
121
+ ratio: stats.parseable > 0 ? stats.superseded / stats.parseable : 0,
122
+ bytes: Buffer.byteLength(raw, "utf8"),
123
+ };
124
+ }
125
+
126
+ // Writes to a temp file in the same directory and renames over the original.
127
+ // Same atomic-replace pattern the project-queue rewrite already uses. An
128
+ // interrupt between write and rename leaves the original untouched — the temp
129
+ // file is the only casualty.
130
+ //
131
+ // The CALLER holds the sync lock. This does not take it, because the lock is
132
+ // per-invocation state owned by the sync command, and a second acquisition
133
+ // inside would deadlock against the first.
134
+ function compactQueue(queuePath) {
135
+ let raw;
136
+ try {
137
+ raw = fs.readFileSync(queuePath, "utf8");
138
+ } catch (e) {
139
+ if (e?.code === "ENOENT") return { changed: false, reason: "no queue file" };
140
+ throw e;
141
+ }
142
+
143
+ const before = Buffer.byteLength(raw, "utf8");
144
+ const { lines, stats } = planCompaction(raw);
145
+ if (stats.superseded === 0) {
146
+ return { changed: false, reason: "nothing superseded", ...stats, bytesBefore: before, bytesAfter: before };
147
+ }
148
+
149
+ const out = lines.join("\n") + "\n";
150
+ const tmp = path.join(
151
+ path.dirname(queuePath),
152
+ `${path.basename(queuePath)}.compact.${process.pid}.tmp`,
153
+ );
154
+ fs.writeFileSync(tmp, out, "utf8");
155
+ try {
156
+ fs.renameSync(tmp, queuePath);
157
+ } catch (e) {
158
+ fs.unlinkSync(tmp);
159
+ throw e;
160
+ }
161
+
162
+ return {
163
+ changed: true,
164
+ ...stats,
165
+ bytesBefore: before,
166
+ bytesAfter: Buffer.byteLength(out, "utf8"),
167
+ };
168
+ }
169
+
170
+ // CLAUDE.md states the column invariant in prose:
171
+ //
172
+ // total = input + output + cache_creation + cache_read + reasoning
173
+ //
174
+ // Nothing enforced it at runtime. A miswritten or corrupt row was aggregated and
175
+ // rendered, not flagged — and a parser bug of exactly this shape is the class
176
+ // CLAUDE.md records at 1.6-7x magnitude. Same conversion as the curated-expiry
177
+ // and version-lockstep checks: a rule that lived in a document starts running.
178
+ //
179
+ // Returns one finding per violating row, capped by the caller.
180
+ function findRowViolations(rows) {
181
+ const findings = [];
182
+ rows.forEach((row, index) => {
183
+ const where = `row ${index + 1} (${row.source || "?"}|${row.model || "?"}|${row.hour_start || "?"})`;
184
+
185
+ for (const column of TOKEN_COLUMNS) {
186
+ const value = Number(row[column] ?? 0);
187
+ if (!Number.isFinite(value)) {
188
+ findings.push(`${where}: ${column} is not a number (${JSON.stringify(row[column])})`);
189
+ } else if (value < 0) {
190
+ findings.push(`${where}: ${column} is negative (${value})`);
191
+ }
192
+ }
193
+
194
+ const sum = expectedTotal(row);
195
+ const total = Number(row.total_tokens ?? 0);
196
+ if (Number.isFinite(total) && total !== sum) {
197
+ findings.push(`${where}: total_tokens ${total} != expected ${sum}`);
198
+ }
199
+
200
+ const bucket = Date.parse(row.hour_start);
201
+ if (!Number.isFinite(bucket)) {
202
+ findings.push(`${where}: hour_start is not a timestamp`);
203
+ } else if (bucket % THIRTY_MINUTES_MS !== 0) {
204
+ findings.push(`${where}: hour_start is not on a 30-minute UTC boundary`);
205
+ }
206
+ });
207
+ return findings;
208
+ }
209
+
210
+ module.exports = {
211
+ queueRowKey,
212
+ expectedTotal,
213
+ REASONING_FOLDED_INTO_OUTPUT,
214
+ planCompaction,
215
+ analyzeQueue,
216
+ compactQueue,
217
+ findRowViolations,
218
+ TOKEN_COLUMNS,
219
+ THIRTY_MINUTES_MS,
220
+ };
@@ -937,11 +937,12 @@ async function parseRolloutFile({
937
937
  projectState,
938
938
  currentProjectKey,
939
939
  source,
940
+ model,
940
941
  bucketStart,
941
942
  currentProjectRef,
942
943
  );
943
944
  addTotals(projectBucket.totals, delta);
944
- projectTouchedBuckets.add(projectBucketKey(currentProjectKey, source, bucketStart));
945
+ projectTouchedBuckets.add(projectBucketKey(currentProjectKey, source, model, bucketStart));
945
946
  }
946
947
  eventsAggregated += 1;
947
948
  }
@@ -1061,11 +1062,12 @@ async function parseClaudeFile({
1061
1062
  projectState,
1062
1063
  projectKey,
1063
1064
  source,
1065
+ model,
1064
1066
  bucketStart,
1065
1067
  projectRef,
1066
1068
  );
1067
1069
  addTotals(projectBucket.totals, delta);
1068
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
1070
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
1069
1071
  }
1070
1072
  eventsAggregated += 1;
1071
1073
  }
@@ -1145,11 +1147,12 @@ async function parseGeminiFile({
1145
1147
  projectState,
1146
1148
  projectKey,
1147
1149
  source,
1150
+ model,
1148
1151
  bucketStart,
1149
1152
  projectRef,
1150
1153
  );
1151
1154
  addTotals(projectBucket.totals, delta);
1152
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
1155
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
1153
1156
  }
1154
1157
  eventsAggregated += 1;
1155
1158
  totals = currentTotals;
@@ -1256,11 +1259,12 @@ async function parseOpencodeMessageFile({
1256
1259
  projectState,
1257
1260
  projectKey,
1258
1261
  source,
1262
+ model,
1259
1263
  bucketStart,
1260
1264
  projectRef,
1261
1265
  );
1262
1266
  addTotals(projectBucket.totals, delta);
1263
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
1267
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
1264
1268
  }
1265
1269
  return { messageKey, lastTotals: currentTotals, eventsAggregated: 1, shouldUpdate: true };
1266
1270
  }
@@ -1598,6 +1602,7 @@ async function enqueueTouchedProjectBuckets({
1598
1602
  project_ref: projectRef,
1599
1603
  project_key: projectKey,
1600
1604
  source: bucket.source,
1605
+ model: bucket.model || PROJECT_MODEL_UNATTRIBUTED,
1601
1606
  hour_start: bucket.hour_start,
1602
1607
  input_tokens: totals.input_tokens,
1603
1608
  cached_input_tokens: totals.cached_input_tokens,
@@ -1767,12 +1772,16 @@ function normalizeProjectState(raw) {
1767
1772
  projects[key] = { ...value };
1768
1773
  }
1769
1774
 
1770
- return {
1771
- version: 2,
1775
+ const normalized = {
1776
+ version: 3,
1772
1777
  buckets,
1773
1778
  projects,
1774
1779
  updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null,
1775
1780
  };
1781
+ // Every parser reaches project state through here, so one call covers all of
1782
+ // them. Idempotent: a v3 key already has four segments and is skipped.
1783
+ migrateProjectBucketsToModelKey(normalized);
1784
+ return normalized;
1776
1785
  }
1777
1786
 
1778
1787
  function normalizeOpencodeState(raw) {
@@ -1819,10 +1828,11 @@ function getHourlyBucket(state, source, model, hourStart) {
1819
1828
  return bucket;
1820
1829
  }
1821
1830
 
1822
- function getProjectBucket(state, projectKey, source, hourStart, projectRef) {
1831
+ function getProjectBucket(state, projectKey, source, model, hourStart, projectRef) {
1823
1832
  const buckets = state.buckets;
1824
1833
  const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
1825
- const key = projectBucketKey(projectKey, normalizedSource, hourStart);
1834
+ const normalizedModel = normalizeModelInput(model) || PROJECT_MODEL_UNATTRIBUTED;
1835
+ const key = projectBucketKey(projectKey, normalizedSource, normalizedModel, hourStart);
1826
1836
  let bucket = buckets[key];
1827
1837
  if (!bucket || typeof bucket !== "object") {
1828
1838
  bucket = {
@@ -1831,6 +1841,7 @@ function getProjectBucket(state, projectKey, source, hourStart, projectRef) {
1831
1841
  project_key: projectKey,
1832
1842
  project_ref: projectRef,
1833
1843
  source: normalizedSource,
1844
+ model: normalizedModel,
1834
1845
  hour_start: hourStart,
1835
1846
  };
1836
1847
  buckets[key] = bucket;
@@ -1921,9 +1932,53 @@ function bucketKey(source, model, hourStart) {
1921
1932
  return `${safeSource}${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`;
1922
1933
  }
1923
1934
 
1924
- function projectBucketKey(projectKey, source, hourStart) {
1935
+ // Per-repo COST is per-model, and the key had no model in it, so the repo x model
1936
+ // join this product's README promises ("which repo, which model, and which
1937
+ // hour") had never existed. Adding model is what makes computeRowCost usable
1938
+ // per project.
1939
+ //
1940
+ // The transition hazard is the whole job. A legacy row keyed
1941
+ // project|source|hour and a new row keyed project|source|model|hour describe the
1942
+ // SAME bucket at different granularity, so a reader that keeps both
1943
+ // double-counts. Rows written before this change are keyed with
1944
+ // PROJECT_MODEL_UNATTRIBUTED, so they occupy one slot alongside the model-keyed
1945
+ // rows rather than shadowing them — see migrateProjectBucketsToModelKey.
1946
+ const PROJECT_MODEL_UNATTRIBUTED = "unknown";
1947
+
1948
+ function projectBucketKey(projectKey, source, model, hourStart) {
1925
1949
  const safeSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
1926
- return `${projectKey}${BUCKET_SEPARATOR}${safeSource}${BUCKET_SEPARATOR}${hourStart}`;
1950
+ const safeModel = normalizeModelInput(model) || PROJECT_MODEL_UNATTRIBUTED;
1951
+ return (
1952
+ `${projectKey}${BUCKET_SEPARATOR}${safeSource}` +
1953
+ `${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`
1954
+ );
1955
+ }
1956
+
1957
+ // Re-keys buckets carried in cursors.json from the pre-model key to the new one,
1958
+ // tagging them PROJECT_MODEL_UNATTRIBUTED.
1959
+ //
1960
+ // Without this the old bucket keeps its old key, never matches again, and its
1961
+ // running total is stranded: new usage in the same hour starts from zero under
1962
+ // the model key while the reader still sees the old row. Sum = double count.
1963
+ //
1964
+ // After it, the stranded bucket becomes the `unknown` slot for that hour. It
1965
+ // stops receiving new usage (that goes to the model-specific bucket), so it is
1966
+ // frozen at its pre-upgrade total and the two sum to the right number — no
1967
+ // double count, and nothing lost.
1968
+ function migrateProjectBucketsToModelKey(projectState) {
1969
+ const buckets = projectState?.buckets;
1970
+ if (!buckets || typeof buckets !== "object") return 0;
1971
+ let migrated = 0;
1972
+ for (const [key, bucket] of Object.entries(buckets)) {
1973
+ if (key.split(BUCKET_SEPARATOR).length !== 3) continue;
1974
+ const [projectKey, source, hourStart] = key.split(BUCKET_SEPARATOR);
1975
+ const nextKey = projectBucketKey(projectKey, source, PROJECT_MODEL_UNATTRIBUTED, hourStart);
1976
+ if (buckets[nextKey]) continue;
1977
+ buckets[nextKey] = { ...bucket, model: PROJECT_MODEL_UNATTRIBUTED };
1978
+ delete buckets[key];
1979
+ migrated += 1;
1980
+ }
1981
+ return migrated;
1927
1982
  }
1928
1983
 
1929
1984
  function groupBucketKey(source, hourStart) {
@@ -2663,11 +2718,14 @@ async function parseOpencodeDbIncremental({
2663
2718
  projectState,
2664
2719
  projectKey,
2665
2720
  defaultSource,
2721
+ model,
2666
2722
  bucketStart,
2667
2723
  projectRef,
2668
2724
  );
2669
2725
  addTotals(projectBucket.totals, delta);
2670
- projectTouchedBuckets.add(projectBucketKey(projectKey, defaultSource, bucketStart));
2726
+ projectTouchedBuckets.add(
2727
+ projectBucketKey(projectKey, defaultSource, model, bucketStart),
2728
+ );
2671
2729
  }
2672
2730
  }
2673
2731
 
@@ -8608,11 +8666,12 @@ async function parseAntigravityFile({
8608
8666
  projectState,
8609
8667
  projectKey,
8610
8668
  source,
8669
+ model,
8611
8670
  bucketStart,
8612
8671
  projectRef,
8613
8672
  );
8614
8673
  addTotals(projectBucket.totals, delta);
8615
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
8674
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
8616
8675
  }
8617
8676
  eventsAggregated += 1;
8618
8677
  // Snapshot the pre-planner context first. The planner's own content+tool_calls
@@ -8707,6 +8766,11 @@ function isCjkCodePoint(code) {
8707
8766
  }
8708
8767
 
8709
8768
  module.exports = {
8769
+ // Exported for tests: the mixed-era rule is the risky part of this change.
8770
+ projectBucketKey,
8771
+ migrateProjectBucketsToModelKey,
8772
+ normalizeProjectState,
8773
+ PROJECT_MODEL_UNATTRIBUTED,
8710
8774
  listRolloutFiles,
8711
8775
  listClaudeProjectFiles,
8712
8776
  listGeminiSessionFiles,
@@ -287,13 +287,13 @@ function hashDirectory(dir) {
287
287
  continue;
288
288
  }
289
289
  const execBit = process.platform === "win32" ? 0 : stat.mode & 0o111 ? 1 : 0;
290
- hash.update(`${rel}${execBit}`);
290
+ hash.update(`${rel}\0${execBit}\0`);
291
291
  try {
292
292
  hash.update(fs.readFileSync(abs));
293
293
  } catch (_e) {
294
294
  // unreadable file — fold its absence in deterministically
295
295
  }
296
- hash.update("");
296
+ hash.update("\0");
297
297
  }
298
298
  }
299
299
  };
@@ -218,10 +218,23 @@ async function fetchCodexUsageLimits(
218
218
  method: "GET",
219
219
  headers,
220
220
  });
221
- // 401/403/404 from wham means "no usage data available for this auth state" — render
222
- // a neutral empty state instead of a red "Fetch failed" error.
221
+ // 401/403/404 from wham means "no usage data available for this auth state" —
222
+ // a free or multi-account user, or a token that went stale. #52 asked for a
223
+ // neutral state here rather than a red "Fetch failed".
224
+ //
225
+ // It got NO state. With no error and no windows, LimitChips falls through to
226
+ // `windows.length === 0 -> return null` and the chip disappears entirely,
227
+ // which is the outcome #105 calls worse than never having had a chip: the user
228
+ // has been trained to look there and now reads absence as "plenty left".
229
+ //
230
+ // `notice` is the third state the UI was missing. Visible and subtle, so #52's
231
+ // intent survives without #105's failure mode.
223
232
  if (res.status === 401 || res.status === 403 || res.status === 404) {
224
- return { primary_window: null, secondary_window: null };
233
+ return {
234
+ primary_window: null,
235
+ secondary_window: null,
236
+ notice: "No usage data for this sign-in. Run `codex` to sign in again.",
237
+ };
225
238
  }
226
239
  if (!res.ok) {
227
240
  throw new Error(`Codex API returned ${res.status}`);
@@ -2013,6 +2026,10 @@ async function getUsageLimits({
2013
2026
  codex = {
2014
2027
  configured: true,
2015
2028
  error: null,
2029
+ // A 4xx from wham produces no windows and no error. Carrying its `notice`
2030
+ // through is what keeps the chip on screen instead of falling through to
2031
+ // LimitChips' `windows.length === 0 -> return null`.
2032
+ notice: codexResult.value.notice || null,
2016
2033
  plan_type: codexPlanType || null,
2017
2034
  primary_window: codexResult.value.primary_window,
2018
2035
  secondary_window: codexResult.value.secondary_window,