diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md
index 801bb6e91..21cf48d63 100644
--- a/packages/ai/CHANGELOG.md
+++ b/packages/ai/CHANGELOG.md
@@ -116,6 +116,10 @@
- Changed the GitLab Duo Agent `goal` transcript to render replayed tool calls as past-tense `{args}` records and tool results as `` / ``, replacing the prior `{"name":…,"arguments":…}` / `` markers. The model was mimicking the old `{name,arguments}` shape as its own emittable text instead of using the structured tool-use channel, because the historical-record markers were byte-identical to a plausible live-call grammar. The new form reads as a past record (a call that already ran), drops the `{name,arguments}` wrapper (the tool name moves onto the tag, args ride the body), and omits the tool name from the result header (call→result pair by adjacency). This also trims ~48 bytes per call/result pair in the worked example.
- Changed the GitLab Duo Agent inline flow's system prompt to append a short history-note whenever the `goal` is a multi-turn ChatML transcript, telling the model the transcript's `<|im_start|>`/``/`` markers are a past record of already-executed turns and tool calls — not a syntax to emit — and to call tools only through its structured tool-use channel. Reframing the markers to past tense reduced but did not eliminate the model copying them as its own output; the explicit instruction closes the remaining gap. The note rides the system slot (not the goal), is appended only for multi-turn transcripts (a lone bare-text prompt has no markers), and lives in a static `.md` file imported as text.
+### Added
+
+- Added `credits` field (array of `UsageResetCreditDetail` with `grantedAt`, `expiresAt`, `status`) to `UsageResetCredits`, so display layers can show when banked rate-limit resets expire. The OpenAI Codex usage provider now calls `listCodexResetCredits` to populate individual credit details when `availableCount > 0` ([#3339](https://github.com/can1357/oh-my-pi/issues/3339)).
+
## [16.1.16] - 2026-06-23
### Fixed
diff --git a/packages/ai/src/auth-broker/wire-schemas.ts b/packages/ai/src/auth-broker/wire-schemas.ts
index 07c842f35..05b66499d 100644
--- a/packages/ai/src/auth-broker/wire-schemas.ts
+++ b/packages/ai/src/auth-broker/wire-schemas.ts
@@ -183,8 +183,15 @@ const usageLimitSchema = type({
"notes?": "string[]",
});
+const usageResetCreditDetailSchema = type({
+ "grantedAt?": "string",
+ "expiresAt?": "string",
+ "status?": "string",
+});
+
const usageResetCreditsSchema = type({
availableCount: "number",
+ "credits?": usageResetCreditDetailSchema.array(),
});
const arkUsageReportSchema = type({
diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts
index c0cf013d7..166a616cc 100644
--- a/packages/ai/src/usage.ts
+++ b/packages/ai/src/usage.ts
@@ -63,6 +63,23 @@ export interface UsageLimit {
notes?: string[];
}
+/**
+ * Per-credit detail for a saved/banked rate-limit reset.
+ *
+ * Populated when the provider's listing endpoint returns individual credit
+ * metadata (e.g. OpenAI Codex `wham/rate-limit-reset-credits`). Callers that
+ * only need the count can ignore this; display layers use `expiresAt` to show
+ * when banked resets expire ([#3339](https://github.com/can1357/oh-my-pi/issues/3339)).
+ */
+export interface UsageResetCreditDetail {
+ /** ISO timestamp when the credit was granted. */
+ grantedAt?: string;
+ /** ISO timestamp when the credit expires and can no longer be redeemed. */
+ expiresAt?: string;
+ /** Backend status, e.g. `available`, `redeemed`. */
+ status?: string;
+}
+
/**
* Saved/banked rate-limit resets an account can redeem on demand.
*
@@ -73,6 +90,8 @@ export interface UsageLimit {
export interface UsageResetCredits {
/** Number of resets available to redeem right now. */
availableCount: number;
+ /** Individual credit details (expiry dates, etc.) when the provider exposes them. */
+ credits?: UsageResetCreditDetail[];
}
/** Aggregated usage report for a provider. */
@@ -202,8 +221,15 @@ export const usageLimitSchema = type({
"notes?": "string[]",
});
+export const usageResetCreditDetailSchema = type({
+ "grantedAt?": "string",
+ "expiresAt?": "string",
+ "status?": "string",
+});
+
export const usageResetCreditsSchema = type({
availableCount: "number",
+ "credits?": usageResetCreditDetailSchema.array(),
});
export const usageReportSchema = type({
diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts
index 1efc2fbc1..9d5c21a46 100644
--- a/packages/ai/src/usage/openai-codex.ts
+++ b/packages/ai/src/usage/openai-codex.ts
@@ -12,6 +12,7 @@ import type {
UsageWindow,
} from "../usage";
import { isRecord } from "../utils";
+import { listCodexResetCredits } from "./openai-codex-reset";
import { toNumber } from "./shared";
const CODEX_USAGE_PATH = "wham/usage";
@@ -470,6 +471,34 @@ export const openaiCodexUsageProvider: UsageProvider = {
}
const resetCredits = parseResetCredits(payload);
+ if (resetCredits && resetCredits.availableCount > 0) {
+ try {
+ const list = await listCodexResetCredits({
+ accessToken,
+ accountId,
+ baseUrl: params.baseUrl,
+ fetch: ctx.fetch,
+ signal: params.signal,
+ });
+ if (list?.credits.length) {
+ resetCredits.credits = list.credits
+ .filter(c => (c.status ?? "available") === "available")
+ .map(c => ({
+ grantedAt: c.grantedAt,
+ expiresAt: c.expiresAt,
+ status: c.status,
+ }));
+ }
+ // Always sync the live count from the detail endpoint — it may report
+ // fewer or zero available credits after expiry/redeem, even when the
+ // /wham/usage payload still has a stale count.
+ if (list) {
+ resetCredits.availableCount = list.availableCount;
+ }
+ } catch (error) {
+ ctx.logger?.warn("Codex reset credits detail fetch failed", { error: String(error) });
+ }
+ }
const report: UsageReport = {
provider: "openai-codex",
fetchedAt: nowMs,
diff --git a/packages/ai/test/openai-codex-usage.test.ts b/packages/ai/test/openai-codex-usage.test.ts
index 4407d98fb..a89dbd5d0 100644
--- a/packages/ai/test/openai-codex-usage.test.ts
+++ b/packages/ai/test/openai-codex-usage.test.ts
@@ -128,13 +128,23 @@ describe("openai-codex usage parser", () => {
});
it("surfaces rate_limit_reset_credits.available_count as report.resetCredits", async () => {
- const payload = { ...makePayload(), rate_limit_reset_credits: { available_count: 1 } };
+ const usagePayload = { ...makePayload(), rate_limit_reset_credits: { available_count: 1 } };
+ const fetchImpl: FetchImpl = (async (url: string | URL | Request) => {
+ const path = typeof url === "string" ? url : url.toString();
+ // Return an empty credits list for the detail endpoint — the count
+ // from /wham/usage should be synced from the live response.
+ const body = path.includes("rate-limit-reset-credits") ? { available_count: 1, credits: [] } : usagePayload;
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }) as unknown as FetchImpl;
const report = await openaiCodexUsageProvider.fetchUsage(
{
provider: "openai-codex",
credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" },
},
- { fetch: fakeFetch(payload) },
+ { fetch: fetchImpl },
);
expect(report?.resetCredits).toEqual({ availableCount: 1 });
});
@@ -149,4 +159,71 @@ describe("openai-codex usage parser", () => {
);
expect(report?.resetCredits).toBeUndefined();
});
+ it("populates resetCredits.credits with expiry dates when available_count > 0", async () => {
+ const usagePayload = { ...makePayload(), rate_limit_reset_credits: { available_count: 2 } };
+ const creditsPayload = {
+ available_count: 2,
+ credits: [
+ {
+ id: "RateLimitResetCredit_1",
+ status: "available",
+ granted_at: "2025-01-15T00:00:00Z",
+ expires_at: "2025-02-14T00:00:00Z",
+ },
+ {
+ id: "RateLimitResetCredit_2",
+ status: "available",
+ granted_at: "2025-01-20T00:00:00Z",
+ expires_at: "2025-02-19T00:00:00Z",
+ },
+ {
+ id: "RateLimitResetCredit_3",
+ status: "redeemed",
+ granted_at: "2025-01-01T00:00:00Z",
+ expires_at: "2025-01-31T00:00:00Z",
+ },
+ ],
+ };
+ const fetchImpl: FetchImpl = (async (url: string | URL | Request) => {
+ const path = typeof url === "string" ? url : url.toString();
+ const body = path.includes("rate-limit-reset-credits") ? creditsPayload : usagePayload;
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }) as unknown as FetchImpl;
+ const report = await openaiCodexUsageProvider.fetchUsage(
+ {
+ provider: "openai-codex",
+ credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" },
+ },
+ { fetch: fetchImpl },
+ );
+ expect(report?.resetCredits?.availableCount).toBe(2);
+ // Redeemed credits are filtered out; only available ones surface
+ expect(report?.resetCredits?.credits).toHaveLength(2);
+ expect(report?.resetCredits?.credits?.[0]?.expiresAt).toBe("2025-02-14T00:00:00Z");
+ expect(report?.resetCredits?.credits?.[1]?.expiresAt).toBe("2025-02-19T00:00:00Z");
+ });
+
+ it("does not call listCodexResetCredits when available_count is 0", async () => {
+ const usagePayload = { ...makePayload(), rate_limit_reset_credits: { available_count: 0 } };
+ let extraFetchCalls = 0;
+ const fetchImpl: FetchImpl = (async (url: string | URL | Request) => {
+ const path = typeof url === "string" ? url : url.toString();
+ if (path.includes("rate-limit-reset-credits")) extraFetchCalls++;
+ return new Response(JSON.stringify(usagePayload), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }) as unknown as FetchImpl;
+ await openaiCodexUsageProvider.fetchUsage(
+ {
+ provider: "openai-codex",
+ credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" },
+ },
+ { fetch: fetchImpl },
+ );
+ expect(extraFetchCalls).toBe(0);
+ });
});
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index acfdf9b43..fabd48076 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -16,6 +16,10 @@
- Changed the `inlineToolDescriptors` setting ("Inline Tool Descriptors") from a boolean to a three-way enum (`auto` | `on` | `off`), defaulting to `auto`. `auto` inlines tool descriptors into the system prompt (and strips them from provider tool schemas) only for Gemini models, leaving them in the schemas otherwise; `on`/`off` force the behavior regardless of model. Existing `true`/`false` configs migrate to `on`/`off`.
- Replaced `as string | undefined` inline casts with `typeof` guards in the TUI usage renderer's account identity resolution (`formatAccountLabel`, `formatUnlimitedReportLabel`, reset-credits label, and unlimited-plan tier), so empty-string metadata values fall through to the next fallback instead of being displayed
+### Added
+
+- The `/usage` display (TUI, ACP text, and `omp usage` CLI) now shows expiry dates for banked Codex rate-limit resets, so users can plan when to redeem them before they expire ([#3339](https://github.com/can1357/oh-my-pi/issues/3339)).
+
### Fixed
- Fixed `snapcompact` compaction silently falling back to an LLM summary when local preflight rejects the archive; manual and auto snapcompact now fail locally with the blocker instead of making provider calls. ([#3599](https://github.com/can1357/oh-my-pi/issues/3599))
@@ -156,6 +160,7 @@
- Fixed GitLab Duo Agent namespace/project discovery reading the original repo's git remote after a `/move`. The session's working directory is now resolved live (per LLM call) from the `SessionManager` instead of being captured when the agent was constructed, so moving the session re-scopes Duo workspace discovery to the new repository.
- Fixed `omp auth-broker login gitlab-duo-agent` (and `--via`) hanging until timeout: the provider uses GitLab's fixed `vscode://` OAuth redirect, which never reaches the broker's local callback server, and `runLocalLogin` supplied no `onManualCodeInput` fallback. The broker login now offers the same paste-the-redirect-URL prompt the interactive sign-in uses, so credentials can be saved.
+
## [16.1.16] - 2026-06-23
### Breaking Changes
diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts
index aaa5408d0..268ca04d2 100644
--- a/packages/coding-agent/src/cli/usage-cli.ts
+++ b/packages/coding-agent/src/cli/usage-cli.ts
@@ -326,7 +326,23 @@ function formatAccountHeader(
const planType = report.metadata?.planType;
if (typeof planType === "string" && planType) header += chalk.dim(` · plan: ${planType}`);
const savedResets = report.resetCredits?.availableCount ?? 0;
- if (savedResets > 0) header += chalk.cyan(` · ✦ ${savedResets} saved reset${savedResets === 1 ? "" : "s"}`);
+ if (savedResets > 0) {
+ header += chalk.cyan(` · ✦ ${savedResets} saved reset${savedResets === 1 ? "" : "s"}`);
+ const credits = report.resetCredits?.credits;
+ if (credits) {
+ const upcoming = credits
+ .filter(c => c.expiresAt)
+ .map(c => ({ date: c.expiresAt!, ms: Date.parse(c.expiresAt!) }))
+ .filter(c => !Number.isNaN(c.ms))
+ .sort((a, b) => a.ms - b.ms)
+ .find(c => c.ms > nowMs);
+ if (upcoming) {
+ header += chalk.dim(
+ ` · soonest expires in ${formatDuration(upcoming.ms - nowMs)} (${upcoming.date.slice(0, 10)})`,
+ );
+ }
+ }
+ }
if (report.fetchedAt && nowMs - report.fetchedAt > 90_000) {
header += chalk.dim(` · fetched ${formatDuration(nowMs - report.fetchedAt)} ago`);
}
diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts
index db8ebd7b9..e543e0f37 100644
--- a/packages/coding-agent/src/modes/controllers/command-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/command-controller.ts
@@ -1604,6 +1604,23 @@ export function renderUsageReports(
resetAccountLines.push(
` • ${label}: ${count} saved reset${count === 1 ? "" : "s"}${isActive ? " (active)" : ""}`,
);
+ const credits = report.resetCredits?.credits;
+ if (credits) {
+ for (const credit of credits) {
+ if (credit.expiresAt) {
+ const expiryMs = Date.parse(credit.expiresAt);
+ if (!Number.isNaN(expiryMs)) {
+ const remaining = expiryMs - nowMs;
+ const expiryDate = credit.expiresAt.slice(0, 10);
+ if (remaining > 0) {
+ resetAccountLines.push(` expires in ${formatDuration(remaining)} (${expiryDate})`);
+ } else {
+ resetAccountLines.push(` expired (${expiryDate})`);
+ }
+ }
+ }
+ }
+ }
}
if (resetAccountLines.length > 0) {
lines.push(
diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts
index 1fec09a75..40865efc0 100644
--- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts
+++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts
@@ -75,6 +75,22 @@ function renderUsageReports(
lines.push(
`- ${resetLabel}: ${savedResets} saved rate-limit reset${savedResets === 1 ? "" : "s"} available — /usage reset to spend`,
);
+ const credits = report.resetCredits?.credits;
+ if (credits) {
+ for (const credit of credits) {
+ if (credit.expiresAt) {
+ const expiryMs = Date.parse(credit.expiresAt);
+ if (!Number.isNaN(expiryMs)) {
+ const remaining = expiryMs - nowMs;
+ if (remaining > 0) {
+ lines.push(` expires in ${formatDuration(remaining)} (${credit.expiresAt.slice(0, 10)})`);
+ } else {
+ lines.push(` expired (${credit.expiresAt.slice(0, 10)})`);
+ }
+ }
+ }
+ }
+ }
}
if (report.limits.length === 0) {
const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account";