From 74691175503faa489d55a72f67474694c8f53dd8 Mon Sep 17 00:00:00 2001 From: PowerUserZ <55040543+PowerUserZ@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:10:00 +0300 Subject: [PATCH 1/2] fix: port OpenUsage 0.7.14 provider corrections to Windows plugins --- .github/workflows/ci.yml | 23 +++++ CHANGELOG.md | 9 ++ README.md | 4 +- docs/plugins/api.md | 7 ++ docs/providers/codex.md | 9 ++ docs/providers/cursor.md | 4 + docs/providers/opencode-go.md | 4 + plugins/codex/plugin.accounts.test.js | 44 ++++++++ plugins/codex/plugin.js | 20 ++-- plugins/codex/plugin.test.js | 6 +- plugins/cursor/plugin.js | 29 ++++-- plugins/cursor/plugin.team-pools.test.js | 66 ++++++++++++ plugins/opencode-go/plugin.js | 17 +++- plugins/opencode-go/plugin.reset.test.js | 43 ++++++++ src-tauri/src/plugin_engine/host_api.rs | 54 +++++++++- src-tauri/src/plugin_engine/mod.rs | 1 + src-tauri/src/plugin_engine/retained_scan.rs | 101 +++++++++++++++++++ 17 files changed, 416 insertions(+), 25 deletions(-) create mode 100644 plugins/codex/plugin.accounts.test.js create mode 100644 plugins/cursor/plugin.team-pools.test.js create mode 100644 plugins/opencode-go/plugin.reset.test.js create mode 100644 src-tauri/src/plugin_engine/retained_scan.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a318123..dbcd7e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ name: CI on: + push: + branches: [main, "port/**"] pull_request: branches: [main] @@ -25,3 +27,24 @@ jobs: - name: Run tests run: bun run test + + windows-rust: + name: Windows Rust tests + runs-on: windows-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 + with: + toolchain: stable + components: rustfmt + - uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: "./src-tauri -> target" + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.4.2" + - run: bun install --frozen-lockfile + - run: bun run bundle:plugins + - run: bun run build + - name: Test native plugin host + run: cargo test --locked --manifest-path src-tauri/Cargo.toml --lib diff --git a/CHANGELOG.md b/CHANGELOG.md index 7555cc2..8d1f1af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### Bug Fixes +- Provider fixes adapted from upstream OpenUsage 0.7.14: Codex scans the selected login folder, keeps slow history scans in the background, and uses Pro 100 / Pro 200 / Pro 500 plan names +- Codex history waits at most 150 ms per refresh; completed scans are retained for the next refresh and scoped by home, account and date range. WSL history stays disabled +- Cursor prefers structured team usage pools over legacy dollar allowances; legacy accounts keep their dollar meter +- OpenCode Go keeps reset countdowns for active sessions rounded below 1%, and drops untouched rolling-window placeholder resets + + ## v0.7.3 Logins inside WSL get their own cards, Antigravity and Copilot work from their CLI logins, and the Codex logo follows the theme. diff --git a/README.md b/README.md index 488ffb2..1897b1e 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ Settings → **Tray icon** picks what sits next to the clock. Settings → **Tas - [**Amp**](docs/providers/amp.md) / free tier, bonus, credits - [**Antigravity**](docs/providers/antigravity.md) / Gemini and Claude models, session and weekly - [**Claude**](docs/providers/claude.md) / session, weekly, Fable, extra usage, rate limit resets, local token usage (ccusage) -- [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits +- [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits, local spend per account - [**Copilot**](docs/providers/copilot.md) / credits, extra usage, chat, completions -- [**Cursor**](docs/providers/cursor.md) / credits, total usage, requests, auto usage, API usage, Grok Bot, on-demand, bonus spend, CLI auth +- [**Cursor**](docs/providers/cursor.md) / credits, total usage, structured team pools, requests, auto usage, API usage, Grok Bot, on-demand, bonus spend, CLI auth - [**Factory / Droid**](docs/providers/factory.md) / standard, premium tokens - [**Grok**](docs/providers/grok.md) / weekly limit, credits used, pay-as-you-go cap - [**JetBrains AI Assistant**](docs/providers/jetbrains-ai-assistant.md) / quota, remaining diff --git a/docs/plugins/api.md b/docs/plugins/api.md index d37bc11..ac7526a 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -529,6 +529,7 @@ host.ccusage.query(opts: { provider?: "claude" | "codex", // Optional; defaults to plugin id, then "claude" since?: string, // Start date (YYYYMMDD or YYYY-MM-DD) until?: string, // End date (YYYYMMDD or YYYY-MM-DD) + accountId?: string // Codex retained-history scope; not sent to the CLI homePath?: string, // Provider home override (CLAUDE_CONFIG_DIR or CODEX_HOME) claudePath?: string, // Legacy Claude-only override (deprecated; use homePath) }): @@ -596,3 +597,9 @@ if (result.status === "ok") { ## See Also - [Plugin Schema](./schema.md) - Plugin structure, manifest format, and output schema + +Codex history queries return `pending` while their background scan is running and no retained result +is available. They wait at most 150 ms; later queries retrieve the completed result. The worker has +its own bounded execution budget and is shared by home folder. The optional `accountId` and date +bounds invalidate retained results when the login or query range changes. Plugins should leave live +quota visible and omit unknown spend while pending. WSL account copies still return `no_runner`. diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 88613a4..3ae1a24 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -131,3 +131,12 @@ Response returns new `access_token`, and optionally new `refresh_token` and `id_ ## WSL A login inside a running WSL distro shows as its own `Codex · WSL` card when it's a different account than the ones already shown. A stopped distro is never started; its card keeps its last data. + +### Upstream 0.7.14 compatibility + +The plugin already refreshes OAuth tokens and saves them to the selected account's own `auth.json`. +Local spend now follows that same file's parent folder, including `~/.config/codex` and extra accounts. +The host starts one background history scan per home, waits at most 150 ms for it, and retains the +result for later probes. Home, account ID and query dates scope retained data. Pending history does +not hide live quota or invent zero spend. History in WSL remains disabled to avoid slow network paths. +Plans `prolite`, `pro`, and `promax` display as Pro 100, Pro 200, and Pro 500. diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index 96b610f..23cb6ab 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -227,3 +227,7 @@ WorkosCursorSessionToken=%3A%3A ``` `userId` is derived from JWT `sub` (e.g. `google-oauth2|user_abc` -> `user_abc`). + +Modern team seats use structured Auto/API pool percentages, and the reported total percentage when +present. The old dollar allowance does not replace those pools or synthesize a missing total. +Zero-valued placeholders beside positive spend, and malformed/incomplete pools, retain legacy handling. diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md index 8024d6d..a0794ad 100644 --- a/docs/providers/opencode-go.md +++ b/docs/providers/opencode-go.md @@ -31,3 +31,7 @@ Percents are clamped to 0–100. ## WSL A login inside a running WSL distro shows as its own `OpenCode Go · WSL` card when it's a different account than the ones already shown. A stopped distro is never started; its card keeps its last data. + +A zero-percent rolling session can already be active. Its real reset remains visible; only a reset +within two seconds of a full five-hour period at capture time is treated as an untouched placeholder. +The HTTP Date header supplies the reference clock when valid. Weekly and monthly resets are preserved. diff --git a/plugins/codex/plugin.accounts.test.js b/plugins/codex/plugin.accounts.test.js new file mode 100644 index 0000000..c649edf --- /dev/null +++ b/plugins/codex/plugin.accounts.test.js @@ -0,0 +1,44 @@ +import { beforeEach, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +beforeEach(() => vi.resetModules()) + +async function setup(home, accountId = "work-account") { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => name === "CODEX_HOME" ? home : null) + const path = (home || "~/.config/codex") + "/auth.json" + ctx.host.fs.writeText(path, JSON.stringify({ tokens: { + access_token: "old-token", refresh_token: "old-refresh", account_id: accountId, + }, last_refresh: "2000-01-01T00:00:00Z", extra: "preserve" })) + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ tokens: { access_token: "personal-token" } })) + const personal = ctx.host.fs.readText("~/.codex/auth.json") + ctx.host.http.request.mockImplementation(({ url, headers }) => { + if (url.includes("oauth/token")) return { status: 200, bodyText: JSON.stringify({ + access_token: "fresh-token", refresh_token: "fresh-refresh", id_token: "fresh-id", + }) } + expect(headers.Authorization).toBe("Bearer fresh-token") + expect(headers["ChatGPT-Account-Id"]).toBe(accountId) + return { status: 200, headers: {}, bodyText: JSON.stringify({ plan_type: "promax", rate_limit: { + primary_window: { used_percent: 15, limit_window_seconds: 18000 }, + } }) } + }) + ctx.host.ccusage.query.mockReturnValue({ status: "pending" }) + await import("./plugin.js") + const result = globalThis.__openusage_plugin.probe(ctx) + return { ctx, path, personal, result } +} + +it.each(["C:/Users/test/extra-codex", null])("refreshes and scans the selected auth home %s", async (home) => { + const { ctx, path, personal, result } = await setup(home) + expect(JSON.parse(ctx.host.fs.readText(path))).toMatchObject({ extra: "preserve", tokens: { + access_token: "fresh-token", refresh_token: "fresh-refresh", account_id: "work-account", + } }) + expect(ctx.host.fs.readText("~/.codex/auth.json")).toBe(personal) + expect(ctx.host.keychain.readGenericPassword).not.toHaveBeenCalled() + expect(ctx.host.ccusage.query).toHaveBeenCalledWith(expect.objectContaining({ + homePath: home || "~/.config/codex", accountId: "work-account", provider: "codex", + })) + expect(result.lines.find((line) => line.label === "Session").used).toBe(15) + expect(result.lines.some((line) => line.label === "Today")).toBe(false) + expect(result.plan).toBe("Pro 500") +}) diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index e6b5e7d..65a02cc 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -347,8 +347,9 @@ function formatCodexPlan(ctx, planType) { const rawPlan = typeof planType === "string" ? planType.trim() : "" if (!rawPlan) return null - if (rawPlan.toLowerCase() === "prolite") return "Pro 5x" - if (rawPlan.toLowerCase() === "pro") return "Pro 20x" + if (rawPlan.toLowerCase() === "prolite") return "Pro 100" + if (rawPlan.toLowerCase() === "pro") return "Pro 200" + if (rawPlan.toLowerCase() === "promax") return "Pro 500" if (rawPlan.toLowerCase() === "self_serve_business_prolite") return "Business Premium" return ctx.fmt.planLabel(rawPlan) || null } @@ -408,7 +409,7 @@ } } - function queryTokenUsage(ctx) { + function queryTokenUsage(ctx, authState) { if (!ctx.host.ccusage || typeof ctx.host.ccusage.query !== "function") { return { status: "no_runner", data: null } } @@ -421,10 +422,13 @@ const d = since.getDate() const sinceStr = "" + y + (m < 10 ? "0" : "") + m + (d < 10 ? "0" : "") + d const queryOpts = { provider: "codex", since: sinceStr } - const codexHome = readCodexHome(ctx) - if (codexHome) { - queryOpts.homePath = codexHome - } + const codexHome = authState.authPath + ? authState.authPath.replace(/[\\/]auth\.json$/, "") + : readCodexHome(ctx) + if (codexHome) queryOpts.homePath = codexHome + // Separate retained history when the login in a home changes. + const accountId = authState.auth.tokens && authState.auth.tokens.account_id + queryOpts.accountId = accountId || ctx.host.crypto.sha256Hex(authState.auth.tokens.access_token) const result = ctx.host.ccusage.query(queryOpts) if (!result || typeof result !== "object" || typeof result.status !== "string") { @@ -848,7 +852,7 @@ } } - const tokenUsageResult = queryTokenUsage(ctx) + const tokenUsageResult = queryTokenUsage(ctx, authState) if (tokenUsageResult.status === "ok") { const tokenUsage = tokenUsageResult.data const now = new Date() diff --git a/plugins/codex/plugin.test.js b/plugins/codex/plugin.test.js index e1d17ff..237ceef 100644 --- a/plugins/codex/plugin.test.js +++ b/plugins/codex/plugin.test.js @@ -225,7 +225,7 @@ describe("codex plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBe("Pro 20x") + expect(result.plan).toBe("Pro 200") expect(result.lines.find((line) => line.label === "Session")).toBeTruthy() expect(result.lines.find((line) => line.label === "Weekly")).toBeTruthy() const credits = result.lines.find((line) => line.label === "Credits") @@ -300,7 +300,7 @@ describe("codex plugin", () => { expect(weekly.used).toBe(44) }) - it("maps prolite plan to Pro 5x", async () => { + it("maps prolite plan to Pro 100", async () => { const ctx = makeCtx() ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ tokens: { access_token: "token" }, @@ -324,7 +324,7 @@ describe("codex plugin", () => { const plugin = await loadPlugin() const result = plugin.probe(ctx) - expect(result.plan).toBe("Pro 5x") + expect(result.plan).toBe("Pro 100") expect(result.lines.find((line) => line.label === "Session")).toBeTruthy() expect(result.lines.find((line) => line.label === "Weekly")).toBeTruthy() const credits = result.lines.find((line) => line.label === "Credits") diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 99c296e..a223e00 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -617,7 +617,7 @@ } const normalizedPlanName = typeof planName === "string" - ? planName.toLowerCase() + ? planName.trim().toLowerCase() : "" const hasPlanUsage = !!usage.planUsage @@ -629,9 +629,16 @@ typeof usage.planUsage.totalPercentUsed === "number" && Number.isFinite(usage.planUsage.totalPercentUsed) + const poolUsage = usage.planUsage || {} + const spent = readNumber(poolUsage.totalSpend) ?? + ((readNumber(poolUsage.limit) ?? 0) - (readNumber(poolUsage.remaining) ?? readNumber(poolUsage.limit) ?? 0)) + const hasModelPools = [poolUsage.autoPercentUsed, poolUsage.apiPercentUsed].every( + (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 + ) && (poolUsage.autoPercentUsed > 0 || poolUsage.apiPercentUsed > 0 || spent === 0) + // Enterprise and some Team request-based accounts can return no planUsage // or a planUsage object without limit from the Connect API. - const needsRequestBasedFallback = usage.enabled !== false && (!hasPlanUsage || planUsageLimitMissing) && ( + const needsRequestBasedFallback = usage.enabled !== false && (!hasPlanUsage || (planUsageLimitMissing && !hasModelPools)) && ( normalizedPlanName === "enterprise" || normalizedPlanName === "team" ) @@ -645,7 +652,7 @@ } const needsFallbackWithoutPlanInfo = usage.enabled !== false && - (!hasPlanUsage || planUsageLimitMissing) && + (!hasPlanUsage || (planUsageLimitMissing && !hasModelPools)) && !hasTotalUsagePercent && !normalizedPlanName && planInfoUnavailable @@ -654,7 +661,7 @@ return buildUnknownRequestBasedResult(ctx, accessToken, planName) } - if (usage.enabled !== false && planUsageLimitMissing && !hasTotalUsagePercent) { + if (usage.enabled !== false && planUsageLimitMissing && !hasTotalUsagePercent && !hasModelPools) { ctx.host.log.warn("planUsage.limit missing, attempting REST usage API fallback") try { return buildUnknownRequestBasedResult(ctx, accessToken, planName) @@ -712,7 +719,7 @@ } // Total usage (always present) - fallback primary metric - if (!hasPlanUsageLimit && !hasTotalUsagePercent) { + if (!hasPlanUsageLimit && !hasTotalUsagePercent && !hasModelPools) { throw "Total usage limit missing from API response." } const planUsed = hasPlanUsageLimit @@ -738,11 +745,19 @@ const su = usage.spendLimitUsage const isTeamAccount = ( normalizedPlanName === "team" || - (su && su.limitType === "team") || + (su && typeof su.limitType === "string" && su.limitType.toLowerCase() === "team") || (su && typeof su.pooledLimit === "number" && su.pooledLimit > 0) ) - if (isTeamAccount) { + if (isTeamAccount && hasModelPools) { + if (hasTotalUsagePercent) { + lines.push(ctx.line.progress({ + label: "Total usage", used: pu.totalPercentUsed, limit: 100, + format: { kind: "percent" }, resetsAt: ctx.util.toIso(usage.billingCycleEnd), + periodDurationMs: billingPeriodMs, + })) + } + } else if (isTeamAccount) { if (!hasPlanUsageLimit) { ctx.host.log.warn("team-inferred account missing planUsage.limit, attempting REST usage API fallback") return buildUnknownRequestBasedResult(ctx, accessToken, planName) diff --git a/plugins/cursor/plugin.team-pools.test.js b/plugins/cursor/plugin.team-pools.test.js new file mode 100644 index 0000000..ad624b0 --- /dev/null +++ b/plugins/cursor/plugin.team-pools.test.js @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() +}) + +async function probe(planUsage, planName = "Team", spendLimitUsage = { limitType: "team" }) { + const ctx = makeCtx() + ctx.host.keychain.readGenericPassword.mockImplementation((service) => + service === "cursor-access-token" ? "access-token" : null) + ctx.host.http.request.mockImplementation(({ url }) => { + if (url.includes("GetCurrentPeriodUsage")) return { + status: 200, bodyText: JSON.stringify({ enabled: true, planUsage, spendLimitUsage }), + } + if (url.includes("GetPlanInfo")) return { + status: 200, bodyText: JSON.stringify({ planInfo: { planName } }), + } + return { status: 200, bodyText: "{}" } + }) + await import("./plugin.js") + return { result: globalThis.__openusage_plugin.probe(ctx), ctx } +} + +const total = (result) => result.lines.find((line) => line.label === "Total usage") + +describe("Cursor structured team pools", () => { + it("prefers reported total percent over the old $20 allowance", async () => { + const { result } = await probe({ limit: 2000, totalSpend: 1022, + autoPercentUsed: 2.6476, apiPercentUsed: 11.65, totalPercentUsed: 4.088 }) + expect(total(result)).toMatchObject({ used: 4.088, limit: 100, format: { kind: "percent" } }) + expect(result.lines.find((line) => line.label === "API usage").used).toBe(11.65) + }) + + it("does not invent a total from legacy dollars when pools have no total", async () => { + const { result } = await probe({ limit: 2000, totalSpend: 666, + autoPercentUsed: 0.37, apiPercentUsed: 2.025 }) + expect(total(result)).toBeUndefined() + expect(result.lines.find((line) => line.label === "Auto usage").used).toBe(0.37) + }) + + it.each(["Team", "Enterprise", "", " Team "])("keeps pools without a limit for %s", async (name) => { + const { result, ctx } = await probe({ autoPercentUsed: 0, apiPercentUsed: 0 }, name) + expect(total(result)).toBeUndefined() + expect(result.lines.filter((line) => ["Auto usage", "API usage"].includes(line.label))).toHaveLength(2) + expect(ctx.host.http.request.mock.calls.some(([opts]) => opts.url.endsWith("/api/usage"))).toBe(false) + }) + + it.each([ + { autoPercentUsed: 0, apiPercentUsed: 0, totalPercentUsed: 0 }, + { autoPercentUsed: 1 }, + { autoPercentUsed: -1, apiPercentUsed: 2 }, + { autoPercentUsed: true, apiPercentUsed: 2 }, + ])("preserves legacy dollar meters for placeholder or invalid pools %j", async (pools) => { + const { result } = await probe({ limit: 500000, totalSpend: 234500, ...pools }) + expect(total(result)).toMatchObject({ used: 2345, limit: 5000, format: { kind: "dollars" } }) + }) + + it("keeps credits and on-demand usage with structured pools", async () => { + const { result } = await probe({ autoPercentUsed: 0, apiPercentUsed: 12 }, "Team", { + individualLimit: 5000, individualRemaining: 4000, + }) + expect(result.lines.find((line) => line.label === "On-demand")).toMatchObject({ used: 10, limit: 50 }) + }) +}) diff --git a/plugins/opencode-go/plugin.js b/plugins/opencode-go/plugin.js index fcda53d..7f0405c 100644 --- a/plugins/opencode-go/plugin.js +++ b/plugins/opencode-go/plugin.js @@ -49,18 +49,25 @@ return typeof type === "string" ? type.trim() : ""; } - function buildLine(ctx, raw, window) { + function buildLine(ctx, raw, window, capturedAt) { const percent = raw && typeof raw === "object" ? readNumber(raw.percent) : null; if (percent === null) { ctx.host.log.error("usage response missing percent for " + window.key); throw PARSE_FAILED; } + let resetsAt = typeof raw.resetsAt === "string" ? ctx.util.toIso(raw.resetsAt) : null; + // An untouched rolling window reports now + 5h. A real sub-1% session rounds to 0, + // but keeps its earlier reset. Compare at capture time, preferably on the server clock. + if (window.key === "rolling" && percent <= 0 && resetsAt && + Math.abs(Date.parse(resetsAt) - capturedAt - window.periodMs) <= 2000) { + resetsAt = null; + } return ctx.line.progress({ label: window.label, used: Math.max(0, Math.min(100, percent)), limit: 100, format: { kind: "percent" }, - resetsAt: typeof raw.resetsAt === "string" ? ctx.util.toIso(raw.resetsAt) : null, + resetsAt, periodDurationMs: window.periodMs, }); } @@ -106,9 +113,13 @@ throw PARSE_FAILED; } + const headers = result.resp.headers || {}; + const dateHeader = Object.keys(headers).find((key) => key.toLowerCase() === "date"); + const serverTime = dateHeader ? Date.parse(headers[dateHeader]) : NaN; + const capturedAt = Number.isFinite(serverTime) ? serverTime : Date.now(); return { plan: "Go", - lines: WINDOWS.map((window) => buildLine(ctx, usage[window.key], window)), + lines: WINDOWS.map((window) => buildLine(ctx, usage[window.key], window, capturedAt)), }; } diff --git a/plugins/opencode-go/plugin.reset.test.js b/plugins/opencode-go/plugin.reset.test.js new file mode 100644 index 0000000..291f3e7 --- /dev/null +++ b/plugins/opencode-go/plugin.reset.test.js @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-10-07T08:00:00Z")) +}) +afterEach(() => vi.useRealTimers()) + +async function session(reset, headers = {}, percent = 0) { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.local/share/opencode/auth.json", JSON.stringify({ "opencode-go": { key: "key" } })) + ctx.host.http.request.mockReturnValue({ status: 200, headers, bodyText: JSON.stringify({ usage: { + rolling: { percent, resetsAt: reset }, + weekly: { percent: 0, resetsAt: "2026-10-14T08:00:00Z" }, + monthly: { percent: 0, resetsAt: "2026-11-06T08:00:00Z" }, + } }) }) + await import("./plugin.js") + return globalThis.__openusage_plugin.probe(ctx).lines +} + +it("keeps the reset of an active session rounded below 1%", async () => { + expect((await session("2026-10-07T12:30:00Z"))[0].resetsAt).toBe("2026-10-07T12:30:00.000Z") +}) +it.each([0, -2000, 2000])("drops only a zero-usage rolling placeholder at tolerance %i", async (delta) => { + const lines = await session(new Date(Date.parse("2026-10-07T13:00:00Z") + delta).toISOString()) + expect(lines[0].resetsAt).toBeUndefined() + expect(lines[1].resetsAt).toBeTruthy() + expect(lines[2].resetsAt).toBeTruthy() +}) +it("keeps real resets outside the two second tolerance", async () => { + expect((await session("2026-10-07T12:59:57Z"))[0].resetsAt).toBeTruthy() +}) +it("uses the HTTP Date clock when the local clock is skewed", async () => { + expect((await session("2026-10-07T14:00:00Z", { Date: "Wed, 07 Oct 2026 09:00:00 GMT" }))[0].resetsAt).toBeUndefined() +}) +it("falls back to capture time for an invalid Date header", async () => { + expect((await session("2026-10-07T13:00:00Z", { date: "invalid" }))[0].resetsAt).toBeUndefined() +}) +it("keeps a full-period reset with positive usage", async () => { + expect((await session("2026-10-07T13:00:00Z", {}, 0.1))[0].resetsAt).toBeTruthy() +}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 2dd2ba2..05117ec 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; /// Create a `Command` that won't flash a console window on Windows. @@ -1818,7 +1818,7 @@ const CCUSAGE_POLL_INTERVAL_MS: u64 = 100; /// "probe timed out". const CCUSAGE_PROBE_RESERVE: Duration = Duration::from_secs(5); -#[derive(Default, serde::Deserialize)] +#[derive(Default, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] struct CcusageQueryOpts { provider: Option, @@ -1826,6 +1826,7 @@ struct CcusageQueryOpts { until: Option, home_path: Option, claude_path: Option, + account_id: Option, } #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] @@ -2672,6 +2673,34 @@ where serde_json::json!({ "status": "runner_failed" }).to_string() } +type CodexScan = Arc>; +static CODEX_HISTORY_SCANS: OnceLock>> = OnceLock::new(); + +fn retained_codex_history(opts: CcusageQueryOpts, pid: String, deadline: ProbeDeadline) -> String { + let Some(wait) = deadline.clamp_duration_reserving(Duration::from_millis(150), CCUSAGE_PROBE_RESERVE) else { + return serde_json::json!({ "status": "runner_failed" }).to_string(); + }; + // One worker per home. Scope also includes account identity and date bounds, so a new login + // or midnight rollover cannot receive a completed scan from the preceding scope. + let home = ccusage_home_override(&opts, CcusageProvider::Codex).map(expand_path) + .map(|path| path.to_string_lossy().into_owned()).unwrap_or_default(); + let scope = serde_json::to_string(&opts).expect("serialize ccusage scope"); + let scans = CODEX_HISTORY_SCANS.get_or_init(|| Mutex::new(HashMap::new())); + let scan = scans.lock().unwrap_or_else(|err| err.into_inner()) + .entry(home).or_default().clone(); + let result = scan.lock().unwrap_or_else(|err| err.into_inner()).value(scope, wait, move || { + let Some(_guard) = CcusageQueryGuard::acquire(CcusageProvider::Codex, ccusage_home_override(&opts, CcusageProvider::Codex)) else { + return serde_json::json!({ "status": "runner_failed" }).to_string(); + }; + let scan_deadline = ProbeDeadline::at(Instant::now() + Duration::from_secs(CCUSAGE_TIMEOUT_SECS + 5)); + run_ccusage_query_with_runners(collect_ccusage_runners(), &opts, CcusageProvider::Codex, &pid, + |kind, program, opts, provider, plugin_id| { + run_ccusage_with_runner_deadline(kind, program, opts, provider, plugin_id, scan_deadline) + }) + }); + result.unwrap_or_else(|| serde_json::json!({ "status": "pending" }).to_string()) +} + fn inject_ccusage<'js>( ctx: &Ctx<'js>, host: &Object<'js>, @@ -2698,6 +2727,9 @@ fn inject_ccusage<'js>( } }; let provider = resolve_ccusage_provider(&opts, &pid); + if provider == CcusageProvider::Codex { + return Ok(retained_codex_history(opts, pid.clone(), deadline)); + } let Some(_active_query) = CcusageQueryGuard::acquire(provider, ccusage_home_override(&opts, provider)) else { @@ -4099,6 +4131,17 @@ mod tests { ); } + #[test] + fn redact_body_covers_ported_provider_fields() { + let body = r#"{"accountId":"work-account-identity","tokens":{"access_token":"access-secret-value","refresh_token":"refresh-secret-value","id_token":"identity-secret-value"},"planUsage":{"autoPercentUsed":2.6,"apiPercentUsed":11.65,"totalPercentUsed":4.088},"usage":{"rolling":{"percent":0,"resetsAt":"2026-10-07T12:30:00Z"}}}"#; + let redacted = redact_body(body); + for secret in ["work-account-identity", "access-secret-value", "refresh-secret-value", "identity-secret-value"] { + assert!(!redacted.contains(secret)); + } + assert!(redacted.contains(r#""autoPercentUsed":2.6"#)); + assert!(redacted.contains(r#""resetsAt":"2026-10-07T12:30:00Z"#)); + } + #[test] fn redact_url_preserves_antigravity_quota_endpoints() { // [antigravity] new Cloud Code quota endpoints and local LS RPC carry no @@ -4156,6 +4199,7 @@ mod tests { until: Some("20260131".to_string()), home_path: None, claude_path: None, + account_id: None, }; let expected_ccusage_package = ccusage_package_spec(); assert_eq!(expected_ccusage_package, "ccusage@20.0.14"); @@ -4305,6 +4349,7 @@ mod tests { until: Some("20260131".to_string()), home_path: None, claude_path: None, + account_id: None, }; let expected_ccusage_package = ccusage_package_spec(); let expected_npm_exec_package = format!("--package={expected_ccusage_package}"); @@ -4399,6 +4444,7 @@ mod tests { until: Some("20260131".to_string()), home_path: None, claude_path: None, + account_id: None, }; let claude = ccusage_runner_args( @@ -4709,6 +4755,7 @@ mod tests { until: None, home_path: None, claude_path: None, + account_id: None, }; assert_eq!( resolve_ccusage_provider(&opts_explicit, "claude"), @@ -4738,6 +4785,7 @@ mod tests { until: None, home_path: Some("/tmp/shared-home".to_string()), claude_path: Some("/tmp/claude-home".to_string()), + account_id: None, }; assert_eq!( ccusage_home_override(&with_home, CcusageProvider::Claude), @@ -4754,6 +4802,7 @@ mod tests { until: None, home_path: None, claude_path: Some("/tmp/legacy-claude-path".to_string()), + account_id: None, }; assert_eq!( ccusage_home_override(&claude_compat, CcusageProvider::Claude), @@ -4918,6 +4967,7 @@ esac until: None, home_path: None, claude_path: None, + account_id: None, }; let result = run_ccusage_with_runner( CcusageRunnerKind::Bunx, diff --git a/src-tauri/src/plugin_engine/mod.rs b/src-tauri/src/plugin_engine/mod.rs index 8b8c9a6..6f31575 100644 --- a/src-tauri/src/plugin_engine/mod.rs +++ b/src-tauri/src/plugin_engine/mod.rs @@ -1,5 +1,6 @@ pub mod host_api; mod http_gate; +mod retained_scan; pub mod manifest; pub mod runtime; mod sqlite; diff --git a/src-tauri/src/plugin_engine/retained_scan.rs b/src-tauri/src/plugin_engine/retained_scan.rs new file mode 100644 index 0000000..6b9beda --- /dev/null +++ b/src-tauri/src/plugin_engine/retained_scan.rs @@ -0,0 +1,101 @@ +use std::sync::mpsc::{self, Receiver}; +use std::time::Duration; + +/// Keep one scan alive across probes, with a short caller wait. Scope changes invalidate +/// retained data immediately; the old worker finishes before another scan starts. +#[derive(Default)] +pub(super) struct RetainedScan { + scope: String, + running: Option<(String, Receiver)>, + retained: Option, +} + +impl RetainedScan { + pub(super) fn value( + &mut self, + scope: String, + wait: Duration, + operation: impl FnOnce() -> String + Send + 'static, + ) -> Option { + if self.scope != scope { + self.scope = scope.clone(); + self.retained = None; + } + if self.running.is_none() { + let (sender, receiver) = mpsc::channel(); + self.running = Some((scope, receiver)); + std::thread::spawn(move || { + let _ = sender.send(operation()); + }); + } + let (running_scope, receiver) = self.running.as_ref().unwrap(); + match receiver.recv_timeout(wait) { + Ok(value) => { + if *running_scope == self.scope { + self.retained = Some(value); + } + self.running = None; + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + eprintln!("Codex history worker disconnected before returning a result"); + self.retained = None; + self.running = None; + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + self.retained.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, atomic::{AtomicUsize, Ordering}}; + + #[test] + fn slow_scan_returns_without_waiting_and_is_consumed_on_next_probe() { + let mut scan = RetainedScan::default(); + let (release, blocked) = mpsc::channel(); + let (done, finished) = mpsc::channel(); + let calls = Arc::new(AtomicUsize::new(0)); + let worker_calls = calls.clone(); + assert_eq!(scan.value("a".into(), Duration::ZERO, move || { + worker_calls.fetch_add(1, Ordering::SeqCst); + blocked.recv().unwrap(); + done.send(()).unwrap(); + "history-a".into() + }), None); + assert_eq!(scan.value("a".into(), Duration::ZERO, || panic!("overlap")), None); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!(scan.value("a".into(), Duration::from_secs(2), || panic!("overlap")), Some("history-a".into())); + assert_eq!(calls.load(Ordering::SeqCst), 1); + let (_release, blocked) = mpsc::channel::<()>(); + assert_eq!(scan.value("a".into(), Duration::ZERO, move || { + let _ = blocked.recv(); + "updated".into() + }), Some("history-a".into())); + } + + #[test] + fn account_or_date_change_drops_old_result_without_starting_overlapping_scan() { + let mut scan = RetainedScan::default(); + let (release, blocked) = mpsc::channel(); + scan.value("account-a/day-1".into(), Duration::ZERO, move || { + blocked.recv().unwrap(); + "old-account".into() + }); + assert_eq!(scan.value("account-b/day-2".into(), Duration::ZERO, || panic!("overlap")), None); + release.send(()).unwrap(); + assert_eq!(scan.value("account-b/day-2".into(), Duration::from_secs(2), || panic!("overlap")), None); + assert_eq!(scan.value("account-b/day-2".into(), Duration::from_secs(2), || "new-account".into()), Some("new-account".into())); + } + + #[test] + fn independent_homes_do_not_share_results() { + let mut first = RetainedScan::default(); + let mut second = RetainedScan::default(); + assert_eq!(first.value("a".into(), Duration::from_secs(2), || "home-a".into()), Some("home-a".into())); + assert_eq!(second.value("a".into(), Duration::from_secs(2), || "home-b".into()), Some("home-b".into())); + } +} From de8bb0d24feb10f02cb2447f1f925f12312ee85a Mon Sep 17 00:00:00 2001 From: PowerUserZ <55040543+PowerUserZ@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:13:01 +0300 Subject: [PATCH 2/2] test: document upstream scope and cover Codex plan identifiers --- docs/plugins/api.md | 3 +- docs/upstream-0.7.14-port.md | 27 ++++++++++++++++ plugins/codex/plugin.accounts.test.js | 13 ++++++++ src-tauri/src/plugin_engine/host_api.rs | 41 ++++++++++++++++--------- 4 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 docs/upstream-0.7.14-port.md diff --git a/docs/plugins/api.md b/docs/plugins/api.md index ac7526a..09a2aa7 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -529,13 +529,14 @@ host.ccusage.query(opts: { provider?: "claude" | "codex", // Optional; defaults to plugin id, then "claude" since?: string, // Start date (YYYYMMDD or YYYY-MM-DD) until?: string, // End date (YYYYMMDD or YYYY-MM-DD) - accountId?: string // Codex retained-history scope; not sent to the CLI + accountId?: string, // Codex retained-history scope; not sent to the CLI homePath?: string, // Provider home override (CLAUDE_CONFIG_DIR or CODEX_HOME) claudePath?: string, // Legacy Claude-only override (deprecated; use homePath) }): | { status: "ok", data: { daily: DailyUsage[] } } | { status: "no_runner" } | { status: "runner_failed" } + | { status: "pending" } // Codex only: quota can render before local history finishes ``` Queries local token usage via provider-focused ccusage commands: diff --git a/docs/upstream-0.7.14-port.md b/docs/upstream-0.7.14-port.md new file mode 100644 index 0000000..c11e816 --- /dev/null +++ b/docs/upstream-0.7.14-port.md @@ -0,0 +1,27 @@ +# OpenUsage 0.7.14 provider port + +Compared OpenTokenUsage main `d5feca2bf6a062e0fd2faedef2c1fdd9987a4f6d` (v0.7.3) +with upstream tags v0.7.13 and v0.7.14 (`cb21465e3d88d8b2075ca48d38c936b82177fb78`). +[Upstream release](https://github.com/robinebers/openusage/releases/tag/v0.7.14) +was published on October 6, 2026. The fork's v0.7.0 changelog entry records provider ports through +0.7.13; this comparison checks the code present in v0.7.3 rather than assuming those ports are complete. + +| Upstream change | Windows/Tauri decision | +| --- | --- | +| Refresh and save independent Codex home tokens (#1322) | Already supported by file-backed auth, guarded reload and account environment bindings. Add a regression for Windows paths, token rotation and preserving the default login. | +| Codex multi-account local spend (#1349) | Bind local history to the selected auth file's parent, including `~/.config/codex`. Each configured account scans its own home. Swift's discovered-home/account assembly does not match the fork's explicit account cards. | +| Slow Codex history (#1338) | Keep one host worker per home across probes. Wait at most 150 ms; scope retained results by account identity and query dates. A completed result is available on later refreshes; pending history leaves live quota intact. WSL history stays disabled. | +| Codex plan names (#1332) | Map `prolite`, `pro`, `promax` to Pro 100, Pro 200, Pro 500. Keep other plan names. Swift header layout changes are excluded. | +| Cursor structured team pools (#1337) | Prefer two valid model pools and the reported total percent. Omit absent totals instead of computing them from legacy dollars. Preserve legacy meters for zero placeholders beside positive spend or incomplete/invalid pools. Keep existing labels and translations. | +| OpenCode reset below 1% | The React card already displays zero-usage resets. Preserve active session resets; drop only untouched rolling placeholders within two seconds of a full period at capture time, using HTTP Date when valid. Keep weekly/monthly resets. | +| Report an Issue (#1343) | Existing Help navigation already opens the fork's issues page. | +| macOS panel anchoring/navigation (#1345, #1346) | Excluded: native Swift panel behavior does not apply to the Windows panel. | +| PostHog 3.85.3 (#1347) | Excluded: no applicable analytics dependency in this fork. | + +No plugin identities, metric labels, translations, logos or UI layout are changed. Existing brand +colors and `currentColor` logos remain valid. The new history `accountId` is local scope metadata, +not a CLI argument or network parameter. The host's existing redaction covers it and OAuth tokens; +a regression checks nested tokens while preserving pool percentages and reset timestamps. + +CI now runs before a PR on `port/**` branches, and tests the native host on Windows as well as +running the frontend type check, build and full Vitest suite on Linux. diff --git a/plugins/codex/plugin.accounts.test.js b/plugins/codex/plugin.accounts.test.js index c649edf..bfa5dab 100644 --- a/plugins/codex/plugin.accounts.test.js +++ b/plugins/codex/plugin.accounts.test.js @@ -42,3 +42,16 @@ it.each(["C:/Users/test/extra-codex", null])("refreshes and scans the selected a expect(result.lines.some((line) => line.label === "Today")).toBe(false) expect(result.plan).toBe("Pro 500") }) + +it.each([ + ["prolite", "Pro 100"], [" PRO ", "Pro 200"], [" ProMax ", "Pro 500"], + ["self_serve_business_prolite", "Business Premium"], ["plus", "Plus"], + ["future_plan", "Future_plan"], ["", null], [500, null], +])("maps upstream plan %s to %s", async (raw, expected) => { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ tokens: { access_token: "token" } })) + ctx.host.http.request.mockReturnValue({ status: 200, headers: {}, bodyText: JSON.stringify({ plan_type: raw }) }) + ctx.host.ccusage.query.mockReturnValue({ status: "pending" }) + await import("./plugin.js") + expect(globalThis.__openusage_plugin.probe(ctx).plan).toBe(expected) +}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 05117ec..ed32dcc 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -2677,27 +2677,38 @@ type CodexScan = Arc>; static CODEX_HISTORY_SCANS: OnceLock>> = OnceLock::new(); fn retained_codex_history(opts: CcusageQueryOpts, pid: String, deadline: ProbeDeadline) -> String { - let Some(wait) = deadline.clamp_duration_reserving(Duration::from_millis(150), CCUSAGE_PROBE_RESERVE) else { + let Some(wait) = deadline.clamp_duration_reserving( + Duration::from_millis(150), CCUSAGE_PROBE_RESERVE, + ) else { return serde_json::json!({ "status": "runner_failed" }).to_string(); }; - // One worker per home. Scope also includes account identity and date bounds, so a new login - // or midnight rollover cannot receive a completed scan from the preceding scope. - let home = ccusage_home_override(&opts, CcusageProvider::Codex).map(expand_path) - .map(|path| path.to_string_lossy().into_owned()).unwrap_or_default(); + // One worker per home. Account identity and date bounds prevent a new login or midnight + // rollover from receiving a completed scan from the preceding scope. + let home = ccusage_home_override(&opts, CcusageProvider::Codex) + .map(expand_path).unwrap_or_default(); let scope = serde_json::to_string(&opts).expect("serialize ccusage scope"); let scans = CODEX_HISTORY_SCANS.get_or_init(|| Mutex::new(HashMap::new())); let scan = scans.lock().unwrap_or_else(|err| err.into_inner()) .entry(home).or_default().clone(); - let result = scan.lock().unwrap_or_else(|err| err.into_inner()).value(scope, wait, move || { - let Some(_guard) = CcusageQueryGuard::acquire(CcusageProvider::Codex, ccusage_home_override(&opts, CcusageProvider::Codex)) else { - return serde_json::json!({ "status": "runner_failed" }).to_string(); - }; - let scan_deadline = ProbeDeadline::at(Instant::now() + Duration::from_secs(CCUSAGE_TIMEOUT_SECS + 5)); - run_ccusage_query_with_runners(collect_ccusage_runners(), &opts, CcusageProvider::Codex, &pid, - |kind, program, opts, provider, plugin_id| { - run_ccusage_with_runner_deadline(kind, program, opts, provider, plugin_id, scan_deadline) - }) - }); + let result = scan.lock().unwrap_or_else(|err| err.into_inner()) + .value(scope, wait, move || { + let Some(_guard) = CcusageQueryGuard::acquire( + CcusageProvider::Codex, ccusage_home_override(&opts, CcusageProvider::Codex), + ) else { + return serde_json::json!({ "status": "runner_failed" }).to_string(); + }; + let scan_deadline = ProbeDeadline::at( + Instant::now() + Duration::from_secs(CCUSAGE_TIMEOUT_SECS + 5), + ); + run_ccusage_query_with_runners( + collect_ccusage_runners(), &opts, CcusageProvider::Codex, &pid, + |kind, program, opts, provider, plugin_id| { + run_ccusage_with_runner_deadline( + kind, program, opts, provider, plugin_id, scan_deadline, + ) + }, + ) + }); result.unwrap_or_else(|| serde_json::json!({ "status": "pending" }).to_string()) }