@diegopetrucci/pi-extensions 0.1.71 → 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.
- package/README.md +2 -2
- package/extensions/agent-workflow-audit/index.ts +41 -8
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/README.md +7 -3
- package/extensions/annotate-git-diff/git.ts +3 -2
- package/extensions/annotate-git-diff/index.ts +366 -81
- package/extensions/annotate-git-diff/package.json +2 -2
- package/extensions/annotate-git-diff/quiet-glimpse.ts +4 -2
- package/extensions/annotate-git-diff/types.ts +6 -0
- package/extensions/annotate-git-diff/web/app.js +54 -4
- package/extensions/annotate-git-diff/web/index.html +2 -2
- package/extensions/annotate-last-message/README.md +9 -1
- package/extensions/annotate-last-message/index.ts +53 -13
- package/extensions/annotate-last-message/package.json +2 -1
- package/extensions/annotate-last-message/quiet-glimpse.ts +4 -2
- package/extensions/annotate-last-message/theme.ts +247 -0
- package/extensions/annotate-last-message/ui.ts +28 -2
- package/extensions/annotate-last-message/web/app.js +351 -162
- package/extensions/annotate-last-message/web/index.html +153 -24
- package/extensions/annotate-last-message/web/md-renderer.js +458 -0
- package/extensions/code-reviewer/index.ts +12 -1
- package/extensions/code-reviewer/package.json +2 -1
- package/extensions/context-cap/README.md +33 -4
- package/extensions/context-cap/index.ts +271 -68
- package/extensions/context-cap/package.json +1 -1
- package/extensions/context-inspector/README.md +2 -0
- package/extensions/context-inspector/index.ts +90 -9
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/index.ts +21 -15
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dynamic-context-pruning/README.md +17 -5
- package/extensions/dynamic-context-pruning/index.ts +17 -4
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +25 -4
- package/extensions/fast/index.ts +4 -4
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/README.md +1 -0
- package/extensions/git-footer/package.json +1 -1
- package/extensions/librarian/index.ts +7 -0
- package/extensions/librarian/package.json +2 -1
- package/extensions/minimal-footer/README.md +16 -4
- package/extensions/minimal-footer/index.ts +263 -14
- package/extensions/minimal-footer/openai-usage.ts +62 -8
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/oracle/index.ts +19 -18
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +6 -0
- package/extensions/permission-gate/index.ts +37 -16
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/quiet-tools/README.md +1 -1
- package/extensions/quiet-tools/package.json +1 -1
- package/extensions/triage-comments/package.json +2 -1
- 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
|
|
777
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|