@monotykamary/pi-better-openai 0.2.0 → 0.2.2
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 +6 -6
- package/src/config.ts +14 -0
- package/src/reset-controller.ts +201 -9
- package/src/reset-guard.ts +62 -0
- package/src/resets.ts +37 -6
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 shows the auto-redemption note beside each eligible 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. Simultaneously expiring credits are not drained. 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. Separate machines/agent directories cannot coordinate this local guard.
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monotykamary/pi-better-openai",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -62,9 +62,9 @@
|
|
|
62
62
|
"ws": "8.21.2"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@earendil-works/pi-ai": "0.
|
|
66
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
67
|
-
"@earendil-works/pi-tui": "0.
|
|
65
|
+
"@earendil-works/pi-ai": "0.86.0",
|
|
66
|
+
"@earendil-works/pi-coding-agent": "0.86.0",
|
|
67
|
+
"@earendil-works/pi-tui": "0.86.0",
|
|
68
68
|
"@types/node": "~22.19.0",
|
|
69
69
|
"@types/proxy-from-env": "1.0.4",
|
|
70
70
|
"@types/ws": "8.18.1",
|
|
@@ -86,8 +86,8 @@
|
|
|
86
86
|
"@oh-my-pi/pi-natives-win32-x64": "17.2.9"
|
|
87
87
|
},
|
|
88
88
|
"overrides": {
|
|
89
|
-
"@earendil-works/pi-agent-core": "0.
|
|
90
|
-
"@earendil-works/pi-ai": "0.
|
|
89
|
+
"@earendil-works/pi-agent-core": "0.86.0",
|
|
90
|
+
"@earendil-works/pi-ai": "0.86.0",
|
|
91
91
|
"brace-expansion": "5.0.9",
|
|
92
92
|
"nanoid": "3.3.18",
|
|
93
93
|
"postcss": "8.5.25",
|
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,169 @@ 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 (!reserveBankedResetRedemption(credentials.accountId, credit.id)) return;
|
|
222
|
+
const result = await consumeBankedReset(
|
|
223
|
+
ctx,
|
|
224
|
+
credit.id,
|
|
225
|
+
newRedeemRequestId(),
|
|
226
|
+
signal,
|
|
227
|
+
credentials,
|
|
228
|
+
);
|
|
229
|
+
if (signal.aborted || generation !== this.generation) return;
|
|
230
|
+
const outcome = formatConsumeOutcome(result);
|
|
231
|
+
ctx.ui.notify(`Auto-redeem: ${outcome.message}`, outcome.level);
|
|
232
|
+
if (result.code === "reset") this.onAutoRedeemed(ctx);
|
|
233
|
+
void this.refresh(ctx, { force: true });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (generation !== this.generation || this.lifetime.signal.aborted) return;
|
|
236
|
+
this.error = sanitizeDiagnosticError(error instanceof Error ? error.message : String(error));
|
|
237
|
+
try {
|
|
238
|
+
ctx.ui.notify(
|
|
239
|
+
`Banked reset auto-redemption failed; no automatic retry: ${this.error}`,
|
|
240
|
+
"warning",
|
|
241
|
+
);
|
|
242
|
+
} catch {
|
|
243
|
+
/* The context may have become stale. */
|
|
244
|
+
}
|
|
245
|
+
} finally {
|
|
246
|
+
if (ownsRedemption && generation === this.generation) {
|
|
247
|
+
this.redeeming = false;
|
|
248
|
+
this.scheduleAutoRedeem();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
71
253
|
start(ctx: ExtensionContext): void {
|
|
72
254
|
this.stop();
|
|
255
|
+
this.lifetime = new AbortController();
|
|
256
|
+
this.cache = undefined;
|
|
257
|
+
this.lastFetchAt = undefined;
|
|
73
258
|
if (ctx.signal?.aborted) return;
|
|
259
|
+
this.activeCtx = ctx;
|
|
74
260
|
this.sessionSignal = ctx.signal;
|
|
75
261
|
this.sessionAbortHandler = () => this.stop();
|
|
76
262
|
ctx.signal?.addEventListener("abort", this.sessionAbortHandler, { once: true });
|
|
@@ -86,6 +272,12 @@ export class ResetController {
|
|
|
86
272
|
}
|
|
87
273
|
|
|
88
274
|
stop(): void {
|
|
275
|
+
this.generation++;
|
|
276
|
+
this.lifetime.abort();
|
|
277
|
+
this.activeCtx = undefined;
|
|
278
|
+
this.inFlight = undefined;
|
|
279
|
+
this.redeeming = false;
|
|
280
|
+
this.clearAutoTimer();
|
|
89
281
|
if (this.timer) clearInterval(this.timer);
|
|
90
282
|
this.timer = undefined;
|
|
91
283
|
if (this.sessionSignal && this.sessionAbortHandler) {
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
// Reserve before sending a POST, not after receiving its outcome. A timeout may
|
|
8
|
+
// already have spent the credit. Shared by manual/automatic redemption and all
|
|
9
|
+
// pi processes using this agent directory. Corrupt state or an orphaned lock
|
|
10
|
+
// fails closed rather than risking another redemption.
|
|
11
|
+
export function reserveBankedResetRedemption(
|
|
12
|
+
accountId: string,
|
|
13
|
+
creditId: string,
|
|
14
|
+
now = Date.now(),
|
|
15
|
+
): boolean {
|
|
16
|
+
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
17
|
+
const dir = join(piAgentDir(), "pi-better-openai", "reset-redemptions");
|
|
18
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
19
|
+
const path = join(dir, `${digest(accountId)}.json`);
|
|
20
|
+
const lock = `${path}.lock`;
|
|
21
|
+
let fd: number;
|
|
22
|
+
try {
|
|
23
|
+
fd = openSync(lock, "wx", 0o600);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
26
|
+
throw new Error("Banked reset safety reservation unavailable.");
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
let previous: { blockedUntilMs?: unknown; creditHash?: unknown } | undefined;
|
|
30
|
+
try {
|
|
31
|
+
previous = JSON.parse(readFileSync(path, "utf8"));
|
|
32
|
+
if (
|
|
33
|
+
!previous ||
|
|
34
|
+
typeof previous.blockedUntilMs !== "number" ||
|
|
35
|
+
!Number.isFinite(previous.blockedUntilMs) ||
|
|
36
|
+
typeof previous.creditHash !== "string"
|
|
37
|
+
)
|
|
38
|
+
throw new Error("Invalid reservation");
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
41
|
+
throw new Error("Banked reset safety reservation unavailable.");
|
|
42
|
+
}
|
|
43
|
+
const creditHash = digest(creditId);
|
|
44
|
+
if (
|
|
45
|
+
previous &&
|
|
46
|
+
((previous.blockedUntilMs as number) > now || previous.creditHash === creditHash)
|
|
47
|
+
)
|
|
48
|
+
return false;
|
|
49
|
+
writeFileSync(
|
|
50
|
+
path,
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
blockedUntilMs: now + BANKED_RESET_AUTO_REDEEM_LEAD_MS,
|
|
53
|
+
creditHash,
|
|
54
|
+
}),
|
|
55
|
+
{ mode: 0o600 },
|
|
56
|
+
);
|
|
57
|
+
return true;
|
|
58
|
+
} finally {
|
|
59
|
+
closeSync(fd);
|
|
60
|
+
unlinkSync(lock);
|
|
61
|
+
}
|
|
62
|
+
}
|
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,19 @@ function formatResetTimestamp(ms: number | null): string {
|
|
|
139
156
|
});
|
|
140
157
|
}
|
|
141
158
|
|
|
142
|
-
export function formatBankedResetChoice(
|
|
159
|
+
export function formatBankedResetChoice(
|
|
160
|
+
credit: BankedResetCredit,
|
|
161
|
+
index: number,
|
|
162
|
+
autoRedeem = false,
|
|
163
|
+
): string {
|
|
143
164
|
const title = credit.title ?? "Codex banked reset";
|
|
144
165
|
const expires =
|
|
145
166
|
credit.expiresAtMs === null
|
|
146
167
|
? "no expiry"
|
|
147
168
|
: `expires ${formatResetTimestamp(credit.expiresAtMs)}`;
|
|
148
|
-
|
|
169
|
+
const note =
|
|
170
|
+
autoRedeem && selectAutoRedeemCredit([credit]) ? " · auto-redeems 5 min before expiry" : "";
|
|
171
|
+
return `${index + 1}. ${title} · ${expires}${note}`;
|
|
149
172
|
}
|
|
150
173
|
|
|
151
174
|
export function formatConsumeOutcome(result: ConsumeBankedResetResult): {
|
|
@@ -173,6 +196,7 @@ export function formatConsumeOutcome(result: ConsumeBankedResetResult): {
|
|
|
173
196
|
export function buildBankedResetConfirmation(options: {
|
|
174
197
|
credit?: BankedResetCredit;
|
|
175
198
|
availableCount: number;
|
|
199
|
+
autoRedeem?: boolean;
|
|
176
200
|
snapshot?: Pick<UsageSnapshot, "fiveHourLeftPercent" | "sevenDayLeftPercent">;
|
|
177
201
|
}): { title: string; message: string } {
|
|
178
202
|
const credit = options.credit;
|
|
@@ -181,7 +205,11 @@ export function buildBankedResetConfirmation(options: {
|
|
|
181
205
|
lines.push("");
|
|
182
206
|
if (credit?.grantedAtMs != null)
|
|
183
207
|
lines.push(`Granted: ${formatResetTimestamp(credit.grantedAtMs)}`);
|
|
184
|
-
|
|
208
|
+
const autoNote =
|
|
209
|
+
options.autoRedeem && credit && selectAutoRedeemCredit([credit])
|
|
210
|
+
? " · auto-redeems 5 min before expiry"
|
|
211
|
+
: "";
|
|
212
|
+
lines.push(`Expires: ${formatResetTimestamp(credit?.expiresAtMs ?? null)}${autoNote}`);
|
|
185
213
|
lines.push(`Available: ${options.availableCount}`);
|
|
186
214
|
const windows: string[] = [];
|
|
187
215
|
if (options.snapshot?.fiveHourLeftPercent != null)
|
|
@@ -199,10 +227,11 @@ export function buildBankedResetConfirmation(options: {
|
|
|
199
227
|
export async function requestBankedResetCredits(
|
|
200
228
|
ctxOrSignal?: ExtensionContext | AbortSignal,
|
|
201
229
|
signal?: AbortSignal,
|
|
230
|
+
pinnedCredentials?: CodexCredentials,
|
|
202
231
|
): Promise<BankedResetCredits | undefined> {
|
|
203
232
|
const ctx = isAbortSignal(ctxOrSignal) ? undefined : ctxOrSignal;
|
|
204
233
|
const requestSignal = isAbortSignal(ctxOrSignal) ? ctxOrSignal : signal;
|
|
205
|
-
const credentials = await getCodexCredentials(ctx, requestSignal);
|
|
234
|
+
const credentials = pinnedCredentials ?? (await getCodexCredentials(ctx, requestSignal));
|
|
206
235
|
if (!credentials) return undefined;
|
|
207
236
|
const response = await fetch(RESET_CREDITS_URL, {
|
|
208
237
|
headers: {
|
|
@@ -221,8 +250,10 @@ export async function consumeBankedReset(
|
|
|
221
250
|
creditId: string | undefined,
|
|
222
251
|
redeemRequestId: string,
|
|
223
252
|
signal?: AbortSignal,
|
|
253
|
+
pinnedCredentials?: CodexCredentials,
|
|
224
254
|
): Promise<ConsumeBankedResetResult> {
|
|
225
|
-
const credentials = await getCodexCredentials(ctx, signal);
|
|
255
|
+
const credentials = pinnedCredentials ?? (await getCodexCredentials(ctx, signal));
|
|
256
|
+
signal?.throwIfAborted();
|
|
226
257
|
if (!credentials)
|
|
227
258
|
throw new Error("OpenAI Codex authentication is unavailable. Run /login first.");
|
|
228
259
|
const response = await fetch(CONSUME_RESET_URL, {
|