@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
package/src/sticky.js ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Quota snapshot persistence and transient failure resilience.
3
+ *
4
+ * Keeps the last good snapshot when a refresh fails transiently (429, timeout, 5xx).
5
+ * Permanent failures (expired sign-in, missing credential, rejected session)
6
+ * are passed through, because hiding them would be misleading.
7
+ */
8
+
9
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+
13
+ import { isAuthFailure } from "./providers/backoff.js";
14
+
15
+ /** Maximum age for a sticky / last-good snapshot before it is considered stale (4 hours). */
16
+ export const DEFAULT_MAX_STICKY_AGE_MS = 4 * 60 * 60 * 1000;
17
+
18
+ /**
19
+ * Rebuild the provider list in the report's own order, falling back to the
20
+ * byFamily grouping when a caller passed an inconsistent report (empty
21
+ * `providers`).
22
+ *
23
+ * @param {import("./engine.js").PiQuotaReport} report
24
+ * @param {Map<string, import("./model.js").QuotaResult>} replacements
25
+ * @param {Record<string, import("./model.js").QuotaResult[]>} byFamily
26
+ * @returns {import("./model.js").QuotaResult[]}
27
+ */
28
+ function orderedProviders(report, replacements, byFamily) {
29
+ if (report.providers.length === 0) {
30
+ return Object.values(byFamily).flat();
31
+ }
32
+ return report.providers.map((provider) => replacements.get(provider.family) ?? provider);
33
+ }
34
+
35
+ /**
36
+ * Failures that are expected to clear on their own.
37
+ *
38
+ * `backing off` matters: that is the message this project's own backoff layer
39
+ * emits, and leaving it out made the "keep the last good values" path decline to
40
+ * act exactly while a provider was throttled — the one case it exists for.
41
+ */
42
+ const TRANSIENT =
43
+ /HTTP 5\d\d|429|rate limited|throttle|backing off|timed out|timeout|ECONNRESET|socket|network|fetch failed|ENOTFOUND|ETIMEDOUT|EPIPE|ECONNREFUSED|aborted|hang up|request failed/i;
44
+
45
+ /**
46
+ * @param {string | null} error
47
+ * @returns {boolean}
48
+ */
49
+ export function isTransientError(error) {
50
+ if (!error) return false;
51
+ if (isAuthFailure(error)) return false;
52
+ return TRANSIENT.test(error);
53
+ }
54
+
55
+ /**
56
+ * Merge a fresh report over a previous one, preserving healthy windows when
57
+ * a provider fails transiently.
58
+ *
59
+ * @param {import("./engine.js").PiQuotaReport | null} previous
60
+ * @param {import("./engine.js").PiQuotaReport} next
61
+ * @param {{ now?: number, maxAgeMs?: number }} [options]
62
+ * @returns {{ report: import("./engine.js").PiQuotaReport, reused: string[] }}
63
+ */
64
+ export function mergeSticky(previous, next, options = {}) {
65
+ if (!previous) return { report: next, reused: [] };
66
+ const nextTime = next.generatedAt ? Date.parse(next.generatedAt) : NaN;
67
+ const now = options.now ?? (!Number.isNaN(nextTime) ? nextTime : Date.now());
68
+ const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_STICKY_AGE_MS;
69
+
70
+ /** @type {string[]} */
71
+ const reused = [];
72
+ /** @type {Record<string, import("./model.js").QuotaResult[]>} */
73
+ const byFamily = {};
74
+ /** @type {Map<string, import("./model.js").QuotaResult>} */
75
+ const replacements = new Map();
76
+
77
+ for (const [family, results] of Object.entries(next.byFamily)) {
78
+ const fresh = results[0];
79
+ const prior = previous.byFamily[family]?.[0];
80
+ const priorTimestamp = prior?.updatedAt ? Date.parse(prior.updatedAt) : NaN;
81
+ const isPriorFresh = Number.isNaN(priorTimestamp) || now - priorTimestamp <= maxAgeMs;
82
+ const hasExpiredToken = fresh?.expiresInMin !== null && fresh?.expiresInMin !== undefined && fresh.expiresInMin <= 0;
83
+ const keepPrior =
84
+ fresh &&
85
+ prior &&
86
+ !fresh.ok &&
87
+ prior.ok &&
88
+ prior.windows.length > 0 &&
89
+ !hasExpiredToken &&
90
+ !isAuthFailure(fresh.error) &&
91
+ isPriorFresh &&
92
+ isTransientError(fresh.error);
93
+
94
+ if (!keepPrior) {
95
+ byFamily[family] = results;
96
+ continue;
97
+ }
98
+
99
+ reused.push(family);
100
+ const priorList = previous.byFamily[family] || (prior ? [prior] : []);
101
+ const carriedList = priorList.map((item) => ({
102
+ ...item,
103
+ account: item.account,
104
+ updatedAt: item.updatedAt,
105
+ windows: (item.windows ?? []).map((win) => {
106
+ if (!win.resetsAt) return win;
107
+ const parsedMs = Date.parse(win.resetsAt);
108
+ if (Number.isNaN(parsedMs)) return win;
109
+ return { ...win, resetsInSec: Math.max(0, Math.round((parsedMs - now) / 1000)) };
110
+ }),
111
+ }));
112
+ byFamily[family] = carriedList;
113
+ if (carriedList[0]) replacements.set(family, carriedList[0]);
114
+ }
115
+
116
+ // Rebuild in the report's own order so the rings never jump position between
117
+ // refreshes; the map is only a lookup.
118
+ const providers = orderedProviders(next, replacements, byFamily);
119
+
120
+ return {
121
+ report: {
122
+ ...next,
123
+ providers,
124
+ byFamily,
125
+ warnings: reused.length > 0
126
+ ? [...next.warnings, `reused the previous snapshot for: ${reused.join(", ")} (transient upstream error)`]
127
+ : next.warnings,
128
+ },
129
+ reused,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
135
+ * @returns {string}
136
+ */
137
+ export function resolveLastPublishedPath(options = {}) {
138
+ if (options.path) return options.path;
139
+ const env = options.env ?? process.env;
140
+ const cacheHome = options.home ? join(options.home, ".cache") : (env.XDG_CACHE_HOME || join(homedir(), ".cache"));
141
+ return join(cacheHome, "pi-quota", "last-published.json");
142
+ }
143
+
144
+ /**
145
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
146
+ * @returns {import("./engine.js").PiQuotaReport | null}
147
+ */
148
+ export function loadLastPublished(options = {}) {
149
+ try {
150
+ const parsed = JSON.parse(readFileSync(resolveLastPublishedPath(options), "utf-8"));
151
+ return parsed?.report && Array.isArray(parsed.report.providers) ? parsed.report : null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * @param {import("./engine.js").PiQuotaReport} report
159
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
160
+ * @returns {{ ok: boolean, error?: string }}
161
+ */
162
+ export function saveLastPublished(report, options = {}) {
163
+ const path = resolveLastPublishedPath(options);
164
+ try {
165
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
166
+ const temporary = `${path}.${process.pid}.tmp`;
167
+ writeFileSync(temporary, JSON.stringify({ savedAt: Date.now(), report }, null, 2), {
168
+ encoding: "utf-8",
169
+ mode: 0o600,
170
+ });
171
+ renameSync(temporary, path);
172
+ return { ok: true };
173
+ } catch (error) {
174
+ return { ok: false, error: /** @type {{ message?: string }} */ (error)?.message ?? String(error) };
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Per-family "last good" store.
180
+ *
181
+ * Keeps the last *healthy* snapshot per family and restores it when that
182
+ * family fails transiently. Permanent failures (expired sign-in, missing
183
+ * credential) are never masked, and every restore is labelled with the age of
184
+ * the data so nothing is presented as fresher than it is.
185
+ */
186
+
187
+ /**
188
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
189
+ * @returns {string}
190
+ */
191
+ export function resolveLastGoodPath(options = {}) {
192
+ if (options.path) return options.path;
193
+ const env = options.env ?? process.env;
194
+ const cacheHome = options.home ? join(options.home, ".cache") : (env.XDG_CACHE_HOME || join(homedir(), ".cache"));
195
+ return join(cacheHome, "pi-quota", "last-good.json");
196
+ }
197
+
198
+ /**
199
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
200
+ * @returns {Record<string, { results?: import("./model.js").QuotaResult[], result: import("./model.js").QuotaResult, savedAt: number }>}
201
+ */
202
+ export function readLastGood(options = {}) {
203
+ try {
204
+ const parsed = JSON.parse(readFileSync(resolveLastGoodPath(options), "utf-8"));
205
+ return parsed && typeof parsed === "object" ? parsed : {};
206
+ } catch {
207
+ return {};
208
+ }
209
+ }
210
+
211
+ /**
212
+ * @param {Record<string, { results?: import("./model.js").QuotaResult[], result: import("./model.js").QuotaResult, savedAt: number }>} state
213
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
214
+ * @returns {boolean}
215
+ */
216
+ function writeLastGood(state, options = {}) {
217
+ const path = resolveLastGoodPath(options);
218
+ try {
219
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
220
+ const temporary = `${path}.${process.pid}.tmp`;
221
+ writeFileSync(temporary, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
222
+ renameSync(temporary, path);
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Update the store from a report and restore transiently-failed families.
231
+ *
232
+ * @param {import("./engine.js").PiQuotaReport} report
233
+ * @param {{
234
+ * now?: number,
235
+ * env?: Record<string, string | undefined>,
236
+ * home?: string,
237
+ * path?: string,
238
+ * maxAgeMs?: number,
239
+ * }} [options]
240
+ * @returns {{ report: import("./engine.js").PiQuotaReport, restored: string[], saved: string[] }}
241
+ */
242
+ export function mergeLastGood(report, options = {}) {
243
+ const now = options.now ?? Date.now();
244
+ const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_STICKY_AGE_MS;
245
+ const state = readLastGood(options);
246
+
247
+ /** @type {string[]} */
248
+ const restored = [];
249
+ /** @type {string[]} */
250
+ const saved = [];
251
+ /** @type {Record<string, import("./model.js").QuotaResult[]>} */
252
+ const byFamily = {};
253
+ /** @type {Map<string, import("./model.js").QuotaResult>} */
254
+ const replacements = new Map();
255
+ /** @type {string[]} */
256
+ const warnings = [...report.warnings];
257
+
258
+ for (const [family, results] of Object.entries(report.byFamily)) {
259
+ const fresh = results[0];
260
+ if (fresh?.ok && fresh.windows.length > 0) {
261
+ state[family] = { results, result: fresh, savedAt: now };
262
+ saved.push(family);
263
+ byFamily[family] = results;
264
+ continue;
265
+ }
266
+
267
+ const stored = state[family];
268
+ const ageMs = stored ? now - stored.savedAt : Infinity;
269
+ const isStoredFresh = ageMs <= maxAgeMs;
270
+ const hasExpiredToken = fresh?.expiresInMin !== null && fresh?.expiresInMin !== undefined && fresh.expiresInMin <= 0;
271
+ const canRestore =
272
+ stored?.result?.ok &&
273
+ stored.result.windows.length > 0 &&
274
+ !hasExpiredToken &&
275
+ !isAuthFailure(fresh?.error ?? null) &&
276
+ isStoredFresh &&
277
+ isTransientError(fresh?.error ?? null);
278
+ if (!canRestore) {
279
+ if (stored?.result?.ok && !isStoredFresh && isTransientError(fresh?.error ?? null)) {
280
+ warnings.push(
281
+ `${family}: discarded last known values because they are stale (${Math.round(ageMs / 60000)} min old, limit ${Math.round(maxAgeMs / 60000)} min)`,
282
+ );
283
+ }
284
+ byFamily[family] = results;
285
+ continue;
286
+ }
287
+
288
+ const ageMinutes = Math.max(0, Math.round((now - stored.savedAt) / 60000));
289
+ const storedList = stored.results || (stored.result ? [stored.result] : []);
290
+ const carriedList = storedList.map((item) => ({
291
+ ...item,
292
+ error: null,
293
+ note: `last known values, ${ageMinutes} min old (upstream temporarily unavailable)`,
294
+ windows: (item.windows ?? []).map((win) => {
295
+ if (!win.resetsAt) return win;
296
+ const parsedMs = Date.parse(win.resetsAt);
297
+ if (Number.isNaN(parsedMs)) return win;
298
+ return { ...win, resetsInSec: Math.max(0, Math.round((parsedMs - now) / 1000)) };
299
+ }),
300
+ }));
301
+ restored.push(family);
302
+ warnings.push(
303
+ `${family}: showing the last known values (${ageMinutes} min old) because the upstream call failed transiently`,
304
+ );
305
+ byFamily[family] = carriedList;
306
+ if (carriedList[0]) replacements.set(family, carriedList[0]);
307
+ }
308
+
309
+ if (saved.length > 0) writeLastGood(state, options);
310
+
311
+ // Same reason as above: keep the canonical order.
312
+ const providers = orderedProviders(report, replacements, byFamily);
313
+
314
+ return {
315
+ report: { ...report, providers, byFamily, warnings },
316
+ restored,
317
+ saved,
318
+ };
319
+ }