pi-spark 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -6,6 +6,7 @@ A small, opinionated collection of [pi](https://pi.dev/) extensions.
6
6
 
7
7
  ## Extensions
8
8
 
9
+ - **Codex usage:** shows your OpenAI Codex (ChatGPT) rate-limit usage as a footer status when a Codex model is active.
9
10
  - **Editor:** replaces the default editor with a compact working indicator (inspired by [Amp](https://ampcode.com/)) and current model info.
10
11
  - **Footer:** shows session information, extension statuses, cost, and context usage on one line.
11
12
  - **Fullscreen:** clears the screen and scrollback on session start, pins the editor and footer to the bottom for a full-screen session, and clears again on exit.
@@ -64,6 +65,10 @@ Example:
64
65
  }
65
66
  ```
66
67
 
68
+ ### Codex usage
69
+
70
+ - pi-spark queries the ChatGPT backend and shows your Codex 5-hour (`5h`) and 7-day (`7d`) rate-limit usage as a footer status, refreshing on session start, model change, and after billable turns. The status appears only while a Codex (`openai-codex`) model is active and uses its stored OAuth credential.
71
+
67
72
  ### Editor
68
73
 
69
74
  - `editor.spinner` controls the working indicator style and can be `dots`, `lights`, or `tildes`.
@@ -0,0 +1,69 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const CODEX_PROVIDER = "openai-codex";
4
+ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
5
+ const REQUEST_TIMEOUT_MS = 30_000;
6
+
7
+ /** Normalized Codex usage with rate-limit windows reduced to used percentages. */
8
+ export interface CodexUsage {
9
+ unlimited: boolean;
10
+ /** Used percentage of the 5-hour window, clamped to 0-100. */
11
+ primaryPercent: number | undefined;
12
+ /** Used percentage of the 7-day window, clamped to 0-100. */
13
+ secondaryPercent: number | undefined;
14
+ }
15
+
16
+ interface CodexUsageResponse {
17
+ rate_limit?: {
18
+ primary_window?: CodexRateWindow | null;
19
+ secondary_window?: CodexRateWindow | null;
20
+ } | null;
21
+ credits?: {
22
+ unlimited?: boolean;
23
+ } | null;
24
+ }
25
+
26
+ interface CodexRateWindow {
27
+ used_percent?: number | string;
28
+ }
29
+
30
+ /** Read the ChatGPT account id from the stored Codex OAuth credential, if present. */
31
+ export function readAccountId(ctx: ExtensionContext): string | undefined {
32
+ const credential = ctx.modelRegistry.authStorage.get(CODEX_PROVIDER) as { accountId?: string } | undefined;
33
+ const accountId = credential?.accountId;
34
+
35
+ return typeof accountId === "string" && accountId.trim() ? accountId.trim() : undefined;
36
+ }
37
+
38
+ /** Fetch and normalize Codex usage, aborting on timeout or when the parent signal aborts. */
39
+ export async function fetchCodexUsage(token: string, accountId?: string, parentSignal?: AbortSignal): Promise<CodexUsage> {
40
+ const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS)];
41
+ if (parentSignal) signals.push(parentSignal);
42
+
43
+ const headers: Record<string, string> = {
44
+ Accept: "application/json",
45
+ Authorization: `Bearer ${token}`,
46
+ };
47
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId;
48
+
49
+ const response = await fetch(USAGE_URL, { method: "GET", headers, signal: AbortSignal.any(signals) });
50
+ if (!response.ok) throw new Error("request failed");
51
+
52
+ return normalizeUsage((await response.json()) as CodexUsageResponse);
53
+ }
54
+
55
+ function normalizeUsage(payload: CodexUsageResponse): CodexUsage {
56
+ return {
57
+ unlimited: payload.credits?.unlimited === true,
58
+ primaryPercent: parseUsedPercent(payload.rate_limit?.primary_window),
59
+ secondaryPercent: parseUsedPercent(payload.rate_limit?.secondary_window),
60
+ };
61
+ }
62
+
63
+ function parseUsedPercent(window: CodexRateWindow | null | undefined): number | undefined {
64
+ const raw = window?.used_percent;
65
+ if (raw === undefined || raw === null) return undefined;
66
+
67
+ const value = typeof raw === "number" ? raw : Number(raw);
68
+ return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : undefined;
69
+ }
@@ -0,0 +1,5 @@
1
+ import * as z from "zod";
2
+
3
+ export const codexUsageConfigSchema = z.object({});
4
+
5
+ export type CodexUsageConfig = z.infer<typeof codexUsageConfigSchema>;
@@ -0,0 +1,43 @@
1
+ import { CodexUsageManager } from "./manager";
2
+ import { loadConfig } from "../shared/config";
3
+ import { isUsage } from "../shared/usage";
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
7
+
8
+ function hasCostedUsage(messages: AgentMessage[]): boolean {
9
+ return messages.some((message) => {
10
+ const usage = (message as { usage?: unknown }).usage;
11
+ if (!isUsage(usage)) return false;
12
+
13
+ return usage.cost.total > 0 || usage.input > 0 || usage.output > 0;
14
+ });
15
+ }
16
+
17
+ export default function (pi: ExtensionAPI) {
18
+ let codexUsageManager: CodexUsageManager | undefined = undefined;
19
+
20
+ pi.on("session_start", (_event, ctx) => {
21
+ if (!ctx.hasUI) return;
22
+
23
+ const config = loadConfig(ctx, "codexUsage");
24
+ if (!config) return;
25
+
26
+ codexUsageManager = new CodexUsageManager();
27
+ codexUsageManager.refresh(ctx);
28
+ });
29
+
30
+ pi.on("model_select", (_event, ctx) => {
31
+ codexUsageManager?.refresh(ctx);
32
+ });
33
+
34
+ pi.on("agent_end", (event, ctx) => {
35
+ if (!hasCostedUsage(event.messages)) return;
36
+
37
+ codexUsageManager?.refresh(ctx);
38
+ });
39
+
40
+ pi.on("session_shutdown", () => {
41
+ codexUsageManager = undefined;
42
+ });
43
+ }
@@ -0,0 +1,47 @@
1
+ import { CODEX_PROVIDER, fetchCodexUsage, readAccountId } from "./client";
2
+ import { renderError, renderUsage } from "./status";
3
+
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ const STATUS_KEY = "codex-usage";
7
+
8
+ export class CodexUsageManager {
9
+ private inflight: Promise<void> | undefined = undefined;
10
+
11
+ async refresh(ctx: ExtensionContext): Promise<void> {
12
+ if (!this.isCodexContext(ctx)) {
13
+ ctx.ui.setStatus(STATUS_KEY, undefined);
14
+ return;
15
+ }
16
+
17
+ if (this.inflight) return this.inflight;
18
+
19
+ this.inflight = this.fetch(ctx).finally(() => {
20
+ this.inflight = undefined;
21
+ });
22
+
23
+ return this.inflight;
24
+ }
25
+
26
+ private async fetch(ctx: ExtensionContext): Promise<void> {
27
+ try {
28
+ const token = await ctx.modelRegistry.getApiKeyForProvider(CODEX_PROVIDER);
29
+ if (!token) {
30
+ ctx.ui.setStatus(STATUS_KEY, undefined);
31
+ return;
32
+ }
33
+
34
+ const usage = await fetchCodexUsage(token, readAccountId(ctx), ctx.signal);
35
+ if (!this.isCodexContext(ctx)) return;
36
+
37
+ ctx.ui.setStatus(STATUS_KEY, renderUsage(ctx.ui.theme, usage));
38
+ } catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ ctx.ui.setStatus(STATUS_KEY, renderError(ctx.ui.theme, message));
41
+ }
42
+ }
43
+
44
+ private isCodexContext(ctx: ExtensionContext): boolean {
45
+ return ctx.model?.provider === CODEX_PROVIDER;
46
+ }
47
+ }
@@ -0,0 +1,31 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { CodexUsage } from "./client";
4
+
5
+ const LABEL = "Codex";
6
+
7
+ /** Render a footer status string for normalized Codex usage. */
8
+ export function renderUsage(theme: Theme, usage: CodexUsage): string {
9
+ const label = theme.fg("dim", LABEL);
10
+
11
+ if (usage.unlimited || (usage.primaryPercent === undefined && usage.secondaryPercent === undefined)) {
12
+ return `${label} ${theme.fg("success", "unlimited")}`;
13
+ }
14
+
15
+ const fiveHour = renderLane(theme, "5h", usage.primaryPercent);
16
+ const sevenDay = renderLane(theme, "7d", usage.secondaryPercent);
17
+ return `${label} ${fiveHour} ${sevenDay}`;
18
+ }
19
+
20
+ /** Render a footer status string for a usage fetch error. */
21
+ export function renderError(theme: Theme, message: string): string {
22
+ return theme.fg("error", `${LABEL} usage unavailable: ${message}`);
23
+ }
24
+
25
+ function renderLane(theme: Theme, label: string, percent: number | undefined): string {
26
+ const text = `${label} ${percent === undefined ? "?" : percent.toFixed(1)}%`;
27
+
28
+ if (percent && percent > 90) return theme.fg("error", text);
29
+ if (percent && percent > 70) return theme.fg("warning", text);
30
+ return theme.fg("dim", text);
31
+ }
@@ -35,15 +35,11 @@ class FooterComponent implements Component {
35
35
  private getLeft(): string {
36
36
  const cwd = this.ctx.sessionManager.getCwd();
37
37
  const url = pathToFileURL(resolve(cwd));
38
- let text = linkText(formatCwd(cwd, homedir()), url.href);
39
-
38
+ const cwdText = linkText(formatCwd(cwd, homedir()), url.href);
40
39
  const branch = this.footerData.getGitBranch();
41
- if (branch) text = `${text} [${branch}]`;
42
-
43
40
  const sessionName = this.ctx.sessionManager.getSessionName();
44
- if (sessionName) text = `${text} • ${sessionName}`;
45
41
 
46
- return this.theme.fg("dim", text);
42
+ return this.theme.fg("dim", [cwdText, branch, sessionName].filter(Boolean).join(" • "));
47
43
  }
48
44
 
49
45
  private getRight(): string {
@@ -1,5 +1,6 @@
1
1
  import * as z from "zod";
2
2
 
3
+ import { codexUsageConfigSchema } from "../../codex-usage/config";
3
4
  import { editorConfigSchema } from "../../editor/config";
4
5
  import { footerConfigSchema } from "../../footer/config";
5
6
  import { fullscreenConfigSchema } from "../../fullscreen/config";
@@ -8,6 +9,7 @@ import { recapConfigSchema } from "../../recap/config";
8
9
  import { setSessionNameConfigSchema } from "../../set-session-name/config";
9
10
 
10
11
  export const configSchemas = {
12
+ codexUsage: codexUsageConfigSchema,
11
13
  editor: editorConfigSchema,
12
14
  footer: footerConfigSchema,
13
15
  fullscreen: fullscreenConfigSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "A small, opinionated collection of pi extensions",
5
5
  "keywords": [
6
6
  "pi-coding-agent",