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
@@ -88,6 +88,7 @@ import {
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type AgentUsageSession,
type FetchCatalogOptions,
type ProviderRefreshContext,
type ImportableProviderSession,
@@ -112,6 +113,7 @@ import {
import { importSessionFromPersistence } from "../provider-session-import.js";
import {
checkProviderLaunchAvailable,
createProviderEnv,
createProviderEnvSpec,
resolveProviderLaunch,
type ProviderRuntimeSettings,
@@ -456,6 +458,8 @@ export type ACPCatalogModelResolver = (
interface ACPAgentClientOptions {
provider: string;
// Provider id usage sources match a session against; ACP sessions without one report no usage.
usageProvider?: string;
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
defaultCommand: [string, ...string[]];
@@ -488,6 +492,7 @@ interface ACPAgentClientOptions {
interface ACPAgentSessionOptions {
provider: string;
usageProvider?: string;
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
defaultCommand: [string, ...string[]];
@@ -910,6 +915,7 @@ function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): b
export class ACPAgentClient implements AgentClient {
readonly provider: string;
private readonly usageProvider?: string;
readonly capabilities: AgentCapabilityFlags;
readonly resolveCreateConfig = resolveACPCreateConfig;
readonly isCreateConfigUnattended = isACPCreateConfigUnattended;
@@ -951,6 +957,7 @@ export class ACPAgentClient implements AgentClient {
constructor(options: ACPAgentClientOptions) {
this.provider = options.provider;
this.usageProvider = options.usageProvider;
this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill;
this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES;
this.logger = options.logger.child({
@@ -989,6 +996,7 @@ export class ACPAgentClient implements AgentClient {
{ ...config, provider: this.provider },
{
provider: this.provider,
usageProvider: this.usageProvider,
logger: this.logger,
runtimeSettings: this.runtimeSettings,
defaultCommand: this.defaultCommand,
@@ -1046,6 +1054,7 @@ export class ACPAgentClient implements AgentClient {
const providerOptions = ACPProviderOptionsSchema.parse(mergedConfig.providerOptions ?? {});
const session = new ACPAgentSession(mergedConfig, {
provider: this.provider,
usageProvider: this.usageProvider,
logger: this.logger,
runtimeSettings: this.runtimeSettings,
defaultCommand: this.defaultCommand,
@@ -1715,6 +1724,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
) => Promise<void>;
private readonly agentId?: string;
private readonly launchEnv?: Record<string, string>;
private readonly usageProvider?: string;
private readonly usageSessionKey = randomUUID();
private readonly harnessEnvironment: Record<string, string>;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly pendingPermissions = new Map<string, PendingPermission>();
private pendingUserMessage: PendingUserMessage | null = null;
@@ -1778,6 +1790,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.availableModes = options.defaultModes;
this.agentId = options.agentId;
this.launchEnv = options.launchEnv;
this.usageProvider = options.usageProvider;
this.harnessEnvironment = createProviderEnv({
runtimeSettings: options.runtimeSettings,
overlays: [options.launchEnv],
});
this.initialHandle = options.handle;
this.resumePurpose = options.resumePurpose ?? "interactive";
this.config = { ...config, provider: options.provider };
@@ -1794,6 +1811,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.sessionId;
}
usageSession(): AgentUsageSession | null {
if (this.closed || !this.usageProvider) return null;
return {
provider: this.usageProvider,
model: this.currentModel ?? undefined,
env: this.harnessEnvironment,
sessionKey: this.usageSessionKey,
};
}
async initializeNewSession(): Promise<void> {
try {
const spawned = await this.spawnProcess();
@@ -1,10 +1,14 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { ACPAgentSession } from "./acp-agent.js";
import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js";
import type { AgentSessionConfig } from "../agent-sdk-types.js";
import { CURSOR_FAST_FEATURE_OPTION, CursorACPAgentClient } from "./cursor-acp-agent.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
function captureWarnings(): {
@@ -355,3 +359,70 @@ describe("CursorACPAgentClient session start", () => {
await expect(session.initializeNewSession()).rejects.toThrow("Invalid params");
});
});
describe("CursorACPAgentClient usage session", () => {
test("matches usage sources as cursor, with the agent's provider and launch env", async () => {
await withFakeACPAgent(async (command, cwd) => {
const client = new CursorACPAgentClient({
logger: createTestLogger(),
command,
env: { XDG_CONFIG_HOME: "/profile/config" },
});
const session = await client.createSession(
{ provider: "acp", cwd },
{ agentId: "agent-1", env: { HOME: "/agent/home" } },
);
const usage = session.usageSession?.();
expect(usage).toMatchObject({
provider: "cursor",
env: { XDG_CONFIG_HOME: "/profile/config", HOME: "/agent/home" },
});
expect(session.usageSession?.()?.sessionKey).toBe(usage?.sessionKey);
await session.close();
expect(session.usageSession?.()).toBeNull();
});
});
test("leaves other ACP agents without a usage session", async () => {
await withFakeACPAgent(async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
expect(session.usageSession?.()).toBeNull();
} finally {
await session.close();
}
});
});
});
async function withFakeACPAgent(
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-cursor-usage-"));
try {
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
await run([process.execPath, scriptPath], testDir);
} finally {
await rm(testDir, { recursive: true, force: true });
}
}
const fakeACPAgentScript = `
const readline = require("node:readline");
const rl = readline.createInterface({ input: process.stdin });
function write(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") {
write({ id: message.id, result: { protocolVersion: message.params?.protocolVersion ?? 1, agentCapabilities: {} } });
} else if (message.method === "session/new") {
write({ id: message.id, result: { sessionId: "session-1" } });
} else if (message.id !== undefined) {
write({ id: message.id, result: {} });
}
});
`;
@@ -92,6 +92,7 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
env: options.env,
providerId: options.providerId,
label: options.label,
usageProvider: "cursor",
// cursor-agent publishes slash commands asynchronously via available_commands_update.
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
@@ -22,6 +22,7 @@ interface GenericACPAgentClientOptions {
env?: Record<string, string>;
providerId?: string;
label?: string;
usageProvider?: string;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
diagnosticPhaseTimeoutMs?: number;
@@ -41,6 +42,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
constructor(options: GenericACPAgentClientOptions) {
super({
provider: "acp",
usageProvider: options.usageProvider,
logger: options.logger,
runtimeSettings: {
env: options.env,
+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");
}
+4 -1
View File
@@ -411,11 +411,14 @@ Built-in session routes:
| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store |
| Codex | `codex` | `CODEX_HOME/auth.json`, or the default |
| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store |
| Cursor | `cursor` | cursor-agent's `auth.json` for that session's `XDG_CONFIG_HOME`, `APPDATA`, or home directory |
| Muse | `muse` | That session's own Muse launch/config |
| Other built-in sources | Any | No session discovery |
Claude excludes Bedrock, Vertex, and foreign `ANTHROPIC_BASE_URL` sessions. Codex excludes sessions
with `OPENAI_BASE_URL` set.
with `OPENAI_BASE_URL` set. Cursor excludes sessions that authenticate with `CURSOR_API_KEY` or
`CURSOR_AUTH_TOKEN`, and macOS sessions that keep the login in the Keychain, the default unless
`AGENT_CLI_CREDENTIAL_STORE=file`.
Use a stable account key: 1–128 characters from `[A-Za-z0-9._-]`. It identifies the account or
organization whose quota is metered and survives token rotation. Never use a credential or raw