@bacnh85/pi-sub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +90 -0
  2. package/index.ts +349 -0
  3. package/package.json +43 -0
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # pi-sub
2
+
3
+ Pi extension that shows subscription usage for the currently selected supported model provider.
4
+
5
+ V1 supports OpenAI Codex models via `@loongphy/codex-auth` and displays a compact footer status only while the active Pi model provider is `openai-codex`.
6
+
7
+ ## Install
8
+
9
+ From npm after the package is published:
10
+
11
+ ```bash
12
+ pi install npm:@bacnh85/pi-sub
13
+ ```
14
+
15
+ From this repository checkout:
16
+
17
+ ```bash
18
+ cd extensions/pi-sub
19
+ npm install
20
+ cd ../..
21
+
22
+ pi install ./extensions/pi-sub
23
+ # or test directly
24
+ pi -e ./extensions/pi-sub
25
+ ```
26
+
27
+ ## What it shows
28
+
29
+ The footer status includes:
30
+
31
+ - active account email;
32
+ - subscription plan, such as `Plus`;
33
+ - 5-hour usage percentage and reset time;
34
+ - weekly usage percentage and reset time/date;
35
+ - active Codex model id.
36
+
37
+ Example compact footer text:
38
+
39
+ ```text
40
+ Sub Plus user@example.com 5H 85%→18:12 W 80%→2 Jul 08:00 · gpt-5-codex
41
+ ```
42
+
43
+ When the current model provider is not supported, `pi-sub` clears its footer status and does not refresh subscription data.
44
+
45
+ ## Commands
46
+
47
+ | Command | Description |
48
+ | --- | --- |
49
+ | `/sub` | Show detailed subscription usage for the current supported provider. |
50
+ | `/sub status` | Same as `/sub`. |
51
+ | `/sub refresh` | Force a usage refresh, then show details. |
52
+
53
+ When Codex account data is available, `/sub` shows a table similar to `codex-auth list`:
54
+
55
+ ```text
56
+ ACCOUNT PLAN 5H USAGE WEEKLY USAGE LAST ACTIVITY
57
+ * hangdanchi@gmail.com Plus 85% (18:12) 80% (08:00 on 2 Jul) Now
58
+ mail@bacnh.com Plus 75% (14:21) 20% (14:23 on 29 Jun) Now
59
+ ```
60
+
61
+ ## Refresh behavior
62
+
63
+ `pi-sub` refreshes usage data:
64
+
65
+ - when a session starts on a supported provider;
66
+ - when switching into a supported provider;
67
+ - after provider responses, debounced;
68
+ - periodically while a supported provider remains active;
69
+ - when `/sub refresh` is run.
70
+
71
+ Refreshes are cached briefly to avoid excessive `codex-auth` calls.
72
+
73
+ ## Requirements and troubleshooting
74
+
75
+ - `@loongphy/codex-auth` is installed as a runtime dependency of this package.
76
+ - Codex account data must already be configured for `codex-auth list` to return usage.
77
+ - Node.js 22+ is required by `@loongphy/codex-auth`.
78
+ - `pi-sub` redacts auth/token-related errors in the footer and never prints Codex tokens.
79
+
80
+ If usage is unavailable, run:
81
+
82
+ ```bash
83
+ npx @loongphy/codex-auth list
84
+ ```
85
+
86
+ outside Pi to verify the underlying Codex account state.
87
+
88
+ ## Design notes
89
+
90
+ The extension is named `pi-sub` rather than `pi-codex-usage` so future subscription providers can be added as separate adapters. V1 intentionally supports only OpenAI Codex.
package/index.ts ADDED
@@ -0,0 +1,349 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Text } from "@earendil-works/pi-tui";
3
+ import { spawn } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const STATUS_KEY = "pi-sub";
8
+ const MESSAGE_TYPE = "pi-sub-status";
9
+ const REFRESH_INTERVAL_MS = 60_000;
10
+ const REFRESH_TTL_MS = 30_000;
11
+ const REFRESH_DEBOUNCE_MS = 2_000;
12
+
13
+ type ModelLike = { provider?: string; id?: string } | undefined;
14
+
15
+ interface UsageWindow {
16
+ used?: number;
17
+ limit?: number;
18
+ percent?: number;
19
+ resetLabel?: string;
20
+ resetsAt?: string | number | Date;
21
+ label?: string;
22
+ }
23
+
24
+ interface SubscriptionAccountSnapshot {
25
+ id?: string;
26
+ isActive?: boolean;
27
+ accountLabel?: string;
28
+ plan?: string;
29
+ fiveHour?: UsageWindow;
30
+ weekly?: UsageWindow;
31
+ lastActivity?: string;
32
+ }
33
+
34
+ interface SubscriptionUsageSnapshot {
35
+ providerId: string;
36
+ providerDisplayName: string;
37
+ accounts: SubscriptionAccountSnapshot[];
38
+ activeAccount?: SubscriptionAccountSnapshot;
39
+ fetchedAt: number;
40
+ error?: string;
41
+ }
42
+
43
+ interface SubscriptionProviderAdapter {
44
+ id: string;
45
+ displayName: string;
46
+ isModelSupported(model: ModelLike): boolean;
47
+ fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot>;
48
+ }
49
+
50
+ interface State {
51
+ model?: ModelLike;
52
+ adapter?: SubscriptionProviderAdapter;
53
+ snapshot?: SubscriptionUsageSnapshot;
54
+ lastRefreshAt: number;
55
+ inFlight?: Promise<SubscriptionUsageSnapshot>;
56
+ refreshTimer?: NodeJS.Timeout;
57
+ debounceTimer?: NodeJS.Timeout;
58
+ }
59
+
60
+ function isCodexModel(model: ModelLike): boolean {
61
+ const provider = model?.provider?.toLowerCase() ?? "";
62
+ return provider === "openai-codex" || provider.includes("openai-codex");
63
+ }
64
+
65
+ function parsePercent(value: string): number | undefined {
66
+ const match = value.match(/(\d+(?:\.\d+)?)\s*%/);
67
+ return match ? Number(match[1]) : undefined;
68
+ }
69
+
70
+ function parseUsageWindow(value: string | undefined): UsageWindow | undefined {
71
+ if (!value) return undefined;
72
+ const trimmed = value.trim();
73
+ if (!trimmed || trimmed === "-" || trimmed === "—") return undefined;
74
+ const percent = parsePercent(trimmed);
75
+ const resetMatch = trimmed.match(/\(([^)]+)\)/);
76
+ return {
77
+ percent,
78
+ resetLabel: resetMatch?.[1]?.trim(),
79
+ label: trimmed,
80
+ };
81
+ }
82
+
83
+ function stripAnsi(value: string): string {
84
+ return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
85
+ }
86
+
87
+ function parseCodexAuthList(output: string): SubscriptionUsageSnapshot {
88
+ const accounts: SubscriptionAccountSnapshot[] = [];
89
+ const lines = stripAnsi(output).split(/\r?\n/).map((line) => line.trimEnd()).filter(Boolean);
90
+ const dataLines = lines.filter((line) => !/^ACCOUNT\s+PLAN\s+5H USAGE/i.test(line) && !/^-{5,}$/.test(line.trim()));
91
+
92
+ for (const line of dataLines) {
93
+ const match = line.match(/^(\*)?\s*(?:(\d+)\s+)?(\S+@\S+)\s+(\S+)\s+(\d+(?:\.\d+)?%\s*\([^)]*\)|[-—?])\s+(\d+(?:\.\d+)?%\s*\([^)]*\)|[-—?])\s*(.*)$/);
94
+ if (!match) continue;
95
+ accounts.push({
96
+ isActive: Boolean(match[1]),
97
+ id: match[2],
98
+ accountLabel: match[3],
99
+ plan: match[4],
100
+ fiveHour: parseUsageWindow(match[5]),
101
+ weekly: parseUsageWindow(match[6]),
102
+ lastActivity: match[7]?.trim() || undefined,
103
+ });
104
+ }
105
+
106
+ return {
107
+ providerId: "openai-codex",
108
+ providerDisplayName: "Codex",
109
+ accounts,
110
+ activeAccount: accounts.find((account) => account.isActive) ?? accounts[0],
111
+ fetchedAt: Date.now(),
112
+ };
113
+ }
114
+
115
+ function redactedError(error: unknown): string {
116
+ const message = error instanceof Error ? error.message : String(error || "Unknown error");
117
+ if (/missing platform package|cannot find module|ENOENT/i.test(message)) return "codex-auth is not installed correctly";
118
+ if (/timed out|timeout/i.test(message)) return "codex-auth timed out";
119
+ if (/not logged in|auth|token|unauthorized|forbidden/i.test(message)) return "Codex auth unavailable";
120
+ return "Codex usage unavailable";
121
+ }
122
+
123
+ function resolveCodexAuthBin(): string {
124
+ return require.resolve("@loongphy/codex-auth/bin/codex-auth.js");
125
+ }
126
+
127
+ function runCodexAuthList(signal?: AbortSignal): Promise<string> {
128
+ return new Promise((resolve, reject) => {
129
+ const child = spawn(process.execPath, [resolveCodexAuthBin(), "list"], {
130
+ stdio: ["ignore", "pipe", "pipe"],
131
+ env: { ...process.env, NO_COLOR: "1" },
132
+ });
133
+ let stdout = "";
134
+ let stderr = "";
135
+ const timeout = setTimeout(() => {
136
+ child.kill("SIGTERM");
137
+ reject(new Error("codex-auth list timed out"));
138
+ }, 20_000);
139
+ const onAbort = () => {
140
+ child.kill("SIGTERM");
141
+ reject(signal?.reason ?? new Error("Operation aborted"));
142
+ };
143
+ signal?.addEventListener("abort", onAbort, { once: true });
144
+ child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
145
+ child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
146
+ child.on("error", reject);
147
+ child.on("close", (code) => {
148
+ clearTimeout(timeout);
149
+ signal?.removeEventListener("abort", onAbort);
150
+ if (code === 0) resolve(stdout);
151
+ else reject(new Error(stderr.trim() || `codex-auth list exited with code ${code}`));
152
+ });
153
+ });
154
+ }
155
+
156
+ const codexAdapter: SubscriptionProviderAdapter = {
157
+ id: "openai-codex",
158
+ displayName: "Codex",
159
+ isModelSupported: isCodexModel,
160
+ async fetchUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
161
+ try {
162
+ return parseCodexAuthList(await runCodexAuthList(signal));
163
+ } catch (error) {
164
+ return {
165
+ providerId: "openai-codex",
166
+ providerDisplayName: "Codex",
167
+ accounts: [],
168
+ fetchedAt: Date.now(),
169
+ error: redactedError(error),
170
+ };
171
+ }
172
+ },
173
+ };
174
+
175
+ const adapters = [codexAdapter];
176
+
177
+ function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undefined {
178
+ return adapters.find((adapter) => adapter.isModelSupported(model));
179
+ }
180
+
181
+ function formatWindow(window: UsageWindow | undefined, compact = true): string {
182
+ if (!window) return "?";
183
+ if (window.percent !== undefined && window.resetLabel) return compact ? `${window.percent}%→${compactResetLabel(window.resetLabel)}` : `${window.percent}% (${window.resetLabel})`;
184
+ if (window.label) return window.label;
185
+ if (window.percent !== undefined) return `${window.percent}%`;
186
+ if (window.used !== undefined && window.limit !== undefined) return `${window.used}/${window.limit}`;
187
+ return "?";
188
+ }
189
+
190
+ function compactResetLabel(label: string): string {
191
+ return label.replace(/^0?(\d+:\d+) on (\d+ \w+)$/i, "$2 $1");
192
+ }
193
+
194
+ function maxPercent(account: SubscriptionAccountSnapshot | undefined): number {
195
+ return Math.max(account?.fiveHour?.percent ?? 0, account?.weekly?.percent ?? 0);
196
+ }
197
+
198
+ function renderStatus(ctx: ExtensionContext, state: State): void {
199
+ if (!state.adapter) {
200
+ ctx.ui.setStatus(STATUS_KEY, undefined);
201
+ return;
202
+ }
203
+ const theme = ctx.ui.theme;
204
+ const snapshot = state.snapshot;
205
+ const modelId = state.model?.id ?? "unknown-model";
206
+ if (!snapshot) {
207
+ ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", `Sub ${state.adapter.displayName} loading · ${modelId}`));
208
+ return;
209
+ }
210
+ if (snapshot.error) {
211
+ ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `Sub ${snapshot.error} · ${modelId}`));
212
+ return;
213
+ }
214
+ const account = snapshot.activeAccount;
215
+ const pieces = ["Sub"];
216
+ if (account?.plan) pieces.push(account.plan);
217
+ pieces.push(account?.accountLabel ?? "unknown account");
218
+ pieces.push(`5H ${formatWindow(account?.fiveHour)}`);
219
+ pieces.push(`W ${formatWindow(account?.weekly)}`);
220
+ pieces.push("·", modelId);
221
+ const color = maxPercent(account) >= 90 ? "error" : maxPercent(account) >= 80 ? "warning" : "dim";
222
+ ctx.ui.setStatus(STATUS_KEY, theme.fg(color, pieces.join(" ")));
223
+ }
224
+
225
+ function startTimer(ctx: ExtensionContext, state: State): void {
226
+ if (state.refreshTimer || !state.adapter) return;
227
+ state.refreshTimer = setInterval(() => {
228
+ void refreshUsage(ctx, state, false);
229
+ }, REFRESH_INTERVAL_MS);
230
+ }
231
+
232
+ function stopTimer(state: State): void {
233
+ if (state.refreshTimer) clearInterval(state.refreshTimer);
234
+ if (state.debounceTimer) clearTimeout(state.debounceTimer);
235
+ state.refreshTimer = undefined;
236
+ state.debounceTimer = undefined;
237
+ }
238
+
239
+ function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLike): void {
240
+ state.model = model;
241
+ state.adapter = supportedAdapter(model);
242
+ if (!state.adapter) {
243
+ state.snapshot = undefined;
244
+ state.lastRefreshAt = 0;
245
+ stopTimer(state);
246
+ }
247
+ renderStatus(ctx, state);
248
+ if (state.adapter) startTimer(ctx, state);
249
+ }
250
+
251
+ async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean): Promise<SubscriptionUsageSnapshot | undefined> {
252
+ const adapter = state.adapter;
253
+ if (!adapter) {
254
+ renderStatus(ctx, state);
255
+ return undefined;
256
+ }
257
+ if (!force && state.snapshot && Date.now() - state.lastRefreshAt < REFRESH_TTL_MS) return state.snapshot;
258
+ if (state.inFlight) return state.inFlight;
259
+ renderStatus(ctx, state);
260
+ state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
261
+ state.snapshot = snapshot;
262
+ state.lastRefreshAt = Date.now();
263
+ renderStatus(ctx, state);
264
+ return snapshot;
265
+ }).finally(() => {
266
+ state.inFlight = undefined;
267
+ });
268
+ return state.inFlight;
269
+ }
270
+
271
+ function scheduleRefresh(ctx: ExtensionContext, state: State): void {
272
+ if (!state.adapter) return;
273
+ if (state.debounceTimer) clearTimeout(state.debounceTimer);
274
+ state.debounceTimer = setTimeout(() => {
275
+ state.debounceTimer = undefined;
276
+ void refreshUsage(ctx, state, true);
277
+ }, REFRESH_DEBOUNCE_MS);
278
+ }
279
+
280
+ function pad(value: string, width: number): string {
281
+ return value.length >= width ? value : value + " ".repeat(width - value.length);
282
+ }
283
+
284
+ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: State): string {
285
+ if (!state.adapter) return `Subscription tracking inactive for current model provider (${state.model?.provider ?? "unknown"}).`;
286
+ if (!snapshot) return "Subscription usage has not been loaded yet.";
287
+ if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
288
+ if (snapshot.accounts.length === 0) return `${snapshot.providerDisplayName}: no accounts found.`;
289
+ const rows = snapshot.accounts.map((account) => ({
290
+ active: account.isActive ? "*" : " ",
291
+ account: account.accountLabel ?? "unknown",
292
+ plan: account.plan ?? "?",
293
+ five: formatWindow(account.fiveHour, false),
294
+ weekly: formatWindow(account.weekly, false),
295
+ activity: account.lastActivity ?? "",
296
+ }));
297
+ const widths = {
298
+ account: Math.max("ACCOUNT".length, ...rows.map((row) => row.account.length)),
299
+ plan: Math.max("PLAN".length, ...rows.map((row) => row.plan.length)),
300
+ five: Math.max("5H USAGE".length, ...rows.map((row) => row.five.length)),
301
+ weekly: Math.max("WEEKLY USAGE".length, ...rows.map((row) => row.weekly.length)),
302
+ };
303
+ const header = ` ${pad("ACCOUNT", widths.account)} ${pad("PLAN", widths.plan)} ${pad("5H USAGE", widths.five)} ${pad("WEEKLY USAGE", widths.weekly)} LAST ACTIVITY`;
304
+ const sep = "-".repeat(header.length);
305
+ const body = rows.map((row) => `${row.active} ${pad(row.account, widths.account)} ${pad(row.plan, widths.plan)} ${pad(row.five, widths.five)} ${pad(row.weekly, widths.weekly)} ${row.activity}`);
306
+ return [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}`, "", header, sep, ...body].join("\n");
307
+ }
308
+
309
+ export default function (pi: ExtensionAPI) {
310
+ const state: State = { lastRefreshAt: 0 };
311
+
312
+ pi.registerMessageRenderer(MESSAGE_TYPE, (message, _options, theme) => {
313
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
314
+ box.addChild(new Text(String(message.content ?? ""), 0, 0));
315
+ return box;
316
+ });
317
+
318
+ pi.on("session_start", async (_event, ctx) => {
319
+ updateActiveAdapter(ctx, state, ctx.model);
320
+ if (state.adapter) void refreshUsage(ctx, state, true);
321
+ });
322
+
323
+ pi.on("model_select", async (event, ctx) => {
324
+ updateActiveAdapter(ctx, state, event.model);
325
+ if (state.adapter) void refreshUsage(ctx, state, true);
326
+ });
327
+
328
+ pi.on("after_provider_response", async (_event, ctx) => {
329
+ if (state.adapter) scheduleRefresh(ctx, state);
330
+ });
331
+
332
+ pi.on("session_shutdown", async (_event, ctx) => {
333
+ stopTimer(state);
334
+ ctx.ui.setStatus(STATUS_KEY, undefined);
335
+ });
336
+
337
+ pi.registerCommand("sub", {
338
+ description: "Show subscription usage for the current supported model provider (use /sub refresh to force refresh).",
339
+ handler: async (args, ctx) => {
340
+ updateActiveAdapter(ctx, state, ctx.model);
341
+ const command = args.trim().toLowerCase();
342
+ const force = command === "refresh";
343
+ const snapshot = state.adapter ? await refreshUsage(ctx, state, force || !state.snapshot) : undefined;
344
+ const details = buildDetails(snapshot ?? state.snapshot, state);
345
+ pi.sendMessage({ customType: MESSAGE_TYPE, content: details, display: true });
346
+ if (force) ctx.ui.notify("Subscription usage refreshed", "info");
347
+ },
348
+ });
349
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@bacnh85/pi-sub",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension showing subscription usage for supported model providers.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "homepage": "https://github.com/bacnh85/skills#readme",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/bacnh85/skills.git",
14
+ "directory": "extensions/pi-sub"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/bacnh85/skills/issues"
18
+ },
19
+ "keywords": [
20
+ "pi-package",
21
+ "pi-extension",
22
+ "subscription",
23
+ "usage",
24
+ "codex",
25
+ "openai"
26
+ ],
27
+ "files": [
28
+ "README.md",
29
+ "index.ts"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ]
35
+ },
36
+ "dependencies": {
37
+ "@loongphy/codex-auth": "^0.2.10"
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*"
42
+ }
43
+ }