@monotykamary/pi-better-openai 0.2.1 → 0.2.3

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
@@ -46,8 +46,17 @@ Usage display, image generation, and live voice require pi's `openai-codex` OAut
46
46
  - `/live` starts or stops realtime voice mode. `Ctrl+Shift+L` is the keyboard toggle.
47
47
  - `/pets [help|list|wake [slug]|tuck|select <slug>]` renders or manages custom pets from `${CODEX_HOME:-~/.codex}/pets`.
48
48
  - `/openai-usage` shows current OpenAI subscription usage.
49
+ - `/openai-resets` inspects and manually redeems a banked Codex reset.
49
50
  - `/openai-settings` opens settings, diagnostics, and config details.
50
51
 
52
+ ## Banked resets
53
+
54
+ Unused banked Codex resets **auto-redeem by default, 5 minutes before expiry**, while an interactive pi session is running and Codex credentials are available. Starting pi within that final five-minute window also triggers the check; expired credits are skipped. This runs independently of the usage display and current model. The reset picker and confirmation show each credit's actual local auto-redemption date and time (expiry minus five minutes) beside its expiry. Disable **Auto-redeem banked resets** in `/openai-settings` or set `usage.autoRedeemBankedResets` to `false` to opt out.
55
+
56
+ For safety, each attempt targets one explicit, freshly checked credit ID, with no fallback to another credit and no automatic retry after a consume request (including errors or `nothing_to_reset`). A persistent per-account guard permits at most one redemption attempt in five minutes across pi sessions sharing the same agent directory; manual redemption uses the same guard. Automatic reservations recheck the five-minute eligibility window while holding an exclusive filesystem lock, and attempted credit IDs remain blocked even after later redemptions or restarts. Simultaneously expiring credits are not drained, and later credits wait for their own final five-minute window. Reservations live under `$PI_CODING_AGENT_DIR/pi-better-openai/reset-redemptions` (default `~/.pi/agent/pi-better-openai/reset-redemptions`); unreadable state or an orphaned lock blocks redemption rather than risking a duplicate. Update/restart all pi instances to use the current guard. Separate machines/agent directories cannot coordinate this local guard; instances using the same account should share an agent directory.
57
+
58
+ Pi must remain running and awake; this is not an OS-level scheduled task. No eligible usage window or unavailable credentials can prevent redemption.
59
+
51
60
  ## Configuration
52
61
 
53
62
  The extension reads JSON config from two locations:
@@ -84,7 +93,8 @@ Example config:
84
93
  "enabled": true,
85
94
  "refreshIntervalMs": 60000,
86
95
  "showOnlyOnSubscriptionModels": true,
87
- "showResetTimes": true
96
+ "showResetTimes": true,
97
+ "autoRedeemBankedResets": true
88
98
  },
89
99
  "footer": {
90
100
  "mode": "status"
package/index.ts CHANGED
@@ -72,10 +72,8 @@ import {
72
72
  } from "./src/usage.ts";
73
73
  import {
74
74
  buildBankedResetConfirmation,
75
- consumeBankedReset,
76
75
  formatBankedResetChoice,
77
76
  formatConsumeOutcome,
78
- newRedeemRequestId,
79
77
  selectBankedResetCredit,
80
78
  } from "./src/resets.ts";
81
79
  import { ResetController } from "./src/reset-controller.ts";
@@ -291,7 +289,12 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
291
289
  let cachedSessionNameLeafId: string | null | undefined;
292
290
  let cachedSessionName: string | undefined;
293
291
  const usageController = new UsageController(config, updateFooter);
294
- const resetController = new ResetController();
292
+ const resetController = new ResetController(
293
+ (ctx) => hasTerminalUI(ctx) && config(ctx).usage.autoRedeemBankedResets,
294
+ (ctx) => {
295
+ void usageController.refresh(ctx, ctx.model?.id, { force: true });
296
+ },
297
+ );
295
298
  const petController = new PetFooterController(config, updateFooter, () => footerInstalled);
296
299
  let multiproviderService: MultiproviderService | undefined;
297
300
  let unsubscribeMultiprovider: (() => void) | undefined;
@@ -444,6 +447,15 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
444
447
  }
445
448
 
446
449
  async function redeemBankedReset(ctx: ExtensionContext): Promise<void> {
450
+ const resume = resetController.pauseAutoRedeem();
451
+ try {
452
+ await chooseBankedReset(ctx);
453
+ } finally {
454
+ resume();
455
+ }
456
+ }
457
+
458
+ async function chooseBankedReset(ctx: ExtensionContext): Promise<void> {
447
459
  if (!hasTerminalUI(ctx)) {
448
460
  ctx.ui.notify("/openai-resets requires an interactive TUI session.", "warning");
449
461
  return;
@@ -466,16 +478,26 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
466
478
  const available = credits.credits.filter((credit) => credit.status === "available");
467
479
  let selected = selectBankedResetCredit(credits.credits);
468
480
  if (available.length > 1 && selected) {
469
- const options = available.map((credit, index) => formatBankedResetChoice(credit, index));
481
+ const options = available.map((credit, index) =>
482
+ formatBankedResetChoice(credit, index, config(ctx).usage.autoRedeemBankedResets),
483
+ );
470
484
  const chosen = await ctx.ui.select("Redeem which banked reset?", options);
471
485
  if (chosen === undefined) {
472
486
  ctx.ui.notify("Banked reset redemption cancelled.", "info");
473
487
  return;
474
488
  }
475
- selected = available[Math.max(0, options.indexOf(chosen))];
489
+ selected = available[options.indexOf(chosen)];
490
+ }
491
+ if (!selected) {
492
+ ctx.ui.notify(
493
+ "No individually identifiable banked reset is available; nothing was redeemed.",
494
+ "warning",
495
+ );
496
+ return;
476
497
  }
477
498
  const confirmation = buildBankedResetConfirmation({
478
499
  credit: selected,
500
+ autoRedeem: config(ctx).usage.autoRedeemBankedResets,
479
501
  availableCount: credits.availableCount,
480
502
  snapshot: usageController.snapshot,
481
503
  });
@@ -484,9 +506,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
484
506
  return;
485
507
  }
486
508
  try {
487
- const timeoutSignal = AbortSignal.timeout(15_000);
488
- const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
489
- const result = await consumeBankedReset(ctx, selected?.id, newRedeemRequestId(), signal);
509
+ const result = await resetController.redeem(ctx, selected.id);
490
510
  const outcome = formatConsumeOutcome(result);
491
511
  ctx.ui.notify(outcome.message, outcome.level);
492
512
  void usageController.refresh(ctx, ctx.model?.id, { force: true });
@@ -1000,6 +1020,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1000
1020
  void petController.refresh(ctx, next);
1001
1021
  if (id.startsWith("usage.")) {
1002
1022
  usageController.restartAfterSettingsChange(ctx, next);
1023
+ resetController.start(ctx);
1003
1024
  }
1004
1025
  updateFooter(ctx);
1005
1026
  }
@@ -1435,7 +1456,11 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1435
1456
  updateFooter(ctx);
1436
1457
  if (hasTerminalUI(ctx) && nextConfig.pets.enabled) void petController.refresh(ctx, nextConfig);
1437
1458
  usageController.start(ctx);
1438
- if (nextConfig.usage.enabled && isOpenAISubscriptionModel(ctx, nextConfig))
1459
+ if (
1460
+ hasTerminalUI(ctx) &&
1461
+ (nextConfig.usage.autoRedeemBankedResets ||
1462
+ (nextConfig.usage.enabled && isOpenAISubscriptionModel(ctx, nextConfig)))
1463
+ )
1439
1464
  resetController.start(ctx);
1440
1465
  if (fastController.active) ctx.ui.notify(fastController.stateText(ctx, nextConfig), "info");
1441
1466
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-better-openai",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Improve OpenAI in pi with fast mode, usage stats, realtime voice, image generation, and footer polish.",
5
5
  "keywords": [
6
6
  "fast",
package/src/config.ts CHANGED
@@ -59,6 +59,7 @@ export type UsageConfig = {
59
59
  showOnlyOnSubscriptionModels?: boolean;
60
60
  showResetTimes?: boolean;
61
61
  showBankedResets?: boolean;
62
+ autoRedeemBankedResets?: boolean;
62
63
  };
63
64
 
64
65
  export type FooterConfig = {
@@ -144,6 +145,7 @@ export const DEFAULT_USAGE_CONFIG: Required<UsageConfig> = {
144
145
  showOnlyOnSubscriptionModels: true,
145
146
  showResetTimes: true,
146
147
  showBankedResets: true,
148
+ autoRedeemBankedResets: true,
147
149
  };
148
150
 
149
151
  export const DEFAULT_FOOTER_CONFIG: Required<FooterConfig> = {
@@ -322,6 +324,16 @@ export const USAGE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
322
324
  description: "Show the available banked Codex reset count in the usage status line.",
323
325
  parse: booleanSetting,
324
326
  },
327
+ {
328
+ id: "usage.autoRedeemBankedResets",
329
+ section: "usage",
330
+ key: "autoRedeemBankedResets",
331
+ label: "Auto-redeem banked resets",
332
+ currentValue: (cfg) => String(cfg.usage.autoRedeemBankedResets),
333
+ values: ["true", "false"],
334
+ description: "Redeem one unused banked reset 5 minutes before expiry while pi is running.",
335
+ parse: booleanSetting,
336
+ },
325
337
  ];
326
338
 
327
339
  export const IMAGE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
@@ -659,6 +671,8 @@ export function readConfig(path: string): ConfigFile | undefined {
659
671
  config.usage.showResetTimes = parsed.usage.showResetTimes;
660
672
  if (typeof parsed.usage.showBankedResets === "boolean")
661
673
  config.usage.showBankedResets = parsed.usage.showBankedResets;
674
+ if (typeof parsed.usage.autoRedeemBankedResets === "boolean")
675
+ config.usage.autoRedeemBankedResets = parsed.usage.autoRedeemBankedResets;
662
676
  }
663
677
  if (
664
678
  isRecord(parsed.footer) &&
@@ -1,6 +1,18 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getCodexCredentials } from "./codex-auth.ts";
2
3
  import { sanitizeDiagnosticError } from "./format.ts";
3
- import { type BankedResetCredits, requestBankedResetCredits } from "./resets.ts";
4
+ import { reserveBankedResetRedemption } from "./reset-guard.ts";
5
+ import {
6
+ BANKED_RESET_AUTO_REDEEM_LEAD_MS,
7
+ type BankedResetCredit,
8
+ type BankedResetCredits,
9
+ type ConsumeBankedResetResult,
10
+ consumeBankedReset,
11
+ formatConsumeOutcome,
12
+ newRedeemRequestId,
13
+ requestBankedResetCredits,
14
+ selectAutoRedeemCredit,
15
+ } from "./resets.ts";
4
16
 
5
17
  export const BANKED_RESET_CACHE_TTL_MS = 5 * 60_000;
6
18
  const BANKED_RESET_REQUEST_TIMEOUT_MS = 10_000;
@@ -10,19 +22,34 @@ export type BankedResetCache = {
10
22
  updatedAt: number;
11
23
  };
12
24
 
13
- // Keeps the banked-reset credit list warm so /openai-resets opens without
14
- // paying the credential-resolution + wham round trip. The cache is prefetched
15
- // at session start, refreshed on a TTL, and served even when stale (a stale
16
- // open still kicks off a background refresh; the backend re-validates every
17
- // redemption server-side, so a stale offer can never be consumed twice).
25
+ // Keep the picker warm, but never redeem from the cache alone. Automatic
26
+ // redemption revalidates one exact credit and pins its account for the POST.
18
27
  export class ResetController {
19
28
  private cache: BankedResetCache | undefined;
20
29
  private error: string | undefined;
21
30
  private lastFetchAt: number | undefined;
22
31
  private inFlight: Promise<void> | undefined;
23
32
  private timer: ReturnType<typeof setInterval> | undefined;
33
+ private autoTimer: ReturnType<typeof setTimeout> | undefined;
24
34
  private sessionSignal: AbortSignal | undefined;
25
35
  private sessionAbortHandler: (() => void) | undefined;
36
+ private lifetime = new AbortController();
37
+ private generation = 0;
38
+ private activeCtx: ExtensionContext | undefined;
39
+ private redeeming = false;
40
+ private manualPickers = 0;
41
+ private blockedUntilMs = 0;
42
+
43
+ private readonly autoRedeemEnabled: (ctx: ExtensionContext) => boolean;
44
+ private readonly onAutoRedeemed: (ctx: ExtensionContext) => void;
45
+
46
+ constructor(
47
+ autoRedeemEnabled: (ctx: ExtensionContext) => boolean = () => false,
48
+ onAutoRedeemed: (ctx: ExtensionContext) => void = () => {},
49
+ ) {
50
+ this.autoRedeemEnabled = autoRedeemEnabled;
51
+ this.onAutoRedeemed = onAutoRedeemed;
52
+ }
26
53
 
27
54
  get snapshot(): BankedResetCache | undefined {
28
55
  return this.cache;
@@ -36,7 +63,16 @@ export class ResetController {
36
63
  return this.cache !== undefined && now - this.cache.updatedAt < BANKED_RESET_CACHE_TTL_MS;
37
64
  }
38
65
 
66
+ private requestSignal(ctx: ExtensionContext): AbortSignal {
67
+ return AbortSignal.any([
68
+ this.lifetime.signal,
69
+ AbortSignal.timeout(BANKED_RESET_REQUEST_TIMEOUT_MS),
70
+ ...(ctx.signal ? [ctx.signal] : []),
71
+ ]);
72
+ }
73
+
39
74
  refresh(ctx: ExtensionContext, options?: { force?: boolean }): Promise<void> {
75
+ if (this.lifetime.signal.aborted) return Promise.resolve();
40
76
  if (this.inFlight) return this.inFlight;
41
77
  if (
42
78
  !options?.force &&
@@ -45,11 +81,12 @@ export class ResetController {
45
81
  )
46
82
  return Promise.resolve();
47
83
  this.lastFetchAt = Date.now();
48
- const timeoutSignal = AbortSignal.timeout(BANKED_RESET_REQUEST_TIMEOUT_MS);
49
- const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
84
+ const generation = this.generation;
85
+ const signal = this.requestSignal(ctx);
50
86
  const task = (async () => {
51
87
  try {
52
88
  const credits = await requestBankedResetCredits(ctx, signal);
89
+ if (signal.aborted || generation !== this.generation) return;
53
90
  if (credits) {
54
91
  this.cache = { credits, updatedAt: Date.now() };
55
92
  this.error = undefined;
@@ -57,20 +94,178 @@ export class ResetController {
57
94
  this.error = "OpenAI Codex credentials unavailable.";
58
95
  }
59
96
  } catch (error) {
97
+ if (generation !== this.generation || signal.aborted) return;
60
98
  this.error = sanitizeDiagnosticError(
61
99
  error instanceof Error ? error.message : String(error),
62
100
  );
63
101
  } finally {
64
- this.inFlight = undefined;
102
+ if (generation === this.generation) {
103
+ this.inFlight = undefined;
104
+ this.scheduleAutoRedeem();
105
+ }
65
106
  }
66
107
  })();
67
108
  this.inFlight = task;
68
109
  return task;
69
110
  }
70
111
 
112
+ // While a person is choosing/confirming, the timer must not spend a different
113
+ // credit behind their dialog. An already-running redemption remains locked.
114
+ pauseAutoRedeem(): () => void {
115
+ this.manualPickers++;
116
+ this.clearAutoTimer();
117
+ return () => {
118
+ this.manualPickers--;
119
+ this.scheduleAutoRedeem();
120
+ };
121
+ }
122
+
123
+ async redeem(ctx: ExtensionContext, creditId: string): Promise<ConsumeBankedResetResult> {
124
+ if (!creditId) throw new Error("An explicit banked reset credit is required.");
125
+ if (this.redeeming) throw new Error("A banked reset redemption is already in progress.");
126
+ this.redeeming = true;
127
+ const generation = this.generation;
128
+ this.blockedUntilMs = Date.now() + BANKED_RESET_AUTO_REDEEM_LEAD_MS;
129
+ try {
130
+ const signal = this.requestSignal(ctx);
131
+ const credentials = await getCodexCredentials(ctx, signal);
132
+ signal.throwIfAborted();
133
+ if (!credentials) throw new Error("OpenAI Codex credentials unavailable.");
134
+ if (!reserveBankedResetRedemption(credentials.accountId, creditId))
135
+ throw new Error(
136
+ "A banked reset was already attempted recently; no additional credit was spent.",
137
+ );
138
+ return await consumeBankedReset(ctx, creditId, newRedeemRequestId(), signal, credentials);
139
+ } finally {
140
+ if (generation === this.generation) {
141
+ this.redeeming = false;
142
+ this.scheduleAutoRedeem();
143
+ }
144
+ }
145
+ }
146
+
147
+ private clearAutoTimer(): void {
148
+ if (this.autoTimer) clearTimeout(this.autoTimer);
149
+ this.autoTimer = undefined;
150
+ }
151
+
152
+ private canAutoRedeem(ctx: ExtensionContext): boolean {
153
+ return (
154
+ this.activeCtx === ctx &&
155
+ !this.lifetime.signal.aborted &&
156
+ !ctx.signal?.aborted &&
157
+ this.manualPickers === 0 &&
158
+ this.autoRedeemEnabled(ctx)
159
+ );
160
+ }
161
+
162
+ private scheduleAutoRedeem(): void {
163
+ const ctx = this.activeCtx;
164
+ if (!ctx || this.redeeming) return;
165
+ try {
166
+ if (!this.canAutoRedeem(ctx)) {
167
+ this.clearAutoTimer();
168
+ return;
169
+ }
170
+ // A cache refresh must not replace a due timer with the next credit,
171
+ // particularly when the scheduled one was redeemed in another client.
172
+ if (this.autoTimer) return;
173
+ const credit = selectAutoRedeemCredit(this.cache?.credits.credits ?? []);
174
+ if (!credit || credit.expiresAtMs === null) return;
175
+ const dueAt = Math.max(
176
+ credit.expiresAtMs - BANKED_RESET_AUTO_REDEEM_LEAD_MS,
177
+ this.blockedUntilMs,
178
+ );
179
+ this.autoTimer = setTimeout(
180
+ () => {
181
+ this.autoTimer = undefined;
182
+ if (Date.now() < dueAt) this.scheduleAutoRedeem();
183
+ else void this.autoRedeem(ctx, credit);
184
+ },
185
+ Math.max(0, Math.min(dueAt - Date.now(), BANKED_RESET_CACHE_TTL_MS)),
186
+ );
187
+ this.autoTimer.unref?.();
188
+ } catch {
189
+ // Stale extension contexts must not leave a background timer running.
190
+ this.stop();
191
+ }
192
+ }
193
+
194
+ private async autoRedeem(ctx: ExtensionContext, scheduled: BankedResetCredit): Promise<void> {
195
+ const generation = this.generation;
196
+ let ownsRedemption = false;
197
+ try {
198
+ if (this.redeeming || !this.canAutoRedeem(ctx) || Date.now() < this.blockedUntilMs) return;
199
+ this.redeeming = true;
200
+ ownsRedemption = true;
201
+ // Even a no-op or ambiguous failure ends this window: never try the next
202
+ // credit. The persistent reservation below enforces this across processes.
203
+ this.blockedUntilMs = Date.now() + BANKED_RESET_AUTO_REDEEM_LEAD_MS;
204
+ const signal = this.requestSignal(ctx);
205
+ const credentials = await getCodexCredentials(ctx, signal);
206
+ if (!credentials) return;
207
+ const fresh = await requestBankedResetCredits(ctx, signal, credentials);
208
+ if (signal.aborted || generation !== this.generation || !this.canAutoRedeem(ctx)) return;
209
+ if (!fresh) return;
210
+ this.cache = { credits: fresh, updatedAt: Date.now() };
211
+ this.error = undefined;
212
+ const credit = selectAutoRedeemCredit(fresh.credits.filter((row) => row.id === scheduled.id));
213
+ if (
214
+ !credit ||
215
+ credit.expiresAtMs === null ||
216
+ fresh.availableCount <= 0 ||
217
+ fresh.applicableCount === 0 ||
218
+ credit.expiresAtMs - Date.now() > BANKED_RESET_AUTO_REDEEM_LEAD_MS
219
+ )
220
+ return;
221
+ if (
222
+ !reserveBankedResetRedemption(credentials.accountId, credit.id, {
223
+ expiresAtMs: credit.expiresAtMs,
224
+ })
225
+ )
226
+ return;
227
+ // Filesystem contention or a clock adjustment must not turn a valid
228
+ // reservation into a POST for an expired or not-yet-due credit.
229
+ const remainingMs = credit.expiresAtMs - Date.now();
230
+ if (remainingMs <= 0 || remainingMs > BANKED_RESET_AUTO_REDEEM_LEAD_MS) return;
231
+ const result = await consumeBankedReset(
232
+ ctx,
233
+ credit.id,
234
+ newRedeemRequestId(),
235
+ signal,
236
+ credentials,
237
+ );
238
+ if (signal.aborted || generation !== this.generation) return;
239
+ const outcome = formatConsumeOutcome(result);
240
+ ctx.ui.notify(`Auto-redeem: ${outcome.message}`, outcome.level);
241
+ if (result.code === "reset") this.onAutoRedeemed(ctx);
242
+ void this.refresh(ctx, { force: true });
243
+ } catch (error) {
244
+ if (generation !== this.generation || this.lifetime.signal.aborted) return;
245
+ this.error = sanitizeDiagnosticError(error instanceof Error ? error.message : String(error));
246
+ try {
247
+ ctx.ui.notify(
248
+ `Banked reset auto-redemption failed; no automatic retry: ${this.error}`,
249
+ "warning",
250
+ );
251
+ } catch {
252
+ /* The context may have become stale. */
253
+ }
254
+ } finally {
255
+ if (ownsRedemption && generation === this.generation) {
256
+ this.redeeming = false;
257
+ this.scheduleAutoRedeem();
258
+ }
259
+ }
260
+ }
261
+
71
262
  start(ctx: ExtensionContext): void {
72
263
  this.stop();
264
+ this.lifetime = new AbortController();
265
+ this.cache = undefined;
266
+ this.lastFetchAt = undefined;
73
267
  if (ctx.signal?.aborted) return;
268
+ this.activeCtx = ctx;
74
269
  this.sessionSignal = ctx.signal;
75
270
  this.sessionAbortHandler = () => this.stop();
76
271
  ctx.signal?.addEventListener("abort", this.sessionAbortHandler, { once: true });
@@ -86,6 +281,12 @@ export class ResetController {
86
281
  }
87
282
 
88
283
  stop(): void {
284
+ this.generation++;
285
+ this.lifetime.abort();
286
+ this.activeCtx = undefined;
287
+ this.inFlight = undefined;
288
+ this.redeeming = false;
289
+ this.clearAutoTimer();
89
290
  if (this.timer) clearInterval(this.timer);
90
291
  this.timer = undefined;
91
292
  if (this.sessionSignal && this.sessionAbortHandler) {
@@ -0,0 +1,88 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { piAgentDir } from "./paths.ts";
5
+ import { BANKED_RESET_AUTO_REDEEM_LEAD_MS } from "./resets.ts";
6
+
7
+ type RedemptionReservation = {
8
+ blockedUntilMs: number;
9
+ creditHash: string;
10
+ attemptedCreditHashes?: string[];
11
+ };
12
+
13
+ // Reserve before sending a POST, not after receiving its outcome. A timeout may
14
+ // already have spent the credit. Shared by manual/automatic redemption and all
15
+ // pi processes using this agent directory. Corrupt state or an orphaned lock
16
+ // fails closed rather than risking another redemption.
17
+ export function reserveBankedResetRedemption(
18
+ accountId: string,
19
+ creditId: string,
20
+ options: { expiresAtMs?: number } = {},
21
+ ): boolean {
22
+ if (!accountId.trim() || !creditId.trim()) return false;
23
+ const digest = (value: string) => createHash("sha256").update(value).digest("hex");
24
+ const dir = join(piAgentDir(), "pi-better-openai", "reset-redemptions");
25
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ const path = join(dir, `${digest(accountId)}.json`);
27
+ const lock = `${path}.lock`;
28
+ let fd: number;
29
+ try {
30
+ fd = openSync(lock, "wx", 0o600);
31
+ } catch (error) {
32
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
33
+ throw new Error("Banked reset safety reservation unavailable.");
34
+ }
35
+ try {
36
+ let previous: RedemptionReservation | undefined;
37
+ try {
38
+ previous = JSON.parse(readFileSync(path, "utf8"));
39
+ if (
40
+ !previous ||
41
+ typeof previous.blockedUntilMs !== "number" ||
42
+ !Number.isFinite(previous.blockedUntilMs) ||
43
+ typeof previous.creditHash !== "string" ||
44
+ !/^[0-9a-f]{64}$/.test(previous.creditHash) ||
45
+ (previous.attemptedCreditHashes !== undefined &&
46
+ (!Array.isArray(previous.attemptedCreditHashes) ||
47
+ !previous.attemptedCreditHashes.every(
48
+ (hash) => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash),
49
+ ) ||
50
+ !previous.attemptedCreditHashes.includes(previous.creditHash)))
51
+ )
52
+ throw new Error("Invalid reservation");
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
55
+ throw new Error("Banked reset safety reservation unavailable.");
56
+ }
57
+ // Check the actual clock under the account lock, not a cached timer's time.
58
+ // Manual, explicitly confirmed redemptions do not supply an expiry limit.
59
+ const now = Date.now();
60
+ if (
61
+ options.expiresAtMs !== undefined &&
62
+ (!Number.isFinite(options.expiresAtMs) ||
63
+ options.expiresAtMs <= now ||
64
+ options.expiresAtMs - now > BANKED_RESET_AUTO_REDEEM_LEAD_MS)
65
+ )
66
+ return false;
67
+ const creditHash = digest(creditId);
68
+ // Migrate old single-credit state. Never forget an uncertain attempt just
69
+ // because another credit was subsequently redeemed.
70
+ const attemptedCreditHashes =
71
+ previous?.attemptedCreditHashes ?? (previous ? [previous.creditHash] : []);
72
+ if ((previous && previous.blockedUntilMs > now) || attemptedCreditHashes.includes(creditHash))
73
+ return false;
74
+ writeFileSync(
75
+ path,
76
+ JSON.stringify({
77
+ blockedUntilMs: now + BANKED_RESET_AUTO_REDEEM_LEAD_MS,
78
+ creditHash,
79
+ attemptedCreditHashes: [...attemptedCreditHashes, creditHash],
80
+ }),
81
+ { mode: 0o600, flush: true },
82
+ );
83
+ return true;
84
+ } finally {
85
+ closeSync(fd);
86
+ unlinkSync(lock);
87
+ }
88
+ }
package/src/resets.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { getCodexCredentials } from "./codex-auth.ts";
3
+ import { getCodexCredentials, type CodexCredentials } from "./codex-auth.ts";
4
4
  import { formatPercent, type UsageSnapshot } from "./usage.ts";
5
5
 
6
6
  export const RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
7
7
  export const CONSUME_RESET_URL =
8
8
  "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
9
9
 
10
+ export const BANKED_RESET_AUTO_REDEEM_LEAD_MS = 5 * 60_000;
11
+
10
12
  export type BankedResetStatus = "available" | "redeeming" | "redeemed" | "unknown";
11
13
 
12
14
  export type BankedResetCredit = {
@@ -129,6 +131,21 @@ export function selectBankedResetCredit(
129
131
  })[0];
130
132
  }
131
133
 
134
+ export function selectAutoRedeemCredit(
135
+ credits: readonly BankedResetCredit[],
136
+ now = Date.now(),
137
+ ): BankedResetCredit | undefined {
138
+ return selectBankedResetCredit(
139
+ credits.filter(
140
+ (credit) =>
141
+ credit.resetType === "codex_rate_limits" &&
142
+ credit.expiresAtMs !== null &&
143
+ Number.isFinite(credit.expiresAtMs) &&
144
+ credit.expiresAtMs > now,
145
+ ),
146
+ );
147
+ }
148
+
132
149
  function formatResetTimestamp(ms: number | null): string {
133
150
  if (ms === null) return "unknown";
134
151
  return new Date(ms).toLocaleString(undefined, {
@@ -139,13 +156,24 @@ function formatResetTimestamp(ms: number | null): string {
139
156
  });
140
157
  }
141
158
 
142
- export function formatBankedResetChoice(credit: BankedResetCredit, index: number): string {
159
+ function formatAutoRedeemNote(credit: BankedResetCredit | undefined, enabled = false): string {
160
+ if (!enabled || !credit || !selectAutoRedeemCredit([credit]) || credit.expiresAtMs === null)
161
+ return "";
162
+ return ` · auto-redeems ${formatResetTimestamp(credit.expiresAtMs - BANKED_RESET_AUTO_REDEEM_LEAD_MS)}`;
163
+ }
164
+
165
+ export function formatBankedResetChoice(
166
+ credit: BankedResetCredit,
167
+ index: number,
168
+ autoRedeem = false,
169
+ ): string {
143
170
  const title = credit.title ?? "Codex banked reset";
144
171
  const expires =
145
172
  credit.expiresAtMs === null
146
173
  ? "no expiry"
147
174
  : `expires ${formatResetTimestamp(credit.expiresAtMs)}`;
148
- return `${index + 1}. ${title} · ${expires}`;
175
+ const note = formatAutoRedeemNote(credit, autoRedeem);
176
+ return `${index + 1}. ${title} · ${expires}${note}`;
149
177
  }
150
178
 
151
179
  export function formatConsumeOutcome(result: ConsumeBankedResetResult): {
@@ -173,6 +201,7 @@ export function formatConsumeOutcome(result: ConsumeBankedResetResult): {
173
201
  export function buildBankedResetConfirmation(options: {
174
202
  credit?: BankedResetCredit;
175
203
  availableCount: number;
204
+ autoRedeem?: boolean;
176
205
  snapshot?: Pick<UsageSnapshot, "fiveHourLeftPercent" | "sevenDayLeftPercent">;
177
206
  }): { title: string; message: string } {
178
207
  const credit = options.credit;
@@ -181,7 +210,8 @@ export function buildBankedResetConfirmation(options: {
181
210
  lines.push("");
182
211
  if (credit?.grantedAtMs != null)
183
212
  lines.push(`Granted: ${formatResetTimestamp(credit.grantedAtMs)}`);
184
- lines.push(`Expires: ${formatResetTimestamp(credit?.expiresAtMs ?? null)}`);
213
+ const autoNote = formatAutoRedeemNote(credit, options.autoRedeem);
214
+ lines.push(`Expires: ${formatResetTimestamp(credit?.expiresAtMs ?? null)}${autoNote}`);
185
215
  lines.push(`Available: ${options.availableCount}`);
186
216
  const windows: string[] = [];
187
217
  if (options.snapshot?.fiveHourLeftPercent != null)
@@ -199,10 +229,11 @@ export function buildBankedResetConfirmation(options: {
199
229
  export async function requestBankedResetCredits(
200
230
  ctxOrSignal?: ExtensionContext | AbortSignal,
201
231
  signal?: AbortSignal,
232
+ pinnedCredentials?: CodexCredentials,
202
233
  ): Promise<BankedResetCredits | undefined> {
203
234
  const ctx = isAbortSignal(ctxOrSignal) ? undefined : ctxOrSignal;
204
235
  const requestSignal = isAbortSignal(ctxOrSignal) ? ctxOrSignal : signal;
205
- const credentials = await getCodexCredentials(ctx, requestSignal);
236
+ const credentials = pinnedCredentials ?? (await getCodexCredentials(ctx, requestSignal));
206
237
  if (!credentials) return undefined;
207
238
  const response = await fetch(RESET_CREDITS_URL, {
208
239
  headers: {
@@ -221,8 +252,11 @@ export async function consumeBankedReset(
221
252
  creditId: string | undefined,
222
253
  redeemRequestId: string,
223
254
  signal?: AbortSignal,
255
+ pinnedCredentials?: CodexCredentials,
224
256
  ): Promise<ConsumeBankedResetResult> {
225
- const credentials = await getCodexCredentials(ctx, signal);
257
+ if (!creditId?.trim()) throw new Error("An explicit banked reset credit is required.");
258
+ const credentials = pinnedCredentials ?? (await getCodexCredentials(ctx, signal));
259
+ signal?.throwIfAborted();
226
260
  if (!credentials)
227
261
  throw new Error("OpenAI Codex authentication is unavailable. Run /login first.");
228
262
  const response = await fetch(CONSUME_RESET_URL, {
@@ -233,11 +267,7 @@ export async function consumeBankedReset(
233
267
  "chatgpt-account-id": credentials.accountId,
234
268
  "content-type": "application/json",
235
269
  },
236
- body: JSON.stringify(
237
- creditId
238
- ? { credit_id: creditId, redeem_request_id: redeemRequestId }
239
- : { redeem_request_id: redeemRequestId },
240
- ),
270
+ body: JSON.stringify({ credit_id: creditId, redeem_request_id: redeemRequestId }),
241
271
  signal,
242
272
  });
243
273
  if (!response.ok) throw new Error(`Codex reset consume request failed (${response.status})`);