fix(cursor): show plan usage in Cursor agents' context details (#6353)

Cursor agents never showed plan usage cards under their context details.
ACP sessions had no usageSession(), and the Cursor usage source only ran
global discovery, so per-agent discovery always came back empty.

Cursor ACP sessions now report a usage session (provider "cursor" plus
their launch env). The Cursor usage source resolves the cursor-agent
auth.json for that env. Sessions on CURSOR_API_KEY or CURSOR_AUTH_TOKEN,
the in-memory store, or the macOS Keychain (the default there) report
no login.
This commit is contained in:
Jiawen Geng
2026-10-16 16:56:39 +02:00
committed by GitHub
parent 0d7a234e63
commit 64693f21cf
8 changed files with 209 additions and 4 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
import { fetchUsage, discover } from "./server/usage.js";
import { fetchUsage, discover, discoverSession } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "Cursor",
icon: "icon.svg",
input: inputSchema,
discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])),
discover: (scope) => (scope.kind === "global" ? discover() : discoverSession(scope)),
fetch: fetchUsage,
});
return () => {};
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { discover, fetchUsage } from "./usage.js";
import { discover, discoverSession, fetchUsage } from "./usage.js";
import type { UsageReport } from "@getpaseo/plugin/server/usage";
// node:sqlite has no @types/node@20 typings; require it with a narrow local type.
@@ -256,6 +256,78 @@ describe("cursor usage source", () => {
writeFileSync(join(directory, "state.vscdb"), "invalid database");
expect(await discover()).toEqual([]);
});
describe("session discovery", () => {
let sessionHome: string;
beforeEach(() => {
sessionHome = mkdtempSync(join(tmpdir(), "usage-session-home-"));
});
afterEach(() => {
rmSync(sessionHome, { recursive: true, force: true });
});
function cursorSession(env: Record<string, string>, provider = "cursor") {
return { kind: "session" as const, provider, env: { HOME: sessionHome, ...env } };
}
function writeAuthJson(path: string): void {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, JSON.stringify({ accessToken: "session-token" }));
}
it("reads the login cursor-agent uses under the session's XDG_CONFIG_HOME", async () => {
const configHome = join(sessionHome, "xdg");
const authPath = join(configHome, "cursor", "auth.json");
writeAuthJson(authPath);
expect(
await discoverSession(cursorSession({ XDG_CONFIG_HOME: configHome }), "linux"),
).toEqual([{ key: "default", input: { store: "file", locator: authPath } }]);
});
it("reads the login under the session's HOME, not the daemon's", async () => {
writeCursorAuthJson(homeDir, "daemon-token");
writeCursorAuthJson(sessionHome, "session-token");
expect(await discoverSession(cursorSession({}), "linux")).toEqual([
{
key: "default",
input: { store: "file", locator: join(sessionHome, ".config", "cursor", "auth.json") },
},
]);
});
it("reads %APPDATA%/Cursor/auth.json on Windows", async () => {
const appData = join(sessionHome, "AppData", "Roaming");
const authPath = join(appData, "Cursor", "auth.json");
writeAuthJson(authPath);
expect(await discoverSession(cursorSession({ APPDATA: appData }), "win32")).toEqual([
{ key: "default", input: { store: "file", locator: authPath } },
]);
});
it("reads ~/.cursor/auth.json on macOS only when cursor-agent uses the file store", async () => {
const authPath = join(sessionHome, ".cursor", "auth.json");
writeAuthJson(authPath);
expect(await discoverSession(cursorSession({}), "darwin")).toEqual([]);
expect(
await discoverSession(cursorSession({ AGENT_CLI_CREDENTIAL_STORE: "file" }), "darwin"),
).toEqual([{ key: "default", input: { store: "file", locator: authPath } }]);
});
it.each([
["another provider", cursorSession({}, "claude")],
["a CURSOR_API_KEY session", cursorSession({ CURSOR_API_KEY: "key" })],
["a CURSOR_AUTH_TOKEN session", cursorSession({ CURSOR_AUTH_TOKEN: "token" })],
["an in-memory credential store", cursorSession({ AGENT_CLI_CREDENTIAL_STORE: "memory" })],
])("returns no login for %s", async (_scenario, scope) => {
writeCursorAuthJson(sessionHome, "session-token");
expect(await discoverSession(scope, "linux")).toEqual([]);
});
it("never falls back to the daemon's default stores", async () => {
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "desktop-token" });
writeCursorAuthJson(homeDir, "daemon-token");
process.env["CURSOR_ACCESS_TOKEN"] = "daemon-env-token";
expect(await discoverSession(cursorSession({}), "linux")).toEqual([]);
});
});
});
it("discovery returns a locator when fetch finds cursor credentials", async () => {
@@ -11,6 +11,7 @@ import {
type UsageAccount,
type UsageReport,
type UsageBalance,
type UsageScope,
} from "@getpaseo/plugin/server/usage";
const ApiNumberSchema = z.coerce.number().finite();
@@ -209,6 +210,7 @@ async function readToken(input: UsageInput): Promise<string | undefined> {
: await readCursorTokenFromAuthJson(input.locator);
return token ?? undefined;
}
export async function discover(): Promise<UsageAccount[]> {
const home = homedir();
const candidates: UsageInput[] = ["CURSOR_ACCESS_TOKEN", "CURSOR_TOKEN"].map((locator) => ({
@@ -238,3 +240,30 @@ export async function discover(): Promise<UsageAccount[]> {
for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }];
return [];
}
// Resolves the login a cursor-agent session runs on. The key matches global discovery so
// the session's login joins the host's Cursor card instead of adding a second one.
export async function discoverSession(
scope: Extract<UsageScope, { kind: "session" }>,
platform: NodeJS.Platform = process.platform,
): Promise<UsageAccount[]> {
if (scope.provider !== "cursor") return [];
const locator = cursorAgentAuthPath(scope.env, platform);
if (!locator) return [];
const input: UsageInput = { store: "file", locator };
return (await readToken(input)) ? [{ key: "default", input }] : [];
}
// Mirrors cursor-agent's credential store. Env credentials win over the stored login and
// can't be named by an input. AGENT_CLI_CREDENTIAL_STORE picks "file" or "memory"; by
// default macOS keeps the login in the Keychain and other platforms in auth.json.
function cursorAgentAuthPath(env: Record<string, string>, platform: NodeJS.Platform) {
if (env.CURSOR_API_KEY || env.CURSOR_AUTH_TOKEN) return null;
const store = env.AGENT_CLI_CREDENTIAL_STORE;
if (store === "memory" || (platform === "darwin" && store !== "file")) return null;
const home = env.HOME || env.USERPROFILE || homedir();
if (platform === "win32")
return join(env.APPDATA || join(home, "AppData", "Roaming"), "Cursor", "auth.json");
if (platform === "darwin") return join(home, ".cursor", "auth.json");
return join(env.XDG_CONFIG_HOME || join(home, ".config"), "cursor", "auth.json");
}