mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user