@cad0p/pi-usage 0.11.5 → 0.11.6-20261107.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.
@@ -26,14 +26,26 @@
26
26
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
27
  import { existsSync, readFileSync } from "node:fs";
28
28
  import { homedir } from "node:os";
29
- import { dirname, join } from "node:path";
29
+ import { join } from "node:path";
30
30
  import { fileURLToPath, pathToFileURL } from "node:url";
31
31
 
32
- // `mergeLastGood` is intentionally NOT imported here: it lives in src/sticky.js
33
- // and the CLI (`bin/piquota.js`) already applies it on every `--json` run, so the
34
- // extension sees a report whose transiently-failed families already carry the last
35
- // good snapshot in `provider.note`. The classifier below mirrors src/providers/error-kind.js
36
- // so the extension can render category-specific glyphs without depending on the repo.
32
+ import { extractChatGptAccountId, extractExpiresAtMs } from "../src/auth/jwt.js";
33
+ import { mergeReportIntoCache, writeCache } from "../src/cache.js";
34
+ import { normalizeClaudeSourceMode, collectFamily, enabledFamilies, FAMILIES } from "../src/engine.js";
35
+ import { historyJson } from "../src/cli/commands/stats.js";
36
+ import { resolveHistoryDir } from "../src/local/history.js";
37
+ import { startLocalStatsWorker } from "../src/local/worker.js";
38
+ import { publishRecords } from "../src/omarchy/publish.js";
39
+ import { buildRecords } from "../src/omarchy/record.js";
40
+ import { skippedFamilies } from "../src/cli/commands/omarchy.js";
41
+ import { isKeyRelease, matchesKey, matchesPrintable } from "../src/overlay-keys.js";
42
+ import { mergeLastGood } from "../src/sticky.js";
43
+ import { readRefreshState, resolveFamilyTtls, staleFamilies, writeRefreshState } from "../src/refresh.js";
44
+
45
+ // The in-process collector does not pass through the CLI's sticky layer, so this
46
+ // extension applies the same last-good merge as the CLI before painting or publishing.
47
+ // The classifier below mirrors src/providers/error-kind.js so the extension can
48
+ // render category-specific glyphs without depending on the repo.
37
49
 
38
50
  const STATUS_KEY = "00-quota";
39
51
  const WIDGET_KEY = "quota-line";
@@ -42,7 +54,12 @@ const RAIL_KEY = "quota";
42
54
  const SIDEBAR_STATE_KEY = Symbol.for("gentle-pi.experimental-sidebar.state");
43
55
  const REFRESH_MS = Number(process.env.PI_QUOTA_REFRESH_MS) || 300_000;
44
56
  const AGENT_END_DEBOUNCE_MS = 1_500;
45
- const EXEC_TIMEOUT_MS = 30_000;
57
+ /** The report cache is keyed by the Claude source its numbers came from. */
58
+ const CLAUDE_SOURCE_CACHE_PREFIX = "claude-source:";
59
+
60
+ function cacheKeyFor(source: string): string {
61
+ return `${CLAUDE_SOURCE_CACHE_PREFIX}${normalizeClaudeSourceMode(source)}`;
62
+ }
46
63
 
47
64
  type QuotaWindow = {
48
65
  id: string;
@@ -124,9 +141,32 @@ type Theme = {
124
141
  bg?: (key: string, text: string) => string;
125
142
  };
126
143
 
144
+ /**
145
+ * One running local token scan. It is a handle, not a promise, because the scan
146
+ * can outlive the session that started it: a shutdown has to be able to end it
147
+ * and have its lock and cache left clean.
148
+ */
149
+ type LocalStatsScan = {
150
+ result: Promise<{ byFamily: Record<string, LocalFamilyStats>; warnings: string[]; scan: { partial?: boolean } | null }>;
151
+ stop: () => Promise<void>;
152
+ };
153
+
154
+ type ExtensionDependencies = {
155
+ collectFamily?: typeof collectFamily;
156
+ startLocalStats?: () => LocalStatsScan;
157
+ getProviderAuth?: (ctx: UiContext, provider: string) => Promise<{ auth?: { apiKey?: string }; accountId?: string; source?: string } | undefined>;
158
+ loadHistory?: (month: string | null) => Promise<HistoryPayload> | HistoryPayload;
159
+ publishOmarchy?: (report: QuotaReport, prune: boolean) => void;
160
+ railPatcherPath?: string;
161
+ };
162
+
127
163
  /** Only the UI surface this extension uses. */
128
164
  type UiContext = {
129
165
  hasUI: boolean;
166
+ /** Pi resolves/renews its OAuth credentials under its own auth-store lock. */
167
+ modelRegistry?: {
168
+ getProviderAuth?: (provider: string) => Promise<{ auth?: { apiKey?: string }; source?: string } | undefined>;
169
+ };
130
170
  /** Pi's run mode; `custom` components only exist in the TUI. */
131
171
  mode?: string;
132
172
  ui: {
@@ -147,6 +187,7 @@ const BRAND: Record<string, string> = {
147
187
  codex: "#10A37F",
148
188
  antigravity: "#4285F4",
149
189
  "opencode-go": "#007AFF",
190
+ mistral: "#FA520F",
150
191
  "github-actions": "#2088FF",
151
192
  };
152
193
 
@@ -156,6 +197,7 @@ const SHORT_NAME: Record<string, string> = {
156
197
  codex: "Codex",
157
198
  antigravity: "Agy",
158
199
  "opencode-go": "OP-Go",
200
+ mistral: "Mistral",
159
201
  "github-actions": "GH",
160
202
  };
161
203
 
@@ -193,23 +235,7 @@ function usedOf(window: QuotaWindow | null): number | null {
193
235
  return null;
194
236
  }
195
237
 
196
- function resolveCliPath(): string | null {
197
- const override = process.env.PI_QUOTA_BIN;
198
- if (override && existsSync(override)) return override;
199
- try {
200
- const sibling = fileURLToPath(new URL("../bin/piquota.js", import.meta.url));
201
- if (existsSync(sibling)) return sibling;
202
- } catch {}
203
- for (const candidate of [
204
- join(homedir(), ".local", "share", "pi-quota", "bin", "piquota.js"),
205
- join(homedir(), ".local", "bin", "piquota"),
206
- join(homedir(), ".pi", "agent", "npm", "node_modules", "@cad0p", "pi-usage", "bin", "piquota.js"),
207
- join(homedir(), ".pi", "agent", "npm", "node_modules", "@j3pf", "piquota", "bin", "piquota.js"),
208
- ]) {
209
- if (existsSync(candidate)) return candidate;
210
- }
211
- return null;
212
- }
238
+
213
239
 
214
240
  /**
215
241
  * Rank by how soon a window constrains you: 5h/session first, then daily,
@@ -445,6 +471,9 @@ export function renderBox(report: QuotaReport, theme: Theme, totalWidth?: number
445
471
  { label: "Agy C/G:", color: "#7AA2F7", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
446
472
  { label: "OP-Go: ", color: "#007AFF", ...windowFor("opencode-go", isFiveHour) },
447
473
  ];
474
+ if (providerFor("mistral") && providerFor("mistral")?.notConfigured !== true) {
475
+ rows.push({ label: "Mistral:", color: "#FA520F", ...windowFor("mistral", (window) => window.id === "vibe") });
476
+ }
448
477
  if (providerFor("github-actions")) {
449
478
  rows.push({ label: "GH Actions:", color: "#2088FF", ...windowFor("github-actions", (window) => window.id === "monthly") });
450
479
  }
@@ -494,6 +523,9 @@ function renderSidebarCard(report: QuotaReport | null, theme: Theme, width: numb
494
523
  { label: "Agy: ", color: "#4285F4", ...windowFor("antigravity", (window) => window.id === "gemini-5h" || (/gemini/i.test(window.id) && isFiveHour(window))) },
495
524
  { label: "Agy C/G:", color: "#7AA2F7", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
496
525
  { label: "OP-Go: ", color: "#007AFF", ...windowFor("opencode-go", isFiveHour) },
526
+ ...(providerFor("mistral") && providerFor("mistral")?.notConfigured !== true
527
+ ? [{ label: "Mistral:", color: "#FA520F", ...windowFor("mistral", (window) => window.id === "vibe") }]
528
+ : []),
497
529
  ...(providerFor("github-actions")
498
530
  ? [{ label: "GH Actions:", color: "#2088FF", ...windowFor("github-actions", (window) => window.id === "monthly") }]
499
531
  : []),
@@ -940,27 +972,6 @@ function renderPanel(report: QuotaReport, theme: Theme): string[] {
940
972
  return [...sections.flatMap((section) => [section.header, ...section.body]), ...tail];
941
973
  }
942
974
 
943
- /**
944
- * Raw input sequences for the keys the overlay turns into actions. The
945
- * extension ships as one file with no runtime dependencies, so this mirrors
946
- * the subset of pi-tui's matchesKey() the overlay needs: arrows, paging and
947
- * home/end in the encodings terminals and tmux actually send.
948
- */
949
- const KEY_SEQUENCES: Record<string, string[]> = {
950
- up: ["\u001b[A", "\u001bOA"],
951
- down: ["\u001b[B", "\u001bOB"],
952
- left: ["\u001b[D", "\u001bOD"],
953
- right: ["\u001b[C", "\u001bOC"],
954
- pageUp: ["\u001b[5~", "\u001b[[5~"],
955
- pageDown: ["\u001b[6~", "\u001b[[6~"],
956
- home: ["\u001b[H", "\u001bOH", "\u001b[1~", "\u001b[7~"],
957
- end: ["\u001b[F", "\u001bOF", "\u001b[4~", "\u001b[8~"],
958
- };
959
-
960
- function isKey(data: string, name: string): boolean {
961
- return (KEY_SEQUENCES[name] ?? []).includes(data);
962
- }
963
-
964
975
  /** Pi reports the terminal height as a number; fall back to a usable frame. */
965
976
  function terminalRows(value: number | undefined): number {
966
977
  return Number.isFinite(value) ? Math.max(1, Math.floor(value as number)) : 24;
@@ -1036,9 +1047,33 @@ type HistoryFamily = {
1036
1047
  models: Map<string, number>;
1037
1048
  accounts: Map<string, { tokens: number; prompts: number; models: Map<string, number> }>;
1038
1049
  allTime: number;
1039
- allTimeAccounts: Map<string, number>;
1050
+ allTimePrompts: number;
1051
+ allTimeAccounts: Map<string, { tokens: number; prompts: number }>;
1052
+ /** The live day's delta, added to both the month and the all-time figures. */
1053
+ liveTokens: number;
1054
+ livePrompts: number;
1055
+ liveAccounts: Map<string, { tokens: number; prompts: number }>;
1040
1056
  };
1041
1057
 
1058
+ /** An all-time figure, and whether the rollup behind it lags the month on screen. */
1059
+ type AllTimeFigure = { value: number; lags: boolean };
1060
+
1061
+ /**
1062
+ * The all-time figure for one scope. All time contains every frozen day, so it
1063
+ * can never be smaller than a month of it: a smaller rollup means totals.json is
1064
+ * behind the month file — a freeze whose totals write failed, a month restored
1065
+ * behind the rollup. That is printed, dimmed and with a note, never hidden: the
1066
+ * figure is the only answer to "how much has this burned ever", and dropping it
1067
+ * silently is a worse lie than a number with a caveat.
1068
+ */
1069
+ function allTimeFigure(month: number, allTime: number): AllTimeFigure {
1070
+ if (allTime <= 0) return { value: 0, lags: false };
1071
+ return { value: allTime, lags: allTime < month };
1072
+ }
1073
+
1074
+ /** The note printed with an all-time figure that cannot cover the month on screen. */
1075
+ const ALL_TIME_LAGS_NOTE = "all-time lags this month; run piquota stats --rebuild-totals";
1076
+
1042
1077
  /**
1043
1078
  * What outlives one overlay instance. Every bare `/usage` builds a fresh view,
1044
1079
  * so the folds and the page it was closed on live in the extension — for the pi
@@ -1080,15 +1115,19 @@ type UsageOverlayOptions = {
1080
1115
  getRows: () => number;
1081
1116
  /** The newest report the extension holds, or null before the first one. */
1082
1117
  getReport: () => QuotaReport | null;
1083
- /** The last refresh failure, shown as a line instead of thrown. */
1084
- getError: () => string | null;
1118
+ /**
1119
+ * Every family's refresh failure, one line each, in family order. A failure
1120
+ * is not cleared by another family's success: the families finish out of
1121
+ * order, and a single shared slot made the last arrival erase the others.
1122
+ */
1123
+ getErrors: () => string[];
1085
1124
  isRefreshing: () => boolean;
1086
1125
  /** Run the same forced refresh `/usage` always ran, in the background. */
1087
1126
  refresh: () => void;
1088
1127
  /**
1089
- * Ask the extension to run `piquota stats --json` for one month (null: newest).
1090
- * The request id comes back with the answer, so a slow fetch cannot apply a
1091
- * month the last keypress did not ask for.
1128
+ * Ask the extension to read one month in-process (null: current month). The
1129
+ * request id comes back with the answer, so a slow read cannot apply a month
1130
+ * the last keypress did not ask for.
1092
1131
  */
1093
1132
  loadHistory: (month: string | null, request: number) => void;
1094
1133
  };
@@ -1099,6 +1138,7 @@ const FAMILY_LABEL: Record<string, string> = {
1099
1138
  codex: "Codex (Pi)",
1100
1139
  antigravity: "Antigravity (Pi)",
1101
1140
  "opencode-go": "OpenCode Go (Pi)",
1141
+ mistral: "Mistral (Pi)",
1102
1142
  "github-actions": "GitHub Actions",
1103
1143
  };
1104
1144
 
@@ -1132,18 +1172,20 @@ const HISTORY_HINTS: ReadonlyArray<readonly [string, string]> = [
1132
1172
  ["esc/q", "close"],
1133
1173
  ];
1134
1174
 
1135
- /** The report cache the CLI writes after every successful run. */
1175
+ /** The report cache the CLI and in-process extension share. */
1136
1176
  function reportCachePath(): string {
1137
1177
  const cacheHome = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
1138
1178
  return join(cacheHome, "pi-quota", "usage.json");
1139
1179
  }
1140
1180
 
1141
1181
  /**
1142
- * The last report the CLI cached, read synchronously so the overlay can paint
1143
- * on the same keystroke as /usage. Read-only, age is irrelevant for a first
1144
- * frame, and a missing or malformed cache is simply "no cached report".
1182
+ * The last report on disk, read synchronously so the overlay paints on the same
1183
+ * keystroke as /usage: read-only, age is irrelevant for a first frame, and a
1184
+ * missing or malformed cache is simply "no cached report". The key travels back
1185
+ * with it because it names the Claude source those numbers came from, and a
1186
+ * later partial write has to keep describing the same data.
1145
1187
  */
1146
- function readCachedReport(): QuotaReport | null {
1188
+ function readCachedReport(): { report: QuotaReport; key: string | null } | null {
1147
1189
  try {
1148
1190
  const parsed = JSON.parse(readFileSync(reportCachePath(), "utf-8"));
1149
1191
  const report = parsed?.report;
@@ -1157,7 +1199,10 @@ function readCachedReport(): QuotaReport | null {
1157
1199
  if (!Number.isNaN(resetsAtMs)) window.resetsInSec = Math.max(0, Math.round((resetsAtMs - now) / 1000));
1158
1200
  }
1159
1201
  }
1160
- return report as QuotaReport;
1202
+ return {
1203
+ report: report as QuotaReport,
1204
+ key: typeof parsed?.key === "string" ? parsed.key : null,
1205
+ };
1161
1206
  } catch {
1162
1207
  return null;
1163
1208
  }
@@ -1188,32 +1233,23 @@ function redactCause(value: string): string {
1188
1233
  .replace(/\b((?:__Host-console_session|auth)=)[^;\s]+/gi, "$1<redacted>");
1189
1234
  }
1190
1235
 
1191
- /**
1192
- * One short, redacted line for a failed `stats --json` run: the CLI's own first
1193
- * stderr line when it printed one, otherwise its exit status. The raw parse
1194
- * error ("Unexpected end of JSON input") says nothing about why the read failed.
1195
- */
1196
- function cliFailureLine(result: { stdout?: string; stderr?: string; code?: number; killed?: boolean } | null | undefined): string {
1197
- const first = String(result?.stderr ?? "")
1198
- .split("\n")
1199
- .map((line) => line.trim())
1200
- .find((line) => line !== "");
1201
- const code = Number(result?.code);
1202
- const cause = first
1203
- ? redactCause(first)
1204
- : result?.killed
1205
- ? "piquota stats was stopped (timeout)"
1206
- : Number.isFinite(code) && code !== 0
1207
- ? `piquota stats exited ${code}`
1208
- : "piquota stats --json printed no history payload";
1209
- return capCause(cause);
1210
- }
1211
-
1212
1236
  /** A stderr line or a thrown message can quote a whole path list; the frame gets one line. */
1213
1237
  function capCause(cause: string): string {
1214
1238
  return cause.length > 160 ? `${cause.slice(0, 157)}…` : cause;
1215
1239
  }
1216
1240
 
1241
+ /** Append warnings that are not already there: a repeated scan repeats its notes. */
1242
+ function mergeWarnings(existing: string[], added: string[] | undefined): string[] {
1243
+ const seen = new Set(existing);
1244
+ const merged = [...existing];
1245
+ for (const warning of added ?? []) {
1246
+ if (typeof warning !== "string" || warning === "" || seen.has(warning)) continue;
1247
+ seen.add(warning);
1248
+ merged.push(warning);
1249
+ }
1250
+ return merged;
1251
+ }
1252
+
1217
1253
  /** Local YYYY-MM-DD, the history's day key. */
1218
1254
  function localDayKey(now = new Date()): string {
1219
1255
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
@@ -1443,7 +1479,7 @@ export class UsageOverlay {
1443
1479
  }
1444
1480
 
1445
1481
  /**
1446
- * A month (or the first payload, with the month list) arrived from the CLI.
1482
+ * A month (or the first payload, with the month list) arrived from the in-process reader.
1447
1483
  * Every answer is cached — a later ← must not refetch what already arrived —
1448
1484
  * but only the answer to the newest request may move the page: ←/→ overlap,
1449
1485
  * and a late answer for a month the last keypress did not ask for used to snap
@@ -1481,21 +1517,32 @@ export class UsageOverlay {
1481
1517
  this.options.requestRender();
1482
1518
  }
1483
1519
 
1484
- /** Handle one raw key: switch view, fold, move, month, refresh, close. */
1520
+ /**
1521
+ * Handle one raw key: switch view, fold, move, month, refresh, close.
1522
+ *
1523
+ * Keys arrive in the terminal's own encoding — legacy escape sequences or
1524
+ * kitty CSI sequences with an event type — so every test goes through
1525
+ * src/overlay-keys.js. A kitty release is the second half of a press the
1526
+ * terminal already reported: acting on it would move twice per keystroke.
1527
+ * Repeats stay presses, so a held arrow keeps moving as it does in tmux.
1528
+ */
1485
1529
  public handleInput(data: string): void {
1486
1530
  if (this.disposed) return;
1487
- if (data === "\u001b" || data === "q") {
1531
+ if (isKeyRelease(data)) return;
1532
+ if (matchesKey(data, "escape") || matchesPrintable(data, "q")) {
1488
1533
  this.dispose();
1489
1534
  this.options.done();
1490
1535
  return;
1491
1536
  }
1492
- if (data === "\t" || data === "1" || data === "2") {
1493
- const target = data === "1" ? "now" : data === "2" ? "history" : this.view === "now" ? "history" : "now";
1537
+ const nowKey = matchesPrintable(data, "1");
1538
+ const historyKey = matchesPrintable(data, "2");
1539
+ if (matchesKey(data, "tab") || nowKey || historyKey) {
1540
+ const target = nowKey ? "now" : historyKey ? "history" : this.view === "now" ? "history" : "now";
1494
1541
  this.switchView(target);
1495
1542
  this.invalidate();
1496
1543
  return;
1497
1544
  }
1498
- if (data === "r") {
1545
+ if (matchesPrintable(data, "r")) {
1499
1546
  // History's today row comes from the live report, so a refresh there
1500
1547
  // reloads the month and the report together; one key, one meaning.
1501
1548
  if (this.view === "history") this.reloadMonth();
@@ -1503,17 +1550,17 @@ export class UsageOverlay {
1503
1550
  this.invalidate();
1504
1551
  return;
1505
1552
  }
1506
- if (isKey(data, "up") || data === "k") this.moveCursor(-1);
1507
- else if (isKey(data, "down") || data === "j") this.moveCursor(1);
1508
- else if (data === "\r" || data === "\n" || data === " ") this.toggleSection();
1509
- else if (isKey(data, "pageUp")) this.page(-1);
1510
- else if (isKey(data, "pageDown")) this.page(1);
1511
- else if (isKey(data, "home") || data === "g") this.jumpTo(0);
1512
- else if (isKey(data, "end") || data === "G") this.jumpTo(-1);
1513
- else if (this.view === "history" && (isKey(data, "left") || data === "[")) this.moveMonth(-1);
1514
- else if (this.view === "history" && (isKey(data, "right") || data === "]")) this.moveMonth(1);
1515
- else if (isKey(data, "left") || data === "h") this.setExpanded(false);
1516
- else if (isKey(data, "right") || data === "l") this.setExpanded(true);
1553
+ if (matchesKey(data, "up") || matchesPrintable(data, "k")) this.moveCursor(-1);
1554
+ else if (matchesKey(data, "down") || matchesPrintable(data, "j")) this.moveCursor(1);
1555
+ else if (matchesKey(data, "enter") || matchesKey(data, "space")) this.toggleSection();
1556
+ else if (matchesKey(data, "pageUp")) this.page(-1);
1557
+ else if (matchesKey(data, "pageDown")) this.page(1);
1558
+ else if (matchesKey(data, "home") || matchesPrintable(data, "g")) this.jumpTo(0);
1559
+ else if (matchesKey(data, "end") || matchesPrintable(data, "G")) this.jumpTo(-1);
1560
+ else if (this.view === "history" && (matchesKey(data, "left") || matchesPrintable(data, "["))) this.moveMonth(-1);
1561
+ else if (this.view === "history" && (matchesKey(data, "right") || matchesPrintable(data, "]"))) this.moveMonth(1);
1562
+ else if (matchesKey(data, "left") || matchesPrintable(data, "h")) this.setExpanded(false);
1563
+ else if (matchesKey(data, "right") || matchesPrintable(data, "l")) this.setExpanded(true);
1517
1564
  else return;
1518
1565
  // The TUI renders the component again after every key, so the cached frame
1519
1566
  // and the cached row map must be dropped here, not only on report changes.
@@ -1620,8 +1667,9 @@ export class UsageOverlay {
1620
1667
  this.pushRow(rows, ` ${this.theme.fg("error", `! ${failure}`)}${hint}`, -1, false);
1621
1668
  }
1622
1669
  } else {
1623
- const error = this.options.getError();
1624
- if (error) rows.push({ text: ` ${this.theme.fg("error", `! ${error}`)}`, section: -1, header: false });
1670
+ for (const error of this.options.getErrors()) {
1671
+ rows.push({ text: ` ${this.theme.fg("error", `! ${error}`)}`, section: -1, header: false });
1672
+ }
1625
1673
  }
1626
1674
  if (!layout) {
1627
1675
  // Distinct states, distinct lines: a failed fetch used to say "loading
@@ -1819,7 +1867,19 @@ export class UsageOverlay {
1819
1867
  const ensure = (family: string): HistoryFamily => {
1820
1868
  let entry = families.get(family);
1821
1869
  if (!entry) {
1822
- entry = { tokens: 0, prompts: 0, days: new Map(), models: new Map(), accounts: new Map(), allTime: 0, allTimeAccounts: new Map() };
1870
+ entry = {
1871
+ tokens: 0,
1872
+ prompts: 0,
1873
+ days: new Map(),
1874
+ models: new Map(),
1875
+ accounts: new Map(),
1876
+ allTime: 0,
1877
+ allTimePrompts: 0,
1878
+ allTimeAccounts: new Map(),
1879
+ liveTokens: 0,
1880
+ livePrompts: 0,
1881
+ liveAccounts: new Map(),
1882
+ };
1823
1883
  families.set(family, entry);
1824
1884
  }
1825
1885
  return entry;
@@ -1884,6 +1944,10 @@ export class UsageOverlay {
1884
1944
  const entry = ensure(family);
1885
1945
  entry.tokens += tokens;
1886
1946
  entry.prompts += prompts;
1947
+ // Kept apart from the frozen month: the all-time figures add this same
1948
+ // delta below, and a live day may only ever be counted once in each.
1949
+ entry.liveTokens += tokens;
1950
+ entry.livePrompts += prompts;
1887
1951
  entry.days.set(today, (entry.days.get(today) ?? 0) + tokens);
1888
1952
  for (const [model, modelTokens] of Object.entries(familyStats.todayTokensByModel ?? {})) {
1889
1953
  entry.models.set(model, (entry.models.get(model) ?? 0) + Math.max(0, Math.round(Number(modelTokens) || 0)));
@@ -1891,8 +1955,12 @@ export class UsageOverlay {
1891
1955
  for (const [rawLabel, account] of Object.entries(familyStats.byAccount ?? {})) {
1892
1956
  const label = rawLabel === "#1" ? "" : rawLabel;
1893
1957
  const target = entry.accounts.get(label) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
1894
- target.tokens += Math.max(0, Math.round(Number(account?.todayTokens) || 0));
1895
- target.prompts += Math.max(0, Math.round(Number(account?.todayPrompts) || 0));
1958
+ const accountTokens = Math.max(0, Math.round(Number(account?.todayTokens) || 0));
1959
+ const accountPrompts = Math.max(0, Math.round(Number(account?.todayPrompts) || 0));
1960
+ target.tokens += accountTokens;
1961
+ target.prompts += accountPrompts;
1962
+ const liveAccount = entry.liveAccounts.get(label) ?? { tokens: 0, prompts: 0 };
1963
+ entry.liveAccounts.set(label, { tokens: liveAccount.tokens + accountTokens, prompts: liveAccount.prompts + accountPrompts });
1896
1964
  // The account's own models for today: without them the merged row
1897
1965
  // shows a month total and no model, which reads as "no usage".
1898
1966
  for (const [model, modelTokens] of Object.entries(account?.todayTokensByModel ?? {})) {
@@ -1906,10 +1974,33 @@ export class UsageOverlay {
1906
1974
  for (const [family, totals] of Object.entries(payload.totals?.families ?? {})) {
1907
1975
  const entry = ensure(family);
1908
1976
  entry.allTime = Object.values(totals?.modelUsage ?? {}).reduce((sum, bucket) => sum + totalsModelTokens(bucket), 0);
1977
+ entry.allTimePrompts = Math.max(0, Math.round(Number(totals?.totalPrompts) || 0));
1909
1978
  for (const [rawLabel, account] of Object.entries(totals?.accounts ?? {})) {
1910
1979
  const label = rawLabel === "#1" ? "" : rawLabel;
1911
1980
  const tokens = Object.values(account?.modelUsage ?? {}).reduce((sum, bucket) => sum + totalsModelTokens(bucket), 0);
1912
- entry.allTimeAccounts.set(label, (entry.allTimeAccounts.get(label) ?? 0) + tokens);
1981
+ const prompts = Math.max(0, Math.round(Number(account?.totalPrompts) || 0));
1982
+ const seen = entry.allTimeAccounts.get(label) ?? { tokens: 0, prompts: 0 };
1983
+ entry.allTimeAccounts.set(label, { tokens: seen.tokens + tokens, prompts: seen.prompts + prompts });
1984
+ }
1985
+ }
1986
+
1987
+ // Today's live day is part of all time, not only of this month: it is the
1988
+ // same usage the month row and the day row above already show, so it is
1989
+ // added here exactly once — the single place the live delta is folded into
1990
+ // a rollup, and only under the merge guard above (the day frozen, or a
1991
+ // report from another day, never produces a delta at all).
1992
+ //
1993
+ // A family with no rollup in totals.json gets nothing: there is no all-time
1994
+ // figure to extend, and calling today's tokens "all-time" would hide every
1995
+ // frozen day before it. The page already carries the CLI's note for that.
1996
+ for (const [family, entry] of families) {
1997
+ if (!payload.totals?.families?.[family]) continue;
1998
+ if (entry.liveTokens === 0 && entry.livePrompts === 0) continue;
1999
+ entry.allTime += entry.liveTokens;
2000
+ entry.allTimePrompts += entry.livePrompts;
2001
+ for (const [label, delta] of entry.liveAccounts) {
2002
+ const seen = entry.allTimeAccounts.get(label) ?? { tokens: 0, prompts: 0 };
2003
+ entry.allTimeAccounts.set(label, { tokens: seen.tokens + delta.tokens, prompts: seen.prompts + delta.prompts });
1913
2004
  }
1914
2005
  }
1915
2006
 
@@ -1936,10 +2027,29 @@ export class UsageOverlay {
1936
2027
  // Multi-account families use the same short name as the Now panel header.
1937
2028
  const display = accountLabels.length > 1 ? SHORT_NAME[family] ?? FAMILY_LABEL[family] ?? family : FAMILY_LABEL[family] ?? family;
1938
2029
  const name = paint(BRAND[family] ?? "#8B949E", display);
2030
+ // The month first, then the all-time rollup with today's live day folded
2031
+ // in: every figure here counts a live day once. Prompts are scope-labelled
2032
+ // in their own row below, because an unlabelled count reads as the wrong
2033
+ // one of the two.
2034
+ //
2035
+ // Every all-time figure is computed before the first line is built: the
2036
+ // note a lagging rollup earns belongs at the top of the section, where it
2037
+ // explains the header, the prompts row and the account rows at once.
1939
2038
  const meta = [`${formatTokens(entry.tokens)} in ${month}`];
1940
- if (entry.allTime > 0) meta.push(`${formatTokens(entry.allTime)} all-time`);
1941
- if (entry.prompts > 0) meta.push(`${entry.prompts} prompt${entry.prompts === 1 ? "" : "s"}`);
2039
+ const familyAllTime = allTimeFigure(entry.tokens, entry.allTime);
2040
+ if (familyAllTime.value > 0) meta.push(`${formatTokens(familyAllTime.value)} all-time`);
2041
+ const promptsAllTime = allTimeFigure(entry.prompts, entry.allTimePrompts);
2042
+ const accountAllTime = new Map<string, AllTimeFigure>();
2043
+ for (const accountLabel of accountLabels) {
2044
+ accountAllTime.set(
2045
+ accountLabel,
2046
+ allTimeFigure(entry.accounts.get(accountLabel)?.tokens ?? 0, entry.allTimeAccounts.get(accountLabel)?.tokens ?? 0),
2047
+ );
2048
+ }
2049
+ const lagsMonth =
2050
+ familyAllTime.lags || promptsAllTime.lags || Array.from(accountAllTime.values()).some((figure) => figure.lags);
1942
2051
  const body: string[] = [];
2052
+ if (lagsMonth) body.push(` ${this.theme.fg("warning", `~ ${ALL_TIME_LAGS_NOTE}`)}`);
1943
2053
  const label = (text: string) => ` ${this.theme.fg("dim", text.padEnd(6))} `;
1944
2054
  const rest = ` ${" ".repeat(8)}`;
1945
2055
  const dayCells = Array.from(entry.days.entries())
@@ -1951,6 +2061,22 @@ export class UsageOverlay {
1951
2061
  rest,
1952
2062
  bodyWidth,
1953
2063
  ));
2064
+ // Both scopes, always: the live day is merged into each of them once, so
2065
+ // the month count and the all-time count may never disagree about it.
2066
+ const promptCells: string[] = [];
2067
+ if (entry.prompts > 0) promptCells.push(`${entry.prompts} in ${month}`);
2068
+ if (promptsAllTime.value > 0) {
2069
+ // A figure that lags the month is still a figure: it is printed, dimmed
2070
+ // whole, and the note above the rows says why it is small.
2071
+ promptCells.push(
2072
+ promptsAllTime.lags
2073
+ ? this.theme.fg("dim", `${promptsAllTime.value} all-time`)
2074
+ : `${promptsAllTime.value} ${this.theme.fg("dim", "all-time")}`,
2075
+ );
2076
+ }
2077
+ if (promptCells.length > 0) {
2078
+ body.push(...wrapCells(promptCells, label("prompts"), rest, bodyWidth));
2079
+ }
1954
2080
  if (entry.models.size > 0) {
1955
2081
  const top = Array.from(entry.models.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
1956
2082
  const shown = top.slice(0, 5).map(([model, tokens]) => `${shortModelName(model)} ${formatTokens(tokens)}`);
@@ -1962,8 +2088,14 @@ export class UsageOverlay {
1962
2088
  for (const accountLabel of accountLabels) {
1963
2089
  const account = entry.accounts.get(accountLabel) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
1964
2090
  const parts = [`${this.theme.fg("dim", "month")} ${formatTokens(account.tokens)}`];
1965
- const allTime = entry.allTimeAccounts.get(accountLabel) ?? 0;
1966
- if (allTime > 0) parts.push(`${this.theme.fg("dim", "all-time")} ${formatTokens(allTime)}`);
2091
+ const accountFigure = accountAllTime.get(accountLabel) ?? { value: 0, lags: false };
2092
+ if (accountFigure.value > 0) {
2093
+ parts.push(
2094
+ accountFigure.lags
2095
+ ? this.theme.fg("dim", `all-time ${formatTokens(accountFigure.value)}`)
2096
+ : `${this.theme.fg("dim", "all-time")} ${formatTokens(accountFigure.value)}`,
2097
+ );
2098
+ }
1967
2099
  const best = Array.from(account.models.entries()).sort((a, b) => b[1] - a[1])[0];
1968
2100
  if (best) parts.push(this.theme.fg("dim", shortModelName(best[0])));
1969
2101
  body.push(` ${this.theme.fg("dim", accountLabel.padEnd(labelWidth))} ${parts.join(this.theme.fg("dim", " · "))}`);
@@ -2008,7 +2140,7 @@ export class UsageOverlay {
2008
2140
  } else {
2009
2141
  const report = this.options.getReport();
2010
2142
  if (report) parts.push(this.theme.fg("dim", updatedAtLabel(report)));
2011
- if (this.options.getError()) parts.push(this.theme.fg("error", "refresh failed"));
2143
+ if (this.options.getErrors().length > 0) parts.push(this.theme.fg("error", "refresh failed"));
2012
2144
  }
2013
2145
  if (this.view === "history") {
2014
2146
  const status = this.historyStatus;
@@ -2055,9 +2187,34 @@ export class UsageOverlay {
2055
2187
  }
2056
2188
  }
2057
2189
 
2058
- export default function quotaPanelExtension(pi: ExtensionAPI): void {
2190
+ export default function quotaPanelExtension(pi: ExtensionAPI, dependencies: ExtensionDependencies = {}): void {
2191
+ const loadFamily = dependencies.collectFamily ?? collectFamily;
2192
+ const startScan = dependencies.startLocalStats ?? (() => startLocalStatsWorker() as LocalStatsScan);
2193
+ const getProviderAuth = dependencies.getProviderAuth ?? (async (ctx, provider) => ctx.modelRegistry?.getProviderAuth?.(provider));
2194
+ const readHistory = (month: string | null) => dependencies.loadHistory
2195
+ ? dependencies.loadHistory(month)
2196
+ : (historyJson({ historyDir: resolveHistoryDir(), month }) as HistoryPayload);
2197
+ const publishOmarchy = dependencies.publishOmarchy ?? ((value, prune) => {
2198
+ const skip = skippedFamilies(process.env);
2199
+ const reportToPublish = skip.size > 0
2200
+ ? { ...value, providers: value.providers.filter((provider) => !skip.has(provider.family)) }
2201
+ : value;
2202
+ publishRecords(buildRecords(reportToPublish as any), { prune });
2203
+ });
2059
2204
  let report: QuotaReport | null = null;
2060
- let refreshing = false;
2205
+ const inFlight = new Map<string, Promise<boolean>>();
2206
+ let resultGeneration = 0;
2207
+ let prunedGeneration = 0;
2208
+ let localStatsInFlight: Promise<void> | null = null;
2209
+ let localStatsScan: LocalStatsScan | null = null;
2210
+ let localStatsResult: Awaited<LocalStatsScan["result"]> | null = null;
2211
+ /**
2212
+ * The notes the scan produced (its own warnings, or the one line a failure
2213
+ * becomes). They live here, not only in the report, because the scan can
2214
+ * finish before the first family does: it is the report that is late then,
2215
+ * not the warning.
2216
+ */
2217
+ let localStatsWarnings: string[] = [];
2061
2218
  let timer: ReturnType<typeof setInterval> | null = null;
2062
2219
  let agentEndTimer: ReturnType<typeof setTimeout> | null = null;
2063
2220
  let panelVisible = false;
@@ -2080,22 +2237,30 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2080
2237
  let explicitBox = false;
2081
2238
  /** gentle-pi owns the footer and truncates it from the end, so start off. */
2082
2239
  let statusVisible = false;
2083
- let lastError: string | null = null;
2240
+ /** One failure per family: an out-of-order success must not hide another's. */
2241
+ const familyErrors = new Map<string, string>();
2242
+ /**
2243
+ * The Claude source the report in memory actually came from. The extension
2244
+ * forces Pi's own OAuth when Pi resolves a token, and that is a different
2245
+ * fact from what PI_QUOTA_CLAUDE_SOURCE says: keying the cache by the setting
2246
+ * alone stored Pi-sourced numbers under the CLI's auto/claude-code key, where
2247
+ * the next reader asked the other question and was served them anyway.
2248
+ */
2249
+ let claudeSourceUsed: string | null = null;
2084
2250
  let disposed = false;
2085
2251
  /** True once the installed gentle-pi is known to paint a rail slot for this card. */
2086
2252
  let railMode = false;
2087
2253
 
2088
2254
  /**
2089
- * Load the rail inspector from the installed CLI tree. A missing patcher disables
2090
- * native-layout detection; the runtime chaining hook remains available.
2255
+ * Load the rail inspector from this package, not from a quota CLI subprocess.
2256
+ * A missing patcher disables native-layout detection; the runtime chaining
2257
+ * hook remains available.
2091
2258
  */
2092
2259
  async function loadRailPatcher(): Promise<{
2093
2260
  resolveGentlePiLayout: (options?: { dir?: string }) => { layoutPath: string; present: boolean };
2094
2261
  inspectRailPatch: (options: { layoutPath: string }) => { state: string };
2095
2262
  } | null> {
2096
- const cli = resolveCliPath();
2097
- if (!cli) return null;
2098
- const modulePath = join(dirname(cli), "..", "src", "gentle-pi", "rail-patch.js");
2263
+ const modulePath = dependencies.railPatcherPath ?? fileURLToPath(new URL("../src/gentle-pi/rail-patch.js", import.meta.url));
2099
2264
  if (!existsSync(modulePath)) return null;
2100
2265
  try {
2101
2266
  return await import(pathToFileURL(modulePath).href);
@@ -2115,35 +2280,211 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2115
2280
  railMode = inspection.state === "patched";
2116
2281
  }
2117
2282
 
2283
+ /** Whether a family refresh is in flight; the header shows it while one is. */
2284
+ function isRefreshing(): boolean {
2285
+ return inFlight.size > 0;
2286
+ }
2287
+
2288
+ /** Resolve Pi's own auth for the two rotating OAuth providers. Tokens stay local to this call. */
2289
+ async function authOverridesFor(ctx: UiContext, family: string): Promise<Record<string, { access: string; expiresAtMs: number | null; accountId?: string }> | undefined> {
2290
+ const provider = family === "claude" ? "anthropic" : family === "codex" ? "openai-codex" : null;
2291
+ if (!provider) return undefined;
2292
+ try {
2293
+ const resolved = await getProviderAuth(ctx, provider);
2294
+ // Pi can resolve api_key credentials through the same method. Quota
2295
+ // endpoints here require the subscription OAuth access token, not an API
2296
+ // key; only Pi's OAuth result is eligible for the in-memory override.
2297
+ if (resolved?.source !== "OAuth") return undefined;
2298
+ const token = resolved.auth?.apiKey;
2299
+ if (typeof token !== "string" || token.length === 0) return undefined;
2300
+ const accountId = (typeof resolved?.accountId === "string" && resolved.accountId) || extractChatGptAccountId(token) || undefined;
2301
+ return {
2302
+ [provider]: {
2303
+ access: token,
2304
+ expiresAtMs: extractExpiresAtMs(token),
2305
+ ...(family === "codex" && accountId ? { accountId } : {}),
2306
+ },
2307
+ };
2308
+ } catch {
2309
+ // Never put arbitrary auth errors in a TUI frame; the engine will report its read-only credential state.
2310
+ return undefined;
2311
+ }
2312
+ }
2313
+
2314
+ function mergeFamilyReport(fragment: QuotaReport): void {
2315
+ const prior = report;
2316
+ const merged = mergeReportIntoCache(prior, fragment as any) as QuotaReport;
2317
+ merged.localStats = localStatsResult?.byFamily ?? prior?.localStats ?? fragment.localStats ?? {};
2318
+ merged.localStatsMeta = localStatsResult?.scan ?? prior?.localStatsMeta ?? fragment.localStatsMeta ?? null;
2319
+ merged.warnings = mergeWarnings(merged.warnings ?? [], localStatsWarnings);
2320
+ const families = enabledFamilies({ env: process.env, requested: FAMILIES });
2321
+ const ordering = [...families, ...Object.keys(merged.byFamily).filter((family) => !families.includes(family))];
2322
+ merged.providers = ordering.flatMap((family) => merged.providers.filter((provider) => provider.family === family));
2323
+ merged.byFamily = Object.fromEntries(
2324
+ ordering
2325
+ .map((family) => [family, merged.providers.filter((provider) => provider.family === family)] as const)
2326
+ .filter(([, providers]) => providers.length > 0),
2327
+ );
2328
+ report = merged;
2329
+ }
2330
+
2118
2331
  /**
2119
- * Run the CLI. A report run owns `lastError`; the advisory `omarchy` publish
2120
- * writes human text, not JSON, and must never surface as a quota failure.
2332
+ * The report in memory, or the one on disk. Reading the cache also adopts the
2333
+ * Claude source its key names, so a codex-only write keeps describing the
2334
+ * Claude numbers the file already holds.
2121
2335
  */
2122
- async function runCli(args: string[], options: { reportErrors?: boolean } = {}): Promise<QuotaReport | null> {
2123
- const reportErrors = options.reportErrors !== false;
2124
- const cli = resolveCliPath();
2125
- if (!cli) {
2126
- if (reportErrors) lastError = "piquota not installed: run install.sh from the pi-quota repo";
2127
- return null;
2128
- }
2129
- try {
2130
- const runner = /node(\.exe)?$/i.test(process.execPath) ? process.execPath : "node";
2131
- const result = await pi.exec(runner, [cli, ...args], { timeout: EXEC_TIMEOUT_MS });
2132
- if (!result.stdout || result.stdout.trim() === "") {
2133
- if (reportErrors) lastError = result.stderr?.trim() || "piquota produced no output";
2134
- return null;
2135
- }
2136
- const parsed = JSON.parse(result.stdout) as QuotaReport;
2137
- if (!parsed || !Array.isArray(parsed.providers)) {
2138
- if (reportErrors) lastError = "piquota output was not a quota report";
2139
- return null;
2336
+ function ensureReport(): QuotaReport | null {
2337
+ if (report) return report;
2338
+ const cached = readCachedReport();
2339
+ if (!cached) return null;
2340
+ const stored = cached.key?.startsWith(CLAUDE_SOURCE_CACHE_PREFIX)
2341
+ ? cached.key.slice(CLAUDE_SOURCE_CACHE_PREFIX.length)
2342
+ : null;
2343
+ if (stored) claudeSourceUsed = normalizeClaudeSourceMode(stored);
2344
+ report = cached.report;
2345
+ return report;
2346
+ }
2347
+
2348
+ /** The key this report belongs under: the source it used, not the setting. */
2349
+ function cacheKey(): string {
2350
+ return cacheKeyFor(claudeSourceUsed ?? process.env.PI_QUOTA_CLAUDE_SOURCE ?? "");
2351
+ }
2352
+
2353
+ function persistReport(freshFamilies: string[] = []): void {
2354
+ if (!report) return;
2355
+ // Every family keeps the clock it was fetched on: this report is written one
2356
+ // family at a time, and stamping the whole file with "now" made Claude's
2357
+ // five-minute-old numbers look as fresh as the Codex ones saved beside them.
2358
+ // Only the family that just resolved is "now"; a family without a clock in
2359
+ // refresh-state keeps the cache's, or its own results' age.
2360
+ writeCache(report as any, { key: cacheKey(), fetchedAtMs: readRefreshState().fetchedAtMs, freshFamilies });
2361
+ }
2362
+
2363
+ function updateFamilyClock(family: string): void {
2364
+ const state = readRefreshState();
2365
+ writeRefreshState({ fetchedAtMs: { ...state.fetchedAtMs, [family]: Date.now() } });
2366
+ }
2367
+
2368
+ function startLocalStats(ctx: UiContext): void {
2369
+ if (process.env.PI_QUOTA_LOCAL_STATS === "0" || localStatsInFlight) return;
2370
+ const scan = startScan();
2371
+ localStatsScan = scan;
2372
+ const task = scan.result
2373
+ .then((result) => {
2374
+ if (disposed) return;
2375
+ localStatsResult = result;
2376
+ // The scan's own notes (a corrupt cache, an unreadable file) are part of
2377
+ // the report the CLI already publishes; dropping them here made the same
2378
+ // scan quieter inside Pi than in the terminal.
2379
+ localStatsWarnings = result.warnings ?? [];
2380
+ if (report) {
2381
+ report = {
2382
+ ...report,
2383
+ localStats: result.byFamily,
2384
+ localStatsMeta: result.scan,
2385
+ warnings: mergeWarnings(report.warnings, localStatsWarnings),
2386
+ };
2387
+ persistReport();
2388
+ if (surfaceActive()) paintUi(ctx);
2389
+ try {
2390
+ publishOmarchy(report, false);
2391
+ } catch {
2392
+ // The quota view is independent of the optional Omarchy record writer.
2393
+ }
2394
+ overlayView?.redraw();
2395
+ }
2396
+ })
2397
+ .catch((error: { name?: string; message?: string }) => {
2398
+ // The scan is optional, so a failure never delays or erases quotas — but
2399
+ // it is not the same as a scan that found nothing. The CLI reports a
2400
+ // failed scan as one warning line; a swallowed worker error meant a
2401
+ // crash in the scanner looked exactly like an empty token history.
2402
+ if (disposed || error?.name === "AbortError") return;
2403
+ const cause = capCause(redactCause(error?.message ?? String(error)).split("\n")[0]);
2404
+ localStatsWarnings = mergeWarnings(localStatsWarnings, [`local token stats: ${cause}`]);
2405
+ if (!report) return;
2406
+ report = { ...report, warnings: mergeWarnings(report.warnings, localStatsWarnings) };
2407
+ if (surfaceActive()) paintUi(ctx);
2408
+ overlayView?.redraw();
2409
+ })
2410
+ .finally(() => {
2411
+ if (localStatsScan === scan) localStatsScan = null;
2412
+ if (localStatsInFlight === task) localStatsInFlight = null;
2413
+ });
2414
+ localStatsInFlight = task;
2415
+ }
2416
+
2417
+ /**
2418
+ * End the local scan this session started. Nothing it began may outlive the
2419
+ * session: a /quit or /reload one second into a cold scan would otherwise
2420
+ * keep reading the session store and hold the token-history lock until the
2421
+ * 120 s stale timeout, making the next scan read-only.
2422
+ */
2423
+ async function stopLocalStats(): Promise<void> {
2424
+ const scan = localStatsScan;
2425
+ localStatsScan = null;
2426
+ if (!scan) return;
2427
+ await scan.stop();
2428
+ }
2429
+
2430
+ async function refreshFamily(ctx: UiContext, family: string, force: boolean): Promise<boolean> {
2431
+ const running = inFlight.get(family);
2432
+ if (running) return running;
2433
+ let task: Promise<boolean>;
2434
+ task = (async () => {
2435
+ try {
2436
+ // Explicit delay seam used by the live progressive-refresh proof; absent in normal runs.
2437
+ if (process.env.PI_QUOTA_DEBUG_DELAY_FAMILY === family) {
2438
+ const delay = Math.max(0, Math.min(30_000, Number(process.env.PI_QUOTA_DEBUG_DELAY_MS) || 2_000));
2439
+ await new Promise((resolve) => setTimeout(resolve, delay));
2440
+ }
2441
+ const authOverrides = await authOverridesFor(ctx, family);
2442
+ const env = family === "claude" && authOverrides
2443
+ ? { ...process.env, PI_QUOTA_CLAUDE_SOURCE: "pi" }
2444
+ : process.env;
2445
+ const fragment = await loadFamily(family, { force, authOverrides, env, localStats: false } as any);
2446
+ if (disposed) return false;
2447
+ if (family === "claude") claudeSourceUsed = normalizeClaudeSourceMode(env.PI_QUOTA_CLAUDE_SOURCE);
2448
+ const stickyFragment = mergeLastGood(fragment as any, {}).report as QuotaReport;
2449
+ mergeFamilyReport(stickyFragment);
2450
+ updateFamilyClock(family);
2451
+ persistReport([family]);
2452
+ resultGeneration += 1;
2453
+ familyErrors.delete(family);
2454
+ try {
2455
+ const familyReport = { ...stickyFragment, localStats: report?.localStats ?? stickyFragment.localStats ?? {} };
2456
+ publishOmarchy(familyReport, false);
2457
+ } catch {
2458
+ // A bar-record failure must not turn a quota refresh into an error.
2459
+ }
2460
+ if (surfaceActive()) paintUi(ctx);
2461
+ return true;
2462
+ } catch (error) {
2463
+ const message = (error as { message?: string })?.message ?? String(error);
2464
+ familyErrors.set(family, capCause(redactCause(message).split("\n")[0]));
2465
+ return false;
2466
+ } finally {
2467
+ if (inFlight.get(family) === task) inFlight.delete(family);
2468
+ if (surfaceActive()) paintUi(ctx);
2469
+ overlayView?.redraw();
2140
2470
  }
2141
- if (reportErrors) lastError = null;
2142
- return parsed;
2143
- } catch (error) {
2144
- if (reportErrors) lastError = (error as { message?: string })?.message ?? String(error);
2145
- return null;
2146
- }
2471
+ })();
2472
+ inFlight.set(family, task);
2473
+ return task;
2474
+ }
2475
+
2476
+ /**
2477
+ * One line per failing family, in family order so the list does not reshuffle
2478
+ * as families land. The name is short for the same reason: this is a row in a
2479
+ * dense panel, not a log.
2480
+ */
2481
+ function errorLines(): string[] {
2482
+ const order = [...enabledFamilies({ env: process.env, requested: FAMILIES })];
2483
+ for (const family of familyErrors.keys()) if (!order.includes(family)) order.push(family);
2484
+ return order.flatMap((family) => {
2485
+ const message = familyErrors.get(family);
2486
+ return message ? [`${SHORT_NAME[family] ?? family}: ${message}`] : [];
2487
+ });
2147
2488
  }
2148
2489
 
2149
2490
  function paintUi(ctx: UiContext): void {
@@ -2294,21 +2635,42 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2294
2635
  }
2295
2636
 
2296
2637
  async function refresh(ctx: UiContext, force: boolean): Promise<void> {
2297
- if (refreshing) return;
2298
- refreshing = true;
2299
- try {
2300
- const next = await runCli(force ? ["--json", "--force"] : ["--json"]);
2301
- if (disposed) return;
2302
- if (next) {
2303
- report = next;
2304
- // A bare /usage overlay is not a pinned surface: only repaint the widget
2305
- // and the footer when something is actually on screen.
2306
- if (surfaceActive()) paintUi(ctx);
2307
- void runCli(["omarchy"], { reportErrors: false });
2638
+ if (disposed) return;
2639
+ report = ensureReport();
2640
+ const families = enabledFamilies({ env: process.env, requested: FAMILIES });
2641
+ const now = Date.now();
2642
+ const fetchedAtMs = { ...readRefreshState().fetchedAtMs };
2643
+ // Upgrade path: an older CLI cache has no per-family clock file, but each
2644
+ // provider already carries its own updatedAt. Do not refetch fresh cached data
2645
+ // merely because this extension version has not written clocks yet.
2646
+ for (const family of families) {
2647
+ if (typeof fetchedAtMs[family] === "number") continue;
2648
+ const providerTimes = (report?.providers ?? [])
2649
+ .filter((provider) => provider.family === family)
2650
+ .map((provider) => Date.parse(provider.updatedAt))
2651
+ .filter((time) => Number.isFinite(time));
2652
+ const generatedAt = report?.generatedAt ? Date.parse(report.generatedAt) : NaN;
2653
+ const fallback = providerTimes.length > 0 ? Math.max(...providerTimes) : generatedAt;
2654
+ if (Number.isFinite(fallback)) fetchedAtMs[family] = fallback;
2655
+ }
2656
+ const staleByClock = new Set(staleFamilies({
2657
+ fetchedAtMs,
2658
+ ttls: resolveFamilyTtls({ families }),
2659
+ families,
2660
+ now,
2661
+ }));
2662
+ const cachedFamilies = new Set(report?.providers.map((provider) => provider.family) ?? []);
2663
+ const selected = force ? families : families.filter((family) => !cachedFamilies.has(family) || staleByClock.has(family));
2664
+ if (selected.length === 0) return;
2665
+ startLocalStats(ctx);
2666
+ const outcomes = await Promise.all(selected.map((family) => refreshFamily(ctx, family, force)));
2667
+ if (outcomes.every(Boolean) && inFlight.size === 0 && report && resultGeneration !== prunedGeneration) {
2668
+ try {
2669
+ publishOmarchy(report, true);
2670
+ prunedGeneration = resultGeneration;
2671
+ } catch {
2672
+ // Keep fresh quota data even if the optional final stale-record prune fails.
2308
2673
  }
2309
- } finally {
2310
- refreshing = false;
2311
- overlayView?.redraw();
2312
2674
  }
2313
2675
  }
2314
2676
 
@@ -2368,36 +2730,23 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2368
2730
  ui.ui.setStatus(STATUS_KEY, undefined);
2369
2731
  ui.ui.setWidget(WIDGET_KEY, undefined);
2370
2732
  }
2733
+ // Pi awaits session_shutdown (reason "reload" included), so the scan is gone
2734
+ // and its lock released before the next session or the process starts.
2735
+ await stopLocalStats();
2371
2736
  });
2372
2737
 
2373
2738
  /**
2374
- * Run `piquota stats --json` for one month (null: the newest with a file) and
2375
- * hand the payload to the overlay. Read-only, no lock: the CLI only reads.
2739
+ * Read one month (null: the current local month) in-process and hand the
2740
+ * aggregate-only payload to the overlay. Read-only, no lock: historyJson only reads.
2376
2741
  *
2377
- * A failure is reported the way the report path reports one: the CLI's own
2378
- * stderr line or its exit status, redacted and on one line. The JSON parser's
2742
+ * A reader failure is redacted and limited to one line. The JSON parser's
2379
2743
  * "Unexpected end of JSON input" says nothing about the cause.
2380
2744
  */
2381
2745
  async function loadHistory(month: string | null, request: number): Promise<void> {
2382
- const cli = resolveCliPath();
2383
- if (!cli) {
2384
- overlayView?.setHistoryError("piquota not installed: run install.sh from the pi-quota repo", request);
2385
- return;
2386
- }
2387
2746
  try {
2388
- const runner = /node(\.exe)?$/i.test(process.execPath) ? process.execPath : "node";
2389
- const argv = [cli, "stats", "--json", ...(month ? ["--month", month] : [])];
2390
- const result = await pi.exec(runner, argv, { timeout: EXEC_TIMEOUT_MS });
2391
- const stdout = typeof result?.stdout === "string" ? result.stdout : "";
2392
- let parsed: HistoryPayload | null = null;
2393
- try {
2394
- parsed = stdout.trim() === "" ? null : (JSON.parse(stdout) as HistoryPayload);
2395
- } catch {
2396
- // A truncated or non-JSON stdout: report the run, not the parser.
2397
- parsed = null;
2398
- }
2747
+ const parsed = await readHistory(month);
2399
2748
  if (!parsed || !Array.isArray(parsed.months)) {
2400
- overlayView?.setHistoryError(cliFailureLine(result), request);
2749
+ overlayView?.setHistoryError("piquota stats returned no history payload", request);
2401
2750
  return;
2402
2751
  }
2403
2752
  overlayView?.setHistory(parsed, request);
@@ -2414,9 +2763,9 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2414
2763
  */
2415
2764
  async function showOverlay(ctx: UiContext): Promise<void> {
2416
2765
  // Paint on the same keystroke: the report already in memory, otherwise the
2417
- // CLI's on-disk cache. The forced refresh runs behind the overlay and never
2418
- // delays the first frame.
2419
- if (!report) report = readCachedReport();
2766
+ // on-disk cache. Only stale families refresh behind the overlay; the first
2767
+ // frame never waits for the network.
2768
+ report = ensureReport();
2420
2769
  const custom = ctx.ui.custom;
2421
2770
  if (typeof custom !== "function" || (ctx.mode !== undefined && ctx.mode !== "tui")) {
2422
2771
  // A host without custom components still gets the panel that /usage panel pins.
@@ -2424,7 +2773,8 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2424
2773
  lineVisible = false;
2425
2774
  boxVisible = false;
2426
2775
  explicitBox = false;
2427
- if (!report) await refresh(ctx, false);
2776
+ report = ensureReport();
2777
+ void refresh(ctx, false);
2428
2778
  ensurePolling(ctx);
2429
2779
  paintUi(ctx);
2430
2780
  ctx.ui.notify("This host has no fullscreen overlay; the quota panel is pinned above the editor", "info");
@@ -2437,8 +2787,8 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2437
2787
  requestRender: () => tui?.requestRender?.(),
2438
2788
  getRows: () => tui?.terminal?.rows ?? process.stdout.rows ?? 24,
2439
2789
  getReport: () => report,
2440
- getError: () => lastError,
2441
- isRefreshing: () => refreshing,
2790
+ getErrors: () => errorLines(),
2791
+ isRefreshing: () => isRefreshing(),
2442
2792
  refresh: () => {
2443
2793
  void refresh(ctx, true);
2444
2794
  },
@@ -2474,10 +2824,11 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2474
2824
  switch (action) {
2475
2825
  case "refresh":
2476
2826
  await refresh(ctx, true);
2827
+ const failures = errorLines();
2477
2828
  ctx.ui.notify(
2478
2829
  report
2479
2830
  ? `Quota refreshed (${report.providers.filter((provider) => provider.ok).length}/${report.providers.length} providers)`
2480
- : `Quota refresh failed: ${lastError}`,
2831
+ : `Quota refresh failed: ${failures.length > 1 ? `${failures[0]} (+${failures.length - 1} more)` : failures[0] ?? "unknown error"}`,
2481
2832
  report ? "info" : "error",
2482
2833
  );
2483
2834
  return;
@@ -2487,7 +2838,9 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2487
2838
  boxVisible = false;
2488
2839
  explicitBox = false;
2489
2840
  panelVisible = false;
2841
+ report = ensureReport();
2490
2842
  if (!report) await refresh(ctx, false);
2843
+ else void refresh(ctx, false);
2491
2844
  ensurePolling(ctx);
2492
2845
  paintUi(ctx);
2493
2846
  ctx.ui.notify("Quota line pinned above the editor", "info");
@@ -2497,7 +2850,9 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2497
2850
  lineVisible = false;
2498
2851
  boxVisible = false;
2499
2852
  explicitBox = false;
2853
+ report = ensureReport();
2500
2854
  if (!report) await refresh(ctx, false);
2855
+ else void refresh(ctx, false);
2501
2856
  ensurePolling(ctx);
2502
2857
  paintUi(ctx);
2503
2858
  ctx.ui.notify("Quota panel shown above the editor", "info");
@@ -2507,7 +2862,9 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2507
2862
  boxVisible = true;
2508
2863
  explicitBox = true;
2509
2864
  panelVisible = false;
2865
+ report = ensureReport();
2510
2866
  if (!report) await refresh(ctx, false);
2867
+ else void refresh(ctx, false);
2511
2868
  ensurePolling(ctx);
2512
2869
  paintUi(ctx);
2513
2870
  ctx.ui.notify("Quota box pinned above the editor", "info");
@@ -2534,7 +2891,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2534
2891
  paintUi(ctx);
2535
2892
  return;
2536
2893
  case "json":
2537
- ctx.ui.notify(`${resolveCliPath() ?? "piquota"} --json (cached at ~/.cache/pi-quota/usage.json)`, "info");
2894
+ ctx.ui.notify(`Report cache: ${reportCachePath()}`, "info");
2538
2895
  return;
2539
2896
  default:
2540
2897
  break;
@@ -2543,7 +2900,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
2543
2900
  // Bare /usage (or an unknown argument): open the overlay with whatever the
2544
2901
  // extension already has, and refresh in the background behind it.
2545
2902
  const opening = showOverlay(ctx);
2546
- void refresh(ctx, true);
2903
+ void refresh(ctx, false);
2547
2904
  await opening;
2548
2905
  };
2549
2906