@diegopetrucci/pi-extensions 0.1.72 → 0.1.73

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.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/extensions/agent-workflow-audit/index.ts +41 -8
  3. package/extensions/agent-workflow-audit/package.json +1 -1
  4. package/extensions/annotate-git-diff/README.md +7 -3
  5. package/extensions/annotate-git-diff/git.ts +3 -2
  6. package/extensions/annotate-git-diff/index.ts +366 -81
  7. package/extensions/annotate-git-diff/package.json +2 -2
  8. package/extensions/annotate-git-diff/quiet-glimpse.ts +4 -2
  9. package/extensions/annotate-git-diff/types.ts +6 -0
  10. package/extensions/annotate-git-diff/web/app.js +54 -4
  11. package/extensions/annotate-git-diff/web/index.html +2 -2
  12. package/extensions/annotate-last-message/README.md +9 -1
  13. package/extensions/annotate-last-message/index.ts +53 -13
  14. package/extensions/annotate-last-message/package.json +2 -1
  15. package/extensions/annotate-last-message/quiet-glimpse.ts +4 -2
  16. package/extensions/annotate-last-message/theme.ts +247 -0
  17. package/extensions/annotate-last-message/ui.ts +28 -2
  18. package/extensions/annotate-last-message/web/app.js +351 -162
  19. package/extensions/annotate-last-message/web/index.html +153 -24
  20. package/extensions/annotate-last-message/web/md-renderer.js +458 -0
  21. package/extensions/code-reviewer/index.ts +12 -1
  22. package/extensions/code-reviewer/package.json +2 -1
  23. package/extensions/context-cap/README.md +33 -4
  24. package/extensions/context-cap/index.ts +271 -68
  25. package/extensions/context-cap/package.json +1 -1
  26. package/extensions/context-inspector/README.md +2 -0
  27. package/extensions/context-inspector/index.ts +90 -9
  28. package/extensions/context-inspector/package.json +1 -1
  29. package/extensions/contrarian/index.ts +21 -15
  30. package/extensions/contrarian/package.json +1 -1
  31. package/extensions/dynamic-context-pruning/README.md +17 -5
  32. package/extensions/dynamic-context-pruning/index.ts +17 -4
  33. package/extensions/dynamic-context-pruning/package.json +1 -1
  34. package/extensions/fast/README.md +24 -3
  35. package/extensions/fast/index.ts +2 -2
  36. package/extensions/fast/package.json +1 -1
  37. package/extensions/git-footer/README.md +1 -0
  38. package/extensions/git-footer/package.json +1 -1
  39. package/extensions/librarian/index.ts +7 -0
  40. package/extensions/librarian/package.json +2 -1
  41. package/extensions/minimal-footer/README.md +16 -4
  42. package/extensions/minimal-footer/index.ts +263 -14
  43. package/extensions/minimal-footer/openai-usage.ts +62 -8
  44. package/extensions/minimal-footer/package.json +1 -1
  45. package/extensions/oracle/index.ts +19 -18
  46. package/extensions/oracle/package.json +1 -1
  47. package/extensions/permission-gate/README.md +6 -0
  48. package/extensions/permission-gate/index.ts +37 -16
  49. package/extensions/permission-gate/package.json +1 -1
  50. package/extensions/quiet-tools/README.md +1 -1
  51. package/extensions/quiet-tools/package.json +1 -1
  52. package/extensions/triage-comments/package.json +2 -1
  53. package/package.json +4 -4
@@ -10,8 +10,12 @@ import {
10
10
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import {
12
12
  fetchOpenAICodexUsage,
13
+ formatUsageStatus,
13
14
  formatUsageSummary,
15
+ getOpenAICodexAccountId,
14
16
  isOpenAICodexProvider,
17
+ isOpenAIProvider,
18
+ type UsageAvailability,
15
19
  type UsageSnapshot,
16
20
  } from "./openai-usage.ts";
17
21
 
@@ -62,6 +66,12 @@ const DUMB_ZONE_COLORS = new Set<DumbZoneColor>([
62
66
  "dim",
63
67
  ]);
64
68
 
69
+ // Node treats delays above this value as 1ms. Clamp only the identity-check
70
+ // schedule; the configured cache TTL remains the source of truth for lifecycle
71
+ // usage-result caching.
72
+ const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647;
73
+ const ZERO_CACHE_IDENTITY_CHECK_INTERVAL_MS = 5 * 60 * 1000;
74
+
65
75
  type RecursivePartial<T> = {
66
76
  [P in keyof T]?: T[P] extends object ? RecursivePartial<T[P]> : T[P];
67
77
  };
@@ -106,14 +116,26 @@ interface MinimalFooterConfig {
106
116
  };
107
117
  }
108
118
 
119
+ type UsageIdentity = {
120
+ renderKey: string;
121
+ usageKey: string;
122
+ oauth: boolean;
123
+ accountId?: string;
124
+ };
125
+
109
126
  type UsageSessionState = {
110
127
  config: MinimalFooterConfig;
111
128
  snapshot?: UsageSnapshot;
112
129
  lastFetchedAt?: number;
113
130
  loading: boolean;
114
131
  error?: string;
132
+ availability?: UsageAvailability;
133
+ usageIdentity?: UsageIdentity;
134
+ requestVersion: number;
135
+ disposed: boolean;
115
136
  inflight?: Promise<void>;
116
137
  requestRender?: () => void;
138
+ usageRefreshTimer?: TimerHandle;
117
139
  gitCache?: GitFooterCache;
118
140
  };
119
141
 
@@ -761,6 +783,7 @@ function renderFooterLines(options: {
761
783
  thinkingLevel: string;
762
784
  theme: { fg(color: DumbZoneColor | "dim", text: string): string };
763
785
  usageSnapshot?: UsageSnapshot;
786
+ usageStatus?: UsageAvailability;
764
787
  }): string[] {
765
788
  const repo = basename(options.cwd);
766
789
  const branchText = options.gitStatus
@@ -773,10 +796,14 @@ function renderFooterLines(options: {
773
796
  const dumbZone = options.config.context.dumbZone;
774
797
  const inDumbZone = dumbZone.enabled
775
798
  && (options.contextUsage?.tokens ?? 0) > dumbZone.thresholdTokens;
776
- const usageSummary = shouldShowCodexUsage(options.config)
777
- && isOpenAICodexProvider(options.modelProvider)
799
+ const usageEnabled = shouldShowCodexUsage(options.config);
800
+ const usageSummary = usageEnabled && isOpenAICodexProvider(options.modelProvider)
778
801
  ? formatUsageSummary(options.usageSnapshot, options.config.codexUsage.windows)
779
802
  : undefined;
803
+ const usageStatus = usageEnabled
804
+ && (isOpenAICodexProvider(options.modelProvider) || isOpenAIProvider(options.modelProvider))
805
+ ? formatUsageStatus(options.usageStatus)
806
+ : undefined;
780
807
 
781
808
  const model = options.modelId ?? "no-model";
782
809
  const modelText = options.thinkingLevel === "off"
@@ -789,6 +816,7 @@ function renderFooterLines(options: {
789
816
  if (options.config.context.showPercent) contextParts.push(options.theme.fg("dim", context));
790
817
  if (inDumbZone) contextParts.push(options.theme.fg(dumbZone.color, dumbZone.label));
791
818
  if (usageSummary) contextParts.push(options.theme.fg("dim", usageSummary));
819
+ else if (usageStatus) contextParts.push(options.theme.fg("dim", usageStatus));
792
820
  if (shouldShowExperimentalMarker(options.config)) {
793
821
  const marker = options.config.experimentalMarker;
794
822
  contextParts.push(options.theme.fg(marker.color, marker.label));
@@ -824,16 +852,147 @@ function clearUsageState(state: UsageSessionState): void {
824
852
  state.lastFetchedAt = undefined;
825
853
  state.loading = false;
826
854
  state.error = undefined;
855
+ state.availability = undefined;
856
+ }
857
+
858
+ function isModelUsingOAuth(ctx: ExtensionContext): boolean {
859
+ if (!ctx.model) return false;
860
+ try {
861
+ return ctx.modelRegistry.isUsingOAuth(ctx.model);
862
+ } catch {
863
+ return false;
864
+ }
865
+ }
866
+
867
+ function buildUsageRenderKey(
868
+ model: NonNullable<ExtensionContext["model"]>,
869
+ oauth: boolean,
870
+ ): string {
871
+ return [model.provider, model.api, model.id, oauth ? "oauth" : "api-key"].join("/");
872
+ }
873
+
874
+ function getUsageRenderKey(ctx: ExtensionContext): string | undefined {
875
+ const model = ctx.model;
876
+ if (!model) return undefined;
877
+ return buildUsageRenderKey(model, isModelUsingOAuth(ctx));
878
+ }
879
+
880
+ /**
881
+ * Resolve account-bound identity only at lifecycle refresh boundaries. Footer
882
+ * renders use getUsageRenderKey instead so a streaming repaint never reads
883
+ * auth.json or otherwise reloads account metadata.
884
+ */
885
+ function getUsageIdentity(ctx: ExtensionContext): UsageIdentity | undefined {
886
+ const model = ctx.model;
887
+ if (!model) return undefined;
888
+
889
+ const oauth = isModelUsingOAuth(ctx);
890
+ const renderKey = buildUsageRenderKey(model, oauth);
891
+ const accountId = isOpenAICodexProvider(model.provider) && oauth
892
+ ? getOpenAICodexAccountId(ctx.modelRegistry)
893
+ : undefined;
894
+ return {
895
+ renderKey,
896
+ usageKey: [renderKey, accountId ?? "account-unknown"].join("/"),
897
+ oauth,
898
+ accountId,
899
+ };
900
+ }
901
+
902
+ type UsageMode = "codex" | UsageAvailability;
903
+
904
+ function getUsageMode(
905
+ ctx: ExtensionContext,
906
+ config: MinimalFooterConfig,
907
+ identity?: UsageIdentity,
908
+ ): UsageMode | undefined {
909
+ if (!shouldShowCodexUsage(config)) return undefined;
910
+ const oauth = identity?.oauth ?? isModelUsingOAuth(ctx);
911
+ if (isOpenAICodexProvider(ctx.model?.provider)) {
912
+ return oauth ? "codex" : "unavailable";
913
+ }
914
+ if (isOpenAIProvider(ctx.model?.provider) && oauth) {
915
+ // Pi's new OpenAI ChatGPT OAuth flow targets api.openai.com. There is
916
+ // no documented account-usage contract for that token, so never reuse
917
+ // the legacy openai-codex WHAM request here.
918
+ return "unsupported";
919
+ }
920
+ return undefined;
921
+ }
922
+
923
+ function invalidateUsageState(
924
+ state: UsageSessionState,
925
+ usageIdentity?: UsageIdentity,
926
+ ): void {
927
+ state.requestVersion += 1;
928
+ state.snapshot = undefined;
929
+ state.lastFetchedAt = undefined;
930
+ state.loading = false;
931
+ state.error = undefined;
932
+ state.availability = undefined;
933
+ state.usageIdentity = usageIdentity;
934
+ // The previous promise may still be resolving. Clearing the reference lets
935
+ // a newly selected provider/account start its own request; requestVersion
936
+ // prevents the old promise from repainting this state when it settles.
937
+ state.inflight = undefined;
938
+ }
939
+
940
+ function usageRequestStillCurrent(
941
+ ctx: ExtensionContext,
942
+ state: UsageSessionState,
943
+ requestVersion: number,
944
+ requestUsageKey: string | undefined,
945
+ ): boolean {
946
+ if (
947
+ state.disposed
948
+ || state.requestVersion !== requestVersion
949
+ || state.usageIdentity?.usageKey !== requestUsageKey
950
+ ) {
951
+ return false;
952
+ }
953
+
954
+ // Account-only login changes do not emit an extension event in Pi 0.99.
955
+ // Re-read the lifecycle identity before accepting a response, but never
956
+ // perform this check from render().
957
+ const currentIdentity = getUsageIdentity(ctx);
958
+ if (currentIdentity?.usageKey === requestUsageKey) return true;
959
+
960
+ invalidateUsageState(state, currentIdentity);
961
+ state.requestRender?.();
962
+ void refreshUsageIfNeeded(ctx, state, true, currentIdentity);
963
+ return false;
827
964
  }
828
965
 
829
966
  async function refreshUsageIfNeeded(
830
967
  ctx: ExtensionContext,
831
968
  state: UsageSessionState,
832
969
  force = false,
970
+ usageIdentity?: UsageIdentity,
833
971
  ): Promise<void> {
972
+ if (state.disposed) return;
973
+
974
+ const resolvedUsageIdentity = usageIdentity ?? getUsageIdentity(ctx);
834
975
  const config = state.config;
835
- if (!shouldShowCodexUsage(config) || !isOpenAICodexProvider(ctx.model?.provider)) {
976
+ if (state.usageIdentity?.usageKey !== resolvedUsageIdentity?.usageKey) {
977
+ invalidateUsageState(state, resolvedUsageIdentity);
978
+ state.requestRender?.();
979
+ }
980
+
981
+ const mode = getUsageMode(ctx, config, resolvedUsageIdentity);
982
+ if (!mode) {
983
+ if (state.snapshot || state.availability || state.loading || state.error) {
984
+ clearUsageState(state);
985
+ state.requestRender?.();
986
+ }
987
+ return;
988
+ }
989
+
990
+ if (mode !== "codex") {
991
+ const status: UsageAvailability = mode;
992
+ if (!force && state.availability === status && state.lastFetchedAt !== undefined) return;
836
993
  clearUsageState(state);
994
+ state.availability = status;
995
+ state.lastFetchedAt = Date.now();
837
996
  state.requestRender?.();
838
997
  return;
839
998
  }
@@ -841,23 +1000,28 @@ async function refreshUsageIfNeeded(
841
1000
  const now = Date.now();
842
1001
  if (
843
1002
  !force &&
844
- state.lastFetchedAt &&
1003
+ state.lastFetchedAt !== undefined &&
845
1004
  now - state.lastFetchedAt < config.codexUsage.cacheTtlMs
846
1005
  ) {
847
1006
  return;
848
1007
  }
849
1008
 
850
- if (state.inflight) {
851
- return state.inflight;
852
- }
1009
+ if (state.inflight) return state.inflight;
853
1010
 
1011
+ const requestVersion = state.requestVersion;
1012
+ const requestUsageKey = resolvedUsageIdentity?.usageKey;
854
1013
  state.loading = true;
1014
+ state.availability = undefined;
855
1015
  state.requestRender?.();
856
- state.inflight = (async () => {
1016
+ const request = (async () => {
857
1017
  try {
858
1018
  const snapshot = await fetchOpenAICodexUsage(ctx.modelRegistry, {
859
1019
  timeoutMs: config.codexUsage.requestTimeoutMs,
1020
+ // Passing the lifecycle-resolved identity avoids a second account
1021
+ // lookup and binds the response to the account that was requested.
1022
+ accountId: resolvedUsageIdentity?.accountId,
860
1023
  });
1024
+ if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return;
861
1025
  if (snapshot) {
862
1026
  state.snapshot = snapshot;
863
1027
  state.lastFetchedAt = snapshot.fetchedAt;
@@ -866,17 +1030,63 @@ async function refreshUsageIfNeeded(
866
1030
  state.snapshot = undefined;
867
1031
  state.lastFetchedAt = Date.now();
868
1032
  state.error = undefined;
1033
+ state.availability = "unavailable";
869
1034
  }
870
1035
  } catch (error) {
1036
+ if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return;
1037
+ state.snapshot = undefined;
1038
+ state.lastFetchedAt = Date.now();
1039
+ state.availability = "unavailable";
871
1040
  state.error = error instanceof Error ? error.message : String(error);
872
1041
  } finally {
1042
+ if (state.disposed || state.requestVersion !== requestVersion || state.usageIdentity?.usageKey !== requestUsageKey) return;
873
1043
  state.loading = false;
874
1044
  state.inflight = undefined;
875
1045
  state.requestRender?.();
876
1046
  }
877
1047
  })();
878
1048
 
879
- return state.inflight;
1049
+ state.inflight = request;
1050
+ return request;
1051
+ }
1052
+
1053
+ function checkUsageIdentity(ctx: ExtensionContext, state: UsageSessionState): void {
1054
+ if (state.disposed) return;
1055
+
1056
+ const currentIdentity = getUsageIdentity(ctx);
1057
+ if (state.usageIdentity?.usageKey === currentIdentity?.usageKey) return;
1058
+
1059
+ // The timer is deliberately identity-only. It performs no WHAM request for
1060
+ // an unchanged account; an identity change uses the existing account-bound
1061
+ // refresh path so token/header validation and late-result isolation remain in
1062
+ // one place.
1063
+ invalidateUsageState(state, currentIdentity);
1064
+ state.requestRender?.();
1065
+ void refreshUsageIfNeeded(ctx, state, true, currentIdentity);
1066
+ }
1067
+
1068
+ function startUsageRefreshTimer(ctx: ExtensionContext, state: UsageSessionState): void {
1069
+ const configuredIntervalMs = state.config.codexUsage.cacheTtlMs;
1070
+ if (
1071
+ state.usageRefreshTimer !== undefined
1072
+ || !shouldShowCodexUsage(state.config)
1073
+ ) {
1074
+ return;
1075
+ }
1076
+
1077
+ const requestedIntervalMs = configuredIntervalMs === 0
1078
+ ? ZERO_CACHE_IDENTITY_CHECK_INTERVAL_MS
1079
+ : configuredIntervalMs;
1080
+ const intervalMs = Math.min(requestedIntervalMs, MAX_NODE_TIMER_DELAY_MS);
1081
+ const handle = setInterval(() => checkUsageIdentity(ctx, state), intervalMs);
1082
+ (handle as { unref?: () => void }).unref?.();
1083
+ state.usageRefreshTimer = handle;
1084
+ }
1085
+
1086
+ function disposeUsageRefreshTimer(state: UsageSessionState): void {
1087
+ if (state.usageRefreshTimer === undefined) return;
1088
+ clearInterval(state.usageRefreshTimer as ReturnType<typeof setInterval>);
1089
+ state.usageRefreshTimer = undefined;
880
1090
  }
881
1091
 
882
1092
  export default function (pi: ExtensionAPI) {
@@ -887,6 +1097,21 @@ export default function (pi: ExtensionAPI) {
887
1097
  state.gitCache = undefined;
888
1098
  }
889
1099
 
1100
+ function disposeState(state: UsageSessionState): void {
1101
+ state.disposed = true;
1102
+ state.requestVersion += 1;
1103
+ state.snapshot = undefined;
1104
+ state.lastFetchedAt = undefined;
1105
+ state.loading = false;
1106
+ state.error = undefined;
1107
+ state.availability = undefined;
1108
+ state.usageIdentity = undefined;
1109
+ state.inflight = undefined;
1110
+ state.requestRender = undefined;
1111
+ disposeUsageRefreshTimer(state);
1112
+ disposeGitCache(state);
1113
+ }
1114
+
890
1115
  function ensureGitCache(ctx: ExtensionContext, state: UsageSessionState): void {
891
1116
  if (!shouldShowGitStatus(state.config) || !canRunProjectGit(ctx)) {
892
1117
  disposeGitCache(state);
@@ -905,9 +1130,14 @@ export default function (pi: ExtensionAPI) {
905
1130
  }
906
1131
 
907
1132
  pi.on("session_start", (_event, ctx) => {
1133
+ const previous = states.get(ctx.sessionManager);
1134
+ if (previous) disposeState(previous);
1135
+
908
1136
  const state: UsageSessionState = {
909
1137
  config: loadConfig(ctx),
910
1138
  loading: false,
1139
+ requestVersion: 0,
1140
+ disposed: false,
911
1141
  };
912
1142
  states.set(ctx.sessionManager, state);
913
1143
 
@@ -920,12 +1150,15 @@ export default function (pi: ExtensionAPI) {
920
1150
 
921
1151
  return {
922
1152
  dispose() {
923
- if (state.requestRender) state.requestRender = undefined;
924
- disposeGitCache(state);
1153
+ disposeState(state);
925
1154
  unsub();
926
1155
  },
927
1156
  invalidate() {},
928
1157
  render(width: number): string[] {
1158
+ // Keep repainting cheap: account-bound identity is resolved only
1159
+ // by lifecycle refreshes, never while streaming output renders.
1160
+ const usageRenderKey = getUsageRenderKey(ctx);
1161
+ const usageStateMatchesModel = state.usageIdentity?.renderKey === usageRenderKey;
929
1162
  return renderFooterLines({
930
1163
  width,
931
1164
  cwd: ctx.cwd,
@@ -942,17 +1175,32 @@ export default function (pi: ExtensionAPI) {
942
1175
  modelProvider: ctx.model?.provider,
943
1176
  thinkingLevel: pi.getThinkingLevel(),
944
1177
  theme,
945
- usageSnapshot: state.snapshot,
1178
+ usageSnapshot: usageStateMatchesModel ? state.snapshot : undefined,
1179
+ usageStatus: usageStateMatchesModel ? state.availability : undefined,
946
1180
  });
947
1181
  },
948
1182
  };
949
1183
  });
1184
+ startUsageRefreshTimer(ctx, state);
950
1185
  });
951
1186
 
952
1187
  pi.on("model_select", (_event, ctx) => {
953
1188
  const state = states.get(ctx.sessionManager);
954
1189
  if (!state) return;
955
- void refreshUsageIfNeeded(ctx, state, true);
1190
+ // A model-select event is also the boundary for an account reload. Do
1191
+ // not let a prior provider/account's snapshot or promise survive it.
1192
+ const usageIdentity = getUsageIdentity(ctx);
1193
+ invalidateUsageState(state, usageIdentity);
1194
+ state.requestRender?.();
1195
+ void refreshUsageIfNeeded(ctx, state, true, usageIdentity);
1196
+ });
1197
+
1198
+ pi.on("turn_start", (_event, ctx) => {
1199
+ const state = states.get(ctx.sessionManager);
1200
+ if (!state) return;
1201
+ // Prompt boundaries provide an additional account-change check without
1202
+ // forcing a usage request when the current cache is still fresh.
1203
+ void refreshUsageIfNeeded(ctx, state);
956
1204
  });
957
1205
 
958
1206
  pi.on("turn_end", (_event, ctx) => {
@@ -966,7 +1214,8 @@ export default function (pi: ExtensionAPI) {
966
1214
  pi.on("session_shutdown", (_event, ctx) => {
967
1215
  const state = states.get(ctx.sessionManager);
968
1216
  if (!state) return;
969
- disposeGitCache(state);
1217
+ disposeState(state);
1218
+ states.delete(ctx.sessionManager);
970
1219
  });
971
1220
  }
972
1221
 
@@ -3,6 +3,15 @@ import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  const PROVIDER_ID = "openai-codex";
6
+ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
7
+
8
+ /**
9
+ * Usage status is deliberately separate from a snapshot. A direct OpenAI
10
+ * ChatGPT OAuth credential is sent to api.openai.com by Pi, while this module
11
+ * only knows the legacy openai-codex WHAM contract. Keeping that distinction
12
+ * explicit prevents an unsupported account from reusing legacy data.
13
+ */
14
+ export type UsageAvailability = "unsupported" | "unavailable";
6
15
 
7
16
  interface WhamUsageWindow {
8
17
  limit_window_seconds?: number;
@@ -123,9 +132,33 @@ function getOAuthAccountId(authSource: unknown): string | undefined {
123
132
 
124
133
  if (!isRecord(credential) || credential.type !== "oauth") return undefined;
125
134
  const accountId = credential.accountId;
126
- return typeof accountId === "string" && accountId.trim()
127
- ? accountId.trim()
128
- : undefined;
135
+ return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
136
+ }
137
+
138
+ function getAccountIdFromAccessToken(accessToken: string): string | undefined {
139
+ try {
140
+ // Keep this parser aligned with Pi's openai-codex OAuth implementation:
141
+ // the account identity is the exact JWT claim used when credentials are
142
+ // created, not a separately loaded account option.
143
+ const parts = accessToken.split(".");
144
+ if (parts.length !== 3) return undefined;
145
+ const payload = JSON.parse(atob(parts[1] ?? "")) as unknown;
146
+ if (!isRecord(payload)) return undefined;
147
+ const auth = payload[JWT_CLAIM_PATH];
148
+ if (!isRecord(auth)) return undefined;
149
+ const accountId = auth.chatgpt_account_id;
150
+ return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ export function getOpenAICodexAccountId(authSource: unknown): string | undefined {
157
+ try {
158
+ return getOAuthAccountId(authSource);
159
+ } catch {
160
+ return undefined;
161
+ }
129
162
  }
130
163
 
131
164
  function formatUsagePercent(value?: number): string | undefined {
@@ -167,6 +200,16 @@ export function isOpenAICodexProvider(provider?: string): boolean {
167
200
  return provider === PROVIDER_ID;
168
201
  }
169
202
 
203
+ export function isOpenAIProvider(provider?: string): boolean {
204
+ return provider === "openai";
205
+ }
206
+
207
+ export function formatUsageStatus(status?: UsageAvailability): string | undefined {
208
+ if (status === "unsupported") return "usage unsupported";
209
+ if (status === "unavailable") return "usage unavailable";
210
+ return undefined;
211
+ }
212
+
170
213
  export function formatUsageSummary(
171
214
  snapshot: UsageSnapshot | undefined,
172
215
  windows: UsageSummaryWindowsConfig,
@@ -195,12 +238,25 @@ export function formatUsageSummary(
195
238
 
196
239
  export async function fetchOpenAICodexUsage(
197
240
  authSource: unknown,
198
- options?: { timeoutMs?: number },
241
+ options?: { timeoutMs?: number; accountId?: string },
199
242
  ): Promise<UsageSnapshot | undefined> {
200
243
  const accessToken = await getAccessToken(authSource);
201
244
  if (!accessToken) return undefined;
202
245
 
203
- const accountId = getOAuthAccountId(authSource);
246
+ // Pi derives the Codex credential's accountId from this access-token claim.
247
+ // Resolve it before constructing the request so a stale account option can
248
+ // never become the header, and reject credentials that cannot be bound to an
249
+ // account at all.
250
+ const tokenAccountId = getAccountIdFromAccessToken(accessToken);
251
+ if (!tokenAccountId) return undefined;
252
+
253
+ // A lifecycle caller may provide the account identity observed at the
254
+ // refresh boundary. It is only an expected identity: the token claim remains
255
+ // authoritative, and any mismatch fails closed before fetch is called.
256
+ if (options && "accountId" in options && options.accountId !== tokenAccountId) {
257
+ return undefined;
258
+ }
259
+
204
260
  const controller = new AbortController();
205
261
  const timeoutMs = options?.timeoutMs ?? 10_000;
206
262
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -210,9 +266,7 @@ export async function fetchOpenAICodexUsage(
210
266
  Authorization: `Bearer ${accessToken}`,
211
267
  Accept: "application/json",
212
268
  };
213
- if (accountId) {
214
- headers["ChatGPT-Account-Id"] = accountId;
215
- }
269
+ headers["ChatGPT-Account-Id"] = tokenAccountId;
216
270
 
217
271
  const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
218
272
  headers,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-minimal-footer",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "A minimal custom footer for pi.",
5
5
  "keywords": [
6
6
  "pi-package",