@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,462 @@
1
+ /**
2
+ * Map piQuota's normalized report onto Omarchy's agent usage records.
3
+ *
4
+ * Omarchy's "Agents" bar panel draws whatever `*.json` record it finds in its
5
+ * usage directory. This module is pure: it turns a `PiQuotaReport` into the
6
+ * minimal valid record per provider and never touches the disk, the network or
7
+ * credentials. Only plan names, window labels, percentages and reset times can
8
+ * reach a record; accounts and e-mail addresses are never copied, and every free
9
+ * text field goes through `redact()` first.
10
+ *
11
+ * Record contract (see Omarchy's plugins/agents/Main.qml):
12
+ * id, name, ready, updatedAt, schemaVersion, tierLabel, usageStatusText,
13
+ * authHelpText and `limits[{label, title, percent, resetsAt}]`. The local
14
+ * token statistics are optional and are deliberately left out; the panel
15
+ * treats their absence as "no local stats".
16
+ *
17
+ * `percent` is a FRACTION in 0..1 (the panel multiplies it by 100), so 40.3% used
18
+ * is written as 0.403.
19
+ */
20
+
21
+ import { redact } from "../http.js";
22
+ import { errorKind } from "../providers/error-kind.js";
23
+
24
+ export const RECORD_PREFIX = "pi-";
25
+ export const SCHEMA_VERSION = 1;
26
+
27
+ /**
28
+ * Pi's own provider name per family, for the "sign in again" hint.
29
+ */
30
+ const LOGIN_HINT = {
31
+ claude: "Run `claude` once, or `/login anthropic` in Pi, to restore usage.",
32
+ codex: "Run `/login openai-codex` in Pi to restore usage.",
33
+ antigravity: "Run `/login antigravity` in Pi to restore usage.",
34
+ "opencode-go": "Run `piquota auth opencode` to restore usage.",
35
+ };
36
+
37
+ const HELP_MAX = 200;
38
+
39
+ /**
40
+ * The panel hides a provider that has no limits and no local stats. An error
41
+ * record has neither, so it carries one placeholder window with a negative
42
+ * percent: the panel's own filter (`percent >= 0`) never draws it, but its
43
+ * presence keeps the provider's tab, and the status card, on screen.
44
+ */
45
+ const PLACEHOLDER_LIMIT = Object.freeze({ label: "Unavailable", title: "Unavailable", percent: -1, resetsAt: "" });
46
+
47
+ /**
48
+ * @param {string} family
49
+ * @returns {string}
50
+ */
51
+ export function recordId(family) {
52
+ return `${RECORD_PREFIX}${family}`;
53
+ }
54
+
55
+ /**
56
+ * @param {string | null | undefined} id
57
+ * @returns {boolean}
58
+ */
59
+ export function isPiRecordId(id) {
60
+ return typeof id === "string" && /^pi-[a-z0-9][a-z0-9-]*$/.test(id);
61
+ }
62
+
63
+ /**
64
+ * @param {import("../model.js").QuotaWindow} window
65
+ * @param {import("../model.js").QuotaResult} [provider]
66
+ * @returns {string}
67
+ */
68
+ function windowTitle(window, provider) {
69
+ if (provider?.family === "github-actions") {
70
+ const match = typeof window.note === "string"
71
+ ? window.note.match(/(\d+(?:\.\d+)?)\s+of\s+(\d+(?:\.\d+)?)\s+min/i)
72
+ : null;
73
+ const minutesLabel = match ? `${match[1]} / ${match[2]} min` : null;
74
+
75
+ let org = "";
76
+ if (window.label && window.label !== "Monthly window" && window.label !== "monthly") {
77
+ org = window.label.replace(/\s+(?:monthly.*|\(monthly.*\))$/i, "").trim();
78
+ }
79
+ if (!org && provider.account) {
80
+ org = provider.account;
81
+ }
82
+
83
+ if (minutesLabel) {
84
+ return org ? `${org} (${minutesLabel})` : `Monthly (${minutesLabel})`;
85
+ }
86
+ if (org && org !== "GitHub Actions") {
87
+ return org;
88
+ }
89
+ return "Monthly";
90
+ }
91
+
92
+ switch (window.id) {
93
+ case "5h":
94
+ return "Session";
95
+ case "weekly":
96
+ return "Weekly";
97
+ case "monthly":
98
+ return "Monthly";
99
+ default:
100
+ return window.label || window.id || "Limit";
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @param {import("../model.js").QuotaWindow[]} windows
106
+ * @param {import("../model.js").QuotaResult} [provider]
107
+ * @param {string} [prefix] Account prefix for a multi-account family, e.g. "#2 · ".
108
+ * @returns {Array<{ label: string, title: string, percent: number, resetsAt: string }>}
109
+ */
110
+ function mapLimits(windows, provider, prefix = "") {
111
+ const limits = [];
112
+ for (const window of windows ?? []) {
113
+ // A window without a percentage would otherwise read as "0% used".
114
+ if (typeof window.usedPercent !== "number" || !Number.isFinite(window.usedPercent)) continue;
115
+ limits.push({
116
+ label: redact(`${prefix}${window.label || window.id || "Limit"}`),
117
+ title: redact(`${prefix}${windowTitle(window, provider)}`),
118
+ percent: Math.min(1, Math.max(0, window.usedPercent / 100)),
119
+ resetsAt: typeof window.resetsAt === "string" ? window.resetsAt : "",
120
+ });
121
+ }
122
+ return limits;
123
+ }
124
+
125
+ /**
126
+ * @param {string} family
127
+ * @param {string | null | undefined} error
128
+ * @returns {{ usageStatusText: string, authHelpText: string }}
129
+ */
130
+ export function describeError(family, error) {
131
+ const kind = errorKind(error);
132
+ const login = LOGIN_HINT[family] ?? "Sign in again to restore usage.";
133
+ switch (kind) {
134
+ case "expired":
135
+ return { usageStatusText: "Sign-in expired", authHelpText: login };
136
+ case "auth":
137
+ return { usageStatusText: "Credential rejected", authHelpText: login };
138
+ case "throttle":
139
+ return { usageStatusText: "Rate limited", authHelpText: "The provider throttled the usage endpoint; piQuota retries on its next run." };
140
+ case "transient":
141
+ return { usageStatusText: "Temporarily unavailable", authHelpText: "The provider did not answer; piQuota retries on its next run." };
142
+ case "missing":
143
+ return { usageStatusText: "Not configured", authHelpText: login };
144
+ default:
145
+ return { usageStatusText: "Quota unavailable", authHelpText: redact(error ?? "no data").slice(0, HELP_MAX) };
146
+ }
147
+ }
148
+
149
+ const SHORT_NAMES = {
150
+ "github-actions": "GH Actions",
151
+ antigravity: "Agy",
152
+ "opencode-go": "OP-Go",
153
+ };
154
+
155
+ /**
156
+ * @param {import("../model.js").QuotaResult} provider
157
+ * @returns {string}
158
+ */
159
+ function recordName(provider) {
160
+ if (SHORT_NAMES[provider.family]) {
161
+ return SHORT_NAMES[provider.family];
162
+ }
163
+ return redact(String(provider.label || provider.family).replace(/\s*\(Pi\)\s*$/i, ""));
164
+ }
165
+
166
+ /**
167
+ * @param {import("../model.js").QuotaResult} provider
168
+ * @returns {string}
169
+ */
170
+ function recordTierLabel(provider) {
171
+ if (provider.family === "github-actions") {
172
+ const account = provider.account ? redact(provider.account) : "";
173
+ const plan = provider.plan ? redact(provider.plan) : "";
174
+ if (account && plan) return `${account} · ${plan}`;
175
+ return account || plan || "";
176
+ }
177
+ return provider.plan ? redact(provider.plan) : "";
178
+ }
179
+
180
+ /**
181
+ * Slot label of one account: "#1" for the base entry (or the first result when
182
+ * the provider does not carry slots at all), "#<slot>" for a sibling.
183
+ *
184
+ * @param {import("../model.js").QuotaResult} provider
185
+ * @param {number} index
186
+ * @returns {string}
187
+ */
188
+ function accountLabel(provider, index) {
189
+ if (typeof provider.slot === "string" && provider.slot !== "") return `#${provider.slot}`;
190
+ if (typeof provider.account === "string" && /^#\d/.test(provider.account)) return provider.account;
191
+ return `#${index + 1}`;
192
+ }
193
+
194
+ /**
195
+ * The window an account is actually constrained by: the most consumed one, and
196
+ * on a tie the one that resets first. Unknown reset times sort last.
197
+ *
198
+ * @param {import("../model.js").QuotaWindow[]} windows
199
+ * @returns {import("../model.js").QuotaWindow | null}
200
+ */
201
+ function bindingWindow(windows) {
202
+ /** @type {import("../model.js").QuotaWindow | null} */
203
+ let best = null;
204
+ for (const window of windows ?? []) {
205
+ if (typeof window.usedPercent !== "number" || !Number.isFinite(window.usedPercent)) continue;
206
+ if (!best) {
207
+ best = window;
208
+ continue;
209
+ }
210
+ const used = window.usedPercent;
211
+ const bestUsed = /** @type {number} */ (best.usedPercent);
212
+ if (used > bestUsed) {
213
+ best = window;
214
+ continue;
215
+ }
216
+ if (used === bestUsed && resetOrder(window.resetsAt) < resetOrder(best.resetsAt)) best = window;
217
+ }
218
+ return best;
219
+ }
220
+
221
+ /**
222
+ * @param {string | null | undefined} resetsAt
223
+ * @returns {number} Epoch ms, or Infinity when unknown, so unknown resets sort last.
224
+ */
225
+ function resetOrder(resetsAt) {
226
+ if (typeof resetsAt !== "string" || resetsAt === "") return Number.POSITIVE_INFINITY;
227
+ const parsed = Date.parse(resetsAt);
228
+ return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
229
+ }
230
+
231
+ /**
232
+ * A short, user-facing reason for one failed account.
233
+ *
234
+ * @param {string | null | undefined} error
235
+ * @returns {string}
236
+ */
237
+ function failureReason(error) {
238
+ const text = typeof error === "string" ? error : "";
239
+ if (/key rejected|key was rejected/i.test(text)) return "key rejected";
240
+ if (/Go plan not active|Workspace access denied/i.test(text)) return "no Go plan";
241
+ switch (errorKind(error)) {
242
+ case "expired":
243
+ return "sign-in expired";
244
+ case "auth":
245
+ return "credential rejected";
246
+ case "throttle":
247
+ return "rate limited";
248
+ case "transient":
249
+ return "temporarily unavailable";
250
+ case "missing":
251
+ return "not configured";
252
+ default:
253
+ return "unavailable";
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Collapse consecutive numeric account labels into ranges: #8,#9,#10 -> "#8–#10".
259
+ *
260
+ * @param {string[]} labels
261
+ * @returns {string}
262
+ */
263
+ function compactLabels(labels) {
264
+ /** @type {string[]} */
265
+ const parts = [];
266
+ /** @type {number | null} */
267
+ let runStart = null;
268
+ /** @type {number | null} */
269
+ let runEnd = null;
270
+ const flush = () => {
271
+ if (runStart === null || runEnd === null) return;
272
+ parts.push(runStart === runEnd ? `#${runStart}` : `#${runStart}–#${runEnd}`);
273
+ runStart = null;
274
+ runEnd = null;
275
+ };
276
+ for (const label of labels) {
277
+ const match = /^#(\d+)$/.exec(label);
278
+ if (!match) {
279
+ flush();
280
+ parts.push(label);
281
+ continue;
282
+ }
283
+ const number = Number(match[1]);
284
+ if (runStart === null || runEnd === null) {
285
+ runStart = number;
286
+ runEnd = number;
287
+ } else if (number === runEnd + 1) {
288
+ runEnd = number;
289
+ } else {
290
+ flush();
291
+ runStart = number;
292
+ runEnd = number;
293
+ }
294
+ }
295
+ flush();
296
+ return parts.join(", ");
297
+ }
298
+
299
+ /**
300
+ * Explain failures compactly, grouped by reason in slot order, e.g.
301
+ * "#8–#10 key rejected · #11–#14 no Go plan".
302
+ *
303
+ * @param {Array<{ label: string, reason: string }>} failures
304
+ * @returns {string}
305
+ */
306
+ function compactFailureList(failures) {
307
+ /** @type {string[]} */
308
+ const groups = [];
309
+ let index = 0;
310
+ while (index < failures.length) {
311
+ const reason = failures[index].reason;
312
+ /** @type {string[]} */
313
+ const labels = [];
314
+ while (index < failures.length && failures[index].reason === reason) {
315
+ labels.push(failures[index].label);
316
+ index += 1;
317
+ }
318
+ groups.push(`${compactLabels(labels)} ${reason}`);
319
+ }
320
+ const text = redact(groups.join(" · "));
321
+ return text.length <= HELP_MAX ? text : `${text.slice(0, HELP_MAX - 1).trimEnd()}…`;
322
+ }
323
+
324
+ /**
325
+ * One record for a family with several accounts.
326
+ *
327
+ * Each healthy account contributes only the window that constrains it (the most
328
+ * consumed one), so a fourteen-account family stays a readable tab instead of a
329
+ * wall of forty rows. Failed accounts contribute no rows at all: they are
330
+ * summarised in the status and the compact help text, which is what makes a
331
+ * revoked key distinguishable from a workspace without a plan.
332
+ *
333
+ * @param {string} family
334
+ * @param {import("../model.js").QuotaResult[]} providers
335
+ * @param {{ now?: number }} [options]
336
+ * @returns {{ id: string, record: Record<string, unknown> } | null}
337
+ */
338
+ function buildFamilyRecord(family, providers, options = {}) {
339
+ const now = options.now ?? Date.now();
340
+ const first = providers[0];
341
+ if (!first) return null;
342
+ if (providers.every((provider) => !provider.ok && errorKind(provider.error) === "missing")) return null;
343
+
344
+ /** @type {Array<{ label: string, title: string, percent: number, resetsAt: string }>} */
345
+ const limits = [];
346
+ /** @type {Array<{ label: string, reason: string }>} */
347
+ const failures = [];
348
+ let okCount = 0;
349
+
350
+ providers.forEach((provider, index) => {
351
+ const label = accountLabel(provider, index);
352
+ if (provider.ok) {
353
+ okCount += 1;
354
+ const window = bindingWindow(provider.windows);
355
+ const mapped = window ? mapLimits([window], provider, `${label} · `)[0] : null;
356
+ if (mapped) limits.push(mapped);
357
+ return;
358
+ }
359
+ failures.push({ label, reason: failureReason(provider.error) });
360
+ });
361
+
362
+ const failedCount = providers.length - okCount;
363
+ const allFailed = okCount === 0;
364
+ const status = allFailed
365
+ ? describeError(family, first.error ?? "no usage windows")
366
+ : {
367
+ usageStatusText: failedCount > 0 ? `${failedCount} of ${providers.length} accounts unavailable` : "",
368
+ authHelpText: "",
369
+ };
370
+
371
+ const id = recordId(family);
372
+ return {
373
+ id,
374
+ record: {
375
+ schemaVersion: SCHEMA_VERSION,
376
+ id,
377
+ name: recordName(first),
378
+ ready: okCount > 0,
379
+ tierLabel: `${okCount} of ${providers.length} accounts`,
380
+ limits: limits.length > 0 ? limits : [{ ...PLACEHOLDER_LIMIT }],
381
+ usageStatusText: status.usageStatusText,
382
+ authHelpText: failures.length > 0 ? compactFailureList(failures) : status.authHelpText,
383
+ hasLocalStats: false,
384
+ hasPromptStats: false,
385
+ updatedAt: new Date(now).toISOString(),
386
+ },
387
+ };
388
+ }
389
+
390
+ /**
391
+ * Build the record for one provider, or `null` when the provider is simply not
392
+ * configured: a tab that only says "not configured" is noise, so it is omitted
393
+ * (and any record from an earlier run is removed by the publisher).
394
+ *
395
+ * @param {import("../model.js").QuotaResult} provider
396
+ * @param {{ now?: number }} [options]
397
+ * @returns {{ id: string, record: Record<string, unknown> } | null}
398
+ */
399
+ export function buildRecord(provider, options = {}) {
400
+ const now = options.now ?? Date.now();
401
+ const limits = mapLimits(provider.windows, provider);
402
+ const failed = !provider.ok || limits.length === 0;
403
+
404
+ if (failed && errorKind(provider.error) === "missing") return null;
405
+
406
+ const status = failed
407
+ ? describeError(provider.family, provider.error ?? "no usage windows")
408
+ : { usageStatusText: "", authHelpText: "" };
409
+
410
+ const id = recordId(provider.family);
411
+ return {
412
+ id,
413
+ record: {
414
+ schemaVersion: SCHEMA_VERSION,
415
+ id,
416
+ name: recordName(provider),
417
+ ready: !failed,
418
+ tierLabel: recordTierLabel(provider),
419
+ limits: failed && limits.length === 0 ? [{ ...PLACEHOLDER_LIMIT }] : limits,
420
+ usageStatusText: status.usageStatusText,
421
+ authHelpText: status.authHelpText,
422
+ hasLocalStats: false,
423
+ hasPromptStats: false,
424
+ updatedAt: new Date(now).toISOString(),
425
+ },
426
+ };
427
+ }
428
+
429
+ /**
430
+ * One record per family. A family with a single result produces exactly the same
431
+ * record as before; several accounts collapse into one record with one binding
432
+ * window per healthy account.
433
+ *
434
+ * Never throws: a malformed provider entry is skipped.
435
+ *
436
+ * @param {import("../engine.js").PiQuotaReport} report
437
+ * @param {{ now?: number }} [options]
438
+ * @returns {Array<{ id: string, record: Record<string, unknown> }>}
439
+ */
440
+ export function buildRecords(report, options = {}) {
441
+ /** @type {Map<string, import("../model.js").QuotaResult[]>} */
442
+ const groups = new Map();
443
+ for (const provider of report?.providers ?? []) {
444
+ if (!provider || typeof provider.family !== "string") continue;
445
+ const list = groups.get(provider.family) ?? [];
446
+ list.push(provider);
447
+ groups.set(provider.family, list);
448
+ }
449
+
450
+ const records = [];
451
+ for (const [family, providers] of groups) {
452
+ try {
453
+ const built = providers.length > 1
454
+ ? buildFamilyRecord(family, providers, options)
455
+ : buildRecord(providers[0], options);
456
+ if (built) records.push(built);
457
+ } catch {
458
+ // A broken entry must not take the other providers' records down with it.
459
+ }
460
+ }
461
+ return records;
462
+ }