@cad0p/pi-usage 0.0.0-stage → 0.11.2

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/LICENSE +21 -0
  2. package/README.md +509 -2
  3. package/bin/piquota.js +199 -0
  4. package/extensions/quota-panel.ts +963 -0
  5. package/package.json +59 -4
  6. package/src/auth/claude-code-auth.js +490 -0
  7. package/src/auth/jwt.js +84 -0
  8. package/src/auth/pi-auth.js +424 -0
  9. package/src/browser/cookies.js +557 -0
  10. package/src/browser/history.js +173 -0
  11. package/src/cache.js +224 -0
  12. package/src/cli/args.js +154 -0
  13. package/src/cli/commands/auth.js +216 -0
  14. package/src/cli/commands/gentle-pi.js +44 -0
  15. package/src/cli/commands/omarchy.js +75 -0
  16. package/src/cli/explain.js +99 -0
  17. package/src/cli/output.js +34 -0
  18. package/src/engine.js +404 -0
  19. package/src/exec.js +31 -0
  20. package/src/gentle-pi/rail-patch.js +335 -0
  21. package/src/http.js +182 -0
  22. package/src/model.js +332 -0
  23. package/src/omarchy/assets/antigravity.svg +3 -0
  24. package/src/omarchy/assets/claude.svg +1 -0
  25. package/src/omarchy/assets/codex.svg +1 -0
  26. package/src/omarchy/assets/github-actions.svg +3 -0
  27. package/src/omarchy/assets/opencode-go.svg +3 -0
  28. package/src/omarchy/publish.js +152 -0
  29. package/src/omarchy/record.js +462 -0
  30. package/src/opencode/dashboard.js +337 -0
  31. package/src/opencode/session.js +485 -0
  32. package/src/providers/antigravity-oauth.js +173 -0
  33. package/src/providers/antigravity.js +225 -0
  34. package/src/providers/backoff.js +146 -0
  35. package/src/providers/claude.js +194 -0
  36. package/src/providers/codex.js +151 -0
  37. package/src/providers/error-kind.js +128 -0
  38. package/src/providers/github-actions.js +379 -0
  39. package/src/providers/opencode-go.js +274 -0
  40. package/src/refresh.js +292 -0
  41. package/src/render/panel.js +411 -0
  42. package/src/render/theme.js +102 -0
  43. package/src/sticky.js +319 -0
@@ -0,0 +1,274 @@
1
+ /**
2
+ * OpenCode Go quota.
3
+ *
4
+ * The `opencode-go` entry in Pi's store is an OpenCode Zen API key. That key is
5
+ * enough to read the Go plan windows directly from the usage endpoint
6
+ * (`GET /zen/go/v1/usage` -> rolling/weekly/monthly), so no browser session is
7
+ * needed for the common case, and every numbered sibling account can report its
8
+ * own windows.
9
+ *
10
+ * The authenticated dashboard (src/opencode/session.js) remains as a fallback
11
+ * for two cases only: the key usage endpoint answers 404, or the entry has no
12
+ * key at all. A browser session belongs to one workspace, so the fallback is
13
+ * limited to the base account and is never duplicated under every sibling.
14
+ *
15
+ * This provider never breaks the report: every failure is a precise,
16
+ * actionable message.
17
+ */
18
+
19
+ import { buildWindow, degradedResult, displayIdentity, stringValue } from "../model.js";
20
+ import { requestJson } from "../http.js";
21
+ import { readGoPlan, resolveCookie } from "../opencode/session.js";
22
+
23
+ const GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
24
+ const ZEN_MODELS_URL = "https://opencode.ai/zen/v1/models";
25
+
26
+ /** Canonical window order, shortest first. */
27
+ const GO_WINDOWS = [
28
+ { field: "rolling", id: "5h", label: "5h window", windowSeconds: 18_000 },
29
+ { field: "weekly", id: "weekly", label: "Weekly window", windowSeconds: 604_800 },
30
+ { field: "monthly", id: "monthly", label: "Monthly window", windowSeconds: 2_592_000 },
31
+ ];
32
+
33
+ /**
34
+ * @param {unknown} value
35
+ * @returns {Record<string, unknown> | null}
36
+ */
37
+ function record(value) {
38
+ return value && typeof value === "object" && !Array.isArray(value)
39
+ ? /** @type {Record<string, unknown>} */ (value)
40
+ : null;
41
+ }
42
+
43
+ /**
44
+ * The account label shown by every surface: #1 for the base entry, #<slot> for a
45
+ * numbered sibling. The key itself never appears.
46
+ *
47
+ * @param {{ slot?: string | null }} credential
48
+ * @returns {string}
49
+ */
50
+ function accountLabel(credential) {
51
+ return credential.slot ? `#${credential.slot}` : "#1";
52
+ }
53
+
54
+ /**
55
+ * Map the usage endpoint body onto canonical windows.
56
+ *
57
+ * A non-"ok" status (e.g. "rate-limited") is carried in the window note so the
58
+ * panel can explain a 100% window instead of implying the plan is merely spent.
59
+ *
60
+ * @param {unknown} body
61
+ * @param {number} now
62
+ * @returns {import("../model.js").QuotaWindow[]}
63
+ */
64
+ export function parseUsageWindows(body, now = Date.now()) {
65
+ const usage = record(record(body)?.usage);
66
+ if (!usage) return [];
67
+
68
+ /** @type {import("../model.js").QuotaWindow[]} */
69
+ const windows = [];
70
+ for (const spec of GO_WINDOWS) {
71
+ const entry = record(usage[spec.field]);
72
+ if (!entry) continue;
73
+ if (typeof entry.percent !== "number" && typeof entry.percent !== "string") continue;
74
+ const status = stringValue(entry.status);
75
+ windows.push(
76
+ buildWindow({
77
+ id: spec.id,
78
+ label: spec.label,
79
+ usedPercent: entry.percent,
80
+ resetsAt: entry.resetAt ?? entry.resetsAt,
81
+ windowSeconds: spec.windowSeconds,
82
+ note: status && status.toLowerCase() !== "ok" ? status : null,
83
+ now,
84
+ }),
85
+ );
86
+ }
87
+ return windows;
88
+ }
89
+
90
+ /**
91
+ * Ask the usage endpoint for one key.
92
+ *
93
+ * @param {{ key?: string }} credential
94
+ * @param {{ now?: number, fetchFn?: typeof fetch, timeoutMs?: number }} options
95
+ * @returns {Promise<
96
+ * | { status: "ok", windows: import("../model.js").QuotaWindow[] }
97
+ * | { status: "fallback", reason: "no-key" | "gone" }
98
+ * | { status: "error", message: string }
99
+ * >}
100
+ */
101
+ async function fetchKeyUsage(credential, options = {}) {
102
+ if (!credential.key) return { status: "fallback", reason: "no-key" };
103
+
104
+ const response = await requestJson(GO_USAGE_URL, {
105
+ headers: { Authorization: `Bearer ${credential.key}`, Accept: "application/json" },
106
+ fetchFn: options.fetchFn,
107
+ timeoutMs: options.timeoutMs,
108
+ });
109
+
110
+ if (response.ok) {
111
+ const windows = parseUsageWindows(response.body, options.now ?? Date.now());
112
+ if (windows.length === 0) {
113
+ return { status: "error", message: "OpenCode Go usage endpoint returned no usage windows" };
114
+ }
115
+ return { status: "ok", windows };
116
+ }
117
+ // A 404 means the endpoint is gone. Every other status is a real answer about
118
+ // this key, so falling back to a browser session would mask it.
119
+ if (response.status === 404) return { status: "fallback", reason: "gone" };
120
+
121
+ if (response.status === 401) {
122
+ return { status: "error", message: "OpenCode Go key rejected (401); reconnect that account in Pi" };
123
+ }
124
+ if (response.status === 403) {
125
+ const detail = response.authMessage ? ` ${response.authMessage}` : "";
126
+ return { status: "error", message: `Go plan not active for this key's workspace (403${detail})` };
127
+ }
128
+ return { status: "error", message: `OpenCode Go usage request failed: ${response.error}` };
129
+ }
130
+
131
+ /**
132
+ * Confirm the Zen key still authenticates, so a degradation can distinguish a
133
+ * dead credential from a missing session.
134
+ *
135
+ * @param {string | undefined} key
136
+ * @param {typeof fetch | undefined} fetchFn
137
+ * @param {number | undefined} timeoutMs
138
+ * @returns {Promise<string>}
139
+ */
140
+ async function describeZenKey(key, fetchFn, timeoutMs) {
141
+ if (!key) return "no key stored for opencode-go in the Pi store";
142
+ const response = await requestJson(ZEN_MODELS_URL, {
143
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
144
+ fetchFn,
145
+ timeoutMs,
146
+ });
147
+ if (response.ok) {
148
+ const body = record(response.body);
149
+ const models = Array.isArray(body?.data) ? body.data.length : 0;
150
+ return `Pi OpenCode key is valid (${models} Zen models visible)`;
151
+ }
152
+ if (response.authError) return "Pi OpenCode key was rejected by OpenCode Zen; reconnect OpenCode in Pi";
153
+ return `OpenCode Zen probe failed: ${response.error}`;
154
+ }
155
+
156
+ /**
157
+ * The browser-session path: one workspace, therefore the base account only.
158
+ *
159
+ * @param {{ key?: string, source?: string }} credential
160
+ * @param {{
161
+ * now: number,
162
+ * fetchFn?: typeof fetch,
163
+ * timeoutMs?: number,
164
+ * env?: Record<string, string | undefined>,
165
+ * home?: string,
166
+ * stores?: import("../browser/cookies.js").CookieStore[],
167
+ * allowBrowser?: boolean,
168
+ * }} options
169
+ * @returns {Promise<{ ok: true, account: string, plan: string | null, windows: import("../model.js").QuotaWindow[] } | { ok: false, error: string }>}
170
+ */
171
+ async function fetchDashboardQuota(credential, options) {
172
+ const session = resolveCookie({
173
+ env: options.env,
174
+ home: options.home,
175
+ stores: options.stores,
176
+ allowBrowser: options.allowBrowser,
177
+ });
178
+ const plan = await readGoPlan({
179
+ env: options.env,
180
+ home: options.home,
181
+ stores: options.stores,
182
+ allowBrowser: options.allowBrowser,
183
+ fetchFn: options.fetchFn,
184
+ timeoutMs: options.timeoutMs,
185
+ now: options.now,
186
+ });
187
+
188
+ if (plan.ok) {
189
+ return {
190
+ ok: true,
191
+ account: plan.workspaceId
192
+ ? `workspace ${plan.workspaceId.length > 10 ? `${plan.workspaceId.slice(0, 8)}…` : plan.workspaceId}`
193
+ : displayIdentity(credential),
194
+ plan: plan.planHint ?? "Go subscription",
195
+ windows: plan.windows,
196
+ };
197
+ }
198
+
199
+ const zen = await describeZenKey(credential.key, options.fetchFn, options.timeoutMs);
200
+ const hint =
201
+ session.origin === "browser"
202
+ ? `the browser session was read from ${session.detail} but was not accepted`
203
+ : session.encryptedOnly
204
+ ? "Chrome on Windows encrypts cookies with DPAPI, which WSL cannot read; log in with Firefox instead"
205
+ : "run: piquota auth opencode";
206
+
207
+ return { ok: false, error: `${zen}. Go plan windows unavailable: ${plan.error}. ${hint}` };
208
+ }
209
+
210
+ /**
211
+ * @param {import("../auth/pi-auth.js").PiCredential} credential
212
+ * @param {{
213
+ * now?: number,
214
+ * fetchFn?: typeof fetch,
215
+ * timeoutMs?: number,
216
+ * expiresInMin?: number | null,
217
+ * env?: Record<string, string | undefined>,
218
+ * home?: string,
219
+ * stores?: import("../browser/cookies.js").CookieStore[],
220
+ * allowBrowser?: boolean,
221
+ * }} [options]
222
+ * @returns {Promise<import("../model.js").QuotaResult>}
223
+ */
224
+ export async function fetchQuota(credential, options = {}) {
225
+ const now = options.now ?? Date.now();
226
+ const base = {
227
+ family: "opencode-go",
228
+ label: "OpenCode Go (Pi)",
229
+ account: accountLabel(credential),
230
+ source: credential.source,
231
+ sourceKind: credential.sourceKind ?? "pi",
232
+ expiresInMin: options.expiresInMin ?? null,
233
+ };
234
+
235
+ const keyResult = await fetchKeyUsage(credential, { ...options, now });
236
+ if (keyResult.status === "ok") {
237
+ return {
238
+ ...base,
239
+ plan: "Go plan",
240
+ windows: keyResult.windows,
241
+ error: null,
242
+ ok: true,
243
+ updatedAt: new Date(now).toISOString(),
244
+ };
245
+ }
246
+ if (keyResult.status === "error") {
247
+ return degradedResult({ ...base, error: keyResult.message, plan: "Go plan" });
248
+ }
249
+
250
+ if (credential.slot) {
251
+ const why =
252
+ keyResult.reason === "gone"
253
+ ? "OpenCode Go usage endpoint returned 404"
254
+ : "no key stored for this OpenCode Go account";
255
+ return degradedResult({
256
+ ...base,
257
+ error: `${why}; the browser-session fallback only covers the base account`,
258
+ });
259
+ }
260
+
261
+ const dashboard = await fetchDashboardQuota(credential, { ...options, now });
262
+ if (dashboard.ok) {
263
+ return {
264
+ ...base,
265
+ account: dashboard.account,
266
+ plan: dashboard.plan,
267
+ windows: dashboard.windows,
268
+ error: null,
269
+ ok: true,
270
+ updatedAt: new Date(now).toISOString(),
271
+ };
272
+ }
273
+ return degradedResult({ ...base, error: dashboard.error });
274
+ }
package/src/refresh.js ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Per-family refresh cadence.
3
+ *
4
+ * The watcher used to run on a single clock: refetch every provider every 300 s and
5
+ * re-push the same numbers five times in between, so a card could show a value up to
6
+ * five minutes old while looking freshly published.
7
+ *
8
+ * The slow clock exists for one reason, and it only applies to one provider:
9
+ * Anthropic's usage endpoint answers `429` when it is polled every minute. Every
10
+ * other provider is happy at a minute, and a throttle is already handled by
11
+ * `backoff.js` (that family pauses for at least five minutes) plus `sticky.js`
12
+ * (the card keeps its last real reading), so a short clock cannot hammer anything.
13
+ *
14
+ * This module gives each family its own clock, fetches only what is stale, and
15
+ * merges the result back into the one canonical report every other surface reads.
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+
22
+ import { FAMILIES } from "./engine.js";
23
+ import { readCache, resolveCachePath, writeCache } from "./cache.js";
24
+
25
+ /** Claude pays for the slow clock; everything else is happy at a minute. */
26
+ export const CLAUDE_TTL_SEC = 300;
27
+ export const DEFAULT_FAMILY_TTL_SEC = 60;
28
+
29
+ /**
30
+ * @param {{ claudeTtlSec?: number, defaultTtlSec?: number, families?: string[] }} [options]
31
+ * @returns {Record<string, number>}
32
+ */
33
+ export function resolveFamilyTtls(options = {}) {
34
+ const defaultTtl = options.defaultTtlSec ?? DEFAULT_FAMILY_TTL_SEC;
35
+ const claudeTtl = options.claudeTtlSec ?? CLAUDE_TTL_SEC;
36
+ /** @type {Record<string, number>} */
37
+ const ttls = {};
38
+ for (const family of options.families ?? FAMILIES) {
39
+ ttls[family] = family === "claude" ? claudeTtl : defaultTtl;
40
+ }
41
+ return ttls;
42
+ }
43
+
44
+ /**
45
+ * @param {{
46
+ * fetchedAtMs: Record<string, number>,
47
+ * ttls: Record<string, number>,
48
+ * families?: string[],
49
+ * now?: number,
50
+ * }} options
51
+ * @returns {string[]}
52
+ */
53
+ export function staleFamilies(options) {
54
+ const now = options.now ?? Date.now();
55
+ const families = options.families ?? FAMILIES;
56
+ return families.filter((family) => {
57
+ const fetchedAt = options.fetchedAtMs[family];
58
+ // Never fetched is always stale: a missing clock is not a fresh one.
59
+ if (typeof fetchedAt !== "number") return true;
60
+ const ttlSec = options.ttls[family] ?? DEFAULT_FAMILY_TTL_SEC;
61
+ return now - fetchedAt >= ttlSec * 1000;
62
+ });
63
+ }
64
+
65
+ /**
66
+ * @param {{ env?: Record<string, string | undefined>, home?: string, statePath?: string }} [options]
67
+ * @returns {string}
68
+ */
69
+ export function resolveRefreshStatePath(options = {}) {
70
+ if (options.statePath) return options.statePath;
71
+ const env = options.env ?? process.env;
72
+ const cacheHome = options.home ? join(options.home, ".cache") : (env.XDG_CACHE_HOME || join(homedir(), ".cache"));
73
+ return join(cacheHome, "pi-quota", "refresh-state.json");
74
+ }
75
+
76
+ /**
77
+ * @param {{ statePath?: string, env?: Record<string, string | undefined>, home?: string }} [options]
78
+ * @returns {{ fetchedAtMs: Record<string, number>, path: string, error?: string }}
79
+ */
80
+ export function readRefreshState(options = {}) {
81
+ const path = resolveRefreshStatePath(options);
82
+ if (!existsSync(path)) return { fetchedAtMs: {}, path };
83
+
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(readFileSync(path, { encoding: "utf-8", flag: "r" }));
87
+ } catch {
88
+ // A half-written clock is not evidence that anything is fresh.
89
+ return { fetchedAtMs: {}, path, error: `cannot parse ${path}` };
90
+ }
91
+
92
+ /** @type {Record<string, number>} */
93
+ const fetchedAtMs = {};
94
+ const raw = parsed?.fetchedAtMs;
95
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
96
+ for (const [family, value] of Object.entries(raw)) {
97
+ if (typeof value === "number" && Number.isFinite(value)) fetchedAtMs[family] = value;
98
+ }
99
+ }
100
+ return { fetchedAtMs, path };
101
+ }
102
+
103
+ /**
104
+ * @param {{ fetchedAtMs: Record<string, number> }} state
105
+ * @param {{ statePath?: string, env?: Record<string, string | undefined>, home?: string }} [options]
106
+ * @returns {{ ok: boolean, path: string, error?: string }}
107
+ */
108
+ export function writeRefreshState(state, options = {}) {
109
+ const path = resolveRefreshStatePath(options);
110
+ try {
111
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
112
+ const temporary = `${path}.${process.pid}.tmp`;
113
+ writeFileSync(temporary, JSON.stringify({ fetchedAtMs: state.fetchedAtMs }, null, 2) + "\n", {
114
+ encoding: "utf-8",
115
+ mode: 0o600,
116
+ });
117
+ renameSync(temporary, path);
118
+ return { ok: true, path };
119
+ } catch (error) {
120
+ return { ok: false, path, error: /** @type {{ message?: string }} */ (error)?.message ?? String(error) };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Fold several partial reports into the one canonical report.
126
+ *
127
+ * Every surface reads `providers` in family order, so the merge must not leak the
128
+ * order in which the pieces happened to be fetched.
129
+ *
130
+ * @param {Array<import("./engine.js").PiQuotaReport | null | undefined>} parts
131
+ * @param {{ families?: string[], now?: number, reused?: string[] }} [options]
132
+ * @returns {import("./engine.js").PiQuotaReport}
133
+ */
134
+ export function mergeReports(parts, options = {}) {
135
+ const now = options.now ?? Date.now();
136
+ const families = options.families ?? FAMILIES;
137
+ const reused = new Set(options.reused ?? []);
138
+ const usable = parts.filter((part) => part && Array.isArray(part.providers));
139
+
140
+ /** @type {Map<string, import("./model.js").QuotaResult>} */
141
+ const byFamily = new Map();
142
+ // Later parts win, so the caller can pass the cached copy first and the fresh one
143
+ // last without having to think about precedence.
144
+ for (const part of usable) {
145
+ for (const provider of part.providers) {
146
+ if (!byFamily.has(provider.family) || options.overwrite !== false) byFamily.set(provider.family, provider);
147
+ }
148
+ }
149
+
150
+ const ordered = families
151
+ .map((family) => byFamily.get(family))
152
+ .filter((provider) => provider !== undefined)
153
+ .map((provider) => {
154
+ if (!reused.has(provider.family)) return provider;
155
+ return {
156
+ ...provider,
157
+ windows: (provider.windows ?? []).map((win) => {
158
+ if (!win.resetsAt) return win;
159
+ const parsedMs = Date.parse(win.resetsAt);
160
+ if (Number.isNaN(parsedMs)) return win;
161
+ return { ...win, resetsInSec: Math.max(0, Math.round((parsedMs - now) / 1000)) };
162
+ }),
163
+ };
164
+ });
165
+
166
+ /** @type {string[]} */
167
+ const sources = [];
168
+ for (const part of usable) {
169
+ for (const source of part.sources ?? []) {
170
+ if (!sources.includes(source)) sources.push(source);
171
+ }
172
+ }
173
+
174
+ // A warning is prefixed with its family, so it can be kept only while that family
175
+ // is actually the cached copy. A warning about a family we just refetched is stale
176
+ // by definition.
177
+ /** @type {string[]} */
178
+ const warnings = [];
179
+ const freshParts = usable.filter((part) => !reused.has(part.providers[0]?.family));
180
+ for (const part of freshParts) {
181
+ for (const warning of part.warnings ?? []) {
182
+ if (!warnings.includes(warning)) warnings.push(warning);
183
+ }
184
+ }
185
+ for (const part of usable) {
186
+ for (const warning of part.warnings ?? []) {
187
+ const family = String(warning).split(":")[0];
188
+ if (!reused.has(family)) continue;
189
+ if (!warnings.includes(warning)) warnings.push(warning);
190
+ }
191
+ }
192
+
193
+ const newest = usable
194
+ .map((part) => part.generatedAt)
195
+ .filter((value) => typeof value === "string")
196
+ .sort()
197
+ .at(-1);
198
+
199
+ const withClaude = usable.find((part) => part.claudeSource);
200
+
201
+ return {
202
+ engine: "pi-quota",
203
+ schemaVersion: 1,
204
+ readOnly: true,
205
+ generatedAt: newest ?? new Date(now).toISOString(),
206
+ sources,
207
+ ...(withClaude ? { claudeSource: withClaude.claudeSource } : {}),
208
+ warnings,
209
+ providers: ordered,
210
+ byFamily: Object.fromEntries(
211
+ ordered.map((provider) => [provider.family, ordered.filter((other) => other.family === provider.family)]),
212
+ ),
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Refresh only the families that are past their own clock.
218
+ *
219
+ * @param {{
220
+ * families?: string[],
221
+ * ttls?: Record<string, number>,
222
+ * cachePath?: string,
223
+ * statePath?: string,
224
+ * now?: number,
225
+ * env?: Record<string, string | undefined>,
226
+ * home?: string,
227
+ * loader: (families: string[]) => Promise<import("./engine.js").PiQuotaReport>,
228
+ * }} options
229
+ * @returns {Promise<{
230
+ * report: import("./engine.js").PiQuotaReport,
231
+ * fetched: string[],
232
+ * reused: string[],
233
+ * ageMs: Record<string, number>,
234
+ * }>}
235
+ */
236
+ export async function collectWithCadence(options) {
237
+ const now = options.now ?? Date.now();
238
+ const families = options.families ?? FAMILIES;
239
+ const ttls = options.ttls ?? resolveFamilyTtls({ families });
240
+ const cachePath = options.cachePath ?? resolveCachePath(options);
241
+ const statePath = options.statePath ?? resolveRefreshStatePath(options);
242
+
243
+ // Read the report with no TTL: this layer decides freshness per family, and a
244
+ // null here only means there is nothing to reuse.
245
+ const cached = readCache({ path: cachePath, now, ttlMs: Number.POSITIVE_INFINITY });
246
+ const state = readRefreshState({ statePath });
247
+ const fetchedAtMs = { ...state.fetchedAtMs };
248
+ // Unknown families in the state file are dropped, so a removed provider cannot
249
+ // keep a clock alive forever.
250
+ for (const family of Object.keys(fetchedAtMs)) {
251
+ if (!families.includes(family)) delete fetchedAtMs[family];
252
+ }
253
+
254
+ const cachedFamilies = cached?.report?.byFamily ? Object.keys(cached.report.byFamily) : [];
255
+ const stale = families.filter((family) => {
256
+ if (!cachedFamilies.includes(family)) return true;
257
+ const fetchedAt = fetchedAtMs[family];
258
+ if (typeof fetchedAt !== "number") return true;
259
+ const ttlSec = ttls[family] ?? DEFAULT_FAMILY_TTL_SEC;
260
+ return now - fetchedAt >= ttlSec * 1000;
261
+ });
262
+ const reusable = cached
263
+ ? cached.report.providers.filter((provider) => !stale.includes(provider.family)).map((provider) => provider.family)
264
+ : [];
265
+ const reused = families.filter((family) => reusable.includes(family));
266
+
267
+ let fresh = null;
268
+ if (stale.length > 0) {
269
+ fresh = await options.loader(stale);
270
+ for (const family of stale) fetchedAtMs[family] = now;
271
+ }
272
+
273
+ // The cached part is filtered to the families we are actually keeping: the stale
274
+ // ones are about to be replaced, and dragging them in would let a stale window
275
+ // win if the fresh report ever omitted a family.
276
+ const cachedPart = cached
277
+ ? { ...cached.report, providers: cached.report.providers.filter((provider) => reused.includes(provider.family)) }
278
+ : null;
279
+
280
+ const report = mergeReports([cachedPart, fresh], { families, now, reused });
281
+ writeCache(report, { path: cachePath, now });
282
+ writeRefreshState({ fetchedAtMs }, { statePath });
283
+
284
+ /** @type {Record<string, number>} */
285
+ const ageMs = {};
286
+ for (const family of families) {
287
+ const at = fetchedAtMs[family];
288
+ ageMs[family] = typeof at === "number" ? Math.max(0, now - at) : 0;
289
+ }
290
+
291
+ return { report, fetched: stale, reused, ageMs };
292
+ }