@monotykamary/pi-better-grok 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,7 @@ pi install git:github.com/monotykamary/pi-better-grok
16
16
  | `pi --grok-fast` | Start with fast mode enabled |
17
17
  | `/grok-usage` | Force-refresh and show SuperGrok subscription usage |
18
18
  | `/grok-usage debug` | Show diagnostics (auth source, last fetch, config path) |
19
+ | `/grok-resets` | Inspect and redeem SuperGrok banked rate-limit resets |
19
20
  | `/grok-settings` | Open the settings picker (footer, usage, fast mode) |
20
21
 
21
22
  ## Usage widget
@@ -29,6 +30,16 @@ Data comes from the same revision-pinned Grok subscription surface used by the c
29
30
 
30
31
  Status widget line: `Usage: 66% left · ↺ 5d5h - Mon 5:34 PM` (weekly period + reset clock). Defaults to the widget area below the editor, like pi-better-openai; set `"footer": {"mode": "replace"}` for the full custom footer.
31
32
 
33
+ ## Banked resets
34
+
35
+ SuperGrok plans earn banked rate-limit reset tokens: redeeming one restores the weekly usage limit early. `/grok-resets` lists the remaining inventory, asks which token to redeem when several are available, and always confirms before spending one — redemption is immediate and cannot be undone. The inventory is warmed at session start and refreshed on a 5-minute TTL, so the command opens instantly; the server re-validates every redemption, so a stale offer can never be consumed twice.
36
+
37
+ The inventory and redeem calls use the grok.com consumer billing gRPC-Web service (`prod_mc_billing.ConsumerUiSvc/GetRemainingResets` and `RedeemReset`) that the web usage page itself calls, authenticated with the same xAI OAuth token as the usage meter. This surface is undocumented; request shapes are pinned in `src/resets.ts` and schema drift is expected.
38
+
39
+ ## pi-multiprovider
40
+
41
+ When [pi-multiprovider](https://github.com/monotykamary/pi-multiprovider) pools several `xai` accounts, the session's active account (chosen with `/switch-account`) is resolved first for usage display and banked resets, and the usage widget refreshes on every switch. Without that extension, credential resolution is unchanged: pi's native `xai` OAuth, then `xai-oauth`/`xai-auth` auth-file entries, then the Grok CLI store.
42
+
32
43
  ## Configuration
33
44
 
34
45
  JSON config at `~/.pi/agent/extensions/pi-better-grok.json` (global) or `<project>/.pi/extensions/pi-better-grok.json` (project):
@@ -58,7 +69,7 @@ Unknown fields are preserved on write.
58
69
 
59
70
  ## Acknowledgments
60
71
 
61
- Protocol contracts and prior art: [stnly/pi-grok](https://github.com/stnly/pi-grok), [puetsua/pi-grok-usage](https://github.com/puetsua/pi-grok-usage), [apoapostolov/pi-grok-usage](https://github.com/apoapostolov/pi-grok-usage), [luxus/pi-xai](https://github.com/luxus/pi-xai). See `THIRD_PARTY_NOTICES.md`.
72
+ Protocol contracts and prior art: [stnly/pi-grok](https://github.com/stnly/pi-grok), [puetsua/pi-grok-usage](https://github.com/puetsua/pi-grok-usage), [apoapostolov/pi-grok-usage](https://github.com/apoapostolov/pi-grok-usage), [luxus/pi-xai](https://github.com/luxus/pi-xai), and the SuperGrok reset-token surface documented by [stablyai/orca #18116](https://github.com/stablyai/orca/pull/18116). See `THIRD_PARTY_NOTICES.md`.
62
73
 
63
74
  ## Security
64
75
 
package/index.ts CHANGED
@@ -10,7 +10,27 @@
10
10
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { Container, SettingsList, type SettingsListTheme } from "@earendil-works/pi-tui";
12
12
  import { CONFIG_BASENAME, STATUS_KEY } from "./src/identity.ts";
13
- import { formatTokens, sanitizeStatusText, truncateToWidth, visibleWidth } from "./src/format.ts";
13
+ import {
14
+ formatTokens,
15
+ sanitizeDiagnosticError,
16
+ sanitizeStatusText,
17
+ truncateToWidth,
18
+ visibleWidth,
19
+ } from "./src/format.ts";
20
+ import {
21
+ isMultiproviderService,
22
+ MULTIPROVIDER_SERVICE_EVENT,
23
+ setActiveMultiproviderService,
24
+ type MultiproviderService,
25
+ } from "./src/multiprovider.ts";
26
+ import {
27
+ buildGrokResetConfirmation,
28
+ formatGrokResetChoice,
29
+ formatGrokResetOutcome,
30
+ redeemGrokResetForSession,
31
+ selectGrokResetToken,
32
+ } from "./src/resets.ts";
33
+ import { ResetController } from "./src/reset-controller.ts";
14
34
  import {
15
35
  applySettingToRawConfig,
16
36
  configPaths,
@@ -81,6 +101,7 @@ class DynamicBorder {
81
101
  const COMMAND = "grok-fast";
82
102
  const USAGE_COMMAND = "grok-usage";
83
103
  const SETTINGS_COMMAND = "grok-settings";
104
+ const RESETS_COMMAND = "grok-resets";
84
105
  const FLAG = "grok-fast";
85
106
 
86
107
  type SettingsPickerItem = {
@@ -132,6 +153,39 @@ export default function betterGrok(pi: ExtensionAPI): void {
132
153
  let cachedSessionNameLeafId: string | null | undefined;
133
154
  let cachedSessionName: string | undefined;
134
155
  const usageController = new UsageController(config, updateFooter, fetchUsageSnapshot);
156
+ const resetController = new ResetController();
157
+ let multiproviderService: MultiproviderService | undefined;
158
+ let unsubscribeMultiprovider: (() => void) | undefined;
159
+ let multiproviderRefreshCtx: ExtensionContext | undefined;
160
+
161
+ // Follow pi-multiprovider's active pooled account for the native xAI
162
+ // providers. The event re-fires with the same stable object at load and
163
+ // session start; the identity check keeps the change subscriptions attached
164
+ // exactly once. When the extension is absent, nothing here activates and
165
+ // credential resolution keeps its standalone behavior.
166
+ if (typeof pi.events?.on === "function") {
167
+ pi.events.on(MULTIPROVIDER_SERVICE_EVENT, (value) => {
168
+ if (!isMultiproviderService(value) || value === multiproviderService) return;
169
+ unsubscribeMultiprovider?.();
170
+ multiproviderService = value;
171
+ setActiveMultiproviderService(value);
172
+ const offs = XAI_PROVIDER_IDS.map((providerId) =>
173
+ value.onActiveAccountChanged(providerId, (event) => {
174
+ void usageController.refresh(event.ctx, { force: true });
175
+ void resetController.refresh(event.ctx, { force: true }).catch(() => {});
176
+ updateFooter(event.ctx);
177
+ }),
178
+ );
179
+ unsubscribeMultiprovider = () => {
180
+ for (const off of offs) off();
181
+ };
182
+ const ctx = multiproviderRefreshCtx;
183
+ if (ctx) {
184
+ void usageController.refresh(ctx, { force: true });
185
+ updateFooter(ctx);
186
+ }
187
+ });
188
+ }
135
189
 
136
190
  function refresh(ctx: ExtensionContext): ResolvedConfig {
137
191
  cachedConfig = resolveConfig(ctx.cwd || process.cwd());
@@ -240,6 +294,64 @@ export default function betterGrok(pi: ExtensionAPI): void {
240
294
  },
241
295
  });
242
296
 
297
+ async function redeemBankedReset(ctx: ExtensionContext): Promise<void> {
298
+ if (!hasTerminalUI(ctx)) {
299
+ ctx.ui.notify("/grok-resets requires an interactive TUI session.", "warning");
300
+ return;
301
+ }
302
+ let credits = resetController.snapshot?.credits;
303
+ if (!credits) {
304
+ await resetController.refresh(ctx, { force: true }).catch(() => {});
305
+ credits = resetController.snapshot?.credits;
306
+ }
307
+ if (!credits) {
308
+ const reason = resetController.lastError;
309
+ ctx.ui.notify(`Banked reset lookup failed${reason ? `: ${reason}` : "."}`, "error");
310
+ return;
311
+ }
312
+ void resetController.refresh(ctx).catch(() => {});
313
+ if (credits.availableCount <= 0) {
314
+ ctx.ui.notify("No banked Grok resets are available for this account.", "info");
315
+ return;
316
+ }
317
+ let selected = selectGrokResetToken(credits.tokens);
318
+ if (credits.tokens.length > 1 && selected) {
319
+ const options = credits.tokens.map((token, index) => formatGrokResetChoice(token, index));
320
+ const chosen = await ctx.ui.select("Redeem which banked reset?", options);
321
+ if (chosen === undefined) {
322
+ ctx.ui.notify("Banked reset redemption cancelled.", "info");
323
+ return;
324
+ }
325
+ selected = credits.tokens[Math.max(0, options.indexOf(chosen))];
326
+ }
327
+ const confirmation = buildGrokResetConfirmation({
328
+ token: selected,
329
+ availableCount: credits.availableCount,
330
+ snapshot: usageController.snapshot,
331
+ });
332
+ if (!(await ctx.ui.confirm(confirmation.title, confirmation.message))) {
333
+ ctx.ui.notify("Banked reset redemption cancelled.", "info");
334
+ return;
335
+ }
336
+ try {
337
+ const timeoutSignal = AbortSignal.timeout(30_000);
338
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
339
+ const result = await redeemGrokResetForSession(ctx, selected?.tokenId, signal);
340
+ const outcome = formatGrokResetOutcome(result);
341
+ ctx.ui.notify(outcome.message, outcome.level);
342
+ void usageController.refresh(ctx, { force: true });
343
+ void resetController.refresh(ctx, { force: true }).catch(() => {});
344
+ updateFooter(ctx);
345
+ } catch (error) {
346
+ ctx.ui.notify(
347
+ `Banked reset redemption failed: ${sanitizeDiagnosticError(
348
+ error instanceof Error ? error.message : String(error),
349
+ )}`,
350
+ "error",
351
+ );
352
+ }
353
+ }
354
+
243
355
  pi.registerCommand(USAGE_COMMAND, {
244
356
  description: "Show Grok subscription usage status",
245
357
  handler: async (args, ctx) => {
@@ -354,6 +466,13 @@ export default function betterGrok(pi: ExtensionAPI): void {
354
466
  }
355
467
  }
356
468
 
469
+ pi.registerCommand(RESETS_COMMAND, {
470
+ description: "Inspect and redeem SuperGrok banked rate-limit resets",
471
+ handler: async (_args, ctx) => {
472
+ await redeemBankedReset(ctx);
473
+ },
474
+ });
475
+
357
476
  pi.registerCommand(SETTINGS_COMMAND, {
358
477
  description: "Open Better Grok settings picker",
359
478
  handler: async (_args, ctx) => {
@@ -555,6 +674,7 @@ export default function betterGrok(pi: ExtensionAPI): void {
555
674
  pi.on("session_start", (_event, ctx) => {
556
675
  invalidateContextUsage();
557
676
  invalidateSessionName();
677
+ multiproviderRefreshCtx = ctx;
558
678
  const nextConfig = refresh(ctx);
559
679
  fastController.initializeForSession(ctx, nextConfig, pi.getFlag(FLAG) === true);
560
680
  if (
@@ -566,6 +686,9 @@ export default function betterGrok(pi: ExtensionAPI): void {
566
686
  refreshFooterTotals(ctx);
567
687
  updateFooter(ctx);
568
688
  usageController.start(ctx);
689
+ if (nextConfig.usage.enabled && isGrokSubscriptionModel(ctx, nextConfig)) {
690
+ resetController.start(ctx);
691
+ }
569
692
  if (fastController.active) ctx.ui.notify(fastController.stateText(nextConfig), "info");
570
693
  });
571
694
 
@@ -623,6 +746,7 @@ export default function betterGrok(pi: ExtensionAPI): void {
623
746
  invalidateContextUsage();
624
747
  invalidateSessionName();
625
748
  usageController.shutdown();
749
+ resetController.stop();
626
750
  });
627
751
 
628
752
  pi.on("before_provider_request", (event, ctx) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-better-grok",
3
- "version": "0.1.4",
4
- "description": "Improve Grok/xAI in pi with fast mode, subscription usage stats, footer polish, and settings — mirroring pi-better-openai.",
3
+ "version": "0.2.0",
4
+ "description": "Improve Grok/xAI in pi with fast mode, subscription usage stats, banked reset redemption, multiprovider pools, footer polish, and settings — mirroring pi-better-openai.",
5
5
  "keywords": [
6
6
  "footer",
7
7
  "grok",
package/src/grok-auth.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { getActiveMultiproviderService } from "./multiprovider.ts";
4
5
  import { piAgentDir } from "./paths.ts";
5
6
 
6
7
  // Native pi providers that may hold an xAI OAuth credential, in preference order.
@@ -14,9 +15,16 @@ export const GROK_CLI_LEGACY_AUTH_SCOPE_KEY = "https://accounts.x.ai/sign-in";
14
15
 
15
16
  export type GrokCredential = {
16
17
  token: string;
17
- source: "modelRegistry" | "authFile" | "grokCli";
18
+ source: "multiprovider" | "modelRegistry" | "authFile" | "grokCli";
18
19
  };
19
20
 
21
+ // Credential context slice; the multiprovider resolver keys pool affinity off
22
+ // the session, so a sessionManager must be present for that path.
23
+ export type GrokCredentialContext = Pick<
24
+ ExtensionContext,
25
+ "model" | "modelRegistry" | "sessionManager"
26
+ >;
27
+
20
28
  export function isXaiProvider(provider: unknown): provider is XaiProviderId {
21
29
  return typeof provider === "string" && (XAI_PROVIDER_IDS as readonly string[]).includes(provider);
22
30
  }
@@ -197,8 +205,23 @@ export function readGrokCliToken(env: NodeJS.ProcessEnv = process.env): string |
197
205
  }
198
206
 
199
207
  export async function resolveGrokCredential(
200
- ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
208
+ ctx: GrokCredentialContext,
201
209
  ): Promise<GrokCredential | null> {
210
+ // A pooled account pinned for this session (pi-multiprovider /switch-account)
211
+ // wins over pi's own credential: subscription usage is per-account. Resolve
212
+ // in native xAI provider-id preference order.
213
+ const multiprovider = getActiveMultiproviderService();
214
+ if (multiprovider && ctx) {
215
+ for (const providerId of providerIdsFor(ctx)) {
216
+ try {
217
+ const resolved = await multiprovider.resolveActiveAccountAuth(providerId, ctx);
218
+ const token = resolved?.accessToken?.trim();
219
+ if (token) return { token, source: "multiprovider" };
220
+ } catch {
221
+ // Fall through to pi-owned credential resolution.
222
+ }
223
+ }
224
+ }
202
225
  const registry = (ctx as { modelRegistry?: unknown })?.modelRegistry;
203
226
  const modelRuntime = (ctx as { modelRuntime?: unknown })?.modelRuntime;
204
227
  if (registry && typeof registry === "object" && "find" in registry) {
@@ -0,0 +1,67 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ // Soft bridge to pi-multiprovider, mirroring pi-better-openai. When that
4
+ // extension is installed it emits this event with a service object; without it
5
+ // everything here stays inert and credential resolution keeps its standalone
6
+ // behavior. Unlike Codex, xAI has several native provider ids, so consumers
7
+ // subscribe per id instead of one canonical provider.
8
+ export const MULTIPROVIDER_SERVICE_EVENT = "pi-multiprovider:service";
9
+
10
+ export type MultiproviderActiveAccount = {
11
+ id: string;
12
+ label: string;
13
+ authKind: string;
14
+ };
15
+
16
+ export type MultiproviderAccountAuth = {
17
+ accessToken: string;
18
+ label: string;
19
+ source?: string;
20
+ };
21
+
22
+ export type MultiproviderAccountChangedEvent = {
23
+ providerId: string;
24
+ account: MultiproviderActiveAccount | undefined;
25
+ ctx: ExtensionContext;
26
+ };
27
+
28
+ export type MultiproviderServiceContext = Pick<
29
+ ExtensionContext,
30
+ "modelRegistry" | "model" | "sessionManager"
31
+ >;
32
+
33
+ export type MultiproviderService = {
34
+ getActiveAccount(
35
+ providerId: string,
36
+ ctx: MultiproviderServiceContext,
37
+ ): Promise<MultiproviderActiveAccount | undefined>;
38
+ resolveActiveAccountAuth(
39
+ providerId: string,
40
+ ctx: MultiproviderServiceContext,
41
+ signal?: AbortSignal,
42
+ ): Promise<MultiproviderAccountAuth | undefined>;
43
+ onActiveAccountChanged(
44
+ providerId: string,
45
+ callback: (event: MultiproviderAccountChangedEvent) => void,
46
+ ): () => void;
47
+ };
48
+
49
+ export function isMultiproviderService(value: unknown): value is MultiproviderService {
50
+ if (typeof value !== "object" || value === null) return false;
51
+ const candidate = value as Partial<MultiproviderService>;
52
+ return (
53
+ typeof candidate.getActiveAccount === "function" &&
54
+ typeof candidate.resolveActiveAccountAuth === "function" &&
55
+ typeof candidate.onActiveAccountChanged === "function"
56
+ );
57
+ }
58
+
59
+ let activeService: MultiproviderService | undefined;
60
+
61
+ export function setActiveMultiproviderService(service: MultiproviderService | undefined): void {
62
+ activeService = service;
63
+ }
64
+
65
+ export function getActiveMultiproviderService(): MultiproviderService | undefined {
66
+ return activeService;
67
+ }
@@ -0,0 +1,97 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { sanitizeDiagnosticError } from "./format.ts";
3
+ import { type GrokResetCredits, requestGrokResetCredits } from "./resets.ts";
4
+
5
+ export const BANKED_RESET_CACHE_TTL_MS = 5 * 60_000;
6
+ const BANKED_RESET_REQUEST_TIMEOUT_MS = 10_000;
7
+
8
+ export type BankedResetCache = {
9
+ credits: GrokResetCredits;
10
+ updatedAt: number;
11
+ };
12
+
13
+ // Keeps the banked-reset token inventory warm so /grok-resets opens without
14
+ // paying the credential-resolution + billing RPC round trip. The cache is
15
+ // prefetched at session start, refreshed on a TTL, and served even when stale
16
+ // (a stale open still kicks off a background refresh; the backend re-validates
17
+ // every redemption server-side, so a stale offer can never be consumed twice).
18
+ export class ResetController {
19
+ private cache: BankedResetCache | undefined;
20
+ private error: string | undefined;
21
+ private lastFetchAt: number | undefined;
22
+ private inFlight: Promise<void> | undefined;
23
+ private timer: ReturnType<typeof setInterval> | undefined;
24
+ private sessionSignal: AbortSignal | undefined;
25
+ private sessionAbortHandler: (() => void) | undefined;
26
+
27
+ get snapshot(): BankedResetCache | undefined {
28
+ return this.cache;
29
+ }
30
+
31
+ get lastError(): string | undefined {
32
+ return this.error;
33
+ }
34
+
35
+ isFresh(now = Date.now()): boolean {
36
+ return this.cache !== undefined && now - this.cache.updatedAt < BANKED_RESET_CACHE_TTL_MS;
37
+ }
38
+
39
+ refresh(ctx: ExtensionContext, options?: { force?: boolean }): Promise<void> {
40
+ if (this.inFlight) return this.inFlight;
41
+ if (
42
+ !options?.force &&
43
+ this.lastFetchAt !== undefined &&
44
+ Date.now() - this.lastFetchAt < BANKED_RESET_CACHE_TTL_MS
45
+ )
46
+ return Promise.resolve();
47
+ 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;
50
+ const task = (async () => {
51
+ try {
52
+ const credits = await requestGrokResetCredits(ctx, signal);
53
+ if (credits) {
54
+ this.cache = { credits, updatedAt: Date.now() };
55
+ this.error = undefined;
56
+ } else {
57
+ this.error = "xAI credentials unavailable.";
58
+ }
59
+ } catch (error) {
60
+ this.error = sanitizeDiagnosticError(
61
+ error instanceof Error ? error.message : String(error),
62
+ );
63
+ } finally {
64
+ this.inFlight = undefined;
65
+ }
66
+ })();
67
+ this.inFlight = task;
68
+ return task;
69
+ }
70
+
71
+ start(ctx: ExtensionContext): void {
72
+ this.stop();
73
+ if (ctx.signal?.aborted) return;
74
+ this.sessionSignal = ctx.signal;
75
+ this.sessionAbortHandler = () => this.stop();
76
+ ctx.signal?.addEventListener("abort", this.sessionAbortHandler, { once: true });
77
+ void this.refresh(ctx).catch(() => {});
78
+ this.timer = setInterval(() => {
79
+ if (ctx.signal?.aborted) {
80
+ this.stop();
81
+ return;
82
+ }
83
+ void this.refresh(ctx).catch(() => {});
84
+ }, BANKED_RESET_CACHE_TTL_MS);
85
+ this.timer.unref?.();
86
+ }
87
+
88
+ stop(): void {
89
+ if (this.timer) clearInterval(this.timer);
90
+ this.timer = undefined;
91
+ if (this.sessionSignal && this.sessionAbortHandler) {
92
+ this.sessionSignal.removeEventListener("abort", this.sessionAbortHandler);
93
+ }
94
+ this.sessionSignal = undefined;
95
+ this.sessionAbortHandler = undefined;
96
+ }
97
+ }
package/src/resets.ts ADDED
@@ -0,0 +1,542 @@
1
+ import { maskIdentifier } from "./format.ts";
2
+ import {
3
+ resolveGrokCredential,
4
+ type GrokCredential,
5
+ type GrokCredentialContext,
6
+ } from "./grok-auth.ts";
7
+ import { formatPercent, type UsageSnapshot } from "./usage.ts";
8
+
9
+ // SuperGrok banked rate-limit resets. The inventory and redeem calls ride the
10
+ // grok.com consumer billing gRPC-Web service that the web usage page itself
11
+ // calls, authenticated with the same xAI OAuth token the usage meter uses.
12
+ // This is an undocumented surface: request shapes are pinned here and schema
13
+ // drift is expected — treat responses defensively and re-validate everything.
14
+ export const GROK_REMAINING_RESETS_URL =
15
+ "https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets";
16
+ export const GROK_REDEEM_RESET_URL = "https://grok.com/prod_mc_billing.ConsumerUiSvc/RedeemReset";
17
+
18
+ // Why: the billing RPC accepts the CLI token with only gRPC-Web framing; browser
19
+ // identity headers add no authorization (same pattern as the usage surface).
20
+ export const GROK_CLI_AUTH_HEADER = "xai-grok-cli";
21
+ export const GRPC_WEB_CONTENT_TYPE = "application/grpc-web+proto";
22
+
23
+ const LIST_TIMEOUT_MS = 10_000;
24
+ const REDEEM_TIMEOUT_MS = 30_000;
25
+ const MAX_RESPONSE_BYTES = 256 * 1024;
26
+ const MAX_TOKEN_ID_LENGTH = 128;
27
+ const MAX_TOKENS = 64;
28
+
29
+ export type GrokResetToken = {
30
+ tokenId: string;
31
+ grantedAtMs: number | null;
32
+ expiresAtMs: number | null;
33
+ };
34
+
35
+ export type GrokResetCredits = {
36
+ availableCount: number;
37
+ nextExpiresAtMs: number | null;
38
+ tokens: GrokResetToken[];
39
+ };
40
+
41
+ export type GrokRedeemCode = "reset" | "no_credit" | "already_redeemed";
42
+
43
+ export type GrokRedeemResult = { code: GrokRedeemCode };
44
+
45
+ export type ResetErrorCode = "auth" | "http" | "grpc" | "invalid" | "oversize" | "transport";
46
+
47
+ export class ResetError extends Error {
48
+ readonly code: ResetErrorCode;
49
+ readonly status?: number;
50
+ readonly grpcStatus?: string;
51
+
52
+ constructor(code: ResetErrorCode, message: string, status?: number, grpcStatus?: string) {
53
+ super(message);
54
+ this.name = "ResetError";
55
+ this.code = code;
56
+ this.status = status;
57
+ this.grpcStatus = grpcStatus;
58
+ }
59
+ }
60
+
61
+ export const EMPTY_RESET_CREDITS: GrokResetCredits = {
62
+ availableCount: 0,
63
+ nextExpiresAtMs: null,
64
+ tokens: [],
65
+ };
66
+
67
+ // gRPC-Web protobuf codec. Field numbers follow grok.com's public consumer_ui
68
+ // descriptor: ConsumerRedeemResetReq.token_id is field 10, remaining-reset
69
+ // responses carry repeated token messages on field 10 with inner fields
70
+ // 10 (token_id string), 20 (granted_at Timestamp), 30 (expires_at Timestamp).
71
+ const textEncoder = new TextEncoder();
72
+ const textDecoder = new TextDecoder();
73
+
74
+ function encodeVarint(value: number): Uint8Array<ArrayBuffer> {
75
+ if (!Number.isFinite(value) || value < 0)
76
+ throw new ResetError("invalid", "Varint must be a non-negative finite number.");
77
+ let n = Math.floor(value);
78
+ const bytes: number[] = [];
79
+ while (n > 0x7f) {
80
+ bytes.push((n & 0x7f) | 0x80);
81
+ n = Math.floor(n / 128);
82
+ }
83
+ bytes.push(n);
84
+ return Uint8Array.from(bytes);
85
+ }
86
+
87
+ function encodeKey(field: number, wireType: number): Uint8Array<ArrayBuffer> {
88
+ return encodeVarint((field << 3) | wireType);
89
+ }
90
+
91
+ function concatBytes(parts: readonly Uint8Array<ArrayBufferLike>[]): Uint8Array<ArrayBuffer> {
92
+ const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
93
+ const out = new Uint8Array(total);
94
+ let offset = 0;
95
+ for (const part of parts) {
96
+ out.set(part, offset);
97
+ offset += part.byteLength;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function encodeLengthDelimited(
103
+ field: number,
104
+ data: Uint8Array<ArrayBufferLike>,
105
+ ): Uint8Array<ArrayBuffer> {
106
+ return concatBytes([encodeKey(field, 2), encodeVarint(data.byteLength), data]);
107
+ }
108
+
109
+ export function encodeStringField(field: number, value: string): Uint8Array<ArrayBuffer> {
110
+ return encodeLengthDelimited(field, textEncoder.encode(value));
111
+ }
112
+
113
+ export function encodeRedeemResetRequest(tokenId: string): Uint8Array<ArrayBuffer> {
114
+ return encodeStringField(10, tokenId);
115
+ }
116
+
117
+ function encodeGrpcWebFrame(
118
+ flags: number,
119
+ payload: Uint8Array<ArrayBufferLike>,
120
+ ): Uint8Array<ArrayBuffer> {
121
+ const header = new Uint8Array(5);
122
+ header[0] = flags;
123
+ header[1] = (payload.byteLength >>> 24) & 0xff;
124
+ header[2] = (payload.byteLength >>> 16) & 0xff;
125
+ header[3] = (payload.byteLength >>> 8) & 0xff;
126
+ header[4] = payload.byteLength & 0xff;
127
+ return concatBytes([header, payload]);
128
+ }
129
+
130
+ // Why: grok.com accepts a data frame only on the request; trailers are a
131
+ // response convention.
132
+ export function encodeGrpcWebRequest(
133
+ payload: Uint8Array<ArrayBufferLike>,
134
+ ): Uint8Array<ArrayBuffer> {
135
+ return encodeGrpcWebFrame(0, payload);
136
+ }
137
+
138
+ export type GrpcWebResponse = {
139
+ payload: Uint8Array;
140
+ grpcStatus: string;
141
+ grpcMessage: string | null;
142
+ };
143
+
144
+ function decodeVarint(buf: Uint8Array, start: number): { value: number; next: number } {
145
+ let value = 0;
146
+ let shift = 0;
147
+ let i = start;
148
+ while (i < buf.byteLength) {
149
+ const byte = buf[i++];
150
+ if (byte === undefined) break;
151
+ value += (byte & 0x7f) * 2 ** shift;
152
+ if ((byte & 0x80) === 0) return { value, next: i };
153
+ shift += 7;
154
+ if (shift > 35)
155
+ throw new ResetError("invalid", "Grok reset response contained an oversized varint.");
156
+ }
157
+ throw new ResetError("invalid", "Grok reset response contained a truncated varint.");
158
+ }
159
+
160
+ type ProtoField =
161
+ | { field: number; wireType: 0; value: number }
162
+ | { field: number; wireType: 2; value: Uint8Array };
163
+
164
+ function decodeFields(buf: Uint8Array): ProtoField[] {
165
+ const fields: ProtoField[] = [];
166
+ let i = 0;
167
+ while (i < buf.byteLength) {
168
+ const key = decodeVarint(buf, i);
169
+ i = key.next;
170
+ const field = key.value >>> 3;
171
+ const wireType = key.value & 7;
172
+ if (wireType === 0) {
173
+ const varint = decodeVarint(buf, i);
174
+ i = varint.next;
175
+ fields.push({ field, wireType: 0, value: varint.value });
176
+ } else if (wireType === 2) {
177
+ const length = decodeVarint(buf, i);
178
+ i = length.next;
179
+ const end = i + length.value;
180
+ if (end > buf.byteLength)
181
+ throw new ResetError("invalid", "Grok reset response contained a truncated field.");
182
+ fields.push({ field, wireType: 2, value: buf.subarray(i, end) });
183
+ i = end;
184
+ } else if (wireType === 1) {
185
+ if (i + 8 > buf.byteLength)
186
+ throw new ResetError("invalid", "Grok reset response contained a truncated fixed64 field.");
187
+ i += 8;
188
+ } else if (wireType === 5) {
189
+ if (i + 4 > buf.byteLength)
190
+ throw new ResetError("invalid", "Grok reset response contained a truncated fixed32 field.");
191
+ i += 4;
192
+ } else {
193
+ throw new ResetError(
194
+ "invalid",
195
+ `Grok reset response used an unsupported wire type (${wireType}).`,
196
+ );
197
+ }
198
+ }
199
+ return fields;
200
+ }
201
+
202
+ function decodeTimestampMs(data: Uint8Array): number | null {
203
+ const seconds = decodeFields(data).find((entry) => entry.field === 1 && entry.wireType === 0);
204
+ if (!seconds || seconds.wireType !== 0) return null;
205
+ return seconds.value * 1000;
206
+ }
207
+
208
+ export function decodeRemainingResetTokens(payload: Uint8Array): GrokResetToken[] {
209
+ const tokens: GrokResetToken[] = [];
210
+ for (const entry of decodeFields(payload)) {
211
+ if (entry.field !== 10 || entry.wireType !== 2) continue;
212
+ let tokenId: string | null = null;
213
+ let grantedAtMs: number | null = null;
214
+ let expiresAtMs: number | null = null;
215
+ for (const inner of decodeFields(entry.value)) {
216
+ if (inner.field === 10 && inner.wireType === 2) tokenId = textDecoder.decode(inner.value);
217
+ else if (inner.field === 20 && inner.wireType === 2)
218
+ grantedAtMs = decodeTimestampMs(inner.value);
219
+ else if (inner.field === 30 && inner.wireType === 2)
220
+ expiresAtMs = decodeTimestampMs(inner.value);
221
+ }
222
+ if (tokenId) tokens.push({ tokenId, grantedAtMs, expiresAtMs });
223
+ }
224
+ return tokens;
225
+ }
226
+
227
+ function parseTrailerBlock(text: string): { status: string | null; message: string | null } {
228
+ let status: string | null = null;
229
+ let message: string | null = null;
230
+ for (const line of text.split(/\r?\n/)) {
231
+ const idx = line.indexOf(":");
232
+ if (idx <= 0) continue;
233
+ const key = line.slice(0, idx).trim().toLowerCase();
234
+ const value = line.slice(idx + 1).trim();
235
+ if (key === "grpc-status") status = value;
236
+ else if (key === "grpc-message") {
237
+ try {
238
+ message = decodeURIComponent(value);
239
+ } catch {
240
+ message = value;
241
+ }
242
+ }
243
+ }
244
+ return { status, message };
245
+ }
246
+
247
+ export function decodeGrpcWebResponse(
248
+ raw: Uint8Array,
249
+ headerStatus?: string | null,
250
+ headerMessage?: string | null,
251
+ ): GrpcWebResponse {
252
+ let payload: Uint8Array = new Uint8Array(0);
253
+ let trailerStatus: string | null = null;
254
+ let trailerMessage: string | null = null;
255
+ let i = 0;
256
+ while (i < raw.byteLength) {
257
+ if (raw.byteLength - i < 5)
258
+ throw new ResetError("invalid", "Grok reset response had a truncated gRPC-Web frame header.");
259
+ const flags = raw[i]!;
260
+
261
+ const length =
262
+ raw[i + 1]! * 0x1000000 + raw[i + 2]! * 0x10000 + raw[i + 3]! * 0x100 + raw[i + 4]!;
263
+ i += 5;
264
+ const end = i + length;
265
+ if (end > raw.byteLength)
266
+ throw new ResetError(
267
+ "invalid",
268
+ "Grok reset response had a truncated gRPC-Web frame payload.",
269
+ );
270
+ const chunk = raw.subarray(i, end);
271
+ i = end;
272
+ if (flags & 0x80) {
273
+ const parsed = parseTrailerBlock(textDecoder.decode(chunk));
274
+ trailerStatus = parsed.status;
275
+ trailerMessage = parsed.message;
276
+ break;
277
+ }
278
+ payload = chunk;
279
+ }
280
+ const grpcStatus = trailerStatus ?? headerStatus;
281
+ if (grpcStatus == null)
282
+ throw new ResetError("invalid", "Grok reset response was missing grpc-status.");
283
+ return { payload, grpcStatus, grpcMessage: trailerMessage ?? headerMessage ?? null };
284
+ }
285
+
286
+ function isPrintableAscii(value: string): boolean {
287
+ for (let i = 0; i < value.length; i++) {
288
+ const code = value.charCodeAt(i);
289
+ if (code < 0x21 || code > 0x7e) return false;
290
+ }
291
+ return true;
292
+ }
293
+
294
+ function sanitizeTokenId(value: string): string | null {
295
+ return value && value.length <= MAX_TOKEN_ID_LENGTH && isPrintableAscii(value) ? value : null;
296
+ }
297
+
298
+ export function summarizeResetTokens(tokens: readonly GrokResetToken[]): GrokResetCredits {
299
+ const sanitized = tokens
300
+ .map((token) => {
301
+ const tokenId = sanitizeTokenId(token.tokenId);
302
+ return tokenId ? { ...token, tokenId } : undefined;
303
+ })
304
+ .filter((token): token is GrokResetToken => token !== undefined)
305
+ .slice(0, MAX_TOKENS);
306
+ const expiries = sanitized
307
+ .map((token) => token.expiresAtMs)
308
+ .filter((expiresAtMs): expiresAtMs is number => typeof expiresAtMs === "number")
309
+ .sort((left, right) => left - right);
310
+ return {
311
+ availableCount: sanitized.length,
312
+ nextExpiresAtMs: expiries[0] ?? null,
313
+ tokens: sanitized,
314
+ };
315
+ }
316
+
317
+ // Soonest-expiring token first: banked resets expire, so spend the one closest
318
+ // to expiry. Tokens without an expiry sort last.
319
+ export function selectGrokResetToken(
320
+ tokens: readonly GrokResetToken[],
321
+ ): GrokResetToken | undefined {
322
+ return [...tokens].sort((left, right) => {
323
+ const leftExpiry = left.expiresAtMs ?? Number.POSITIVE_INFINITY;
324
+ const rightExpiry = right.expiresAtMs ?? Number.POSITIVE_INFINITY;
325
+ if (leftExpiry !== rightExpiry) return leftExpiry - rightExpiry;
326
+ return (left.grantedAtMs ?? 0) - (right.grantedAtMs ?? 0);
327
+ })[0];
328
+ }
329
+
330
+ // Redeem outcome mapping, pinned to grok.com's observed grpc-status codes:
331
+ // 0 = reset applied; 9 (FAILED_PRECONDITION-ish) = already redeemed when the
332
+ // message says so, otherwise no credit; 3 (INVALID_ARGUMENT) naming token_id
333
+ // means the token was unknown or spent. Everything else is a hard failure.
334
+ export function mapGrokRedeemStatus(
335
+ grpcStatus: string,
336
+ grpcMessage: string | null,
337
+ ): GrokRedeemResult {
338
+ if (grpcStatus === "0") return { code: "reset" };
339
+ const message = (grpcMessage ?? "").toLowerCase();
340
+ if (grpcStatus === "9") {
341
+ return message.includes("redeem") && message.includes("already")
342
+ ? { code: "already_redeemed" }
343
+ : { code: "no_credit" };
344
+ }
345
+ if (grpcStatus === "3" && message.includes("token_id")) return { code: "no_credit" };
346
+ throw new ResetError(
347
+ "grpc",
348
+ grpcMessage
349
+ ? `Grok reset failed: ${grpcMessage}`
350
+ : `Grok reset failed (grpc-status ${grpcStatus})`,
351
+ undefined,
352
+ grpcStatus,
353
+ );
354
+ }
355
+
356
+ function resetHeaders(credential: GrokCredential): Record<string, string> {
357
+ return {
358
+ Authorization: `Bearer ${credential.token}`,
359
+ "X-XAI-Token-Auth": GROK_CLI_AUTH_HEADER,
360
+ "Content-Type": GRPC_WEB_CONTENT_TYPE,
361
+ "x-grpc-web": "1",
362
+ };
363
+ }
364
+
365
+ async function postGrokRpc(
366
+ url: string,
367
+ credential: GrokCredential,
368
+ payload: Uint8Array,
369
+ options: { signal?: AbortSignal; timeoutMs: number },
370
+ ): Promise<GrpcWebResponse> {
371
+ const timeout = AbortSignal.timeout(options.timeoutMs);
372
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
373
+ let response: Response;
374
+ try {
375
+ response = await fetch(url, {
376
+ method: "POST",
377
+ headers: resetHeaders(credential),
378
+ body: encodeGrpcWebRequest(payload),
379
+ signal,
380
+ });
381
+ } catch (error) {
382
+ const message = error instanceof Error ? error.message : String(error);
383
+ throw new ResetError("transport", `Grok reset request failed: ${message}`);
384
+ }
385
+ if (!response.ok) {
386
+ if (response.status === 401 || response.status === 403) {
387
+ throw new ResetError(
388
+ "auth",
389
+ `xAI reset request was unauthorized (HTTP ${response.status}). Run /login xai and try again.`,
390
+ response.status,
391
+ );
392
+ }
393
+ throw new ResetError(
394
+ "http",
395
+ `Grok reset request failed (HTTP ${response.status}).`,
396
+ response.status,
397
+ );
398
+ }
399
+ const raw = new Uint8Array(await response.arrayBuffer());
400
+ if (raw.byteLength > MAX_RESPONSE_BYTES) {
401
+ throw new ResetError("oversize", "Grok reset response exceeded the size limit.");
402
+ }
403
+ return decodeGrpcWebResponse(
404
+ raw,
405
+ response.headers.get("grpc-status"),
406
+ response.headers.get("grpc-message"),
407
+ );
408
+ }
409
+
410
+ export async function fetchGrokResetCredits(
411
+ credential: GrokCredential,
412
+ options: { signal?: AbortSignal } = {},
413
+ ): Promise<GrokResetCredits> {
414
+ if (!credential.token) {
415
+ throw new ResetError("auth", "xAI OAuth credentials are required. Run /login xai first.");
416
+ }
417
+ const response = await postGrokRpc(GROK_REMAINING_RESETS_URL, credential, new Uint8Array(), {
418
+ signal: options.signal,
419
+ timeoutMs: LIST_TIMEOUT_MS,
420
+ });
421
+ if (response.grpcStatus === "16") {
422
+ throw new ResetError(
423
+ "auth",
424
+ response.grpcMessage
425
+ ? `xAI reset-token inventory rejected the credential: ${response.grpcMessage}`
426
+ : "xAI reset-token inventory rejected the credential. Run /login xai and try again.",
427
+ undefined,
428
+ response.grpcStatus,
429
+ );
430
+ }
431
+ // Accounts without the SuperGrok reset entitlement answer with a non-OK grpc
432
+ // status instead of an empty inventory; treat that as zero so /grok-resets
433
+ // still opens. Redeem re-validates server-side, so this can never over-count.
434
+ if (response.grpcStatus !== "0") return EMPTY_RESET_CREDITS;
435
+ return summarizeResetTokens(decodeRemainingResetTokens(response.payload));
436
+ }
437
+
438
+ export async function redeemGrokReset(
439
+ credential: GrokCredential,
440
+ tokenId: string | undefined,
441
+ options: { signal?: AbortSignal } = {},
442
+ ): Promise<GrokRedeemResult> {
443
+ if (!credential.token) {
444
+ throw new ResetError("auth", "xAI authentication is unavailable. Run /login xai first.");
445
+ }
446
+ let token = tokenId?.trim() ?? "";
447
+ if (!token) {
448
+ const credits = await fetchGrokResetCredits(credential, options);
449
+ token = selectGrokResetToken(credits.tokens)?.tokenId ?? "";
450
+ }
451
+ if (!token) return { code: "no_credit" };
452
+ const response = await postGrokRpc(
453
+ GROK_REDEEM_RESET_URL,
454
+ credential,
455
+ encodeRedeemResetRequest(token),
456
+ { signal: options.signal, timeoutMs: REDEEM_TIMEOUT_MS },
457
+ );
458
+ return mapGrokRedeemStatus(response.grpcStatus, response.grpcMessage);
459
+ }
460
+
461
+ export function decodeRedeemResetTokenId(payload: Uint8Array): string | null {
462
+ for (const field of decodeFields(payload)) {
463
+ if (field.field === 10 && field.wireType === 2) return textDecoder.decode(field.value);
464
+ }
465
+ return null;
466
+ }
467
+
468
+ export type GrokResetContext = GrokCredentialContext;
469
+
470
+ export async function requestGrokResetCredits(
471
+ ctx: GrokResetContext,
472
+ signal?: AbortSignal,
473
+ ): Promise<GrokResetCredits | undefined> {
474
+ const credential = await resolveGrokCredential(ctx);
475
+ if (!credential) return undefined;
476
+ return fetchGrokResetCredits(credential, { signal });
477
+ }
478
+
479
+ export async function redeemGrokResetForSession(
480
+ ctx: GrokResetContext,
481
+ tokenId: string | undefined,
482
+ signal?: AbortSignal,
483
+ ): Promise<GrokRedeemResult> {
484
+ const credential = await resolveGrokCredential(ctx);
485
+ if (!credential) {
486
+ throw new ResetError("auth", "xAI authentication is unavailable. Run /login xai first.");
487
+ }
488
+ return redeemGrokReset(credential, tokenId, { signal });
489
+ }
490
+
491
+ function formatResetTimestamp(ms: number | null): string {
492
+ if (ms === null) return "unknown";
493
+ return new Date(ms).toLocaleString(undefined, {
494
+ month: "short",
495
+ day: "numeric",
496
+ hour: "numeric",
497
+ minute: "2-digit",
498
+ });
499
+ }
500
+
501
+ export function formatGrokResetChoice(token: GrokResetToken, index: number): string {
502
+ const expires = `expires ${formatResetTimestamp(token.expiresAtMs)}`;
503
+ return `${index + 1}. SuperGrok rate-limit reset (${maskIdentifier(token.tokenId)}) · ${expires}`;
504
+ }
505
+
506
+ export function formatGrokResetOutcome(result: GrokRedeemResult): {
507
+ message: string;
508
+ level: "info" | "warning";
509
+ } {
510
+ switch (result.code) {
511
+ case "reset":
512
+ return {
513
+ message: "Banked reset redeemed — your SuperGrok usage limit was restored.",
514
+ level: "info",
515
+ };
516
+ case "no_credit":
517
+ return { message: "No banked Grok resets remain available.", level: "warning" };
518
+ case "already_redeemed":
519
+ return { message: "That banked reset was already redeemed.", level: "warning" };
520
+ }
521
+ }
522
+
523
+ export function buildGrokResetConfirmation(options: {
524
+ token?: GrokResetToken;
525
+ availableCount: number;
526
+ snapshot?: Pick<UsageSnapshot, "creditUsagePercent">;
527
+ }): { title: string; message: string } {
528
+ const token = options.token;
529
+ const lines: string[] = ["SuperGrok rate-limit reset"];
530
+ lines.push(`Token: ${token ? maskIdentifier(token.tokenId) : "auto-selected"}`);
531
+ if (token?.grantedAtMs != null) lines.push(`Granted: ${formatResetTimestamp(token.grantedAtMs)}`);
532
+ lines.push(`Expires: ${formatResetTimestamp(token?.expiresAtMs ?? null)}`);
533
+ lines.push(`Available: ${options.availableCount}`);
534
+ if (options.snapshot?.creditUsagePercent != null) {
535
+ lines.push(`Current usage: weekly ${formatPercent(options.snapshot.creditUsagePercent)} used`);
536
+ }
537
+ lines.push("");
538
+ lines.push(
539
+ "This redeems one SuperGrok reset token, restores your weekly usage limit immediately, and cannot be undone.",
540
+ );
541
+ return { title: "Redeem banked Grok reset?", message: lines.join("\n") };
542
+ }