pi-codex-footer 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # pi-codex-footer
2
+
3
+ A publishable Pi package that adds a readable 2-line footer with live OpenAI Codex quota information.
4
+
5
+ ## What it shows
6
+
7
+ Line 1:
8
+ - current folder
9
+ - git branch
10
+ - provider + model
11
+ - thinking level
12
+ - context usage as `used/window`
13
+ - total session cost
14
+
15
+ Line 2:
16
+ - tokens/sec for the last assistant response
17
+ - real Codex 5h usage
18
+ - real Codex 7d usage
19
+ - real 5h reset countdown
20
+ - real 7d reset countdown
21
+
22
+ For OpenAI Codex, quota data comes from:
23
+
24
+ - `https://chatgpt.com/backend-api/codex/usage`
25
+
26
+ using your existing Pi OAuth auth from:
27
+
28
+ - `~/.pi/agent/auth.json`
29
+
30
+ ## Privacy / secrets
31
+
32
+ This package **does not bundle or publish any secrets**.
33
+
34
+ It reads your existing local `openai-codex` OAuth token and account id from `~/.pi/agent/auth.json` at runtime, then makes a request to OpenAI's ChatGPT backend to fetch live quota data.
35
+
36
+ It does **not**:
37
+ - write your token into the package
38
+ - store your token in the repo
39
+ - print your token in UI output
40
+ - require manual config edits
41
+
42
+ ## Install
43
+
44
+ ### From a local folder
45
+
46
+ ```bash
47
+ pi install ./pi-codex-footer
48
+ ```
49
+
50
+ ### From npm
51
+
52
+ ```bash
53
+ pi install npm:pi-codex-footer
54
+ ```
55
+
56
+ ### From git
57
+
58
+ ```bash
59
+ pi install git:github.com/glnarayanan/pi-codex-footer
60
+ ```
61
+
62
+ Then reload Pi:
63
+
64
+ ```text
65
+ /reload
66
+ ```
67
+
68
+ ## Commands
69
+
70
+ - `/codex-footer-on` — reload Pi and enable the footer
71
+ - `/codex-footer-off` — restore Pi's default footer
72
+ - `/codex-footer-status` — show whether live Codex quota data is available
73
+
74
+ ## Behavior
75
+
76
+ - If the active provider is `openai-codex`, the footer shows live quota numbers.
77
+ - If the active provider is something else, quota fields show `n/a`.
78
+ - Quota data refreshes roughly once per minute.
79
+ - Reset countdowns update every second.
80
+
81
+ ## Development
82
+
83
+ Try it locally without publishing:
84
+
85
+ ```bash
86
+ pi install ./pi-codex-footer
87
+ ```
88
+
89
+ or:
90
+
91
+ ```bash
92
+ pi -e ./pi-codex-footer/extensions/index.ts
93
+ ```
94
+
95
+ ## Publish to npm
96
+
97
+ ```bash
98
+ cd pi-codex-footer
99
+ npm publish --access public
100
+ ```
101
+
102
+ ## Publish from git
103
+
104
+ Push this folder to a repo, then install with:
105
+
106
+ ```bash
107
+ pi install git:github.com/glnarayanan/pi-codex-footer
108
+ ```
@@ -0,0 +1,337 @@
1
+ import { basename } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { request as httpsRequest } from "node:https";
6
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
7
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import { truncateToWidth } from "@mariozechner/pi-tui";
9
+
10
+ const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
11
+ const QUOTA_REFRESH_MS = 60 * 1000;
12
+ const MAX_RESPONSE_BYTES = 64 * 1024; // 64 KB — plenty for the quota JSON
13
+
14
+ interface OpenAICodexAuth {
15
+ type: "oauth";
16
+ access: string;
17
+ accountId: string;
18
+ }
19
+
20
+ interface CodexUsageWindow {
21
+ used_percent: number;
22
+ limit_window_seconds: number;
23
+ reset_after_seconds: number;
24
+ reset_at: number;
25
+ }
26
+
27
+ interface CodexUsageResponse {
28
+ plan_type?: string;
29
+ rate_limit?: {
30
+ allowed: boolean;
31
+ limit_reached: boolean;
32
+ primary_window?: CodexUsageWindow | null;
33
+ secondary_window?: CodexUsageWindow | null;
34
+ };
35
+ }
36
+
37
+ /** Distinguishes auth failures from other errors so callers can notify the user. */
38
+ interface CodexFetchResult {
39
+ usage: CodexUsageResponse | null;
40
+ authExpired: boolean;
41
+ }
42
+
43
+ function fmtInt(n: number): string {
44
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
45
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
46
+ return `${Math.round(n)}`;
47
+ }
48
+
49
+ function fmtMoney(n: number): string {
50
+ return `$${n.toFixed(3)}`;
51
+ }
52
+
53
+ function fmtUsedOfTotal(used: number, total?: number): string {
54
+ if (!total || total <= 0) return fmtInt(used);
55
+ return `${fmtInt(used)}/${fmtInt(total)}`;
56
+ }
57
+
58
+ function fmtWindowUsage(window?: CodexUsageWindow | null): string {
59
+ if (!window) return "n/a";
60
+ const used = Math.max(0, window.used_percent);
61
+ const left = Math.max(0, 100 - used);
62
+ return `${used.toFixed(1)}% used / ${left.toFixed(1)}% left`;
63
+ }
64
+
65
+ function fmtDuration(ms: number): string {
66
+ if (ms <= 0) return "now";
67
+ const totalSeconds = Math.ceil(ms / 1000);
68
+ const days = Math.floor(totalSeconds / 86400);
69
+ const hours = Math.floor((totalSeconds % 86400) / 3600);
70
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
71
+ if (days > 0) return `${days}d ${hours}h`;
72
+ if (hours > 0) return `${hours}h ${minutes}m`;
73
+ return `${minutes}m`;
74
+ }
75
+
76
+ function getAssistantMessages(ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]) {
77
+ return ctx.sessionManager
78
+ .getBranch()
79
+ .filter((e): e is typeof e & { type: "message"; message: AssistantMessage } => e.type === "message" && e.message.role === "assistant")
80
+ .map((e) => e.message);
81
+ }
82
+
83
+ function readOpenAICodexAuth(): OpenAICodexAuth | null {
84
+ try {
85
+ if (!existsSync(AUTH_PATH)) return null;
86
+ const data = JSON.parse(readFileSync(AUTH_PATH, "utf8")) as Record<string, unknown>;
87
+ const cred = data["openai-codex"] as Partial<OpenAICodexAuth> | undefined;
88
+ if (!cred || cred.type !== "oauth" || !cred.access || !cred.accountId) return null;
89
+ return cred as OpenAICodexAuth;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /** Validates that the parsed JSON structurally matches CodexUsageResponse. */
96
+ function isValidCodexResponse(obj: unknown): obj is CodexUsageResponse {
97
+ if (!obj || typeof obj !== "object") return false;
98
+ const record = obj as Record<string, unknown>;
99
+ // rate_limit is the only field we actually consume — require it to be present and object-shaped
100
+ if (record.rate_limit !== undefined && (typeof record.rate_limit !== "object" || record.rate_limit === null)) {
101
+ return false;
102
+ }
103
+ return true;
104
+ }
105
+
106
+ async function fetchCodexUsage(): Promise<CodexFetchResult> {
107
+ const cred = readOpenAICodexAuth();
108
+ if (!cred) return { usage: null, authExpired: false };
109
+
110
+ return await new Promise<CodexFetchResult>((resolve) => {
111
+ let destroyed = false;
112
+ const req = httpsRequest(
113
+ "https://chatgpt.com/backend-api/codex/usage",
114
+ {
115
+ method: "GET",
116
+ headers: {
117
+ Authorization: `Bearer ${cred.access}`,
118
+ "chatgpt-account-id": cred.accountId,
119
+ "OpenAI-Beta": "responses=experimental",
120
+ "User-Agent": "pi-codex-footer"
121
+ }
122
+ },
123
+ (res) => {
124
+ let body = "";
125
+ res.setEncoding("utf8");
126
+ res.on("data", (chunk) => {
127
+ body += chunk;
128
+ // Cap accumulated body to prevent memory exhaustion
129
+ if (body.length > MAX_RESPONSE_BYTES) {
130
+ destroyed = true;
131
+ req.destroy();
132
+ resolve({ usage: null, authExpired: false });
133
+ }
134
+ });
135
+ res.on("end", () => {
136
+ if (destroyed) return;
137
+ const status = res.statusCode ?? 500;
138
+
139
+ // Surface auth failures distinctly so the UI can notify the user
140
+ if (status === 401 || status === 403) {
141
+ resolve({ usage: null, authExpired: true });
142
+ return;
143
+ }
144
+ if (status < 200 || status >= 300) {
145
+ resolve({ usage: null, authExpired: false });
146
+ return;
147
+ }
148
+ try {
149
+ const parsed: unknown = JSON.parse(body);
150
+ if (isValidCodexResponse(parsed)) {
151
+ resolve({ usage: parsed, authExpired: false });
152
+ } else {
153
+ resolve({ usage: null, authExpired: false });
154
+ }
155
+ } catch {
156
+ resolve({ usage: null, authExpired: false });
157
+ }
158
+ });
159
+ }
160
+ );
161
+ req.on("error", () => resolve({ usage: null, authExpired: false }));
162
+ req.setTimeout(15000, () => {
163
+ req.destroy();
164
+ resolve({ usage: null, authExpired: false });
165
+ });
166
+ req.end();
167
+ });
168
+ }
169
+
170
+ export default function (pi: ExtensionAPI) {
171
+ let lastAssistantStart: number | null = null;
172
+ let lastTokensPerSecond: number | null = null;
173
+ let lastKnownCodexUsage: CodexUsageResponse | null = null;
174
+ let authExpiredNotified = false;
175
+
176
+ pi.on("message_start", async (event) => {
177
+ if (event.message.role === "assistant") lastAssistantStart = Date.now();
178
+ });
179
+
180
+ pi.on("message_end", async (event) => {
181
+ if (event.message.role !== "assistant") return;
182
+ const m = event.message as AssistantMessage;
183
+ if (lastAssistantStart) {
184
+ const elapsedSeconds = Math.max((Date.now() - lastAssistantStart) / 1000, 0.001);
185
+ lastTokensPerSecond = m.usage.output / elapsedSeconds;
186
+ }
187
+ lastAssistantStart = null;
188
+ });
189
+
190
+ pi.on("session_start", async (_event, ctx) => {
191
+ let codexUsage: CodexUsageResponse | null = null;
192
+ let codexUsageFetchedAt = 0;
193
+ let codexUsageInFlight: Promise<void> | null = null;
194
+
195
+ const handleFetchResult = (result: CodexFetchResult) => {
196
+ codexUsage = result.usage;
197
+ lastKnownCodexUsage = result.usage;
198
+ codexUsageFetchedAt = Date.now();
199
+ if (result.authExpired && !authExpiredNotified) {
200
+ authExpiredNotified = true;
201
+ ctx.ui.notify("Codex quota: auth token expired or forbidden — re-authenticate with Pi to restore live quota data.", "warning");
202
+ } else if (!result.authExpired && result.usage) {
203
+ // Successful fetch clears the flag so a future expiry is reported again
204
+ authExpiredNotified = false;
205
+ }
206
+ };
207
+
208
+ const primeCodexUsage = async () => {
209
+ if (ctx.model?.provider !== "openai-codex") return;
210
+ if (codexUsageInFlight) return;
211
+ codexUsageInFlight = (async () => {
212
+ try {
213
+ handleFetchResult(await fetchCodexUsage());
214
+ } catch {
215
+ codexUsage = null;
216
+ lastKnownCodexUsage = null;
217
+ codexUsageFetchedAt = Date.now();
218
+ } finally {
219
+ codexUsageInFlight = null;
220
+ }
221
+ })();
222
+ await codexUsageInFlight;
223
+ };
224
+
225
+ void primeCodexUsage();
226
+
227
+ ctx.ui.setFooter((tui, theme, footerData) => {
228
+ const refreshCodexUsage = () => {
229
+ if (codexUsageInFlight) return;
230
+ codexUsageInFlight = (async () => {
231
+ try {
232
+ handleFetchResult(await fetchCodexUsage());
233
+ } catch {
234
+ codexUsage = null;
235
+ lastKnownCodexUsage = null;
236
+ codexUsageFetchedAt = Date.now();
237
+ } finally {
238
+ codexUsageInFlight = null;
239
+ tui.requestRender();
240
+ }
241
+ })();
242
+ };
243
+
244
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
245
+ const interval = setInterval(() => tui.requestRender(), 1000);
246
+
247
+ return {
248
+ dispose: () => {
249
+ unsub();
250
+ clearInterval(interval);
251
+ },
252
+ invalidate() {},
253
+ render(width: number): string[] {
254
+ const now = Date.now();
255
+ const cwdName = basename(ctx.cwd) || ctx.cwd;
256
+ const branch = footerData.getGitBranch() || "no-git";
257
+ const provider = ctx.model?.provider || "no-provider";
258
+ const model = ctx.model?.id || "no-model";
259
+ const thinking = pi.getThinkingLevel();
260
+ const context = ctx.getContextUsage();
261
+ const isCodex = provider === "openai-codex";
262
+
263
+ if (isCodex && Date.now() - codexUsageFetchedAt > QUOTA_REFRESH_MS && !codexUsageInFlight) {
264
+ refreshCodexUsage();
265
+ }
266
+
267
+ const messages = getAssistantMessages(ctx);
268
+ const totalCost = messages.reduce((sum, m) => sum + (m.usage.cost.total || 0), 0);
269
+ const livePrimary = codexUsage?.rate_limit?.primary_window;
270
+ const liveSecondary = codexUsage?.rate_limit?.secondary_window;
271
+ const fiveHourResetText = isCodex && livePrimary
272
+ ? fmtDuration(Math.max(0, livePrimary.reset_at * 1000 - now))
273
+ : "n/a";
274
+ const weeklyResetText = isCodex && liveSecondary
275
+ ? fmtDuration(Math.max(0, liveSecondary.reset_at * 1000 - now))
276
+ : "n/a";
277
+
278
+ const sep = ` ${theme.fg("dim", "•")} `;
279
+ const line1 = [
280
+ `${theme.fg("dim", "📁")} ${cwdName}`,
281
+ `${theme.fg("dim", "")} ${branch}`,
282
+ `${theme.fg("dim", "🤖")} ${provider} - ${model}`,
283
+ `${theme.fg("dim", "🧠")} ${thinking}`,
284
+ `${theme.fg("dim", "◔")} ${context ? fmtUsedOfTotal(context.tokens || 0, context.contextWindow) : "n/a"}`,
285
+ `${theme.fg("dim", "💵")} ${fmtMoney(totalCost)}`
286
+ ].join(sep);
287
+
288
+ const line2 = [
289
+ `${theme.fg("dim", "⚡")} ${lastTokensPerSecond ? `${lastTokensPerSecond.toFixed(1)} tok/s` : "n/a"}`,
290
+ `${theme.fg("dim", "5h")} ${isCodex ? fmtWindowUsage(livePrimary) : "n/a"}`,
291
+ `${theme.fg("dim", "7d")} ${isCodex ? fmtWindowUsage(liveSecondary) : "n/a"}`,
292
+ `${theme.fg("dim", "↺5h")} ${fiveHourResetText}`,
293
+ `${theme.fg("dim", "↺7d")} ${weeklyResetText}`
294
+ ].join(sep);
295
+
296
+ return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
297
+ }
298
+ };
299
+ });
300
+ });
301
+
302
+ pi.registerCommand("codex-footer-status", {
303
+ description: "Show live Codex footer quota status",
304
+ handler: async (_args, commandCtx) => {
305
+ const auth = readOpenAICodexAuth();
306
+ const result = await fetchCodexUsage();
307
+ const usage = result.usage ?? lastKnownCodexUsage;
308
+ const primary = usage?.rate_limit?.primary_window;
309
+ const secondary = usage?.rate_limit?.secondary_window;
310
+ const lines = [
311
+ `provider: ${commandCtx.model?.provider || "unknown"}`,
312
+ `auth: ${auth ? (result.authExpired ? "expired" : "found") : "missing"}`,
313
+ `plan: ${usage?.plan_type || "unknown"}`,
314
+ `5h: ${fmtWindowUsage(primary)}`,
315
+ `7d: ${fmtWindowUsage(secondary)}`,
316
+ `5h reset: ${primary ? fmtDuration(Math.max(0, primary.reset_at * 1000 - Date.now())) : "n/a"}`,
317
+ `7d reset: ${secondary ? fmtDuration(Math.max(0, secondary.reset_at * 1000 - Date.now())) : "n/a"}`
318
+ ];
319
+ commandCtx.ui.notify(lines.join("\n"), usage ? "info" : "warning");
320
+ }
321
+ });
322
+
323
+ pi.registerCommand("codex-footer-off", {
324
+ description: "Restore Pi's default footer",
325
+ handler: async (_args, ctx) => {
326
+ ctx.ui.setFooter(undefined);
327
+ ctx.ui.notify("Default footer restored", "info");
328
+ }
329
+ });
330
+
331
+ pi.registerCommand("codex-footer-on", {
332
+ description: "Enable the Codex quota footer",
333
+ handler: async (_args, ctx) => {
334
+ await ctx.reload();
335
+ }
336
+ });
337
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "pi-codex-footer",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that adds a 2-line footer with live OpenAI Codex 5h/7d quota usage and reset timers.",
5
+ "type": "module",
6
+ "author": "glnarayanan",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/glnarayanan/pi-codex-footer.git"
10
+ },
11
+ "homepage": "https://github.com/glnarayanan/pi-codex-footer#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/glnarayanan/pi-codex-footer/issues"
14
+ },
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi-extension",
18
+ "openai",
19
+ "codex",
20
+ "chatgpt",
21
+ "footer"
22
+ ],
23
+ "license": "MIT",
24
+ "files": [
25
+ "README.md",
26
+ "LICENSE",
27
+ "extensions"
28
+ ],
29
+ "pi": {
30
+ "extensions": [
31
+ "./extensions/index.ts"
32
+ ]
33
+ },
34
+ "peerDependencies": {
35
+ "@mariozechner/pi-ai": "*",
36
+ "@mariozechner/pi-coding-agent": "*",
37
+ "@mariozechner/pi-tui": "*"
38
+ },
39
+ "scripts": {
40
+ "build": "echo 'nothing to build'",
41
+ "check": "echo 'load in pi with pi -e ./extensions/index.ts or pi install ./pi-codex-footer'"
42
+ }
43
+ }