@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,963 @@
1
+ /**
2
+ * Pi quota line — read-only quota for the providers Pi already owns.
3
+ *
4
+ * Surface: nothing at session start. /usage box, /usage line or /usage panel pins
5
+ * a surface above the editor, /usage status enables the footer, and /usage hide
6
+ * removes it. No timer or post-turn refresh runs until a surface is on.
7
+ *
8
+ * Design rules:
9
+ * - the numbers are **used**, not remaining, so the line answers "how much have
10
+ * I burned" and the bar fills up as you spend;
11
+ * - each provider name is painted with its own brand colour, which Pi's theme
12
+ * does not expose, so it is emitted as truecolor ANSI. Widget string arrays
13
+ * are wrapped in Text components, which are ANSI-aware, so the codes are
14
+ * measured correctly. `NO_COLOR` / `TERM=dumb` disables them;
15
+ * - the semaphore is both colour and shape (empty circle = plenty left, filled
16
+ * circle = nearly spent), so it survives a colourblind or mono terminal;
17
+ * - the footer status is off by default: gentle-pi already owns that line and
18
+ * truncates it from the end.
19
+ *
20
+ * Command: /usage, with /usage refresh | line | panel | hide | status | nostatus |
21
+ * json. No surface is pinned at session start: the command is on demand.
22
+ */
23
+
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import { existsSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { dirname, join } from "node:path";
28
+ import { fileURLToPath, pathToFileURL } from "node:url";
29
+
30
+ // `mergeLastGood` is intentionally NOT imported here: it lives in src/sticky.js
31
+ // and the CLI (`bin/piquota.js`) already applies it on every `--json` run, so the
32
+ // extension sees a report whose transiently-failed families already carry the last
33
+ // good snapshot in `provider.note`. The classifier below mirrors src/providers/error-kind.js
34
+ // so the extension can render category-specific glyphs without depending on the repo.
35
+
36
+ const STATUS_KEY = "00-quota";
37
+ const WIDGET_KEY = "quota-line";
38
+ const RAIL_KEY = "quota";
39
+ // Symbol shared with gentle-pi's runtime sidebar state registry.
40
+ const SIDEBAR_STATE_KEY = Symbol.for("gentle-pi.experimental-sidebar.state");
41
+ const REFRESH_MS = Number(process.env.PI_QUOTA_REFRESH_MS) || 300_000;
42
+ const AGENT_END_DEBOUNCE_MS = 1_500;
43
+ const EXEC_TIMEOUT_MS = 30_000;
44
+
45
+ type QuotaWindow = {
46
+ id: string;
47
+ label: string;
48
+ usedPercent: number | null;
49
+ remainingPercent: number | null;
50
+ resetsAt: string | null;
51
+ resetsInSec: number | null;
52
+ windowSeconds?: number | null;
53
+ note: string | null;
54
+ };
55
+
56
+ type QuotaProvider = {
57
+ family: string;
58
+ label: string;
59
+ primaryWindowId?: string | null;
60
+ account: string;
61
+ plan: string | null;
62
+ windows: QuotaWindow[];
63
+ error: string | null;
64
+ note?: string | null;
65
+ ok: boolean;
66
+ notConfigured?: boolean;
67
+ sourceKind?: "pi" | "claude-code";
68
+ updatedAt: string;
69
+ expiresInMin: number | null;
70
+ };
71
+
72
+ type QuotaReport = {
73
+ providers: QuotaProvider[];
74
+ generatedAt: string;
75
+ sources: string[];
76
+ warnings: string[];
77
+ byFamily: Record<string, QuotaProvider[]>;
78
+ };
79
+
80
+ type Theme = {
81
+ fg: (key: string, text: string) => string;
82
+ bold: (text: string) => string;
83
+ bg?: (key: string, text: string) => string;
84
+ };
85
+
86
+ /** Only the UI surface this extension uses. */
87
+ type UiContext = {
88
+ hasUI: boolean;
89
+ ui: {
90
+ theme: Theme;
91
+ setStatus: (key: string, value: string | undefined) => void;
92
+ setWidget: (key: string, value: any, options?: { placement?: string }) => void;
93
+ notify: (message: string, type?: string) => void;
94
+ };
95
+ };
96
+
97
+ /** Brand accents. Anthropic clay, OpenAI teal, Google blue, OpenCode accent. */
98
+ const BRAND: Record<string, string> = {
99
+ claude: "#D97757",
100
+ codex: "#10A37F",
101
+ antigravity: "#4285F4",
102
+ "opencode-go": "#007AFF",
103
+ "github-actions": "#2088FF",
104
+ };
105
+
106
+ /** Short display names that stay legible in one row. */
107
+ const SHORT_NAME: Record<string, string> = {
108
+ claude: "Claude",
109
+ codex: "Codex",
110
+ antigravity: "Agy",
111
+ "opencode-go": "OP-Go",
112
+ "github-actions": "GH",
113
+ };
114
+
115
+ /** Semaphore bands, expressed in *used* percent. */
116
+ const BANDS = [
117
+ { max: 25, color: "#3FB950", glyph: "○" },
118
+ { max: 50, color: "#3FB950", glyph: "◔" },
119
+ { max: 80, color: "#D29922", glyph: "◕" },
120
+ { max: Infinity, color: "#F85149", glyph: "●" },
121
+ ];
122
+
123
+ function colorEnabled(): boolean {
124
+ return process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
125
+ }
126
+
127
+ /** 24-bit foreground colour; a no-op when colours are disabled. */
128
+ function paint(hex: string, text: string): string {
129
+ if (!colorEnabled() || !/^#[0-9a-fA-F]{6}$/.test(hex)) return text;
130
+ const r = Number.parseInt(hex.slice(1, 3), 16);
131
+ const g = Number.parseInt(hex.slice(3, 5), 16);
132
+ const b = Number.parseInt(hex.slice(5, 7), 16);
133
+ return `\u001b[38;2;${r};${g};${b}m${text}\u001b[0m`;
134
+ }
135
+
136
+ function bandFor(usedPercent: number) {
137
+ return BANDS.find((band) => usedPercent < band.max) ?? BANDS[BANDS.length - 1];
138
+ }
139
+
140
+ function usedOf(window: QuotaWindow | null): number | null {
141
+ if (!window) return null;
142
+ if (typeof window.usedPercent === "number" && Number.isFinite(window.usedPercent)) return window.usedPercent;
143
+ if (typeof window.remainingPercent === "number" && Number.isFinite(window.remainingPercent)) {
144
+ return 100 - window.remainingPercent;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function resolveCliPath(): string | null {
150
+ const override = process.env.PI_QUOTA_BIN;
151
+ if (override && existsSync(override)) return override;
152
+ try {
153
+ const sibling = fileURLToPath(new URL("../bin/piquota.js", import.meta.url));
154
+ if (existsSync(sibling)) return sibling;
155
+ } catch {}
156
+ for (const candidate of [
157
+ join(homedir(), ".local", "share", "pi-quota", "bin", "piquota.js"),
158
+ join(homedir(), ".local", "bin", "piquota"),
159
+ join(homedir(), ".pi", "agent", "npm", "node_modules", "@cad0p", "pi-usage", "bin", "piquota.js"),
160
+ join(homedir(), ".pi", "agent", "npm", "node_modules", "@j3pf", "piquota", "bin", "piquota.js"),
161
+ ]) {
162
+ if (existsSync(candidate)) return candidate;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * Rank by how soon a window constrains you: 5h/session first, then daily,
169
+ * weekly, monthly. Kept in sync with windowRank() in src/model.js.
170
+ */
171
+ function windowRank(window: QuotaWindow): number {
172
+ const seconds = window.windowSeconds;
173
+ if (typeof seconds === "number" && seconds > 0) {
174
+ if (seconds <= 6 * 3600) return 0;
175
+ if (seconds <= 36 * 3600) return 1;
176
+ if (seconds <= 8 * 86400) return 2;
177
+ if (seconds <= 31 * 86400) return 3;
178
+ }
179
+ const id = (window.id ?? "").toLowerCase();
180
+ if (/5h|session|hour|rolling/.test(id)) return 0;
181
+ if (/daily|day/.test(id)) return 1;
182
+ if (/weekly|week|7d/.test(id)) return 2;
183
+ if (/monthly|month|30d/.test(id)) return 3;
184
+ return 4;
185
+ }
186
+
187
+ /**
188
+ * The window that represents a provider: the shortest one, so the row answers
189
+ * "can I keep working right now". Prefers the window the CLI already chose.
190
+ */
191
+ function headline(provider: QuotaProvider): QuotaWindow | null {
192
+ const chosen = provider.windows.find((window) => window.id === provider.primaryWindowId);
193
+ if (chosen) return chosen;
194
+
195
+ const scored = provider.windows.filter((window) => usedOf(window) !== null);
196
+ if (scored.length === 0) return provider.windows[0] ?? null;
197
+ return scored.reduce((best, window) => {
198
+ const bestRank = windowRank(best);
199
+ const rank = windowRank(window);
200
+ if (rank !== bestRank) return rank < bestRank ? window : best;
201
+ return (usedOf(window) as number) > (usedOf(best) as number) ? window : best;
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Compact surfaces show the first account per family, because a fourteenth
207
+ * OpenCode Go account must not push Claude out of the footer. The full panel is
208
+ * the one place that lists every account.
209
+ */
210
+ function firstPerFamily(providers: QuotaProvider[]): QuotaProvider[] {
211
+ const seen = new Set<string>();
212
+ const out: QuotaProvider[] = [];
213
+ for (const provider of providers) {
214
+ if (seen.has(provider.family)) continue;
215
+ seen.add(provider.family);
216
+ out.push(provider);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ function bar(usedPercent: number | null, width = 10): string {
222
+ if (usedPercent === null) return "·".repeat(width);
223
+ const filled = Math.round((Math.max(0, Math.min(100, usedPercent)) / 100) * width);
224
+ return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled));
225
+ }
226
+
227
+ function humanDuration(seconds: number | null): string {
228
+ if (seconds === null || !Number.isFinite(seconds)) return "unknown";
229
+ if (seconds <= 0) return "now";
230
+ const total = Math.round(seconds);
231
+ const days = Math.floor(total / 86400);
232
+ const hours = Math.floor((total % 86400) / 3600);
233
+ const minutes = Math.floor((total % 3600) / 60);
234
+ if (days > 0) return `${days}d ${hours}h`;
235
+ if (hours > 0) return `${hours}h ${minutes}m`;
236
+ if (minutes > 0) return `${minutes}m`;
237
+ return `${total}s`;
238
+ }
239
+
240
+ type ErrorKind = "transient" | "throttle" | "expired" | "auth" | "missing" | "unknown";
241
+
242
+ /**
243
+ * Classify a provider error so the renderer can paint a distinct glyph and
244
+ * caption per cause. Mirrors src/providers/error-kind.js so the extension is
245
+ * self-contained: the file lives in ~/.pi/agent/extensions and has no access
246
+ * to the repo's `src/` tree.
247
+ */
248
+ function errorKind(error: string | null | undefined): ErrorKind {
249
+ if (!error || typeof error !== "string") return "unknown";
250
+ if (
251
+ /no [a-z-]+ credential|has no (anthropic|openai-codex|antigravity|opencode-go) (access )?token|no key stored|no "auth" cookie|no opencode\.ai session/i.test(
252
+ error,
253
+ )
254
+ ) {
255
+ return "missing";
256
+ }
257
+ if (
258
+ /token expired|token EXPIRED|token -?\d+m|expires in -\d+|sign-in expired|re-authenticate|expired or rejected|rejected by .* zen|rejected; run \/login|reconnect OpenCode in Pi/i.test(
259
+ error,
260
+ )
261
+ ) {
262
+ return "expired";
263
+ }
264
+ if (/HTTP 401|HTTP 403|401|403|unauthorized|forbidden|invalid_grant|token rejected/i.test(error)) {
265
+ return "auth";
266
+ }
267
+ if (/HTTP 429|rate limited|backing off|throttle/i.test(error)) {
268
+ return "throttle";
269
+ }
270
+ if (/HTTP 5\d\d|timed out|timeout|ECONNRESET|socket|network|fetch failed|ENOTFOUND|ETIMEDOUT|EPIPE|ECONNREFUSED|aborted|hang up/i.test(error)) {
271
+ return "transient";
272
+ }
273
+ return "unknown";
274
+ }
275
+
276
+ function isRecoverableError(error: string | null | undefined): boolean {
277
+ const kind = errorKind(error);
278
+ return kind === "transient" || kind === "throttle";
279
+ }
280
+
281
+ function errorGlyph(error: string | null | undefined): string {
282
+ switch (errorKind(error)) {
283
+ case "transient":
284
+ return "~";
285
+ case "throttle":
286
+ return "…";
287
+ case "expired":
288
+ case "auth":
289
+ return "!";
290
+ case "missing":
291
+ return "·";
292
+ default:
293
+ return "?";
294
+ }
295
+ }
296
+
297
+ function errorCaption(error: string | null | undefined): string {
298
+ switch (errorKind(error)) {
299
+ case "throttle":
300
+ return "rate-limited upstream";
301
+ case "transient":
302
+ return "upstream temporarily unavailable";
303
+ case "expired":
304
+ return "token expired";
305
+ case "auth":
306
+ return "credential rejected";
307
+ case "missing":
308
+ return "not configured";
309
+ default:
310
+ return "unavailable";
311
+ }
312
+ }
313
+
314
+ /**
315
+ * The one-row line: brand-coloured name plus a semaphore and the used percent.
316
+ *
317
+ * Claude:○ 0% Codex:○ 8% Agy:○ 4% Agy C/G:○ 4% OP-Go:○ 3%
318
+ */
319
+ function renderLine(report: QuotaReport, theme: Theme): string {
320
+ // Only display providers that are configured in Pi.
321
+ // A provider that is simply not configured does not belong in the editor line.
322
+ const active = firstPerFamily(report.providers.filter((provider) => provider.notConfigured !== true));
323
+ if (active.length === 0) return theme.fg("dim", "quota: no providers configured in Pi");
324
+
325
+ const renderToken = (provider: QuotaProvider, label: string, window: QuotaWindow | null, color = BRAND[provider.family] ?? "#8B949E"): string => {
326
+ const name = paint(color, `${label}:`);
327
+ if (!provider.ok) {
328
+ const kind = errorKind(provider.error);
329
+ const glyph = errorGlyph(provider.error);
330
+ const tone = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
331
+ return `${name}${theme.fg(tone, glyph)}`;
332
+ }
333
+
334
+ const used = usedOf(window);
335
+ if (used === null) return `${name}${theme.fg("dim", "?")}`;
336
+
337
+ const band = bandFor(used);
338
+ return `${name}${paint(band.color, band.glyph)} ${paint(band.color, `${Math.round(used)}%`)}`;
339
+ };
340
+ const tokens = active.flatMap((provider) => {
341
+ if (provider.family !== "antigravity") {
342
+ return [renderToken(provider, SHORT_NAME[provider.family] ?? provider.family, headline(provider))];
343
+ }
344
+ return [
345
+ renderToken(provider, "Agy", provider.windows.find((window) => window.id === "gemini-5h" || (/gemini/i.test(window.id) && isFiveHour(window)))),
346
+ renderToken(provider, "Agy C/G", provider.windows.find((window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))), "#7AA2F7"),
347
+ ];
348
+ });
349
+ return tokens.join(" ");
350
+ }
351
+
352
+ function visibleLength(text: string): number {
353
+ return text.replace(/\u001b\[[0-9;]*m/g, "").length;
354
+ }
355
+
356
+ /**
357
+ * Clip themed text to a visible width without cutting an ANSI sequence in half.
358
+ * gentle-pi drops the entire rail when one card line overflows its column, so a
359
+ * long error caption must shorten the line instead of widening it.
360
+ */
361
+ function clipVisible(text: string, width: number): string {
362
+ if (width <= 0) return "";
363
+ if (visibleLength(text) <= width) return text;
364
+ const tokens = text.match(/\u001b\[[0-9;]*m|[\s\S]/g) ?? [];
365
+ let out = "";
366
+ let used = 0;
367
+ for (const token of tokens) {
368
+ if (token.startsWith("\u001b")) {
369
+ out += token;
370
+ continue;
371
+ }
372
+ if (used >= width - 1) break;
373
+ out += token;
374
+ used += 1;
375
+ }
376
+ return `${out}\u2026\u001b[0m`;
377
+ }
378
+
379
+ function isFiveHour(window: QuotaWindow): boolean {
380
+ return (
381
+ window.id === "5h" ||
382
+ /5h|session|hour/i.test(`${window.id} ${window.label}`) ||
383
+ (typeof window.windowSeconds === "number" && window.windowSeconds <= 6 * 3600)
384
+ );
385
+ }
386
+
387
+ /** Compact five-row quota widget aligned to the right edge of the editor. */
388
+ export function renderBox(report: QuotaReport, theme: Theme, totalWidth?: number): string[] {
389
+ const providerFor = (family: string): QuotaProvider | undefined => report.providers.find((provider) => provider.family === family);
390
+ const windowFor = (family: string, matches: (window: QuotaWindow) => boolean) => {
391
+ const provider = providerFor(family);
392
+ return { provider, window: provider?.windows.find(matches) ?? null };
393
+ };
394
+ const rows = [
395
+ { label: "Claude: ", color: "#D97757", ...windowFor("claude", isFiveHour) },
396
+ { label: "Codex: ", color: "#10A37F", ...windowFor("codex", isFiveHour) },
397
+ { label: "Agy: ", color: "#4285F4", ...windowFor("antigravity", (window) => window.id === "gemini-5h" || (/gemini/i.test(window.id) && isFiveHour(window))) },
398
+ { label: "Agy C/G:", color: "#7AA2F7", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
399
+ { label: "OP-Go: ", color: "#007AFF", ...windowFor("opencode-go", isFiveHour) },
400
+ ];
401
+ if (providerFor("github-actions")) {
402
+ rows.push({ label: "GH Actions:", color: "#2088FF", ...windowFor("github-actions", (window) => window.id === "monthly") });
403
+ }
404
+ const content = rows.map(({ label, color, provider, window }) => {
405
+ const name = paint(color, label);
406
+ if (!provider || !provider.ok) {
407
+ return { left: `${name} ${theme.fg("error", `! ${provider ? errorCaption(provider.error) : "not configured"}`)}`, right: "" };
408
+ }
409
+ const used = usedOf(window);
410
+ let mid = theme.fg("dim", "? n/a");
411
+ if (used !== null) {
412
+ const band = bandFor(used);
413
+ mid = paint(band.color, `${band.glyph} ${`${Math.round(used)}%`.padStart(4)}`);
414
+ }
415
+ return {
416
+ left: `${name} ${mid}`,
417
+ right: `${theme.fg("dim", "R:")}${paint("#E0AF68", humanDuration(window?.resetsInSec ?? null))}`,
418
+ };
419
+ });
420
+ const innerWidth = Math.max(25, ...content.map(({ left, right }) => visibleLength(left) + (right ? 2 + visibleLength(right) : 0)));
421
+ const box = [
422
+ `╭─ Quota ${"─".repeat(Math.max(0, innerWidth - 6))}╮`,
423
+ ...content.map(({ left, right }) => {
424
+ const padding = right
425
+ ? " ".repeat(Math.max(1, innerWidth - visibleLength(left) - visibleLength(right)))
426
+ : " ".repeat(Math.max(0, innerWidth - visibleLength(left)));
427
+ return `│ ${left}${padding}${right} │`;
428
+ }),
429
+ `╰${"─".repeat(innerWidth + 2)}╯`,
430
+ ];
431
+ const width = totalWidth ?? (process.stdout.columns || 80);
432
+ return box.map((line) => `${" ".repeat(Math.max(0, width - visibleLength(line)))}${line}`);
433
+ }
434
+
435
+ function renderSidebarCard(report: QuotaReport | null, theme: Theme, width: number): string[] {
436
+ const cardWidth = Math.max(4, width);
437
+ const innerWidth = cardWidth - 4;
438
+ const providerFor = (family: string): QuotaProvider | undefined => report?.providers.find((provider) => provider.family === family);
439
+ const windowFor = (family: string, matches: (window: QuotaWindow) => boolean) => {
440
+ const provider = providerFor(family);
441
+ return { provider, window: provider?.windows.find(matches) ?? null };
442
+ };
443
+ const rows = report
444
+ ? [
445
+ { label: "Claude: ", color: "#D97757", ...windowFor("claude", isFiveHour) },
446
+ { label: "Codex: ", color: "#10A37F", ...windowFor("codex", isFiveHour) },
447
+ { label: "Agy: ", color: "#4285F4", ...windowFor("antigravity", (window) => window.id === "gemini-5h" || (/gemini/i.test(window.id) && isFiveHour(window))) },
448
+ { label: "Agy C/G:", color: "#7AA2F7", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
449
+ { label: "OP-Go: ", color: "#007AFF", ...windowFor("opencode-go", isFiveHour) },
450
+ ...(providerFor("github-actions")
451
+ ? [{ label: "GH Actions:", color: "#2088FF", ...windowFor("github-actions", (window) => window.id === "monthly") }]
452
+ : []),
453
+ ]
454
+ : [];
455
+ const content = rows.length > 0
456
+ ? rows.map(({ label, color, provider, window }) => {
457
+ const name = paint(color, label);
458
+ const showingLastKnownValues = Boolean(provider?.note && /last known values/i.test(provider.note));
459
+ if (!provider || (!provider.ok && !showingLastKnownValues)) {
460
+ return { left: `${name} ! ${provider ? errorCaption(provider.error) : "not configured"}`, right: "" };
461
+ }
462
+ const used = usedOf(window);
463
+ if (used === null) return { left: `${name} ? n/a`, right: "" };
464
+ const band = bandFor(used);
465
+ const indicator = showingLastKnownValues ? `${theme.fg("warning", "~")} ` : "";
466
+ return {
467
+ left: `${name} ${indicator}${paint(band.color, `${band.glyph} ${`${Math.round(used)}%`.padStart(4)}`)}`,
468
+ right: `${theme.fg("dim", "R:")}${paint("#E0AF68", humanDuration(window?.resetsInSec ?? null))}`,
469
+ };
470
+ })
471
+ : [{ left: theme.fg("dim", "loading quota …"), right: "" }];
472
+ return [
473
+ `╭─ Quota ${"─".repeat(Math.max(0, cardWidth - 10))}╮`,
474
+ ...content.map(({ left, right }) => {
475
+ // The right column is dropped before the left one is clipped: a reset countdown
476
+ // matters less than the provider name and its percentage.
477
+ const fits = right !== "" && 2 + visibleLength(left) + 2 + visibleLength(right) + 2 <= cardWidth;
478
+ const rightText = fits ? right : "";
479
+ const leftRoom = innerWidth - (rightText === "" ? 0 : visibleLength(rightText) + 1);
480
+ const leftText = clipVisible(left, Math.max(1, leftRoom));
481
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleLength(leftText) - visibleLength(rightText)));
482
+ return `│ ${leftText}${padding}${rightText} │`;
483
+ }),
484
+ `╰${"─".repeat(Math.max(0, cardWidth - 2))}╯`,
485
+ ];
486
+ }
487
+
488
+ /** The detailed panel: every account and every window, with a used-fraction bar. */
489
+ function renderPanel(report: QuotaReport, theme: Theme): string[] {
490
+ const lines: string[] = [];
491
+ const active = report.providers.filter((provider) => provider.notConfigured !== true);
492
+ const unconfigured = report.providers.filter((provider) => provider.notConfigured === true);
493
+
494
+ const familyCounts = new Map<string, number>();
495
+ for (const provider of active) familyCounts.set(provider.family, (familyCounts.get(provider.family) ?? 0) + 1);
496
+
497
+ for (const provider of active) {
498
+ // A multi-account family gets one row per account. The title carries the
499
+ // label ("OP-Go #2") so the account is not repeated in the meta line.
500
+ const multi = (familyCounts.get(provider.family) ?? 0) > 1
501
+ && typeof provider.account === "string"
502
+ && provider.account.startsWith("#");
503
+ const title = multi
504
+ ? `${SHORT_NAME[provider.family] ?? provider.label} ${provider.account}`
505
+ : provider.label;
506
+ const name = paint(BRAND[provider.family] ?? "#8B949E", title);
507
+ const meta: string[] = [];
508
+ if (!multi) meta.push(provider.account);
509
+ if (provider.plan) meta.push(`plan ${provider.plan}`);
510
+ // Two stores can back Claude, and this is the one place that says which.
511
+ if (provider.sourceKind === "claude-code") meta.push("Claude Code CLI");
512
+ if (provider.expiresInMin !== null && provider.expiresInMin <= 120) {
513
+ meta.push(
514
+ provider.expiresInMin < 0
515
+ ? theme.fg("error", "token expired")
516
+ : theme.fg("dim", `token ${provider.expiresInMin}m`),
517
+ );
518
+ }
519
+ lines.push(meta.length > 0 ? `${name} ${theme.fg("dim", `· ${meta.join(" · ")}`)}` : name);
520
+
521
+ if (!provider.ok) {
522
+ const kind = errorKind(provider.error);
523
+ const glyph = errorGlyph(provider.error);
524
+ const color = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
525
+ lines.push(` ${theme.fg(color, glyph)} ${theme.fg("warning", provider.note ?? errorCaption(provider.error))}`);
526
+ if (isRecoverableError(provider.error) && provider.note) lines.push(` ${theme.fg("warning", provider.note)}`);
527
+ continue;
528
+ }
529
+ if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
530
+ if (provider.windows.length === 0) {
531
+ lines.push(` ${theme.fg("dim", "no rate-limit windows reported")}`);
532
+ continue;
533
+ }
534
+
535
+ const width = Math.max(...provider.windows.map((window) => window.label.length));
536
+ for (const window of provider.windows) {
537
+ const used = usedOf(window);
538
+ const label = theme.fg("dim", window.label.padEnd(width));
539
+ if (used === null) {
540
+ lines.push(` ${label} ${theme.fg("dim", window.note ?? "no percentage reported")}`);
541
+ continue;
542
+ }
543
+ const band = bandFor(used);
544
+ const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
545
+ lines.push(
546
+ ` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
547
+ `${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
548
+ `${theme.fg("dim", "used")}${reset}`,
549
+ );
550
+ }
551
+ }
552
+ lines.push(theme.fg("dim", `used % · read-only · ${report.generatedAt}`));
553
+ if (unconfigured.length > 0) {
554
+ const names = unconfigured.map((p) => SHORT_NAME[p.family] ?? p.family).join(", ");
555
+ lines.push(theme.fg("dim", `not configured in Pi: ${names}`));
556
+ }
557
+ for (const warning of report.warnings.slice(0, 3)) {
558
+ lines.push(theme.fg("warning", `warn: ${warning}`));
559
+ }
560
+ return lines;
561
+ }
562
+
563
+ export default function quotaPanelExtension(pi: ExtensionAPI): void {
564
+ let report: QuotaReport | null = null;
565
+ let refreshing = false;
566
+ let timer: ReturnType<typeof setInterval> | null = null;
567
+ let agentEndTimer: ReturnType<typeof setTimeout> | null = null;
568
+ let panelVisible = false;
569
+ /**
570
+ * Nothing is pinned by default. A box or line above the editor is a persistent
571
+ * surface, and a quota command should not claim a row until asked.
572
+ */
573
+ let lineVisible = false;
574
+ let boxVisible = false;
575
+ let explicitBox = false;
576
+ /** gentle-pi owns the footer and truncates it from the end, so start off. */
577
+ let statusVisible = false;
578
+ let lastError: string | null = null;
579
+ let disposed = false;
580
+ /** True once the installed gentle-pi is known to paint a rail slot for this card. */
581
+ let railMode = false;
582
+
583
+ /**
584
+ * Load the rail inspector from the installed CLI tree. A missing patcher disables
585
+ * native-layout detection; the runtime chaining hook remains available.
586
+ */
587
+ async function loadRailPatcher(): Promise<{
588
+ resolveGentlePiLayout: (options?: { dir?: string }) => { layoutPath: string; present: boolean };
589
+ inspectRailPatch: (options: { layoutPath: string }) => { state: string };
590
+ } | null> {
591
+ const cli = resolveCliPath();
592
+ if (!cli) return null;
593
+ const modulePath = join(dirname(cli), "..", "src", "gentle-pi", "rail-patch.js");
594
+ if (!existsSync(modulePath)) return null;
595
+ try {
596
+ return await import(pathToFileURL(modulePath).href);
597
+ } catch {
598
+ return null;
599
+ }
600
+ }
601
+
602
+ /** Inspect the gentle-pi rail slot without changing package files. */
603
+ async function syncRailPatch(): Promise<void> {
604
+ railMode = false;
605
+ const patcher = await loadRailPatcher();
606
+ if (!patcher) return;
607
+ const layout = patcher.resolveGentlePiLayout();
608
+ if (!layout.present) return;
609
+ const inspection = patcher.inspectRailPatch({ layoutPath: layout.layoutPath });
610
+ railMode = inspection.state === "patched";
611
+ }
612
+
613
+ async function runCli(args: string[]): Promise<QuotaReport | null> {
614
+ const cli = resolveCliPath();
615
+ if (!cli) {
616
+ lastError = "piquota not installed: run install.sh from the pi-quota repo";
617
+ return null;
618
+ }
619
+ try {
620
+ const runner = /node(\.exe)?$/i.test(process.execPath) ? process.execPath : "node";
621
+ const result = await pi.exec(runner, [cli, ...args], { timeout: EXEC_TIMEOUT_MS });
622
+ if (!result.stdout || result.stdout.trim() === "") {
623
+ lastError = result.stderr?.trim() || "piquota produced no output";
624
+ return null;
625
+ }
626
+ const parsed = JSON.parse(result.stdout) as QuotaReport;
627
+ if (!parsed || !Array.isArray(parsed.providers)) {
628
+ lastError = "piquota output was not a quota report";
629
+ return null;
630
+ }
631
+ lastError = null;
632
+ return parsed;
633
+ } catch (error) {
634
+ lastError = (error as { message?: string })?.message ?? String(error);
635
+ return null;
636
+ }
637
+ }
638
+
639
+ function paintUi(ctx: UiContext): void {
640
+ if (disposed) return;
641
+ const theme = ctx.ui.theme;
642
+
643
+ if (statusVisible) {
644
+ ctx.ui.setStatus(STATUS_KEY, report ? renderLine(report, theme) : theme.fg("dim", "quota …"));
645
+ } else {
646
+ ctx.ui.setStatus(STATUS_KEY, undefined);
647
+ }
648
+
649
+ if (panelVisible && report) {
650
+ ctx.ui.setWidget(WIDGET_KEY, renderPanel(report, theme), { placement: "aboveEditor" });
651
+ return;
652
+ }
653
+
654
+ if (!boxVisible && !lineVisible) {
655
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
656
+ return;
657
+ }
658
+
659
+ ctx.ui.setWidget(
660
+ WIDGET_KEY,
661
+ (tui: any, widgetTheme: Theme) => {
662
+ const rawState = tui?.terminal?.[SIDEBAR_STATE_KEY];
663
+ const activeTheme = widgetTheme || theme;
664
+ const digest = () =>
665
+ report
666
+ ? `${report.generatedAt}:${report.providers
667
+ .map((provider) =>
668
+ provider.windows
669
+ .map((window) => [window.id, window.usedPercent, window.remainingPercent, window.resetsAt, window.resetsInSec].join(","))
670
+ .join(";"),
671
+ )
672
+ .join("|")}`
673
+ : "loading";
674
+ let nativeQuotaRendered = false;
675
+ const rail = {
676
+ digest,
677
+ render: (w: number) => {
678
+ nativeQuotaRendered = true;
679
+ return renderSidebarCard(report, activeTheme, w);
680
+ },
681
+ invalidate() {},
682
+ };
683
+
684
+ let railActive = Boolean(rawState?.active && rawState?.ownsHost?.());
685
+
686
+ if (rawState?.parts) {
687
+ rawState.parts.set(RAIL_KEY, rail);
688
+
689
+ // Runtime resilience: if gentle-pi's rail is active and has a footer part,
690
+ // but railMode is false (e.g. unpatched gentle-pi, freshly updated package before
691
+ // restart, or read-only filesystem), wrap the footer component so the quota card
692
+ // is seamlessly rendered right under the footer in the rail.
693
+ if (!railMode && rawState.parts.has("footer")) {
694
+ const originalFooter = rawState.parts.get("footer");
695
+ if (originalFooter && !(originalFooter as any).__piQuotaChained) {
696
+ const chainedFooter = {
697
+ ...originalFooter,
698
+ __piQuotaChained: true,
699
+ __piQuotaOriginal: originalFooter,
700
+ digest() {
701
+ const fd = originalFooter.digest?.() ?? "";
702
+ return nativeQuotaRendered ? fd : `${fd}:quota:${digest()}`;
703
+ },
704
+ render(w: number) {
705
+ const fl = [...(originalFooter.render(w) ?? [])];
706
+ if (nativeQuotaRendered) return fl;
707
+ const ql = renderSidebarCard(report, activeTheme, w);
708
+ if (!ql || ql.length === 0) return fl;
709
+ return [...fl, "", ...ql];
710
+ },
711
+ handleMouse(event: any) {
712
+ return originalFooter.handleMouse?.(event);
713
+ },
714
+ invalidate() {
715
+ originalFooter.invalidate?.();
716
+ },
717
+ };
718
+ rawState.parts.set("footer", chainedFooter);
719
+ railActive = Boolean(rawState.active && rawState.ownsHost?.());
720
+ } else if (originalFooter?.__piQuotaChained) {
721
+ railActive = Boolean(rawState.active && rawState.ownsHost?.());
722
+ }
723
+ }
724
+ }
725
+
726
+ const state = (railMode || railActive) ? rawState : undefined;
727
+
728
+ return {
729
+ render: (w: number) => {
730
+ const liveState = tui?.terminal?.[SIDEBAR_STATE_KEY] ?? rawState;
731
+ const isFullscreen = (tui as any)?.mode === "fullscreen";
732
+ const cols = tui?.terminal?.columns ?? process.stdout.columns ?? 0;
733
+ const isRailActive = Boolean(liveState && (liveState.active || (cols >= 140 && isFullscreen)));
734
+ if (isRailActive) return [];
735
+ if (!report) return [activeTheme.fg("dim", "quota …")];
736
+ // When gentle-pi is present and the sidebar is inactive or the terminal is narrow,
737
+ // compact to one row only when the box has not been selected.
738
+ if (((railMode && state !== undefined) || (state && !state.active)) && !explicitBox) {
739
+ return [renderLine(report, activeTheme)];
740
+ }
741
+ if (w < 100 && !explicitBox) {
742
+ return [renderLine(report, activeTheme)];
743
+ }
744
+ if (boxVisible) return renderBox(report, activeTheme, w);
745
+ if (lineVisible) return [renderLine(report, activeTheme)];
746
+ return [];
747
+ },
748
+ dispose: () => {
749
+ if (rawState?.parts?.get(RAIL_KEY) === rail) rawState.parts.delete(RAIL_KEY);
750
+ const currentFooter = rawState?.parts?.get("footer");
751
+ if (currentFooter?.__piQuotaChained && currentFooter.__piQuotaOriginal) {
752
+ rawState.parts.set("footer", currentFooter.__piQuotaOriginal);
753
+ }
754
+ },
755
+ };
756
+ },
757
+ { placement: "aboveEditor" },
758
+ );
759
+ }
760
+
761
+ /** True when something is pinned above the editor or the footer is on. */
762
+ function surfaceActive(): boolean {
763
+ return lineVisible || boxVisible || panelVisible || statusVisible;
764
+ }
765
+
766
+ /**
767
+ * Poll only once a surface has asked for live numbers. A bare /usage refresh is
768
+ * a one-shot read; it must not leave a timer behind.
769
+ */
770
+ function ensurePolling(ui: UiContext): void {
771
+ if (disposed || timer || !surfaceActive()) return;
772
+ timer = setInterval(() => {
773
+ if (!ui.hasUI || disposed) return;
774
+ void refresh(ui, false);
775
+ }, REFRESH_MS);
776
+ // SAFETY: Node.js Timer provides unref to prevent hanging process exit.
777
+ (timer as unknown as { unref?: () => void }).unref?.();
778
+ }
779
+
780
+ function stopPolling(): void {
781
+ if (!timer) return;
782
+ clearInterval(timer);
783
+ timer = null;
784
+ }
785
+
786
+ async function refresh(ctx: UiContext, force: boolean): Promise<void> {
787
+ if (refreshing) return;
788
+ refreshing = true;
789
+ try {
790
+ const next = await runCli(force ? ["--json", "--force"] : ["--json"]);
791
+ if (disposed) return;
792
+ if (next) {
793
+ report = next;
794
+ paintUi(ctx);
795
+ void runCli(["omarchy"]);
796
+ }
797
+ } finally {
798
+ refreshing = false;
799
+ }
800
+ }
801
+
802
+ pi.on("session_start", async (_event, ctx) => {
803
+ // SAFETY: Pi's session context implements UiContext when hasUI is true.
804
+ const ui = ctx as unknown as UiContext;
805
+ if (!ui.hasUI) return;
806
+ disposed = false;
807
+ // Resolve the native rail mode before the first paint.
808
+ await syncRailPatch();
809
+ if (disposed) return;
810
+ // Default: no persistent surface. Nothing is pinned above the editor, the
811
+ // footer stays untouched and no timer polls until /usage turns a surface on.
812
+ stopPolling();
813
+ paintUi(ui);
814
+ });
815
+
816
+ pi.on("agent_start", () => {
817
+ if (agentEndTimer) {
818
+ clearTimeout(agentEndTimer);
819
+ agentEndTimer = null;
820
+ }
821
+ });
822
+
823
+ pi.on("agent_end", async (_event, ctx) => {
824
+ // SAFETY: Pi's agent_end context implements UiContext when hasUI is true.
825
+ const ui = ctx as unknown as UiContext;
826
+ if (!ui.hasUI || disposed) return;
827
+ // A quota refresh after every turn is only useful when its result is visible.
828
+ if (!surfaceActive()) return;
829
+ if (agentEndTimer) clearTimeout(agentEndTimer);
830
+ const debounceMs = Number(process.env.PI_QUOTA_EVENT_DEBOUNCE_MS) || AGENT_END_DEBOUNCE_MS;
831
+ agentEndTimer = setTimeout(() => {
832
+ agentEndTimer = null;
833
+ if (!ui.hasUI || disposed) return;
834
+ void refresh(ui, false);
835
+ }, debounceMs);
836
+ // SAFETY: Node.js Timer provides unref to prevent hanging process exit.
837
+ (agentEndTimer as unknown as { unref?: () => void }).unref?.();
838
+ });
839
+
840
+ pi.on("session_shutdown", async (_event, ctx) => {
841
+ // SAFETY: Pi's session_shutdown context implements UiContext when hasUI is true.
842
+ const ui = ctx as unknown as UiContext;
843
+ disposed = true;
844
+ if (timer) {
845
+ clearInterval(timer);
846
+ timer = null;
847
+ }
848
+ if (agentEndTimer) {
849
+ clearTimeout(agentEndTimer);
850
+ agentEndTimer = null;
851
+ }
852
+ if (ui.hasUI) {
853
+ ui.ui.setStatus(STATUS_KEY, undefined);
854
+ ui.ui.setWidget(WIDGET_KEY, undefined);
855
+ }
856
+ });
857
+
858
+ const handler = async (args: string, ctx: UiContext): Promise<void> => {
859
+ if (!ctx.hasUI) return;
860
+ const action = args.trim().toLowerCase();
861
+
862
+ switch (action) {
863
+ case "refresh":
864
+ await refresh(ctx, true);
865
+ ctx.ui.notify(
866
+ report
867
+ ? `Quota refreshed (${report.providers.filter((provider) => provider.ok).length}/${report.providers.length} providers)`
868
+ : `Quota refresh failed: ${lastError}`,
869
+ report ? "info" : "error",
870
+ );
871
+ return;
872
+ case "line":
873
+ case "widget":
874
+ lineVisible = true;
875
+ boxVisible = false;
876
+ explicitBox = false;
877
+ panelVisible = false;
878
+ if (!report) await refresh(ctx, false);
879
+ ensurePolling(ctx);
880
+ paintUi(ctx);
881
+ ctx.ui.notify("Quota line pinned above the editor", "info");
882
+ return;
883
+ case "panel":
884
+ panelVisible = true;
885
+ lineVisible = false;
886
+ boxVisible = false;
887
+ explicitBox = false;
888
+ if (!report) await refresh(ctx, false);
889
+ ensurePolling(ctx);
890
+ paintUi(ctx);
891
+ ctx.ui.notify("Quota panel shown above the editor", "info");
892
+ return;
893
+ case "box":
894
+ lineVisible = true;
895
+ boxVisible = true;
896
+ explicitBox = true;
897
+ panelVisible = false;
898
+ if (!report) await refresh(ctx, false);
899
+ ensurePolling(ctx);
900
+ paintUi(ctx);
901
+ ctx.ui.notify("Quota box pinned above the editor", "info");
902
+ return;
903
+ case "hide":
904
+ case "off":
905
+ panelVisible = false;
906
+ lineVisible = false;
907
+ boxVisible = false;
908
+ explicitBox = false;
909
+ if (!surfaceActive()) stopPolling();
910
+ paintUi(ctx);
911
+ ctx.ui.notify("Quota hidden", "info");
912
+ return;
913
+ case "status":
914
+ statusVisible = true;
915
+ ensurePolling(ctx);
916
+ paintUi(ctx);
917
+ ctx.ui.notify("Also showing quota in the footer (may be truncated when the bar is full)", "info");
918
+ return;
919
+ case "nostatus":
920
+ statusVisible = false;
921
+ if (!surfaceActive()) stopPolling();
922
+ paintUi(ctx);
923
+ return;
924
+ case "json":
925
+ ctx.ui.notify(`${resolveCliPath() ?? "piquota"} --json (cached at ~/.cache/pi-quota/usage.json)`, "info");
926
+ return;
927
+ default:
928
+ break;
929
+ }
930
+
931
+ // Bare /usage: one on-demand refresh, show the panel, report the percentages.
932
+ panelVisible = true;
933
+ lineVisible = false;
934
+ boxVisible = false;
935
+ await refresh(ctx, true);
936
+ ensurePolling(ctx);
937
+ paintUi(ctx);
938
+ if (!report) {
939
+ ctx.ui.notify(`Quota unavailable: ${lastError}`, "error");
940
+ return;
941
+ }
942
+ const active = firstPerFamily(report.providers.filter((provider) => provider.notConfigured !== true));
943
+ const summary = active
944
+ .map((provider) => {
945
+ const name = SHORT_NAME[provider.family] ?? provider.family;
946
+ if (!provider.ok) return `${name}: ${errorCaption(provider.error)}`;
947
+ const used = usedOf(headline(provider));
948
+ return `${name}: ${used === null ? "n/a" : `${Math.round(used)}% used`}`;
949
+ })
950
+ .join(" · ");
951
+ ctx.ui.notify(summary || "No active providers configured in Pi", "info");
952
+ };
953
+
954
+ pi.registerCommand("usage", {
955
+ description: "Show read-only provider quota on demand (used %, shortest window)",
956
+ getArgumentCompletions: (prefix: string) => {
957
+ const options = ["refresh", "line", "box", "panel", "hide", "status", "nostatus", "json"];
958
+ const matches = options.filter((option) => option.startsWith(prefix));
959
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
960
+ },
961
+ handler,
962
+ });
963
+ }