@llblab/pi-codex-usage 0.4.1 → 0.5.1

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/AGENTS.md CHANGED
@@ -11,3 +11,7 @@
11
11
  - `Compact dual bar`: Encode the 5-hour and weekly quota windows in the ten-character separated-sextant bar.
12
12
  - Trigger: Changing statusline formatting.
13
13
  - Action: Keep a fixed-width `xxxxxxxxxx` bar where top sextants represent the 5-hour window and bottom sextants represent the weekly window, with 20 steps per window.
14
+
15
+ - `Weekly reset countdown`: Append the weekly reset countdown only when the secondary window exposes a reset time.
16
+ - Trigger: Changing reset-time normalization or statusline refresh cadence.
17
+ - Action: Keep `d` labels rounded upward in 144-minute day-tenth steps, `h`/`m`/`s` labels floored, and hold `0s` until a successful quota refresh reports the next window.
package/BACKLOG.md CHANGED
@@ -1,4 +1,4 @@
1
1
  # Backlog
2
2
 
3
- - [ ] `Runtime smoke test` Verify the extension inside a live Pi session against real Codex auth states: usable subscription, missing auth, missing subscription, and transient network failure.
3
+ - [ ] `Runtime smoke test` Verify the extension inside a live Pi session against missing auth, missing subscription, and transient network failure. Usable subscription with weekly reset countdown has been checked against real Codex auth.
4
4
  - [ ] `Statusline polish` Tune the successful-refresh blink duration if the current 150ms redraw feels too visible or too subtle in the Pi footer.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.5.1: Non-Codex Bucket Hotfix
6
+
7
+ - Fixed non-Codex app-server quota buckets so Spark-only or unrelated limits are ignored instead of being displayed as Codex quota.
8
+
9
+ ## 0.5.0: Weekly Reset Countdown
10
+
11
+ - Added refresh request coalescing so repeated statusline events share one quota lookup instead of spawning parallel provider/fallback requests. Impact: transient failures and busy session-tree updates no longer amplify Codex usage polling work.
12
+ - Hardened quota parsing and failure classification by accepting array-shaped app-server rate limits, treating `n/a` as valid only when every failed source reports an unavailable auth/plan/quota state, and adding `node:test` coverage for normalization, bar formatting, and failure classification. Impact: real fallback/runtime failures are surfaced as `error` while expected unavailable states still show `n/a`.
13
+ - Added a weekly reset countdown after the quota bar when the secondary Codex window exposes a reset timestamp, including 144-minute day-tenth steps, exact boundary redraw scheduling, hour/minute/second bucket formatting, and `0s` holdover until the next successful quota refresh. Impact: the statusline now shows both remaining quota and time until the weekly bucket cycles.
14
+
3
15
  ## 0.4.1: Stable Startup Bar
4
16
 
5
17
  - Fixed the initial empty statusline bar to use ten non-trimmed blank glyph cells through the same formatting path as the populated quota bar. Impact: the footer background stays at ten cells during startup before the first quota values arrive.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ![Codex Usage](./banner.png)
6
6
 
7
- This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-codex-usage`](https://github.com/narumiruna/pi-extensions/tree/main/extensions/pi-codex-usage). It keeps the auth and quota-fetching path, but intentionally narrows the interface to the primary Codex 5-hour and weekly windows.
7
+ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-codex-usage`](https://github.com/narumiruna/pi-extensions/tree/main/extensions/pi-codex-usage). It keeps the auth and quota-fetching path, but intentionally narrows the interface to the primary Codex 5-hour and weekly limits.
8
8
 
9
9
  ## Start Here
10
10
 
@@ -15,7 +15,7 @@ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-co
15
15
  ## Features
16
16
 
17
17
  - Shows an empty statusline bar immediately, then refreshes every 30 seconds while the active Pi model uses `openai-codex`
18
- - Statusline output stays compact, with the `codex` label accented and the quota bar drawn on a themed background
18
+ - Statusline output stays compact, with the `codex` label accented and the quota bar plus weekly reset countdown drawn on a themed background
19
19
  - Additional returned buckets, including Spark-specific limits, are ignored
20
20
  - Pi OpenAI Codex provider auth is used first
21
21
  - Codex CLI app-server remains available as a fallback
@@ -43,11 +43,13 @@ pi install git:github.com/llblab/pi-codex-usage
43
43
  Normal usage:
44
44
 
45
45
  ```text
46
- codex ██████▀▀▀▀
46
+ codex ██████▀▀▀▀ 6d
47
47
  ```
48
48
 
49
49
  The ten-character bar encodes two twenty-step limits at once: 40 total bits of quota state in 10 terminal cells. Each step is 5%: the top quadrants are the 5-hour limit, and the bottom quadrants are the weekly limit.
50
50
 
51
+ When the weekly reset time is available, the bar is followed by a countdown. More than a day remains is shown in 144-minute day-tenth steps such as `7d`, `6.9d`, `6.6d`, `5.1d`, `5d`, `3.7d`, `3d`, `2d`, `1.9d`, `1.5d`, `1.1d`, and `1d`, rounded upward to the next tenth. Under a day it switches to floored hours, under an hour to floored minutes, and under a minute to seconds. After the reset timestamp passes, `0s` is held until the next successful quota refresh reports the new weekly window.
52
+
51
53
  Unavailable because Codex auth or subscription quota is not available:
52
54
 
53
55
  ```text
package/banner.png CHANGED
Binary file
package/index.ts CHANGED
@@ -9,7 +9,12 @@ import type {
9
9
  const CODEX_PROVIDER_ID = "openai-codex";
10
10
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
11
11
  const DEFAULT_TIMEOUT_MS = 15_000;
12
- const REFRESH_INTERVAL_MS = 30 * 1000;
12
+ const SECOND_MS = 1000;
13
+ const MINUTE_MS = 60 * SECOND_MS;
14
+ const HOUR_MS = 60 * MINUTE_MS;
15
+ const DAY_MS = 24 * HOUR_MS;
16
+ const DAY_TENTH_MS = 144 * MINUTE_MS;
17
+ const REFRESH_INTERVAL_MS = 30 * SECOND_MS;
13
18
  const REDRAW_BLINK_MS = 150;
14
19
  const STATUS_KEY = "aa-codex-usage";
15
20
  const MAX_ERROR_BODY_CHARS = 600;
@@ -52,7 +57,7 @@ type QueryUsageResult =
52
57
  | { ok: true; report: CodexUsageReport }
53
58
  | { ok: false; errors: UsageQueryError[] };
54
59
 
55
- type UsageQueryError = {
60
+ export type UsageQueryError = {
56
61
  source: UsageSource;
57
62
  message: string;
58
63
  cause?: unknown;
@@ -70,6 +75,7 @@ export type NormalizedRateLimitSnapshot = {
70
75
 
71
76
  export type NormalizedRateLimitWindow = {
72
77
  usedPercent: number;
78
+ resetAt?: number;
73
79
  };
74
80
 
75
81
  type RateLimitStatusPayload = {
@@ -83,6 +89,13 @@ type BackendRateLimitDetails = {
83
89
 
84
90
  type BackendWindowSnapshot = {
85
91
  used_percent?: unknown;
92
+ reset_at?: unknown;
93
+ resets_at?: unknown;
94
+ reset_time?: unknown;
95
+ end_time?: unknown;
96
+ ends_at?: unknown;
97
+ expires_at?: unknown;
98
+ reset_after_seconds?: unknown;
86
99
  };
87
100
 
88
101
  type AppServerRateLimitResponse = {
@@ -97,6 +110,13 @@ type AppServerRateLimitSnapshot = {
97
110
 
98
111
  type AppServerWindowSnapshot = {
99
112
  usedPercent?: unknown;
113
+ resetAt?: unknown;
114
+ resetsAt?: unknown;
115
+ resetTime?: unknown;
116
+ endTime?: unknown;
117
+ endsAt?: unknown;
118
+ expiresAt?: unknown;
119
+ resetAfterSeconds?: unknown;
100
120
  };
101
121
 
102
122
  type RpcResponse = {
@@ -113,17 +133,21 @@ type PendingRpc = {
113
133
  export default function codexUsage(pi: ExtensionAPI) {
114
134
  let cache: CachedReport | undefined;
115
135
  let failedRefreshes = 0;
136
+ let inFlightUsageQuery: Promise<QueryUsageResult> | undefined;
116
137
  let statuslineBlinkTimer: TimeoutHandle | undefined;
117
138
  let statuslineClearTimer: TimeoutHandle | undefined;
139
+ let statuslineCountdownTimer: TimeoutHandle | undefined;
118
140
  let statuslineRefreshTimer: TimeoutHandle | undefined;
119
141
  let statuslineRequestId = 0;
120
142
 
121
143
  const clearStatuslineTimers = () => {
122
144
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
123
145
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
146
+ if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
124
147
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
125
148
  statuslineBlinkTimer = undefined;
126
149
  statuslineClearTimer = undefined;
150
+ statuslineCountdownTimer = undefined;
127
151
  statuslineRefreshTimer = undefined;
128
152
  };
129
153
 
@@ -150,6 +174,29 @@ export default function codexUsage(pi: ExtensionAPI) {
150
174
  statuslineRefreshTimer.unref?.();
151
175
  };
152
176
 
177
+ const scheduleStatuslineCountdown = (
178
+ ctx: ExtensionContext,
179
+ report: CodexUsageReport,
180
+ model: CodexUsageModel | undefined,
181
+ ) => {
182
+ if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
183
+ const delayMs = nextResetCountdownDelayMs(report);
184
+ if (delayMs === undefined) {
185
+ statuslineCountdownTimer = undefined;
186
+ return;
187
+ }
188
+ statuslineCountdownTimer = setTimeout(() => {
189
+ if (isOpenAICodexModel(ctx.model)) {
190
+ ctx.ui.setStatus(
191
+ STATUS_KEY,
192
+ formatCodexUsageStatusline(report, ctx, model),
193
+ );
194
+ scheduleStatuslineCountdown(ctx, report, model);
195
+ }
196
+ }, delayMs) as TimeoutHandle;
197
+ statuslineCountdownTimer.unref?.();
198
+ };
199
+
153
200
  const setUsageStatusline = (
154
201
  ctx: ExtensionContext,
155
202
  report: CodexUsageReport,
@@ -161,23 +208,38 @@ export default function codexUsage(pi: ExtensionAPI) {
161
208
  ) => {
162
209
  if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
163
210
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
211
+ if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
164
212
  statuslineBlinkTimer = undefined;
165
213
  statuslineClearTimer = undefined;
214
+ statuslineCountdownTimer = undefined;
166
215
  const text = formatCodexUsageStatusline(report, ctx, options.model);
167
216
  if (options.blink) {
168
217
  ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
169
218
  statuslineBlinkTimer = setTimeout(() => {
170
219
  ctx.ui.setStatus(STATUS_KEY, text);
220
+ scheduleStatuslineCountdown(ctx, report, options.model);
171
221
  statuslineBlinkTimer = undefined;
172
222
  }, REDRAW_BLINK_MS) as TimeoutHandle;
173
223
  statuslineBlinkTimer.unref?.();
174
224
  } else {
175
225
  ctx.ui.setStatus(STATUS_KEY, text);
226
+ scheduleStatuslineCountdown(ctx, report, options.model);
176
227
  }
177
228
  if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
178
229
  else scheduleTemporaryStatuslineClear(ctx);
179
230
  };
180
231
 
232
+ const queryCurrentUsage = (ctx: ExtensionContext) => {
233
+ if (!inFlightUsageQuery) {
234
+ inFlightUsageQuery = queryUsage(ctx, {
235
+ timeoutMs: DEFAULT_TIMEOUT_MS,
236
+ }).finally(() => {
237
+ inFlightUsageQuery = undefined;
238
+ });
239
+ }
240
+ return inFlightUsageQuery;
241
+ };
242
+
181
243
  const refreshCurrentCodexUsageStatusline = async (
182
244
  ctx: ExtensionContext,
183
245
  force: boolean,
@@ -204,7 +266,7 @@ export default function codexUsage(pi: ExtensionAPI) {
204
266
  return;
205
267
  }
206
268
 
207
- const result = await queryUsage(ctx, { timeoutMs: DEFAULT_TIMEOUT_MS });
269
+ const result = await queryCurrentUsage(ctx);
208
270
  if (requestId !== statuslineRequestId) return;
209
271
  if (!isOpenAICodexModel(ctx.model)) {
210
272
  clearUsageStatusline(ctx);
@@ -578,7 +640,11 @@ export function normalizeBackendPayload(
578
640
  _capturedAt: number,
579
641
  _source: UsageSource,
580
642
  ): CodexUsageReport {
581
- const snapshot = normalizeBackendSnapshot("codex", payload.rate_limit);
643
+ const snapshot = normalizeBackendSnapshot(
644
+ "codex",
645
+ payload.rate_limit,
646
+ _capturedAt,
647
+ );
582
648
  if (!snapshot) {
583
649
  throw new Error(
584
650
  "Codex usage endpoint returned no displayable rate-limit windows.",
@@ -590,20 +656,25 @@ export function normalizeBackendPayload(
590
656
  function normalizeBackendSnapshot(
591
657
  limitId: string,
592
658
  rateLimit: unknown,
659
+ capturedAt: number,
593
660
  ): NormalizedRateLimitSnapshot | undefined {
594
661
  if (rateLimit === null || rateLimit === undefined) return undefined;
595
662
  const details = assertObject(
596
663
  rateLimit,
597
664
  "rate limit",
598
665
  ) as BackendRateLimitDetails;
599
- const primary = normalizeBackendWindow(details.primary_window);
600
- const secondary = normalizeBackendWindow(details.secondary_window);
666
+ const primary = normalizeBackendWindow(details.primary_window, capturedAt);
667
+ const secondary = normalizeBackendWindow(
668
+ details.secondary_window,
669
+ capturedAt,
670
+ );
601
671
  if (!primary && !secondary) return undefined;
602
672
  return { limitId, primary, secondary };
603
673
  }
604
674
 
605
675
  function normalizeBackendWindow(
606
676
  value: unknown,
677
+ capturedAt: number,
607
678
  ): NormalizedRateLimitWindow | undefined {
608
679
  if (value === null || value === undefined) return undefined;
609
680
  const window = assertObject(
@@ -612,7 +683,19 @@ function normalizeBackendWindow(
612
683
  ) as BackendWindowSnapshot;
613
684
  const usedPercent = asNumber(window.used_percent);
614
685
  if (usedPercent === undefined) return undefined;
615
- return { usedPercent };
686
+ const resetAt = asResetTime(
687
+ [
688
+ window.reset_at,
689
+ window.resets_at,
690
+ window.reset_time,
691
+ window.end_time,
692
+ window.ends_at,
693
+ window.expires_at,
694
+ ],
695
+ window.reset_after_seconds,
696
+ capturedAt,
697
+ );
698
+ return resetAt === undefined ? { usedPercent } : { usedPercent, resetAt };
616
699
  }
617
700
 
618
701
  export function normalizeAppServerResponse(
@@ -621,7 +704,7 @@ export function normalizeAppServerResponse(
621
704
  ): CodexUsageReport {
622
705
  const snapshots: NormalizedRateLimitSnapshot[] = [];
623
706
  const addSnapshot = (raw: unknown, fallbackId: string) => {
624
- const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
707
+ const snapshot = normalizeAppServerSnapshot(raw, fallbackId, _capturedAt);
625
708
  if (!snapshot) return;
626
709
  const existingIndex = snapshots.findIndex(
627
710
  (item) => item.limitId === snapshot.limitId,
@@ -634,7 +717,11 @@ export function normalizeAppServerResponse(
634
717
  else snapshots.push(snapshot);
635
718
  };
636
719
 
637
- addSnapshot(response.rateLimits, "codex");
720
+ if (Array.isArray(response.rateLimits)) {
721
+ for (const item of response.rateLimits) addSnapshot(item, "codex");
722
+ } else {
723
+ addSnapshot(response.rateLimits, "codex");
724
+ }
638
725
  if (snapshots.length === 0) {
639
726
  throw new Error(
640
727
  "codex app-server returned no displayable rate-limit windows.",
@@ -647,6 +734,7 @@ export function normalizeAppServerResponse(
647
734
  function normalizeAppServerSnapshot(
648
735
  raw: unknown,
649
736
  fallbackId: string,
737
+ capturedAt: number,
650
738
  ): NormalizedRateLimitSnapshot | undefined {
651
739
  if (raw === null || raw === undefined) return undefined;
652
740
  const snapshot = assertObject(
@@ -654,14 +742,15 @@ function normalizeAppServerSnapshot(
654
742
  "app-server rate-limit snapshot",
655
743
  ) as AppServerRateLimitSnapshot;
656
744
  const limitId = asString(snapshot.limitId) ?? fallbackId;
657
- const primary = normalizeAppServerWindow(snapshot.primary);
658
- const secondary = normalizeAppServerWindow(snapshot.secondary);
745
+ const primary = normalizeAppServerWindow(snapshot.primary, capturedAt);
746
+ const secondary = normalizeAppServerWindow(snapshot.secondary, capturedAt);
659
747
  if (!primary && !secondary) return undefined;
660
748
  return { limitId, primary, secondary };
661
749
  }
662
750
 
663
751
  function normalizeAppServerWindow(
664
752
  value: unknown,
753
+ capturedAt: number,
665
754
  ): NormalizedRateLimitWindow | undefined {
666
755
  if (value === null || value === undefined) return undefined;
667
756
  const window = assertObject(
@@ -670,7 +759,19 @@ function normalizeAppServerWindow(
670
759
  ) as AppServerWindowSnapshot;
671
760
  const usedPercent = asNumber(window.usedPercent);
672
761
  if (usedPercent === undefined) return undefined;
673
- return { usedPercent };
762
+ const resetAt = asResetTime(
763
+ [
764
+ window.resetAt,
765
+ window.resetsAt,
766
+ window.resetTime,
767
+ window.endTime,
768
+ window.endsAt,
769
+ window.expiresAt,
770
+ ],
771
+ window.resetAfterSeconds,
772
+ capturedAt,
773
+ );
774
+ return resetAt === undefined ? { usedPercent } : { usedPercent, resetAt };
674
775
  }
675
776
 
676
777
  function mergeSnapshot(
@@ -690,7 +791,81 @@ export function formatCodexUsageStatusline(
690
791
  _model?: CodexUsageModel,
691
792
  ): string {
692
793
  const bar = formatReportBar(report);
693
- return bar ? formatStatuslineBarText(ctx, bar) : formatStatuslineText(ctx, "n/a");
794
+ if (!bar) return formatStatuslineText(ctx, "n/a");
795
+ const countdown = formatWeeklyResetCountdown(report);
796
+ const barText = formatStatuslineBarText(ctx, bar);
797
+ return countdown
798
+ ? `${barText} ${ctx.ui.theme.fg("dim", countdown)}`
799
+ : barText;
800
+ }
801
+
802
+ export function formatCodexUsageBar(
803
+ report: CodexUsageReport,
804
+ ): string | undefined {
805
+ return formatReportBar(report);
806
+ }
807
+
808
+ export function formatWeeklyResetCountdown(
809
+ report: CodexUsageReport,
810
+ now = Date.now(),
811
+ ): string | undefined {
812
+ const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
813
+ if (resetAt === undefined) return undefined;
814
+ return formatResetCountdown(resetAt, now);
815
+ }
816
+
817
+ export function formatResetCountdown(
818
+ resetAt: number,
819
+ now = Date.now(),
820
+ ): string {
821
+ const remainingMs = Math.max(0, resetAt - now);
822
+ if (remainingMs >= DAY_MS) {
823
+ const dayTenths = Math.max(10, Math.ceil(remainingMs / DAY_TENTH_MS));
824
+ return `${formatTenths(dayTenths)}d`;
825
+ }
826
+ if (remainingMs >= HOUR_MS) return `${Math.floor(remainingMs / HOUR_MS)}h`;
827
+ if (remainingMs >= MINUTE_MS)
828
+ return `${Math.floor(remainingMs / MINUTE_MS)}m`;
829
+ return `${Math.floor(remainingMs / SECOND_MS)}s`;
830
+ }
831
+
832
+ export function nextResetCountdownDelayMs(
833
+ report: CodexUsageReport,
834
+ now = Date.now(),
835
+ ): number | undefined {
836
+ const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
837
+ if (resetAt === undefined) return undefined;
838
+ return nextResetCountdownDelayForRemainingMs(resetAt - now);
839
+ }
840
+
841
+ export function nextResetCountdownDelayForRemainingMs(
842
+ remainingMs: number,
843
+ ): number | undefined {
844
+ if (remainingMs <= 0) return undefined;
845
+ if (remainingMs >= DAY_MS) {
846
+ const dayTenths = Math.max(10, Math.ceil(remainingMs / DAY_TENTH_MS));
847
+ return Math.max(1, remainingMs - (dayTenths - 1) * DAY_TENTH_MS);
848
+ }
849
+ if (remainingMs >= HOUR_MS) {
850
+ return Math.max(
851
+ 1,
852
+ remainingMs - Math.floor(remainingMs / HOUR_MS) * HOUR_MS + 1,
853
+ );
854
+ }
855
+ if (remainingMs >= MINUTE_MS) {
856
+ return Math.max(
857
+ 1,
858
+ remainingMs - Math.floor(remainingMs / MINUTE_MS) * MINUTE_MS + 1,
859
+ );
860
+ }
861
+ return Math.max(
862
+ 1,
863
+ remainingMs - Math.floor(remainingMs / SECOND_MS) * SECOND_MS + 1,
864
+ );
865
+ }
866
+
867
+ function formatTenths(value: number): string {
868
+ return value % 10 === 0 ? String(value / 10) : (value / 10).toFixed(1);
694
869
  }
695
870
 
696
871
  function formatReportBar(report: CodexUsageReport): string | undefined {
@@ -706,10 +881,7 @@ function formatStatuslineText(ctx: ExtensionContext, value: string): string {
706
881
 
707
882
  function formatStatuslineBarText(ctx: ExtensionContext, bar: string): string {
708
883
  const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
709
- const value = ctx.ui.theme.bg(
710
- "selectedBg",
711
- ctx.ui.theme.fg("dim", bar),
712
- );
884
+ const value = ctx.ui.theme.bg("selectedBg", ctx.ui.theme.fg("dim", bar));
713
885
  return `${label} ${value}`;
714
886
  }
715
887
 
@@ -722,36 +894,38 @@ function formatStatuslineProblem(
722
894
  errors: UsageQueryError[],
723
895
  ): string {
724
896
  const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
725
- const value = isUnavailable(errors)
897
+ const value = isUsageUnavailable(errors)
726
898
  ? ctx.ui.theme.fg("muted", "n/a")
727
899
  : ctx.ui.theme.fg("error", "error");
728
900
  return `${label} ${value}`;
729
901
  }
730
902
 
731
- function isUnavailable(errors: UsageQueryError[]): boolean {
732
- return errors.some((error) => {
733
- const message = error.message.toLowerCase();
734
- return (
735
- message.includes("no pi openai codex subscription auth") ||
736
- message.includes("no displayable rate-limit windows") ||
737
- message.includes("returned no displayable rate-limit windows") ||
738
- message.includes("returned 401") ||
739
- message.includes("returned 403") ||
740
- message.includes("unauthorized") ||
741
- message.includes("forbidden") ||
742
- message.includes("subscription") ||
743
- message.includes("no active plan") ||
744
- message.includes("plan unavailable") ||
745
- message.includes("quota unavailable") ||
746
- message.includes("rate limits unavailable")
747
- );
748
- });
903
+ export function isUsageUnavailable(errors: UsageQueryError[]): boolean {
904
+ return errors.length > 0 && errors.every(isUnavailableError);
905
+ }
906
+
907
+ function isUnavailableError(error: UsageQueryError): boolean {
908
+ const message = error.message.toLowerCase();
909
+ return (
910
+ message.includes("no pi openai codex subscription auth") ||
911
+ message.includes("no displayable rate-limit windows") ||
912
+ message.includes("returned no displayable rate-limit windows") ||
913
+ message.includes("returned 401") ||
914
+ message.includes("returned 403") ||
915
+ message.includes("unauthorized") ||
916
+ message.includes("forbidden") ||
917
+ message.includes("subscription") ||
918
+ message.includes("no active plan") ||
919
+ message.includes("plan unavailable") ||
920
+ message.includes("quota unavailable") ||
921
+ message.includes("rate limits unavailable")
922
+ );
749
923
  }
750
924
 
751
925
  function selectPrimaryCodexSnapshot(
752
926
  report: CodexUsageReport,
753
927
  ): NormalizedRateLimitSnapshot | undefined {
754
- return report.snapshots.find(isPrimaryCodexSnapshot) ?? report.snapshots[0];
928
+ return report.snapshots.find(isPrimaryCodexSnapshot);
755
929
  }
756
930
 
757
931
  function normalizedUsageKey(value: string | undefined): string | undefined {
@@ -782,7 +956,9 @@ function formatDualLimitBar(
782
956
  return value;
783
957
  }
784
958
 
785
- function filledTwentieths(window: NormalizedRateLimitWindow | undefined): number {
959
+ function filledTwentieths(
960
+ window: NormalizedRateLimitWindow | undefined,
961
+ ): number {
786
962
  if (!window) return 0;
787
963
  return Math.round(remainingPercent(window) / 5);
788
964
  }
@@ -840,6 +1016,34 @@ function asNumber(value: unknown): number | undefined {
840
1016
  return undefined;
841
1017
  }
842
1018
 
1019
+ function asResetTime(
1020
+ absoluteValues: unknown[],
1021
+ relativeSeconds: unknown,
1022
+ capturedAt: number,
1023
+ ): number | undefined {
1024
+ for (const value of absoluteValues) {
1025
+ const timestamp = asTimestampMs(value);
1026
+ if (timestamp !== undefined) return timestamp;
1027
+ }
1028
+ const seconds = asNumber(relativeSeconds);
1029
+ if (seconds === undefined || seconds < 0) return undefined;
1030
+ return capturedAt + seconds * SECOND_MS;
1031
+ }
1032
+
1033
+ function asTimestampMs(value: unknown): number | undefined {
1034
+ if (typeof value === "number" && Number.isFinite(value)) {
1035
+ if (value <= 0) return undefined;
1036
+ return value < 10_000_000_000 ? value * SECOND_MS : value;
1037
+ }
1038
+ if (typeof value === "string" && value.trim()) {
1039
+ const numeric = Number(value);
1040
+ if (Number.isFinite(numeric)) return asTimestampMs(numeric);
1041
+ const parsed = Date.parse(value);
1042
+ return Number.isFinite(parsed) ? parsed : undefined;
1043
+ }
1044
+ return undefined;
1045
+ }
1046
+
843
1047
  function hasHeader(headers: Record<string, string>, name: string): boolean {
844
1048
  return Object.keys(headers).some(
845
1049
  (key) => key.toLowerCase() === name.toLowerCase(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
6
6
  "keywords": [
@@ -26,9 +26,10 @@
26
26
  },
27
27
  "scripts": {
28
28
  "check": "node --experimental-strip-types -e \"await import('./index.ts'); console.log('pi-codex-usage: extension import ok')\"",
29
+ "test": "node --experimental-strip-types --test test/*.test.ts",
29
30
  "typecheck": "tsc --noEmit",
30
31
  "pack:dry": "npm pack --dry-run",
31
- "validate": "npm run typecheck && npm run check && npm run pack:dry"
32
+ "validate": "npm run typecheck && npm run test && npm run check && npm run pack:dry"
32
33
  },
33
34
  "files": [
34
35
  "index.ts",