Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/agents-usage/src/collectors/cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe("parseCursorUsage", () => {
expect(snap.windows).toHaveLength(2); // on-demand disabled → no extra window

const auto = snap.windows.find((w) => w.id === "cursor-auto")!;
expect(auto.label).toBe("Cursor Models");
expect(auto.usedPercent).toBe(34);
expect(auto.resetsAt).toBe(1_719_600_000_000);
const api = snap.windows.find((w) => w.id === "cursor-api")!;
Expand Down
7 changes: 4 additions & 3 deletions packages/agents-usage/src/collectors/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import type { UsageSnapshot, UsageWindow } from "../types";
* Schema (per codexbar) of GET /api/usage-summary → individualUsage.plan:
* { used (cents), limit (cents), breakdown { included, bonus, total },
* totalPercentUsed, autoPercentUsed, apiPercentUsed } + billingCycleEnd +
* membershipType. Surfaced as Auto and API windows. API dollars use real
* spend over the vendor plan limit; the bar uses apiPercentUsed separately.
* membershipType. Surfaced as Cursor Models (`autoPercentUsed`) and API
* windows. API dollars use real spend over the vendor plan limit; the bar
* uses apiPercentUsed separately.
*/

export const CURSOR_USAGE_ENDPOINT = "https://cursor.com/api/usage-summary";
Expand Down Expand Up @@ -134,7 +135,7 @@ export function parseCursorUsage(
if (autoPercent !== undefined) {
windows.push({
id: "cursor-auto",
label: "Auto + Composer",
label: "Cursor Models",
usedPercent: autoPercent,
unit: "percent",
...withReset,
Expand Down
4 changes: 4 additions & 0 deletions packages/agents-usage/src/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ describe("usageWindowDisplayLabel", () => {
expect(usageWindowDisplayLabel({ id: "weekly-fable", label: "x", usedPercent: 0 })).toBe(
"Weekly · Fable",
);
expect(usageWindowDisplayLabel({ id: "cursor-auto", label: "x", usedPercent: 0 })).toBe(
"Cursor Models",
);
expect(usageWindowDisplayLabel({ id: "cursor-api", label: "x", usedPercent: 0 })).toBe("API");
});

it("honors a collector's custom monthly label (e.g. z.ai 'MCP')", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/agents-usage/src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const KNOWN_WINDOW_LABELS: Record<string, string> = {
"weekly-fable": "Weekly · Fable",
monthly: "Monthly",
"extra-usage": "Extra usage",
"cursor-auto": "Auto + Composer",
"cursor-auto": "Cursor Models",
"cursor-api": "API",
};

Expand Down
15 changes: 8 additions & 7 deletions src/renderer/components/providers/ProviderUsageCircle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import { usageToneColor } from "./usageTone";
* split (Claude/Codex: a 5h session plus a weekly window) render TWO concentric
* rings — like a clock's hands, the faster session is the OUTER ring and the
* slower weekly/monthly is the INNER ring, so a full inner ring flags "weekly
* almost gone" even when the session is idle. Cursor renders Auto + Composer
* outside and API inside. Antigravity shows one of its two quota groups (Gemini
* vs Claude+GPT), selected via `ringGroup`. Every other provider renders a
* SINGLE ring on its most-constrained window — an at-a-glance "closest to the
* limit" read. Which windows map to which ring is a per-provider descriptor in
* `usageProviders.ts` (see {@link pickUsageRings}). Each ring is colored by its
* own tone. Reuses the ring math from ThreadContextIndicator.
* almost gone" even when the session is idle. Cursor renders Cursor Models
* outside and API inside. Antigravity shows one of its two quota groups
* (Gemini vs Claude+GPT), selected via `ringGroup`. Every other provider
* renders a SINGLE ring on its most-constrained window — an at-a-glance
* "closest to the limit" read. Which windows map to which ring is a
* per-provider descriptor in `usageProviders.ts` (see {@link pickUsageRings}).
* Each ring is colored by its own tone. Reuses the ring math from
* ThreadContextIndicator.
*/

function Ring(props: { window: UsageWindow; radius: number }) {
Expand Down
42 changes: 36 additions & 6 deletions src/renderer/state/agentStatusesStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,7 @@ beforeEach(reset);
describe("persisted agent status cache", () => {
it("invalidates v10 statuses whose terminal auth methods lack baseSpawnEnv-derived env", async () => {
const options = useAgentStatusesStore.persist.getOptions();
expect(options.version).toBe(14);
// Mirrors supervisor STATUS_CACHE_VERSION=16: a persisted antigravity
// status from before the derivation would build the `agy` login command
// without `AGY_CLI_DISABLE_AUTO_UPDATE`.
expect(options.version).toBe(15);
const staleLogin = makeStatus({
kind: "antigravity",
label: "Antigravity",
Expand All @@ -75,9 +72,42 @@ describe("persisted agent status cache", () => {
});
});

it("invalidates v14 statuses that grouped Cursor Grok under Other models", async () => {
const options = useAgentStatusesStore.persist.getOptions();
expect(options.version).toBe(15);
const staleCursor = makeStatus({
kind: "cursor",
label: "Cursor",
capabilities: {
...makeStatus().capabilities,
models: [{ id: "grok-4.6", label: "Cursor Grok 4.6" }],
subProviders: [
{ id: "cursor", label: "Cursor Models" },
{ id: "other", label: "Other models" },
],
modelSubProvider: { "grok-4.6": "other" },
},
});
const migrated = await options.migrate!(
{
agentStatuses: [staleCursor],
wslAgentStatuses: [],
windowsLoaded: true,
wslLoaded: true,
},
14,
);
expect(migrated).toMatchObject({
agentStatuses: [],
wslAgentStatuses: [],
windowsLoaded: false,
wslLoaded: false,
});
});

it("invalidates v8 statuses cached before successful ACP sessions established auth", async () => {
const options = useAgentStatusesStore.persist.getOptions();
expect(options.version).toBe(14);
expect(options.version).toBe(15);
const staleAcp = makeStatus({
kind: "acp-generic:example",
label: "Example ACP",
Expand All @@ -104,7 +134,7 @@ describe("persisted agent status cache", () => {

it("invalidates v6 statuses produced without the Grok login-shell environment", async () => {
const options = useAgentStatusesStore.persist.getOptions();
expect(options.version).toBe(14);
expect(options.version).toBe(15);
expect(options.migrate).toBeTypeOf("function");

const grok = makeStatus({
Expand Down
9 changes: 3 additions & 6 deletions src/renderer/state/agentStatusesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,9 @@ export const useAgentStatusesStore = create<AgentStatusesStore>()(
}),
{
name: "poracode-agent-statuses-v1",
version: 14,
// v14 mirrors the supervisor STATUS_CACHE_VERSION=17 bump: Cursor SDK
// runtime variants now carry their own account email. v13 covered
// Cursor profiles becoming SDK-only. This mirrors the supervisor cache
// invalidation, which only covers the supervisor's on-disk cache, not
// this localStorage copy.
version: 15,
// v15 mirrors supervisor STATUS_CACHE_VERSION=18 so this localStorage
// copy does not keep Cursor Grok grouped under Other models.
migrate: (persisted) => {
const prev = (persisted ?? {}) as Partial<AgentStatusesStore>;
return {
Expand Down
51 changes: 51 additions & 0 deletions src/supervisor/agents/cursor/modelGrouping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { cursorModelGroup, cursorModelGrouping } from "./modelGrouping";

describe("cursorModelGroup", () => {
it.each([
["auto", "cursor"],
["default", "cursor"],
["auto-smart", "cursor"],
["auto-smart[optimize_for=intelligence]", "cursor"],
["composer-2.5", "cursor"],
["composer-2.5-fast", "cursor"],
["composer-2.5[effort=high,fast=true]", "cursor"],
["grok-4.6", "cursor"],
["grok-4.5", "cursor"],
["grok-4.6-fast", "cursor"],
["grok-4.6[effort=high,fast=true]", "cursor"],
["composer-3", "cursor"],
["gpt-5.6-sol", "other"],
["gpt-5.6-luna", "other"],
["claude-opus-5", "other"],
["opus-5", "other"],
["sonnet-5", "other"],
["gemini-3.7-flash", "other"],
["kimi-k3", "other"],
["glm-5.1", "other"],
] as const)("%s → %s", (modelId, group) => {
expect(cursorModelGroup(modelId)).toBe(group);
});
});

describe("cursorModelGrouping", () => {
it("keeps unknown Cursor ids in Cursor Models and third-party vendors in Other", () => {
expect(
cursorModelGrouping([
{ id: "composer-2.5", label: "Composer 2.5" },
{ id: "grok-4.6", label: "Cursor Grok 4.6" },
{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" },
]),
).toEqual({
subProviders: [
{ id: "cursor", label: "Cursor Models" },
{ id: "other", label: "Other models" },
],
modelSubProvider: {
"composer-2.5": "cursor",
"grok-4.6": "cursor",
"gpt-5.6-luna": "other",
},
});
});
});
11 changes: 5 additions & 6 deletions src/supervisor/agents/cursor/modelGrouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@ import type { AgentCapability, LabeledOption } from "@/shared/contracts";
import { parseCursorModelId } from "@/shared/cursorModelId";

/**
* Sub-provider grouping shared by every Cursor model picker projection (CLI
* terminal models, ACP models, and the SDK catalog). Kept in its own module so
* the SDK projection doesn't have to import the detection module's process
* probes just to classify a model id.
* Sub-provider grouping for CLI, ACP, and SDK picker projections. Cursor's
* catalog has no usage-pool field: known third-party vendor prefixes are Other
* models; remaining ids are the first-party Cursor Models pool.
*/

export const CURSOR_MODEL_GROUP_ID = "cursor";
export const OTHER_MODEL_GROUP_ID = "other";

const CURSOR_FIRST_PARTY_MODEL_RE = /^(?:default$|auto(?:-smart)?|composer(?:-|$))/iu;
const CURSOR_THIRD_PARTY_MODEL_RE = /^(?:gpt|claude|gemini|kimi|glm|opus|sonnet|haiku)(?:-|$)/iu;

export function cursorModelGroup(modelId: string): "cursor" | "other" {
const baseId = parseCursorModelId(modelId).baseId;
return CURSOR_FIRST_PARTY_MODEL_RE.test(baseId) ? CURSOR_MODEL_GROUP_ID : OTHER_MODEL_GROUP_ID;
return CURSOR_THIRD_PARTY_MODEL_RE.test(baseId) ? OTHER_MODEL_GROUP_ID : CURSOR_MODEL_GROUP_ID;
}

export function cursorModelGrouping(
Expand Down
20 changes: 20 additions & 0 deletions src/supervisor/agents/cursor/sdkModels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ describe("cursorSdkCapabilitiesFromModels", () => {

expect(capabilities.models.map(({ id }) => id)).toEqual(["default", "opus-5", "composer-2.5"]);
expect(capabilities.modelSubProvider?.default).toBe("cursor");
expect(capabilities.modelSubProvider?.["composer-2.5"]).toBe("cursor");
expect(capabilities.modelSubProvider?.["opus-5"]).toBe("other");
expect(
buildCursorSdkModelSelection({ model: "auto" }, [
{ id: "opus-5", displayName: "Opus 5" },
Expand All @@ -263,6 +265,24 @@ describe("cursorSdkCapabilitiesFromModels", () => {
).toEqual({ id: "default" });
});

it("groups first-party Grok with Cursor Models, not the API Other Models pool", () => {
const capabilities = cursorSdkCapabilitiesFromModels([
{ id: "composer-2.5", displayName: "Composer 2.5" },
{ id: "grok-4.6", displayName: "Cursor Grok 4.6" },
{ id: "grok-4.5", displayName: "Cursor Grok 4.5" },
{ id: "gpt-5.6-luna", displayName: "GPT-5.6 Luna" },
{ id: "gemini-3.7-flash", displayName: "Gemini 3.7 Flash" },
]);

expect(capabilities.modelSubProvider).toEqual({
"composer-2.5": "cursor",
"grok-4.6": "cursor",
"grok-4.5": "cursor",
"gpt-5.6-luna": "other",
"gemini-3.7-flash": "other",
});
});

it("removes the effort suffix while keeping Reasoning, Context, and Fast controls", () => {
const currentCatalog = [
{
Expand Down
40 changes: 40 additions & 0 deletions src/supervisor/runtime/agentStatusCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,46 @@ describe("agent status cache", () => {
expect(cached).toEqual({ windows: [], wsl: [], fromCache: false });
});

it("invalidates v17 caches that grouped Cursor Grok under Other models", () => {
const dataDir = makeTempDir();
process.env.PORACODE_DATA_DIR = dataDir;

const { cacheDir, statusCachePath } = resolvePoracodePaths(dataDir);
mkdirSync(cacheDir, { recursive: true });
writeFileSync(
statusCachePath,
JSON.stringify({
version: 17,
windows: [
{
kind: "cursor",
label: "Cursor",
installed: true,
authState: "authenticated",
capabilities: {
models: [{ id: "grok-4.6", label: "Cursor Grok 4.6" }],
modelSubProvider: { "grok-4.6": "other" },
},
},
],
}),
);

const runtime = makeRuntime(() => {});
const cached = (
runtime.agentStatusService as unknown as {
readCachedStatuses: (wslDistros: readonly string[]) => {
windows: AgentStatus[];
wsl: AgentStatus[];
fromCache: boolean;
};
}
).readCachedStatuses([]);

expect(STATUS_CACHE_VERSION).toBe(18);
expect(cached).toEqual({ windows: [], wsl: [], fromCache: false });
});

it("migrates stale cached settingDefs to current schema", () => {
const dataDir = makeTempDir();
process.env.PORACODE_DATA_DIR = dataDir;
Expand Down
5 changes: 4 additions & 1 deletion src/supervisor/runtime/agentStatusService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@ const execFileAsync = promisify(execFile);
* statuses that advertised profile CLI login/ACP variants must be re-probed.
* v17 adds per-runtime `providerMetadata` so Cursor SDK can show the API-key
* account email without overwriting the CLI login identity.
* v18 regroups Cursor first-party models (Grok, Composer, future Cursor ids)
* into the Cursor Models pool by denylisting known third-party vendor prefixes
* instead of allowlisting first-party families.
*/
export const STATUS_CACHE_VERSION = 17;
export const STATUS_CACHE_VERSION = 18;
const WSL_AGENT_DETECTION_TIMEOUT_MS = 60_000;
const WSL_LXSS_REGISTRY_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss";

Expand Down
28 changes: 28 additions & 0 deletions src/supervisor/runtime/usageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ afterEach(() => {
});

describe("UsageService", () => {
it("discards v2 caches that still label the first-party window Auto + Composer", async () => {
const cachePath = tempCachePath();
writeFileSync(
cachePath,
JSON.stringify({
version: 2,
snapshots: [
{
providerId: "cursor",
status: "ok",
windows: [{ id: "cursor-auto", label: "Auto + Composer", usedPercent: 34 }],
fetchedAt: NOW,
},
],
}),
);
const service = new UsageService({
emit: () => {},
cachePath,
host: makeHost({}),
localCollectors: stubLocalCollectors(),
});

const result = await service.getProviderUsage({ providerIds: ["cursor"] });
expect(result.fromCache).toBe(false);
expect(result.snapshots).toEqual([]);
});

it("refresh defaults to Claude and Codex only, emits per-provider then a terminal event", async () => {
const events: SupervisorEvent[] = [];
const service = new UsageService({
Expand Down
7 changes: 5 additions & 2 deletions src/supervisor/runtime/usageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ import { readSupervisorSharedSettings } from "./supervisorSharedSettings";
* 5 min, 2 min floor) — never a fast poll.
*/

/** Bump when the cached snapshot shape changes so stale caches are discarded. */
const USAGE_CACHE_VERSION = 2;
/**
* Bump when the cached snapshot shape changes so stale caches are discarded.
* v3 relabels Cursor's first-party window from Auto + Composer to Cursor Models.
*/
const USAGE_CACHE_VERSION = 3;
/** The full default provider set, from the package catalog (single source of truth). */
const DEFAULT_PROVIDER_IDS: readonly string[] = allUsageProviderDescriptors().map((d) => d.id);
const MIN_REFRESH_INTERVAL_MS = 2 * 60_000;
Expand Down