@howaboua/pi-codex-conversion 1.5.9 → 1.5.10-dev.41.7e2d8d2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.10
4
+
5
+ - Added `/codex usage` and a Usage tab for OpenAI Codex subscription limits, with automatic refresh and aligned 5-hour/weekly usage columns.
6
+ - Moved settings links into a dedicated About tab.
7
+
3
8
  ## 1.5.9
4
9
 
5
10
  - Fixed native Responses compaction replay when provider payloads include in-flight tail items that are not yet persisted in the session branch.
package/README.md CHANGED
@@ -9,7 +9,7 @@ GPT/Codex models are strongest when the tool surface looks like the Codex CLI th
9
9
 
10
10
  The point is to give the model tools it already knows how to use well: shell-first inspection, resumable command sessions, and large one-shot patch edits instead of piecemeal read/edit/write steps.
11
11
 
12
- You can also opt into using the adapter on every provider/model. YMMV: Codex-tuned models are still the best fit, but the shell/patch workflow can help elsewhere too. The extension also has a small `/codex` settings UI for toggling adapter behavior, web search, image generation, fast mode, and verbosity. See [Settings](#settings).
12
+ You can also opt into using the adapter on every provider/model. YMMV: Codex-tuned models are still the best fit, but the shell/patch workflow can help elsewhere too. The extension also has a small `/codex` settings UI for toggling adapter behavior, web search, image generation, fast mode, native Responses compaction, and verbosity. See [Settings](#settings).
13
13
 
14
14
  ## Install
15
15
 
@@ -46,14 +46,17 @@ Use `/codex` to change adapter settings.
46
46
  - `/codex fast` — toggle priority service tier for the OpenAI Codex provider
47
47
  - `/codex search` — toggle native Codex web search
48
48
  - `/codex image` — toggle native Codex image generation
49
+ - `/codex usage` — show Codex subscription usage windows for the active OpenAI Codex model
49
50
  - `/codex low`, `/codex medium`, `/codex high` — set Responses API verbosity
50
51
 
51
52
  Settings are saved globally in `~/.pi/agent/pi-codex-conversion.json`.
52
53
 
53
- The settings UI also has an **Overrides** tab. These options intentionally do not have `/codex ...` command shortcuts:
54
+ The settings UI also has **Usage**, **Overrides**, and **About** tabs. **Usage** refreshes automatically when opened and can be refreshed manually with `r`. Override options intentionally do not have `/codex ...` command shortcuts:
54
55
 
55
56
  - add only the Pi `apply_patch` tool for GPT/Codex models while keeping Pi's default toolkit, prompt, provider behavior, and compaction flow
56
57
 
58
+ The **Compaction** tab can enable native OpenAI Responses compaction and choose the compaction model/reasoning. If native compaction fails, the extension falls back to Pi's normal compaction flow; when an older native compacted window exists, it is included in that Pi fallback summarization request so OpenAI can still use the prior opaque context server-side.
59
+
57
60
  When `all` is on, non-Codex providers get the shell, patch, skill, and prompt-adapter behavior, but keep their normal Pi provider path. Native web search, native image generation, and priority service tier stay limited to the OpenAI Codex provider. Verbosity is applied to Responses API providers.
58
61
 
59
62
  The footer shows the active state, for example:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-codex-conversion",
3
- "version": "1.5.9",
3
+ "version": "1.5.10-dev.41.7e2d8d2",
4
4
  "description": "Codex-oriented tool and prompt adapter for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
@@ -2,9 +2,9 @@ import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent } from "
2
2
  import { clampThinkingLevel, type ModelThinkingLevel, type Tool } from "@earendil-works/pi-ai";
3
3
  import { executeNativeCompaction } from "./compact-client.ts";
4
4
  import { extractCompactionSummaryText, hasCompactionOutputItem, sanitizeCompactedWindow, summarizeCompactionOutputForDiagnostics } from "./compaction-output.ts";
5
- import { resolveLatestNativeCompactionEntry } from "./details-store.ts";
5
+ import { findLatestNativeCompactionEntry, findLatestNativeCompactionEntryIndex, resolveLatestNativeCompactionEntry } from "./details-store.ts";
6
6
  import { rewriteResponsesPayloadWithNativeReplay, serializeLiveTailToResponsesInput } from "./payload-rewrite.ts";
7
- import { resolveNativeCompactionEnvironment } from "./compaction-runtime.ts";
7
+ import { isResponsesCompatiblePayload, resolveNativeCompactionEnvironment, type ResponsesCompatibleRequestPayload } from "./compaction-runtime.ts";
8
8
  import { convertResponsesTools } from "../providers/openai-responses-shared.ts";
9
9
  import {
10
10
  serializeCompactionPreparationToRequest,
@@ -12,7 +12,7 @@ import {
12
12
  type NativeCompactionRequestOptions,
13
13
  type ResponsesInputItem,
14
14
  } from "./serializer.ts";
15
- import { createNativeCompactionDetails, createNativeCompactionShimResult, isNativeCompactionDetails, NATIVE_COMPACTION_SHIM_SUMMARY } from "./types.ts";
15
+ import { createNativeCompactionDetails, createNativeCompactionShimResult, isNativeCompactionDetails, NATIVE_COMPACTION_SHIM_SUMMARY, type NativeCompactionEntry } from "./types.ts";
16
16
  import { isOpenAICodexContext, isResponsesContext } from "./codex-model.ts";
17
17
  import { shouldUseCodexAdapter } from "./activation.ts";
18
18
  import type { AdapterState } from "./state.ts";
@@ -23,6 +23,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
23
23
  return !!value && typeof value === "object" && !Array.isArray(value);
24
24
  }
25
25
 
26
+ function stashLatestNativeWindowForPiCompactionFallback(
27
+ ctx: ExtensionContext,
28
+ branchEntries: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>,
29
+ runtime: { provider: string; api: string; baseUrl: string },
30
+ state: AdapterState,
31
+ ): boolean {
32
+ state.pendingPiCompactionNativeWindow = undefined;
33
+ const nativeEntry = findLatestNativeCompactionEntry(branchEntries, {
34
+ provider: runtime.provider,
35
+ api: runtime.api,
36
+ baseUrl: runtime.baseUrl,
37
+ });
38
+ const compactedWindow = cloneCompactedWindow(nativeEntry?.details?.compactedWindow ?? []);
39
+ if (!compactedWindow || compactedWindow.length === 0) return false;
40
+ state.pendingPiCompactionNativeWindow = {
41
+ window: compactedWindow,
42
+ provider: runtime.provider,
43
+ api: runtime.api,
44
+ baseUrl: runtime.baseUrl,
45
+ sessionId: ctx.sessionManager.getSessionId(),
46
+ sourceCompactionEntryId: nativeEntry?.id,
47
+ };
48
+ return true;
49
+ }
50
+
26
51
  function cloneCompactedWindow(window: readonly unknown[]): ResponsesInputItem[] | undefined {
27
52
  if (!window.every(isRecord)) return undefined;
28
53
  return window.map((item) => structuredClone(item));
@@ -94,7 +119,7 @@ function formatCompactFailureMessage(compactResult: Awaited<ReturnType<typeof ex
94
119
  const status = compactResult.status ? ` HTTP ${compactResult.status}` : "";
95
120
  const response = compactResult.responseText?.trim();
96
121
  const detail = response ? `: ${response.slice(0, 500)}` : compactResult.errorMessage ? `: ${compactResult.errorMessage}` : "";
97
- return `OpenAI native compaction failed (${compactResult.reason}${status})${detail}; Pi compaction was not run.`;
122
+ return `OpenAI native compaction failed (${compactResult.reason}${status})${detail}`;
98
123
  }
99
124
 
100
125
  function formatCompactRequestDiagnostics(request: NativeCompactionRequestBody): string {
@@ -104,6 +129,11 @@ function formatCompactRequestDiagnostics(request: NativeCompactionRequestBody):
104
129
  return `model=${request.model}, input=${request.input.length}, tools=${tools}, reasoning=${reasoning}, service_tier=${serviceTier}`;
105
130
  }
106
131
 
132
+ function notifyNativeCompactionFallback(ctx: ExtensionContext, state: AdapterState, branchEntries: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>, runtime: { provider: string; api: string; baseUrl: string }, message: string): void {
133
+ const stashed = stashLatestNativeWindowForPiCompactionFallback(ctx, branchEntries, runtime, state);
134
+ ctx.ui.notify(`${message}; Pi compaction will run.${stashed ? " Previous native compacted window will be included in Pi compaction fallback." : ""}`, "error");
135
+ }
136
+
107
137
  export async function handleCodexSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx: ExtensionContext, state: AdapterState, pi: ExtensionAPI) {
108
138
  if (!state.config.responsesCompaction || !shouldUseCodexAdapter(ctx, state.config)) {
109
139
  return undefined;
@@ -203,23 +233,23 @@ async function handleCodexSessionBeforeCompactInner(event: SessionBeforeCompactE
203
233
  const compactResult = await executeNativeCompaction({ runtime, request, signal: event.signal });
204
234
  if (!compactResult.ok) {
205
235
  if (compactResult.reason !== "aborted") {
206
- ctx.ui.notify(formatCompactFailureMessage(compactResult), "error");
236
+ notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, formatCompactFailureMessage(compactResult));
207
237
  }
208
- return { cancel: true };
238
+ return compactResult.reason === "aborted" ? { cancel: true } : undefined;
209
239
  }
210
240
  const compactedWindow = sanitizeCompactedWindow(compactResult.compactedWindow);
211
241
  if (compactedWindow.length === 0) {
212
- ctx.ui.notify(`OpenAI native compaction returned no installable compacted context; Pi compaction was not run. Request: ${formatCompactRequestDiagnostics(request)}. Output: ${summarizeCompactionOutputForDiagnostics(compactResult.compactedWindow, compactedWindow)}`, "error");
213
- return { cancel: true };
242
+ notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, `OpenAI native compaction returned no installable compacted context. Request: ${formatCompactRequestDiagnostics(request)}. Output: ${summarizeCompactionOutputForDiagnostics(compactResult.compactedWindow, compactedWindow)}`);
243
+ return undefined;
214
244
  }
215
245
  if (!hasCompactionOutputItem(compactedWindow)) {
216
- ctx.ui.notify(`OpenAI native compaction did not return a compaction item; Pi compaction was not run. Response=${compactResult.compactResponseId ?? "<none>"}. Request: ${formatCompactRequestDiagnostics(request)}. Output: ${summarizeCompactionOutputForDiagnostics(compactResult.compactedWindow, compactedWindow)}`, "error");
217
- return { cancel: true };
246
+ notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, `OpenAI native compaction did not return a compaction item. Response=${compactResult.compactResponseId ?? "<none>"}. Request: ${formatCompactRequestDiagnostics(request)}. Output: ${summarizeCompactionOutputForDiagnostics(compactResult.compactedWindow, compactedWindow)}`);
247
+ return undefined;
218
248
  }
219
249
  const encryptedSummary = extractCompactionSummaryText(compactedWindow);
220
250
  if (!encryptedSummary) {
221
- ctx.ui.notify(`OpenAI native compaction returned compacted context without a displayable summary; Pi compaction was not run. Response=${compactResult.compactResponseId ?? "<none>"}. Request: ${formatCompactRequestDiagnostics(request)}. Output: ${summarizeCompactionOutputForDiagnostics(compactResult.compactedWindow, compactedWindow)}`, "error");
222
- return { cancel: true };
251
+ notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, `OpenAI native compaction returned compacted context without a displayable summary. Response=${compactResult.compactResponseId ?? "<none>"}. Request: ${formatCompactRequestDiagnostics(request)}. Output: ${summarizeCompactionOutputForDiagnostics(compactResult.compactedWindow, compactedWindow)}`);
252
+ return undefined;
223
253
  }
224
254
  try {
225
255
  const details = createNativeCompactionDetails({
@@ -234,8 +264,8 @@ async function handleCodexSessionBeforeCompactInner(event: SessionBeforeCompactE
234
264
  });
235
265
  return { compaction: createNativeCompactionShimResult({ summary: NATIVE_COMPACTION_SHIM_SUMMARY, firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details }) };
236
266
  } catch {
237
- ctx.ui.notify("OpenAI native compaction produced details Pi could not store; Pi compaction was not run.", "error");
238
- return { cancel: true };
267
+ notifyNativeCompactionFallback(ctx, state, branchEntries, runtime, "OpenAI native compaction produced details Pi could not store");
268
+ return undefined;
239
269
  }
240
270
  }
241
271
 
@@ -245,17 +275,49 @@ export async function rewriteCodexCompactedProviderRequest(payload: unknown, ctx
245
275
  if (!resolution.ok) return undefined;
246
276
  const runtime = resolution.runtime;
247
277
  const branchEntries = ctx.sessionManager.getBranch();
248
- const latestNativeCompaction = resolveLatestNativeCompactionEntry(branchEntries, {
278
+ const latestNativeCompactionIndex = findLatestNativeCompactionEntryIndex(branchEntries, {
249
279
  provider: runtime.provider,
250
280
  api: runtime.api,
251
281
  baseUrl: runtime.baseUrl,
252
282
  });
253
- if (!latestNativeCompaction.ok) return undefined;
283
+ if (latestNativeCompactionIndex === undefined) return undefined;
254
284
  if (!runtime.payload) return undefined;
255
- const rewrite = rewriteResponsesPayloadWithNativeReplay({ model: runtime.currentModel, payload: runtime.payload, branchEntries, compactionEntry: latestNativeCompaction.entry });
285
+ const rewrite = rewriteResponsesPayloadWithNativeReplay({ model: runtime.currentModel, payload: runtime.payload, branchEntries, compactionEntry: branchEntries[latestNativeCompactionIndex] as NativeCompactionEntry });
256
286
  if (rewrite.ok) return rewrite.rewrittenPayload;
257
287
  const detail = rewrite.parity?.mismatches.slice(0, 3).join("; ");
258
288
  const message = `OpenAI native compaction replay failed (${rewrite.reason})${detail ? `: ${detail}` : ""}; request was not sent with placeholder compaction context.`;
259
289
  ctx.ui.notify(message, "error");
260
290
  throw new Error(message);
261
291
  }
292
+
293
+ export async function injectPendingNativeWindowIntoPiCompactionRequest(payload: unknown, ctx: ExtensionContext, state: AdapterState): Promise<unknown | undefined> {
294
+ const pending = state.pendingPiCompactionNativeWindow;
295
+ if (!pending || pending.window.length === 0) return undefined;
296
+ if (!isResponsesCompatiblePayload(payload)) return undefined;
297
+ if (pending.sessionId !== ctx.sessionManager.getSessionId()) {
298
+ state.pendingPiCompactionNativeWindow = undefined;
299
+ return undefined;
300
+ }
301
+
302
+ const resolution = await resolveNativeCompactionEnvironment(ctx, { enabled: true }, payload);
303
+ if (!resolution.ok) return undefined;
304
+ const runtime = resolution.runtime;
305
+ if (pending.provider !== runtime.provider || pending.api !== runtime.api || pending.baseUrl !== runtime.baseUrl) return undefined;
306
+
307
+ const input = [...payload.input];
308
+ let insertAt = 0;
309
+ while (insertAt < input.length) {
310
+ const item = input[insertAt];
311
+ if (!isRecord(item) || (item.role !== "system" && item.role !== "developer")) break;
312
+ insertAt++;
313
+ }
314
+
315
+ return {
316
+ ...payload,
317
+ input: [
318
+ ...input.slice(0, insertAt),
319
+ ...pending.window.map((item) => structuredClone(item)),
320
+ ...input.slice(insertAt),
321
+ ],
322
+ };
323
+ }
@@ -5,7 +5,7 @@ import type { AdapterState } from "./state.ts";
5
5
  import { rewriteNativeImageGenerationTool } from "../tools/image-generation-tool.ts";
6
6
  import { rewriteNativeWebSearchTool } from "../tools/web-search-tool.ts";
7
7
  import { shouldUseCodexAdapter } from "./activation.ts";
8
- import { rewriteCodexCompactedProviderRequest } from "./compaction.ts";
8
+ import { injectPendingNativeWindowIntoPiCompactionRequest, rewriteCodexCompactedProviderRequest } from "./compaction.ts";
9
9
 
10
10
  export async function rewriteCodexProviderRequest(payload: unknown, ctx: ExtensionContext, state: AdapterState): Promise<unknown | undefined> {
11
11
  if (!shouldUseCodexAdapter(ctx, state.config) || (!isOpenAICodexContext(ctx) && !isResponsesContext(ctx))) {
@@ -21,5 +21,7 @@ export async function rewriteCodexProviderRequest(payload: unknown, ctx: Extensi
21
21
  serviceTier: isOpenAICodex,
22
22
  verbosity: true,
23
23
  });
24
+ const piCompactionPayload = await injectPendingNativeWindowIntoPiCompactionRequest(configuredPayload, ctx, state);
25
+ if (piCompactionPayload !== undefined) return piCompactionPayload;
24
26
  return (await rewriteCodexCompactedProviderRequest(configuredPayload, ctx, state)) ?? configuredPayload;
25
27
  }
@@ -1,5 +1,15 @@
1
1
  import type { PromptSkill } from "../prompt/build-system-prompt.ts";
2
2
  import type { CodexConversionConfig } from "./config.ts";
3
+ import type { ResponsesInputItem } from "./serializer.ts";
4
+
5
+ export interface PendingPiCompactionNativeWindow {
6
+ window: ResponsesInputItem[];
7
+ provider: string;
8
+ api: string;
9
+ baseUrl: string;
10
+ sessionId: string;
11
+ sourceCompactionEntryId?: string;
12
+ }
3
13
 
4
14
  export interface AdapterState {
5
15
  enabled: boolean;
@@ -8,4 +18,5 @@ export interface AdapterState {
8
18
  previousToolNames?: string[];
9
19
  promptSkills: PromptSkill[];
10
20
  config: CodexConversionConfig;
21
+ pendingPiCompactionNativeWindow?: PendingPiCompactionNativeWindow;
11
22
  }
@@ -8,9 +8,10 @@ import {
8
8
  import { syncAdapter } from "../adapter/activation.ts";
9
9
  import type { AdapterState } from "../adapter/state.ts";
10
10
  import { openCodexSettingsScreen } from "./ui.ts";
11
+ import { fetchCodexUsage, formatCodexUsage } from "./usage.ts";
11
12
 
12
- const CODEX_COMMAND_COMPLETIONS = ["all", "status", "fast", "search", "image", "compact", "low", "medium", "high"] as const;
13
- const CODEX_USAGE = "Usage: /codex, /codex all, /codex status, /codex fast, /codex search, /codex image, /codex compact, /codex low|medium|high";
13
+ const CODEX_COMMAND_COMPLETIONS = ["all", "status", "fast", "search", "image", "compact", "usage", "low", "medium", "high"] as const;
14
+ const CODEX_USAGE = "Usage: /codex, /codex all, /codex status, /codex fast, /codex search, /codex image, /codex compact, /codex usage, /codex low|medium|high";
14
15
 
15
16
  export function registerCodexCommand(pi: ExtensionAPI, state: AdapterState, onConfigApplied?: (config: CodexConversionConfig) => void): void {
16
17
  function saveAndApply(ctx: ExtensionContext, nextConfig: CodexConversionConfig): boolean {
@@ -32,6 +33,36 @@ export function registerCodexCommand(pi: ExtensionAPI, state: AdapterState, onCo
32
33
  handler: async (args, ctx) => {
33
34
  state.config = readCodexConversionConfig();
34
35
  const arg = args.trim().toLowerCase();
36
+ if (arg === "usage") {
37
+ let usage;
38
+ try {
39
+ usage = await fetchCodexUsage(ctx);
40
+ } catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ if (!ctx.hasUI) {
43
+ ctx.ui.notify(message, "error");
44
+ return;
45
+ }
46
+ await openCodexSettingsScreen(ctx, {
47
+ initialConfig: state.config,
48
+ initialTab: "usage",
49
+ initialUsage: { error: message },
50
+ onChange: (config) => saveAndApply(ctx, config),
51
+ });
52
+ return;
53
+ }
54
+ if (!ctx.hasUI) {
55
+ ctx.ui.notify(formatCodexUsage(usage), "info");
56
+ return;
57
+ }
58
+ await openCodexSettingsScreen(ctx, {
59
+ initialConfig: state.config,
60
+ initialTab: "usage",
61
+ initialUsage: usage,
62
+ onChange: (config) => saveAndApply(ctx, config),
63
+ });
64
+ return;
65
+ }
35
66
  if (arg === "compact") {
36
67
  if (!ctx.hasUI) {
37
68
  ctx.ui.notify(formatCodexSettings(state.config), "info");
@@ -10,25 +10,41 @@ import {
10
10
  type CodexConversionConfig,
11
11
  } from "../adapter/config.ts";
12
12
  import { CHANGELOG_URL, DISCORD_URL, GITHUB_URL, ISSUE_URL, openExternalUrl } from "./links.ts";
13
+ import { fetchCodexUsage, formatCodexUsage, type CodexUsageSnapshot } from "./usage.ts";
13
14
 
14
15
  export interface CodexSettingsScreenOptions {
15
16
  initialConfig: CodexConversionConfig;
16
17
  onChange: (nextConfig: CodexConversionConfig) => boolean;
17
18
  initialTab?: SettingsTab;
19
+ initialUsage?: CodexUsageSnapshot | { error: string };
20
+ onRefreshUsage?: () => Promise<CodexUsageSnapshot>;
18
21
  }
19
22
 
20
- type SettingsTab = "general" | "compaction" | "overrides";
23
+ type SettingsTab = "general" | "compaction" | "usage" | "overrides" | "about";
21
24
 
22
- const TAB_ORDER: readonly SettingsTab[] = ["general", "compaction", "overrides"];
25
+ const TAB_ORDER: readonly SettingsTab[] = ["general", "compaction", "usage", "overrides", "about"];
23
26
 
24
27
  export async function openCodexSettingsScreen(ctx: ExtensionContext, options: CodexSettingsScreenOptions): Promise<void> {
25
28
  let draft = { ...options.initialConfig };
26
29
  let activeTab: SettingsTab = options.initialTab ?? "general";
30
+ let usageState: CodexUsageSnapshot | { error: string } | undefined = options.initialUsage;
31
+ let usageLoading = false;
32
+
33
+ const loadUsage = (requestRender: () => void) => {
34
+ if (usageLoading) return;
35
+ usageLoading = true;
36
+ requestRender();
37
+ (options.onRefreshUsage ?? (() => fetchCodexUsage(ctx)))()
38
+ .then((usage) => { usageState = usage; })
39
+ .catch((error) => { usageState = { error: error instanceof Error ? error.message : String(error) }; })
40
+ .finally(() => { usageLoading = false; requestRender(); });
41
+ };
27
42
 
28
43
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
29
44
  let settingsList = createSettingsList(activeTab, draft, options, (nextDraft) => {
30
45
  draft = nextDraft;
31
46
  }, done, () => tui.requestRender());
47
+ if (activeTab === "usage" && !usageState) loadUsage(() => tui.requestRender());
32
48
 
33
49
  const switchTab = () => {
34
50
  const currentIndex = TAB_ORDER.indexOf(activeTab);
@@ -36,6 +52,7 @@ export async function openCodexSettingsScreen(ctx: ExtensionContext, options: Co
36
52
  settingsList = createSettingsList(activeTab, draft, options, (nextDraft) => {
37
53
  draft = nextDraft;
38
54
  }, done, () => tui.requestRender());
55
+ if (activeTab === "usage" && !usageState) loadUsage(() => tui.requestRender());
39
56
  tui.requestRender();
40
57
  };
41
58
 
@@ -47,12 +64,12 @@ export async function openCodexSettingsScreen(ctx: ExtensionContext, options: Co
47
64
  rule(width, theme, "borderMuted"),
48
65
  ...(activeTab === "compaction" ? formatCompactionNotes(theme) : []),
49
66
  ...(activeTab === "overrides" ? formatOverridesNotes(theme) : []),
67
+ ...(activeTab === "usage" ? formatUsageLines(theme, usageState, usageLoading) : []),
68
+ ...(activeTab === "about" ? formatLinks(theme) : []),
50
69
  "",
51
- ...settingsList.render(width),
52
- rule(width, theme, "borderMuted"),
53
- ...formatLinks(theme),
70
+ ...(activeTab === "usage" || activeTab === "about" ? [] : settingsList.render(width)),
54
71
  rule(width, theme, "accent"),
55
- theme.fg("dim", " Tab to switch sections · g/c/d/i open links"),
72
+ theme.fg("dim", formatFooter(activeTab)),
56
73
  ].map((line) => truncateToWidth(line, width, "")),
57
74
  invalidate: () => settingsList.invalidate(),
58
75
  handleInput: (data: string) => {
@@ -60,7 +77,11 @@ export async function openCodexSettingsScreen(ctx: ExtensionContext, options: Co
60
77
  switchTab();
61
78
  return;
62
79
  }
63
- if (handleLinkKey(data, ctx)) return;
80
+ if (activeTab === "about" && handleLinkKey(data, ctx)) return;
81
+ if (activeTab === "usage" && data.toLowerCase() === "r") {
82
+ loadUsage(() => tui.requestRender());
83
+ return;
84
+ }
64
85
  settingsList.handleInput?.(data);
65
86
  tui.requestRender();
66
87
  },
@@ -109,6 +130,8 @@ function createSettingsList(
109
130
  }
110
131
 
111
132
  function buildItems(tab: SettingsTab, draft: CodexConversionConfig): SettingItem[] {
133
+ if (tab === "usage" || tab === "about") return [];
134
+
112
135
  if (tab === "compaction") {
113
136
  return [
114
137
  { id: "responsesCompaction", label: "Responses compaction", currentValue: (draft.responsesCompaction ?? false) ? "on" : "off", values: ["off", "on"] },
@@ -150,7 +173,75 @@ function applySettingChange(id: string, value: string, draft: CodexConversionCon
150
173
 
151
174
  function formatTabs(activeTab: SettingsTab, theme: Theme): string {
152
175
  const renderTab = (tab: SettingsTab, label: string) => activeTab === tab ? theme.bold(label) : theme.fg("dim", label);
153
- return ` ${renderTab("general", "General")} ${theme.fg("dim", "/")} ${renderTab("compaction", "Compaction")} ${theme.fg("dim", "/")} ${renderTab("overrides", "Overrides")}`;
176
+ return ` ${renderTab("general", "General")} ${theme.fg("dim", "/")} ${renderTab("compaction", "Compaction")} ${theme.fg("dim", "/")} ${renderTab("usage", "Usage")} ${theme.fg("dim", "/")} ${renderTab("overrides", "Overrides")} ${theme.fg("dim", "/")} ${renderTab("about", "About")}`;
177
+ }
178
+
179
+ function formatFooter(activeTab: SettingsTab): string {
180
+ if (activeTab === "usage") return " Tab to switch sections · r refresh";
181
+ if (activeTab === "about") return " Tab to switch sections · g/c/d/i open links";
182
+ return " Tab to switch sections";
183
+ }
184
+
185
+ function formatUsageLines(theme: Theme, usageState: CodexUsageSnapshot | { error: string } | undefined, loading: boolean): string[] {
186
+ if (loading && !usageState) return [theme.fg("dim", " Loading Codex usage…")];
187
+ if (!usageState) return [theme.fg("dim", " Loading Codex usage…")];
188
+ if ("error" in usageState) return [theme.fg("error", ` ${usageState.error}`), theme.fg("dim", " Press r to retry.")];
189
+
190
+ const rows = usageState.limits.map((limit) => {
191
+ const primary = usageColumns(limit.primary);
192
+ const secondary = usageColumns(limit.secondary);
193
+ return [limit.limitName ?? limit.limitId, primary.bar, primary.percent, primary.reset, secondary.bar, secondary.percent, secondary.reset];
194
+ });
195
+ const headers = ["Limit", "5h", "", "Reset", "Weekly", "", "Reset"];
196
+ const widths = columnWidths([headers, ...rows]);
197
+ return [
198
+ ` ${theme.bold(`Codex usage${usageState.planType ? ` · ${usageState.planType}` : ""}`)}${loading ? theme.fg("dim", " refreshing…") : ""}`,
199
+ "",
200
+ formatUsageRow(headers.map((header) => theme.fg("dim", header)), widths),
201
+ theme.fg("borderMuted", ` ${"─".repeat(widths.reduce((sum, width) => sum + width, 0) + (2 * (widths.length - 1)))}`),
202
+ ...rows.map((row) => formatUsageRow(row, widths)),
203
+ ];
204
+ }
205
+
206
+ function columnWidths(rows: string[][]): number[] {
207
+ const columnCount = Math.max(...rows.map((row) => row.length));
208
+ return Array.from({ length: columnCount }, (_, index) => Math.max(...rows.map((row) => stripAnsi(row[index] ?? "").length)));
209
+ }
210
+
211
+ function stripAnsi(value: string): string {
212
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
213
+ }
214
+
215
+ function padCell(value: string, width: number): string {
216
+ return value + " ".repeat(Math.max(0, width - stripAnsi(value).length));
217
+ }
218
+
219
+ function formatUsageRow(row: string[], widths: number[]): string {
220
+ return ` ${row.map((cell, index) => padCell(cell, widths[index] ?? 0)).join(" ")}`;
221
+ }
222
+
223
+ function usageColumns(window: { usedPercent?: number; windowMinutes?: number; resetsAt?: number } | undefined): { bar: string; percent: string; reset: string } {
224
+ if (!window) return { bar: "—", percent: "", reset: "" };
225
+ const percent = window.usedPercent === undefined ? undefined : Math.max(0, Math.min(100, window.usedPercent));
226
+ return {
227
+ bar: bar(percent),
228
+ percent: percent === undefined ? "?%" : `${Math.round(percent)}%`,
229
+ reset: formatResetShort(window.resetsAt),
230
+ };
231
+ }
232
+
233
+ function bar(percent: number | undefined): string {
234
+ if (percent === undefined) return "░░░░░░░░░░";
235
+ const filled = Math.max(0, Math.min(10, Math.round(percent / 10)));
236
+ return "█".repeat(filled) + "░".repeat(10 - filled);
237
+ }
238
+
239
+ function formatResetShort(timestampSeconds: number | undefined): string {
240
+ if (!timestampSeconds) return "reset ?";
241
+ const minutes = Math.max(0, Math.round((timestampSeconds * 1000 - Date.now()) / 60000));
242
+ if (minutes < 90) return `~${minutes}m`;
243
+ if (minutes < 60 * 48) return `~${Math.round(minutes / 60)}h`;
244
+ return `~${Math.round(minutes / 1440)}d`;
154
245
  }
155
246
 
156
247
  function formatLinks(theme: Theme): string[] {
@@ -0,0 +1,151 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+
4
+ const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
5
+ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
6
+
7
+ type RuntimeModel = Model<Api>;
8
+
9
+ export interface CodexUsageWindow {
10
+ usedPercent?: number;
11
+ windowMinutes?: number;
12
+ resetsAt?: number;
13
+ }
14
+
15
+ export interface CodexUsageLimit {
16
+ limitId: string;
17
+ limitName?: string;
18
+ primary?: CodexUsageWindow;
19
+ secondary?: CodexUsageWindow;
20
+ }
21
+
22
+ export interface CodexUsageSnapshot {
23
+ planType?: string;
24
+ limits: CodexUsageLimit[];
25
+ raw: unknown;
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ function numberValue(value: unknown): number | undefined {
33
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
34
+ }
35
+
36
+ function stringValue(value: unknown): string | undefined {
37
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
38
+ }
39
+
40
+ export function buildCodexUsageUrl(): string {
41
+ return `${DEFAULT_CODEX_BASE_URL}/wham/usage`;
42
+ }
43
+
44
+ function extractBearerToken(headers: Headers): string | undefined {
45
+ const authorization = headers.get("authorization")?.trim();
46
+ const match = authorization?.match(/^Bearer\s+(.+)$/i);
47
+ return match?.[1]?.trim();
48
+ }
49
+
50
+ function extractAccountId(token: string): string | undefined {
51
+ try {
52
+ const parts = token.split(".");
53
+ if (parts.length !== 3) return undefined;
54
+ const payload = JSON.parse(Buffer.from(parts[1] ?? "", "base64").toString("utf8")) as unknown;
55
+ const authClaims = isRecord(payload) ? payload[JWT_CLAIM_PATH] : undefined;
56
+ const accountId = isRecord(authClaims) ? authClaims.chatgpt_account_id : undefined;
57
+ return stringValue(accountId);
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ async function buildCodexUsageHeaders(ctx: ExtensionContext, model: RuntimeModel): Promise<Headers> {
64
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
65
+ if (!auth.ok) throw new Error(auth.error);
66
+ const headers = new Headers(model.headers);
67
+ for (const [key, value] of Object.entries(auth.headers ?? {})) headers.set(key, value);
68
+ if (auth.apiKey) headers.set("authorization", `Bearer ${auth.apiKey}`);
69
+ const token = auth.apiKey ?? extractBearerToken(headers);
70
+ const accountId = token ? extractAccountId(token) : undefined;
71
+ if (accountId) headers.set("chatgpt-account-id", accountId);
72
+ headers.set("accept", "application/json");
73
+ headers.set("originator", "pi");
74
+ return headers;
75
+ }
76
+
77
+ function parseWindow(value: unknown): CodexUsageWindow | undefined {
78
+ if (!isRecord(value)) return undefined;
79
+ const usedPercent = numberValue(value.used_percent);
80
+ const limitWindowSeconds = numberValue(value.limit_window_seconds);
81
+ const windowMinutes = numberValue(value.window_minutes) ?? (limitWindowSeconds === undefined ? undefined : Math.ceil(limitWindowSeconds / 60));
82
+ const resetsAt = numberValue(value.resets_at) ?? numberValue(value.reset_at);
83
+ return usedPercent === undefined && windowMinutes === undefined && resetsAt === undefined ? undefined : { usedPercent, windowMinutes, resetsAt };
84
+ }
85
+
86
+ function parseRateLimit(value: unknown): { primary?: CodexUsageWindow; secondary?: CodexUsageWindow } {
87
+ if (!isRecord(value)) return {};
88
+ return {
89
+ primary: parseWindow(value.primary_window) ?? parseWindow(value.primary),
90
+ secondary: parseWindow(value.secondary_window) ?? parseWindow(value.secondary),
91
+ };
92
+ }
93
+
94
+ export function parseCodexUsagePayload(payload: unknown): CodexUsageSnapshot {
95
+ const root = isRecord(payload) ? payload : {};
96
+ const limits: CodexUsageLimit[] = [];
97
+ const addLimit = (limitId: string, limitName: string | undefined, source: unknown) => {
98
+ const rateLimit = isRecord(source) && "rate_limit" in source ? source.rate_limit : source;
99
+ const parsed = parseRateLimit(rateLimit);
100
+ limits.push({
101
+ limitId,
102
+ ...(limitName ? { limitName } : {}),
103
+ ...(parsed.primary ? { primary: parsed.primary } : {}),
104
+ ...(parsed.secondary ? { secondary: parsed.secondary } : {}),
105
+ });
106
+ };
107
+ addLimit("codex", undefined, root.rate_limit);
108
+ if (Array.isArray(root.additional_rate_limits)) {
109
+ for (const item of root.additional_rate_limits) {
110
+ if (!isRecord(item)) continue;
111
+ addLimit(stringValue(item.metered_feature) ?? "additional", stringValue(item.limit_name), item);
112
+ }
113
+ }
114
+ return { planType: stringValue(root.plan_type), limits, raw: payload };
115
+ }
116
+
117
+ export async function fetchCodexUsage(ctx: ExtensionContext): Promise<CodexUsageSnapshot> {
118
+ const model = ctx.model;
119
+ if (!model) throw new Error("No active model selected.");
120
+ if (model.provider !== "openai-codex") {
121
+ throw new Error("Codex usage is only available for OpenAI Codex subscription models.");
122
+ }
123
+ const response = await fetch(buildCodexUsageUrl(), { method: "GET", headers: await buildCodexUsageHeaders(ctx, model), signal: ctx.signal });
124
+ const text = await response.text();
125
+ if (!response.ok) throw new Error(`Usage request failed (${response.status}): ${text || response.statusText}`);
126
+ return parseCodexUsagePayload(JSON.parse(text));
127
+ }
128
+
129
+ function formatReset(timestampSeconds: number | undefined): string {
130
+ if (!timestampSeconds) return "reset unknown";
131
+ const ms = timestampSeconds * 1000;
132
+ const minutes = Math.max(0, Math.round((ms - Date.now()) / 60000));
133
+ return minutes < 90 ? `resets in ~${minutes}m` : `resets ${new Date(ms).toLocaleString()}`;
134
+ }
135
+
136
+ function formatWindow(label: string, window: CodexUsageWindow | undefined): string | undefined {
137
+ if (!window) return undefined;
138
+ const percent = window.usedPercent === undefined ? "?" : `${Math.round(window.usedPercent)}%`;
139
+ const span = window.windowMinutes ? `${Math.round(window.windowMinutes)}m` : "window";
140
+ return `${label}: ${percent} used (${span}, ${formatReset(window.resetsAt)})`;
141
+ }
142
+
143
+ export function formatCodexUsage(snapshot: CodexUsageSnapshot): string {
144
+ const lines = [`Codex usage${snapshot.planType ? ` (${snapshot.planType})` : ""}:`];
145
+ for (const limit of snapshot.limits) {
146
+ const title = limit.limitName ?? limit.limitId;
147
+ const parts = [formatWindow("5h", limit.primary), formatWindow("weekly", limit.secondary)].filter(Boolean);
148
+ lines.push(`- ${title}: ${parts.length ? parts.join("; ") : "no usage data"}`);
149
+ }
150
+ return lines.join("\n");
151
+ }
package/src/index.ts CHANGED
@@ -163,6 +163,7 @@ export default function codexConversion(pi: ExtensionAPI) {
163
163
  });
164
164
 
165
165
  pi.on("session_compact", async (event) => {
166
+ state.pendingPiCompactionNativeWindow = undefined;
166
167
  if (!event.fromExtension || !isNativeCompactionDetails(event.compactionEntry.details)) return;
167
168
  pi.sendMessage(
168
169
  {