From 64693f21cf564389a0b74b57777267761e27f71c Mon Sep 17 00:00:00 2001 From: Jiawen Geng Date: Fri, 16 Oct 2026 22:56:39 +0800 Subject: [PATCH] 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. --- .../src/server/agent/providers/acp-agent.ts | 27 +++++++ .../agent/providers/cursor-acp-agent.test.ts | 71 ++++++++++++++++++ .../agent/providers/cursor-acp-agent.ts | 1 + .../agent/providers/generic-acp-agent.ts | 2 + plugins/cursor-usage-source/index.server.ts | 4 +- .../cursor-usage-source/server/usage.test.ts | 74 ++++++++++++++++++- plugins/cursor-usage-source/server/usage.ts | 29 ++++++++ public-docs/plugins/reference.md | 5 +- 8 files changed, 209 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 3b61fcb863..823f941de1 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -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; private readonly agentId?: string; private readonly launchEnv?: Record; + private readonly usageProvider?: string; + private readonly usageSessionKey = randomUUID(); + private readonly harnessEnvironment: Record; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly pendingPermissions = new Map(); 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 { try { const spawned = await this.spawnProcess(); diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index bd43d913e8..97b4c1cc86 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -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, +): Promise { + 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: {} }); + } +}); +`; diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.ts index 0b5b9ac4ec..4c5cd8a9c3 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.ts @@ -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, diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 5a14d99137..37db4d02dd 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -22,6 +22,7 @@ interface GenericACPAgentClientOptions { env?: Record; 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, diff --git a/plugins/cursor-usage-source/index.server.ts b/plugins/cursor-usage-source/index.server.ts index 5b19939833..a357b3d1bb 100644 --- a/plugins/cursor-usage-source/index.server.ts +++ b/plugins/cursor-usage-source/index.server.ts @@ -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 () => {}; diff --git a/plugins/cursor-usage-source/server/usage.test.ts b/plugins/cursor-usage-source/server/usage.test.ts index 21cca3b192..91b657c264 100644 --- a/plugins/cursor-usage-source/server/usage.test.ts +++ b/plugins/cursor-usage-source/server/usage.test.ts @@ -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, 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 () => { diff --git a/plugins/cursor-usage-source/server/usage.ts b/plugins/cursor-usage-source/server/usage.ts index d313b1d739..c5d2ab1391 100644 --- a/plugins/cursor-usage-source/server/usage.ts +++ b/plugins/cursor-usage-source/server/usage.ts @@ -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 { : await readCursorTokenFromAuthJson(input.locator); return token ?? undefined; } + export async function discover(): Promise { const home = homedir(); const candidates: UsageInput[] = ["CURSOR_ACCESS_TOKEN", "CURSOR_TOKEN"].map((locator) => ({ @@ -238,3 +240,30 @@ export async function discover(): Promise { 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, + platform: NodeJS.Platform = process.platform, +): Promise { + 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, 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"); +} diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 409bb3f5a6..525ffe1423 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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