@narumitw/pi-usage 0.50.0 → 0.51.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/README.md CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-usage)](https://www.npmjs.com/package/@narumitw/pi-usage) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- `@narumitw/pi-usage` is a native [Pi coding agent](https://pi.dev) extension that adds one interactive `/usage` command for reading usage from the account Pi is actually using. It supports OpenAI Codex ChatGPT subscription windows, GitHub Copilot allowances, and OpenRouter API-key spend limits without pretending those limits have the same semantics.
5
+ `@narumitw/pi-usage` is a native [Pi coding agent](https://pi.dev) extension that adds an interactive `/usage` command for reading usage from the account Pi is actually using and a `/fast` shortcut for supported OpenAI Codex models. It supports Codex ChatGPT subscription windows, GitHub Copilot allowances, and OpenRouter API-key spend limits without pretending those limits have the same semantics.
6
6
 
7
7
  ## ✨ Features
8
8
 
9
9
  - Opens one interactive `/usage` menu with current state and next actions.
10
10
  - Automatically queries the selected model provider and active runtime account.
11
11
  - Supports OpenAI Codex subscription windows, resets, credits, and model-specific buckets.
12
+ - Toggles persistent Codex Fast routing through `/fast` or the contextual `/usage` action.
12
13
  - Redeems earned Codex usage-limit resets for the active, matching Pi OAuth account with fresh availability, explicit confirmation, and idempotent retry.
13
14
  - Supports GitHub Copilot AI Credits, legacy premium requests, Free chat quota, additional usage, percentage, and reset time.
14
15
  - Supports OpenRouter per-key credit limits plus daily, weekly, monthly, and all-time spend.
@@ -52,6 +53,7 @@ with these actions:
52
53
 
53
54
  ```text
54
55
  Refresh current usage
56
+ Turn Fast mode on/off # Supported current Codex models only
55
57
  Redeem usage limit reset… # Current Codex OAuth accounts only
56
58
  View another configured provider…
57
59
  View all configured providers…
@@ -73,6 +75,25 @@ same redemption request ID so the backend can treat an uncertain retry idempoten
73
75
  already-completed, not-needed, and no-credit outcomes are reported separately, then usage and the
74
76
  statusline are refreshed for the still-current account.
75
77
 
78
+ ### Codex Fast mode
79
+
80
+ Run bare `/fast` to toggle Fast for the active supported Codex model, or use **Turn Fast mode on/off** in `/usage`.
81
+
82
+ Fast is about 1.5× faster and uses more of your plan allowance.
83
+ The preference defaults to Off and is saved as `codexFastMode` in Pi's user agent directory as `pi-usage.json`, normally `~/.pi/agent/pi-usage.json`.
84
+ The extension reloads this file at every session start and does not create it until the first successful toggle.
85
+
86
+ Fast currently applies only to official `openai-codex-responses` requests for `gpt-5.4`, `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` at `https://chatgpt.com`.
87
+ It sends `service_tier: "priority"` while enabled and explicit `service_tier: "default"` otherwise.
88
+ The statusline adds `fast` only while the preference is effective, for example `codex fast 59% 5h`.
89
+ Unsupported models and custom or proxy origins are left unchanged.
90
+
91
+ `/fast` supports TUI and RPC mode, accepts no arguments, and rejects print or JSON mode before mutation.
92
+ A toggle affects provider requests whose payload hook starts after the save; a request already sent is unchanged.
93
+ Settings operations are serialized inside one Pi process, but separate Pi processes are not mutually locked.
94
+ Unknown JSON fields are preserved, writes use a private temporary file plus rename, and a malformed or invalid file is never overwritten.
95
+ Repair or remove an invalid file, then run `/reload` before trying the toggle again.
96
+
76
97
  ## 📋 Provider semantics
77
98
 
78
99
  ### OpenAI Codex
@@ -82,7 +103,7 @@ statusline are refreshed for the still-current account.
82
103
  - Source: the Codex usage and earned-reset endpoints using Pi's resolved runtime authorization
83
104
  - Displayed data: returned duration-based windows, resets, credits, earned usage-limit resets, and additional model buckets
84
105
  - Reset mutation: `POST /wham/rate-limit-reset-credits/consume` with a unique redemption request ID and, when available, the selected opaque credit ID
85
- - Statusline examples: `codex 59% 5h 61% wk` or `codex spark 100% 5h`
106
+ - Statusline examples: `codex 59% 5h 61% wk`, `codex fast 59% 5h`, or `codex spark 100% 5h`
86
107
 
87
108
  The statusline selects a returned bucket that matches the current Codex model when one is available. Unlike `pi-codex-usage`, this successor intentionally has no Codex CLI fallback because the CLI may be logged into a different account than Pi's active runtime account.
88
109
 
@@ -138,7 +159,7 @@ Remove the deprecated package rather than loading both usage extensions together
138
159
 
139
160
  Behavior changes:
140
161
 
141
- - Use `/usage` as the only entry point; `/codex-status` is no longer registered.
162
+ - Use `/usage` for usage management; `/codex-status` is no longer registered.
142
163
  - Refresh and cross-provider operations are menu actions rather than flags.
143
164
  - Codex CLI fallback is removed to preserve active-runtime-account correctness.
144
165
  - The status key changes from `codex-usage` to `usage`.
@@ -153,6 +174,8 @@ Behavior changes:
153
174
  - OpenRouter successful inference responses do not expose proactive request-rate counters; `/usage` reports the documented per-key credit/spend fields instead.
154
175
  - A provider may not return a safe human-readable account identity. In that case the provider and runtime credential state remain visible without exposing secrets.
155
176
  - Immediate account-change events are not available from Pi; auth is re-resolved before commands, turns, and scheduled refreshes.
177
+ - Fast model support is intentionally conservative and may require an extension update when Codex adds or removes service tiers.
178
+ - Another later-loaded extension can replace the final provider payload, so arbitrary third-party payload-rewrite conflicts cannot be prevented.
156
179
 
157
180
  ## 🗂️ Package layout
158
181
 
@@ -160,7 +183,11 @@ Behavior changes:
160
183
  packages/pi-usage/
161
184
  ├── src/
162
185
  │ ├── index.ts # Pi package entrypoint and helper export barrel
163
- │ ├── usage.ts # Menu, cache, and lifecycle orchestration
186
+ │ ├── usage.ts # Menu, cache, and usage lifecycle orchestration
187
+ │ ├── codex-fast.ts # Fast eligibility, request tier, and cost correction
188
+ │ ├── codex-fast-runtime.ts # Fast command, persistence lifecycle, and request hooks
189
+ │ ├── settings.ts # Validated user settings and atomic persistence
190
+ │ ├── usage-helpers.ts # Small orchestration helpers
164
191
  │ ├── query.ts # Runtime auth resolution and bounded provider queries
165
192
  │ ├── codex-resets.ts # Codex reset auth, API contracts, and normalization
166
193
  │ ├── format.ts # Provider-aware notifications and statusline text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, and OpenRouter.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,10 +34,12 @@
34
34
  "typecheck": "tsc --noEmit"
35
35
  },
36
36
  "peerDependencies": {
37
+ "@earendil-works/pi-ai": "*",
37
38
  "@earendil-works/pi-coding-agent": "*"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@biomejs/biome": "2.5.7",
42
+ "@earendil-works/pi-ai": "0.84.1",
41
43
  "@earendil-works/pi-coding-agent": "0.84.1",
42
44
  "@types/node": "26.1.2",
43
45
  "typescript": "7.0.2"
@@ -0,0 +1,212 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ codexFastAvailability,
8
+ codexFastIsEffective,
9
+ codexFastStatusLabel,
10
+ correctCodexFastMessageCost,
11
+ rewriteCodexFastPayload,
12
+ } from "./codex-fast.js";
13
+ import { errorMessage } from "./core.js";
14
+ import { isStaleExtensionContextError } from "./query.js";
15
+ import type { UsageSettingsRuntime, UsageSettingsState } from "./settings.js";
16
+ import type { PiModel } from "./types.js";
17
+
18
+ const NO_FAST_REQUEST = Symbol("no-fast-request");
19
+ type PendingFastRequest = { fastRequested: boolean; model: PiModel };
20
+
21
+ export const FAST_USAGE_WARNING = "Fast is about 1.5× faster and uses more of your plan allowance.";
22
+
23
+ export function registerCodexFastMode(
24
+ pi: ExtensionAPI,
25
+ settingsRuntime: UsageSettingsRuntime,
26
+ refreshStatus: (ctx: ExtensionContext) => void,
27
+ ) {
28
+ let sessionController = new AbortController();
29
+ let generation = 0;
30
+ const pendingFastRequests = new Map<string, PendingFastRequest>();
31
+
32
+ const toggle = async (
33
+ ctx: ExtensionCommandContext,
34
+ enabled: boolean,
35
+ callerSignal?: AbortSignal,
36
+ ): Promise<boolean> => {
37
+ const ownerGeneration = generation;
38
+ const sessionId = ctx.sessionManager.getSessionId();
39
+ const signal = callerSignal
40
+ ? AbortSignal.any([callerSignal, sessionController.signal])
41
+ : sessionController.signal;
42
+ try {
43
+ await settingsRuntime.update({ codexFastMode: enabled }, signal);
44
+ } catch (error) {
45
+ if (isAbortError(error) || isStaleExtensionContextError(error)) return false;
46
+ ctx.ui.notify(`Could not save pi-usage.json: ${errorMessage(error)}`, "error");
47
+ return false;
48
+ }
49
+ if (
50
+ signal.aborted ||
51
+ ownerGeneration !== generation ||
52
+ ctx.sessionManager.getSessionId() !== sessionId
53
+ ) {
54
+ return false;
55
+ }
56
+ refreshStatus(ctx);
57
+ ctx.ui.notify(
58
+ enabled
59
+ ? `Codex Fast mode enabled. ${FAST_USAGE_WARNING}`
60
+ : "Codex Fast mode disabled; standard routing will be used.",
61
+ "info",
62
+ );
63
+ return true;
64
+ };
65
+
66
+ pi.registerCommand("fast", {
67
+ description: "Toggle Codex Fast mode",
68
+ handler: async (args, ctx) => {
69
+ if (args.trim()) {
70
+ if (!ctx.hasUI) throw new Error("/fast does not accept arguments.");
71
+ ctx.ui.notify("/fast does not accept arguments.", "warning");
72
+ return;
73
+ }
74
+ if (!ctx.hasUI) throw new Error("/fast requires TUI or RPC mode.");
75
+ const availability = codexFastAvailability(
76
+ ctx.model,
77
+ settingsRuntime.get().settings.codexFastMode,
78
+ );
79
+ if (availability.kind === "not-codex") {
80
+ ctx.ui.notify("/fast is available only for the active OpenAI Codex model.", "warning");
81
+ return;
82
+ }
83
+ if (availability.kind === "unavailable") {
84
+ ctx.ui.notify(availability.reason, "warning");
85
+ return;
86
+ }
87
+ if (settingsRuntime.get().kind === "invalid") {
88
+ ctx.ui.notify(
89
+ "pi-usage.json is invalid; repair it and run /reload before changing Fast mode.",
90
+ "error",
91
+ );
92
+ return;
93
+ }
94
+ await toggle(ctx, !availability.enabled);
95
+ },
96
+ });
97
+
98
+ pi.on("session_start", async (_event, ctx) => {
99
+ generation += 1;
100
+ sessionController.abort();
101
+ pendingFastRequests.clear();
102
+ sessionController = new AbortController();
103
+ const ownerGeneration = generation;
104
+ const sessionId = ctx.sessionManager.getSessionId();
105
+ let state: Readonly<UsageSettingsState>;
106
+ try {
107
+ state = await settingsRuntime.reload(sessionController.signal);
108
+ } catch (error) {
109
+ if (sessionController.signal.aborted || ownerGeneration !== generation) return;
110
+ if (ctx.hasUI) {
111
+ ctx.ui.notify(
112
+ `Could not load pi-usage.json; using defaults. ${errorMessage(error)}`,
113
+ "warning",
114
+ );
115
+ }
116
+ return;
117
+ }
118
+ if (
119
+ sessionController.signal.aborted ||
120
+ ownerGeneration !== generation ||
121
+ ctx.sessionManager.getSessionId() !== sessionId
122
+ ) {
123
+ return;
124
+ }
125
+ if (ctx.hasUI && state.kind === "invalid") {
126
+ ctx.ui.notify(
127
+ `Invalid pi-usage.json; using defaults without overwriting it. ${state.issue}`,
128
+ "warning",
129
+ );
130
+ }
131
+ refreshStatus(ctx);
132
+ });
133
+
134
+ pi.on("before_provider_request", (event, ctx) => {
135
+ const rewritten = rewriteCodexFastPayload(
136
+ event.payload,
137
+ ctx.model,
138
+ settingsRuntime.get().settings.codexFastMode,
139
+ );
140
+ const key = activeRequestKey(ctx);
141
+ if (key && ctx.model) {
142
+ pendingFastRequests.set(key, {
143
+ fastRequested: isRecord(rewritten) && rewritten.service_tier === "priority",
144
+ model: ctx.model,
145
+ });
146
+ }
147
+ return rewritten;
148
+ });
149
+ pi.on("message_end", (event, ctx) => {
150
+ const request = consumeFastRequest(ctx, event.message, pendingFastRequests);
151
+ if (request === NO_FAST_REQUEST) return undefined;
152
+ const message = correctCodexFastMessageCost(
153
+ event.message,
154
+ request.model,
155
+ request.fastRequested,
156
+ );
157
+ return message ? { message: message as never } : undefined;
158
+ });
159
+ pi.on("session_shutdown", async () => {
160
+ generation += 1;
161
+ sessionController.abort();
162
+ pendingFastRequests.clear();
163
+ await settingsRuntime.flush();
164
+ });
165
+
166
+ return {
167
+ availability(model: PiModel | undefined) {
168
+ return codexFastAvailability(model, settingsRuntime.get().settings.codexFastMode);
169
+ },
170
+ decorateStatus(model: PiModel | undefined, status: string) {
171
+ return codexFastStatusLabel(
172
+ status,
173
+ codexFastIsEffective(model, settingsRuntime.get().settings.codexFastMode),
174
+ );
175
+ },
176
+ toggle,
177
+ };
178
+ }
179
+
180
+ function activeRequestKey(ctx: ExtensionContext): string | undefined {
181
+ const model = ctx.model;
182
+ return model ? `${ctx.sessionManager.getSessionId()}:${model.provider}/${model.id}` : undefined;
183
+ }
184
+
185
+ function consumeFastRequest(
186
+ ctx: ExtensionContext,
187
+ message: unknown,
188
+ pending: Map<string, PendingFastRequest>,
189
+ ): PendingFastRequest | typeof NO_FAST_REQUEST {
190
+ if (!isRecord(message) || message.role !== "assistant") return NO_FAST_REQUEST;
191
+ const key = messageRequestKey(ctx, message);
192
+ if (!key) return NO_FAST_REQUEST;
193
+ const request = pending.get(key);
194
+ pending.delete(key);
195
+ return request ?? NO_FAST_REQUEST;
196
+ }
197
+
198
+ function messageRequestKey(
199
+ ctx: ExtensionContext,
200
+ message: Record<string, unknown>,
201
+ ): string | undefined {
202
+ if (typeof message.provider !== "string" || typeof message.model !== "string") return undefined;
203
+ return `${ctx.sessionManager.getSessionId()}:${message.provider}/${message.model}`;
204
+ }
205
+
206
+ function isRecord(value: unknown): value is Record<string, unknown> {
207
+ return typeof value === "object" && value !== null && !Array.isArray(value);
208
+ }
209
+
210
+ function isAbortError(error: unknown): boolean {
211
+ return error instanceof Error && error.name === "AbortError";
212
+ }
@@ -0,0 +1,124 @@
1
+ import { calculateCost, hasApi } from "@earendil-works/pi-ai";
2
+ import type { PiModel } from "./types.js";
3
+
4
+ export const CODEX_FAST_SERVICE_TIER = "priority";
5
+ export const CODEX_STANDARD_SERVICE_TIER = "default";
6
+
7
+ export const CODEX_FAST_MODEL_IDS: ReadonlySet<string> = new Set([
8
+ "gpt-5.4",
9
+ "gpt-5.5",
10
+ "gpt-5.6-luna",
11
+ "gpt-5.6-sol",
12
+ "gpt-5.6-terra",
13
+ ]);
14
+
15
+ export type CodexFastAvailability =
16
+ | { kind: "available"; enabled: boolean }
17
+ | { kind: "not-codex" }
18
+ | { kind: "unavailable"; reason: string };
19
+
20
+ export function codexFastAvailability(
21
+ model: PiModel | undefined,
22
+ enabled: boolean,
23
+ ): CodexFastAvailability {
24
+ if (model?.provider !== "openai-codex") return { kind: "not-codex" };
25
+ if (!isOfficialCodexModel(model)) {
26
+ return {
27
+ kind: "unavailable",
28
+ reason: "Fast mode requires the official OpenAI Codex Responses endpoint.",
29
+ };
30
+ }
31
+ if (!CODEX_FAST_MODEL_IDS.has(model.id)) {
32
+ return {
33
+ kind: "unavailable",
34
+ reason: `${model.id} does not advertise Codex Fast support.`,
35
+ };
36
+ }
37
+ return { kind: "available", enabled };
38
+ }
39
+
40
+ export function codexFastIsEffective(model: PiModel | undefined, enabled: boolean): boolean {
41
+ return codexFastAvailability(model, enabled).kind === "available" && enabled;
42
+ }
43
+
44
+ export function codexFastRequestTier(
45
+ model: PiModel | undefined,
46
+ enabled: boolean,
47
+ ): typeof CODEX_FAST_SERVICE_TIER | typeof CODEX_STANDARD_SERVICE_TIER | undefined {
48
+ if (!isOfficialCodexModel(model)) return undefined;
49
+ return enabled && CODEX_FAST_MODEL_IDS.has(model.id)
50
+ ? CODEX_FAST_SERVICE_TIER
51
+ : CODEX_STANDARD_SERVICE_TIER;
52
+ }
53
+
54
+ export function rewriteCodexFastPayload(
55
+ payload: unknown,
56
+ model: PiModel | undefined,
57
+ enabled: boolean,
58
+ ): unknown | undefined {
59
+ const serviceTier = codexFastRequestTier(model, enabled);
60
+ if (!serviceTier || !isRecord(payload)) return undefined;
61
+ return { ...payload, service_tier: serviceTier };
62
+ }
63
+
64
+ export function correctCodexFastMessageCost(
65
+ message: unknown,
66
+ model: PiModel | undefined,
67
+ fastRequested: boolean,
68
+ ): unknown | undefined {
69
+ if (
70
+ !codexFastIsEffective(model, fastRequested) ||
71
+ !isRecord(message) ||
72
+ message.role !== "assistant" ||
73
+ message.provider !== model?.provider ||
74
+ message.model !== model?.id
75
+ ) {
76
+ return undefined;
77
+ }
78
+ const usage = isRecord(message.usage) ? message.usage : undefined;
79
+ const cost = usage && isRecord(usage.cost) ? usage.cost : undefined;
80
+ if (!usage || !cost || !hasCompleteUsage(usage) || !isOfficialCodexModel(model)) return undefined;
81
+ const correctedUsage = structuredClone(usage) as typeof usage;
82
+ calculateCost(model, correctedUsage as never);
83
+ const multiplier = model.id === "gpt-5.5" ? 2.5 : 2;
84
+ const correctedCost = correctedUsage.cost as Record<string, number>;
85
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
86
+ correctedCost[key] *= multiplier;
87
+ }
88
+ if (costsEqual(cost, correctedCost)) return undefined;
89
+ return { ...message, usage: correctedUsage };
90
+ }
91
+
92
+ export function codexFastStatusLabel(status: string, enabled: boolean): string {
93
+ if (!enabled || !/^codex(?:\s|$)/u.test(status)) return status;
94
+ return status === "codex" ? "codex fast" : `codex fast${status.slice("codex".length)}`;
95
+ }
96
+
97
+ function isOfficialCodexModel(
98
+ model: PiModel | undefined,
99
+ ): model is PiModel & { api: "openai-codex-responses" } {
100
+ if (model?.provider !== "openai-codex" || !hasApi(model, "openai-codex-responses")) {
101
+ return false;
102
+ }
103
+ try {
104
+ return new URL(model.baseUrl).origin === "https://chatgpt.com";
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ function isRecord(value: unknown): value is Record<string, unknown> {
111
+ return typeof value === "object" && value !== null && !Array.isArray(value);
112
+ }
113
+
114
+ function hasCompleteUsage(value: Record<string, unknown>): boolean {
115
+ return ["input", "output", "cacheRead", "cacheWrite"].every(
116
+ (key) => typeof value[key] === "number" && Number.isFinite(value[key]),
117
+ );
118
+ }
119
+
120
+ function costsEqual(left: Record<string, unknown>, right: Record<string, number>): boolean {
121
+ return ["input", "output", "cacheRead", "cacheWrite", "total"].every(
122
+ (key) => left[key] === right[key],
123
+ );
124
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,14 @@
1
+ export {
2
+ CODEX_FAST_MODEL_IDS,
3
+ CODEX_FAST_SERVICE_TIER,
4
+ CODEX_STANDARD_SERVICE_TIER,
5
+ codexFastAvailability,
6
+ codexFastIsEffective,
7
+ codexFastRequestTier,
8
+ codexFastStatusLabel,
9
+ correctCodexFastMessageCost,
10
+ rewriteCodexFastPayload,
11
+ } from "./codex-fast.js";
1
12
  export type {
2
13
  CodexResetAvailability,
3
14
  CodexResetOption,
@@ -32,6 +43,18 @@ export {
32
43
  resolveUsageAuth,
33
44
  SUPPORTED_ADAPTERS,
34
45
  } from "./query.js";
46
+ export type {
47
+ UsageSettings,
48
+ UsageSettingsRuntime,
49
+ UsageSettingsState,
50
+ } from "./settings.js";
51
+ export {
52
+ createUsageSettingsRuntime,
53
+ DEFAULT_USAGE_SETTINGS,
54
+ loadUsageSettings,
55
+ normalizeUsageSettings,
56
+ usageSettingsPath,
57
+ } from "./settings.js";
35
58
  export type {
36
59
  ProviderUsageState,
37
60
  ResolvedUsageAuth,
@@ -0,0 +1,208 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { chmod, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
7
+ export const USAGE_SETTINGS_FILE = "pi-usage.json";
8
+ export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
9
+
10
+ export interface UsageSettings {
11
+ codexFastMode: boolean;
12
+ }
13
+
14
+ export const DEFAULT_USAGE_SETTINGS: Readonly<UsageSettings> = Object.freeze({
15
+ codexFastMode: false,
16
+ });
17
+
18
+ export interface UsageSettingsState {
19
+ kind: "missing" | "loaded" | "invalid";
20
+ path: string;
21
+ settings: UsageSettings;
22
+ document?: Record<string, unknown>;
23
+ issue?: string;
24
+ }
25
+
26
+ export interface UsageSettingsRuntime {
27
+ get(): Readonly<UsageSettingsState>;
28
+ reload(signal?: AbortSignal): Promise<Readonly<UsageSettingsState>>;
29
+ update(
30
+ patch: Partial<UsageSettings>,
31
+ signal?: AbortSignal,
32
+ ): Promise<Readonly<UsageSettingsState>>;
33
+ flush(): Promise<void>;
34
+ }
35
+
36
+ interface UsageSettingsFileOperations {
37
+ rename: typeof rename;
38
+ writeFile: typeof writeFile;
39
+ }
40
+
41
+ interface UsageSettingsRuntimeOptions {
42
+ operations?: Partial<UsageSettingsFileOperations>;
43
+ path?: string;
44
+ }
45
+
46
+ export function usageSettingsPath(): string {
47
+ return join(getAgentDir(), USAGE_SETTINGS_FILE);
48
+ }
49
+
50
+ export function normalizeUsageSettings(value: unknown): UsageSettings | undefined {
51
+ if (!isRecord(value)) return undefined;
52
+ if (Object.hasOwn(value, "codexFastMode") && typeof value.codexFastMode !== "boolean") {
53
+ return undefined;
54
+ }
55
+ return {
56
+ codexFastMode:
57
+ typeof value.codexFastMode === "boolean"
58
+ ? value.codexFastMode
59
+ : DEFAULT_USAGE_SETTINGS.codexFastMode,
60
+ };
61
+ }
62
+
63
+ export async function loadUsageSettings(
64
+ path = usageSettingsPath(),
65
+ signal?: AbortSignal,
66
+ ): Promise<UsageSettingsState> {
67
+ throwIfAborted(signal);
68
+ try {
69
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
70
+ let text: string;
71
+ try {
72
+ const stats = await handle.stat();
73
+ throwIfAborted(signal);
74
+ if (!stats.isFile()) throw new Error("settings path is not a regular file");
75
+ if (stats.size > MAX_USAGE_SETTINGS_BYTES) {
76
+ throw new Error("settings file exceeds 64 KiB");
77
+ }
78
+ text = await handle.readFile("utf8");
79
+ } finally {
80
+ await handle.close();
81
+ }
82
+ throwIfAborted(signal);
83
+ const document = JSON.parse(text) as unknown;
84
+ const settings = normalizeUsageSettings(document);
85
+ if (!settings || !isRecord(document)) throw new Error("invalid settings shape");
86
+ return { kind: "loaded", path, settings, document };
87
+ } catch (error) {
88
+ if (signal?.aborted) throw error;
89
+ if (isNodeError(error) && error.code === "ENOENT") {
90
+ return {
91
+ kind: "missing",
92
+ path,
93
+ settings: { ...DEFAULT_USAGE_SETTINGS },
94
+ document: {},
95
+ };
96
+ }
97
+ return {
98
+ kind: "invalid",
99
+ path,
100
+ settings: { ...DEFAULT_USAGE_SETTINGS },
101
+ issue:
102
+ isNodeError(error) && error.code === "ELOOP"
103
+ ? "symbolic links are not accepted"
104
+ : error instanceof Error
105
+ ? error.message
106
+ : String(error),
107
+ };
108
+ }
109
+ }
110
+
111
+ export function createUsageSettingsRuntime(
112
+ options: UsageSettingsRuntimeOptions | string = {},
113
+ ): UsageSettingsRuntime {
114
+ const path = typeof options === "string" ? options : (options.path ?? usageSettingsPath());
115
+ const operations: UsageSettingsFileOperations = {
116
+ rename,
117
+ writeFile,
118
+ ...(typeof options === "string" ? undefined : options.operations),
119
+ };
120
+ let state: UsageSettingsState = {
121
+ kind: "missing",
122
+ path,
123
+ settings: { ...DEFAULT_USAGE_SETTINGS },
124
+ document: {},
125
+ };
126
+ let queue = Promise.resolve();
127
+ const enqueue = <T>(operation: () => Promise<T>): Promise<T> => {
128
+ const result = queue.then(operation, operation);
129
+ queue = result.then(
130
+ () => undefined,
131
+ () => undefined,
132
+ );
133
+ return result;
134
+ };
135
+ return {
136
+ get: () => structuredClone(state),
137
+ reload: (signal) =>
138
+ enqueue(async () => {
139
+ const loaded = await loadUsageSettings(path, signal);
140
+ state = loaded;
141
+ return structuredClone(state);
142
+ }),
143
+ update: (patch, signal) =>
144
+ enqueue(async () => {
145
+ const saved = await saveUsageSettingsPatch(path, patch, operations, signal);
146
+ state = saved;
147
+ return structuredClone(state);
148
+ }),
149
+ flush: () => queue,
150
+ };
151
+ }
152
+
153
+ async function saveUsageSettingsPatch(
154
+ path: string,
155
+ patch: Partial<UsageSettings>,
156
+ operations: UsageSettingsFileOperations,
157
+ signal?: AbortSignal,
158
+ ): Promise<UsageSettingsState> {
159
+ const latest = await loadUsageSettings(path, signal);
160
+ if (latest.kind === "invalid") {
161
+ throw new Error("Cannot overwrite an invalid pi-usage.json; repair it and reload first");
162
+ }
163
+ const document = { ...latest.document, ...patch };
164
+ const settings = normalizeUsageSettings(document);
165
+ if (!settings) throw new Error("Refusing to save invalid pi-usage settings");
166
+ const directory = dirname(path);
167
+ const temporaryPath = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
168
+ await mkdir(directory, { recursive: true, mode: 0o700 });
169
+ throwIfAborted(signal);
170
+ try {
171
+ await operations.writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, {
172
+ encoding: "utf8",
173
+ flag: "wx",
174
+ mode: 0o600,
175
+ });
176
+ if (process.platform !== "win32") await chmodPrivate(temporaryPath);
177
+ throwIfAborted(signal);
178
+ const current = await loadUsageSettings(path, signal);
179
+ if (
180
+ current.kind === "invalid" ||
181
+ current.kind !== latest.kind ||
182
+ JSON.stringify(current.document) !== JSON.stringify(latest.document)
183
+ ) {
184
+ throw new Error("pi-usage.json changed while saving; retry the action");
185
+ }
186
+ throwIfAborted(signal);
187
+ await operations.rename(temporaryPath, path);
188
+ } finally {
189
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
190
+ }
191
+ return { kind: "loaded", path, settings, document };
192
+ }
193
+
194
+ async function chmodPrivate(path: string): Promise<void> {
195
+ await chmod(path, 0o600);
196
+ }
197
+
198
+ function throwIfAborted(signal?: AbortSignal): void {
199
+ if (signal?.aborted) throw new DOMException("Settings operation aborted", "AbortError");
200
+ }
201
+
202
+ function isRecord(value: unknown): value is Record<string, unknown> {
203
+ return typeof value === "object" && value !== null && !Array.isArray(value);
204
+ }
205
+
206
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
207
+ return error instanceof Error && "code" in error;
208
+ }
@@ -0,0 +1,40 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { sanitizeDisplayText } from "./core.js";
3
+ import { providerIsConfigured, SUPPORTED_ADAPTERS } from "./query.js";
4
+ import type { PiModel, UsageProviderAdapter } from "./types.js";
5
+
6
+ export function configuredAdapters(ctx: ExtensionContext): UsageProviderAdapter[] {
7
+ return SUPPORTED_ADAPTERS.filter(
8
+ (adapter) => adapter.id === ctx.model?.provider || providerIsConfigured(ctx, adapter.id),
9
+ );
10
+ }
11
+
12
+ export function providerDisplayName(ctx: ExtensionContext, providerId: string): string {
13
+ try {
14
+ return sanitizeDisplayText(ctx.modelRegistry.getProviderDisplayName(providerId), 80);
15
+ } catch {
16
+ return sanitizeDisplayText(providerId, 80);
17
+ }
18
+ }
19
+
20
+ export function setBoundedMap<T>(map: Map<string, T>, key: string, value: T, limit: number): void {
21
+ map.delete(key);
22
+ while (map.size >= limit) {
23
+ const oldest = map.keys().next().value;
24
+ if (oldest === undefined) break;
25
+ map.delete(oldest);
26
+ }
27
+ map.set(key, value);
28
+ }
29
+
30
+ export function modelIdentity(model: PiModel | undefined): string | undefined {
31
+ return model ? `${model.provider}/${model.id}` : undefined;
32
+ }
33
+
34
+ export function isAbortError(error: unknown): boolean {
35
+ return error instanceof Error && error.name === "AbortError";
36
+ }
37
+
38
+ export function isTimeoutError(error: unknown): boolean {
39
+ return error instanceof Error && error.name === "TimeoutError";
40
+ }
package/src/usage.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  ExtensionCommandContext,
5
5
  ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { FAST_USAGE_WARNING, registerCodexFastMode } from "./codex-fast-runtime.js";
7
8
  import {
8
9
  type CodexResetAvailability,
9
10
  type CodexResetOption,
@@ -19,22 +20,15 @@ import {
19
20
  resetOptionExpiration,
20
21
  resolveCodexResetAuth,
21
22
  } from "./codex-resets.js";
22
- import {
23
- awaitWithDeadline,
24
- errorMessage,
25
- runWithConcurrency,
26
- sanitizeDisplayText,
27
- UsageCache,
28
- } from "./core.js";
23
+ import { awaitWithDeadline, errorMessage, runWithConcurrency, UsageCache } from "./core.js";
29
24
  import { formatProviderStates, formatUsageStatusline } from "./format.js";
30
25
  import {
31
26
  adapterForProvider,
32
27
  isStaleExtensionContextError,
33
- providerIsConfigured,
34
28
  queryProviderUsage,
35
29
  resolveUsageAuth,
36
- SUPPORTED_ADAPTERS,
37
30
  } from "./query.js";
31
+ import { createUsageSettingsRuntime, type UsageSettingsRuntime } from "./settings.js";
38
32
  import type {
39
33
  PiModel,
40
34
  ProviderUsageState,
@@ -42,6 +36,14 @@ import type {
42
36
  UsageDisplayState,
43
37
  UsageProviderAdapter,
44
38
  } from "./types.js";
39
+ import {
40
+ configuredAdapters,
41
+ isAbortError,
42
+ isTimeoutError,
43
+ modelIdentity,
44
+ providerDisplayName,
45
+ setBoundedMap,
46
+ } from "./usage-helpers.js";
45
47
 
46
48
  const CACHE_TTL_MS = 5 * 60 * 1000;
47
49
  const DEFAULT_TIMEOUT_MS = 15_000;
@@ -59,6 +61,7 @@ const REDEEM_CODEX_RESET = "Redeem usage limit reset…";
59
61
  type UsageExtensionDependencies = {
60
62
  credentialReader?: (providerId: string) => unknown;
61
63
  createRedemptionId?: () => string;
64
+ settingsRuntime?: UsageSettingsRuntime;
62
65
  };
63
66
 
64
67
  type QueryOutcome = {
@@ -78,6 +81,7 @@ export default function usageExtension(
78
81
  ) {
79
82
  const credentialReader = dependencies.credentialReader;
80
83
  const createRedemptionId = dependencies.createRedemptionId ?? randomUUID;
84
+ const settingsRuntime = dependencies.settingsRuntime ?? createUsageSettingsRuntime();
81
85
  const cache = new UsageCache(CACHE_TTL_MS);
82
86
  const failureBackoff = new Map<string, { until: number; message: string }>();
83
87
  const latestQueries = new Map<string, number>();
@@ -88,6 +92,7 @@ export default function usageExtension(
88
92
  let statusGeneration = 0;
89
93
  let statusRefreshTimer: ReturnType<typeof setTimeout> | undefined;
90
94
  let statusController: AbortController | undefined;
95
+ let fastRuntime: ReturnType<typeof registerCodexFastMode>;
91
96
 
92
97
  const clearStatusTimer = () => {
93
98
  if (statusRefreshTimer) clearTimeout(statusRefreshTimer);
@@ -145,7 +150,8 @@ export default function usageExtension(
145
150
  }
146
151
  return;
147
152
  }
148
- const value = formatUsageStatusline(outcome.state.report, model);
153
+ const rawValue = formatUsageStatusline(outcome.state.report, model);
154
+ const value = rawValue ? fastRuntime.decorateStatus(model, rawValue) : undefined;
149
155
  if (!safeSetStatus(ctx, value)) return;
150
156
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
151
157
  };
@@ -497,6 +503,7 @@ export default function usageExtension(
497
503
  publishStableCurrent(ctx, stableCurrent);
498
504
  let current = stableCurrent.outcome;
499
505
  let visibleStates: ProviderUsageState[] = [current.state];
506
+ let fastState = settingsRuntime.get();
500
507
  let resetAvailability: CodexResetAvailability | undefined;
501
508
  let selectedReset: CodexResetOption | undefined;
502
509
  let resetAuthFingerprint: string | undefined;
@@ -515,6 +522,7 @@ export default function usageExtension(
515
522
  | "reset-error";
516
523
  type Action =
517
524
  | "refresh"
525
+ | "toggle-fast"
518
526
  | "another"
519
527
  | "all"
520
528
  | "provider"
@@ -527,29 +535,54 @@ export default function usageExtension(
527
535
  const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
528
536
  start: "main",
529
537
  screens: {
530
- main: () => ({
531
- kind: "actions",
532
- title: "Provider usage",
533
- lines: formatProviderStates(visibleStates).split("\n"),
534
- items: [
535
- { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
536
- ...(current.state.status === "ready" && current.state.providerId === "openai-codex"
537
- ? [
538
- {
539
- id: "open-resets",
540
- label: REDEEM_CODEX_RESET,
541
- description: codexResetActionDescription(current.state.report),
542
- disabled: codexResetCount(current.state.report) === 0,
543
- action: "open-resets" as const,
544
- },
545
- ]
546
- : []),
547
- { id: "another", label: VIEW_ANOTHER, action: "another" },
548
- { id: "all", label: VIEW_ALL, action: "all" },
549
- { id: "close", label: CLOSE, close: true },
550
- ],
551
- hint: "close",
552
- }),
538
+ main: () => {
539
+ const fastAvailability = fastRuntime.availability(ctx.model);
540
+ const fastLines =
541
+ fastAvailability.kind === "available"
542
+ ? [`Fast mode: ${fastAvailability.enabled ? "On" : "Off"}`, FAST_USAGE_WARNING]
543
+ : fastAvailability.kind === "unavailable"
544
+ ? [`Fast mode: Unavailable · ${fastAvailability.reason}`]
545
+ : [];
546
+ return {
547
+ kind: "actions",
548
+ title: "Provider usage",
549
+ lines: [...formatProviderStates(visibleStates).split("\n"), ...fastLines],
550
+ items: [
551
+ { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
552
+ ...(fastAvailability.kind === "available"
553
+ ? [
554
+ {
555
+ id: "toggle-fast",
556
+ label: fastAvailability.enabled
557
+ ? "Turn Fast mode off"
558
+ : "Turn Fast mode on",
559
+ description:
560
+ fastState.kind === "invalid"
561
+ ? "Repair pi-usage.json and reload before changing Fast mode."
562
+ : FAST_USAGE_WARNING,
563
+ disabled: fastState.kind === "invalid",
564
+ action: "toggle-fast" as const,
565
+ },
566
+ ]
567
+ : []),
568
+ ...(current.state.status === "ready" && current.state.providerId === "openai-codex"
569
+ ? [
570
+ {
571
+ id: "open-resets",
572
+ label: REDEEM_CODEX_RESET,
573
+ description: codexResetActionDescription(current.state.report),
574
+ disabled: codexResetCount(current.state.report) === 0,
575
+ action: "open-resets" as const,
576
+ },
577
+ ]
578
+ : []),
579
+ { id: "another", label: VIEW_ANOTHER, action: "another" },
580
+ { id: "all", label: VIEW_ALL, action: "all" },
581
+ { id: "close", label: CLOSE, close: true },
582
+ ],
583
+ hint: "close",
584
+ };
585
+ },
553
586
  providers: () => ({
554
587
  kind: "actions",
555
588
  title: "Select a configured provider",
@@ -621,6 +654,16 @@ export default function usageExtension(
621
654
  }),
622
655
  },
623
656
  actions: {
657
+ "toggle-fast": async () => {
658
+ const availability = fastRuntime.availability(ctx.model);
659
+ if (availability.kind !== "available" || fastState.kind === "invalid") {
660
+ return { kind: "rejected" };
661
+ }
662
+ const changed = await fastRuntime.toggle(ctx, !availability.enabled, controller.signal);
663
+ if (!changed) return { kind: "rejected" };
664
+ fastState = settingsRuntime.get();
665
+ return { kind: "stay" };
666
+ },
624
667
  "open-resets": async () => {
625
668
  const summaryCount =
626
669
  current.state.status === "ready" && current.state.providerId === "openai-codex"
@@ -891,24 +934,24 @@ export default function usageExtension(
891
934
  }
892
935
  };
893
936
 
894
- const commandHandler = async (args: string, ctx: ExtensionCommandContext) => {
895
- if (args.trim()) {
896
- ctx.ui.notify("/usage does not accept arguments; choose an action from its menu.", "warning");
897
- return;
898
- }
899
- try {
900
- await showMenu(ctx);
901
- } catch (error) {
902
- if (isStaleExtensionContextError(error) || isAbortError(error)) return;
903
- throw error;
904
- }
905
- };
906
-
907
937
  pi.registerCommand("usage", {
908
938
  description: "Show usage for the current runtime account",
909
- handler: commandHandler,
939
+ handler: async (args, ctx) => {
940
+ if (args.trim()) {
941
+ ctx.ui.notify(
942
+ "/usage does not accept arguments; choose an action from its menu.",
943
+ "warning",
944
+ );
945
+ return;
946
+ }
947
+ try {
948
+ await showMenu(ctx);
949
+ } catch (error) {
950
+ if (isStaleExtensionContextError(error) || isAbortError(error)) return;
951
+ throw error;
952
+ }
953
+ },
910
954
  });
911
-
912
955
  pi.on("session_start", (_event, ctx) => {
913
956
  statusGeneration += 1;
914
957
  clearStatusTimer();
@@ -940,40 +983,8 @@ export default function usageExtension(
940
983
  activeCurrentIdentity = undefined;
941
984
  safeSetStatus(ctx, undefined);
942
985
  });
943
- }
944
986
 
945
- function configuredAdapters(ctx: ExtensionContext): UsageProviderAdapter[] {
946
- return SUPPORTED_ADAPTERS.filter(
947
- (adapter) => adapter.id === ctx.model?.provider || providerIsConfigured(ctx, adapter.id),
987
+ fastRuntime = registerCodexFastMode(pi, settingsRuntime, (ctx) =>
988
+ startStatusRefresh(ctx, ctx.model, false),
948
989
  );
949
990
  }
950
-
951
- function providerDisplayName(ctx: ExtensionContext, providerId: string): string {
952
- try {
953
- return sanitizeDisplayText(ctx.modelRegistry.getProviderDisplayName(providerId), 80);
954
- } catch {
955
- return sanitizeDisplayText(providerId, 80);
956
- }
957
- }
958
-
959
- function setBoundedMap<T>(map: Map<string, T>, key: string, value: T, limit: number): void {
960
- map.delete(key);
961
- while (map.size >= limit) {
962
- const oldest = map.keys().next().value;
963
- if (oldest === undefined) break;
964
- map.delete(oldest);
965
- }
966
- map.set(key, value);
967
- }
968
-
969
- function modelIdentity(model: PiModel | undefined): string | undefined {
970
- return model ? `${model.provider}/${model.id}` : undefined;
971
- }
972
-
973
- function isAbortError(error: unknown): boolean {
974
- return error instanceof Error && error.name === "AbortError";
975
- }
976
-
977
- function isTimeoutError(error: unknown): boolean {
978
- return error instanceof Error && error.name === "TimeoutError";
979
- }