@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 +11 -1
- package/index.ts +34 -9
- package/package.json +1 -1
- package/src/config.ts +14 -0
- package/src/reset-controller.ts +210 -9
- package/src/reset-guard.ts +88 -0
- package/src/resets.ts +41 -11
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) =>
|
|
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[
|
|
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
|
|
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 (
|
|
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
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) &&
|
package/src/reset-controller.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
14
|
-
//
|
|
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
|
|
49
|
-
const signal =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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})`);
|