Files
paseo/plugins/cursor-usage-source/server/usage.ts
T
Jiawen Geng 64693f21cf 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.
2026-10-16 16:56:39 +02:00

270 lines
9.1 KiB
TypeScript

import type { UsageInput } from "../shared/input.js";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import {
toneFromUsedPct,
usedPctOf,
unavailable,
type UsageAccount,
type UsageReport,
type UsageBalance,
type UsageScope,
} from "@getpaseo/plugin/server/usage";
const ApiNumberSchema = z.coerce.number().finite();
const ApiNullableNumberSchema = z.preprocess(
(value) => (value == null ? null : value),
ApiNumberSchema.nullable(),
);
function toIsoStringOrNull(timestampMs: number): string | null {
const date = new Date(timestampMs);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
// Cursor desktop stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep
// the access token as a plain JWT string under `cursorAuth/accessToken`; older builds
// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't
// depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many
// Linux hosts) — a missing binary silently rendered Cursor usage unavailable.
// Headless hosts (VPS, cursor-agent only) have no desktop db; their session lives in
// ~/.config/cursor/auth.json instead.
const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken";
const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus";
// @types/node@20 predates the node:sqlite typings; declare the slice we use.
interface CursorStateStatement {
get(...params: unknown[]): Record<string, unknown> | undefined;
}
interface CursorStateDatabase {
prepare(sql: string): CursorStateStatement;
close(): void;
}
interface NodeSqliteModule {
DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase;
}
const CursorBillingCycleTimestampSchema = z.preprocess(
(value) => (typeof value === "string" || typeof value === "number" ? value : null),
z.union([z.string(), z.number()]).nullable(),
);
const CursorUsageResponseSchema = z.object({
planUsage: z
.object({
totalSpend: ApiNullableNumberSchema,
includedSpend: ApiNullableNumberSchema,
bonusSpend: ApiNullableNumberSchema,
remaining: ApiNullableNumberSchema,
limit: ApiNullableNumberSchema,
})
.nullish(),
billingCycleStart: CursorBillingCycleTimestampSchema,
billingCycleEnd: CursorBillingCycleTimestampSchema,
});
const CursorAuthStatusSchema = z.object({
accessToken: z.string().optional(),
});
type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
function parseCursorBillingCycleTimestamp(
value: CursorUsageResponse["billingCycleStart"],
): string | null {
if (value === null) return null;
const raw = String(value).trim();
if (!raw) return null;
const numeric = Number(raw);
if (Number.isFinite(numeric)) {
const timestampMs = Math.abs(numeric) < 10_000_000_000 ? numeric * 1000 : numeric;
return toIsoStringOrNull(timestampMs);
}
return toIsoStringOrNull(new Date(raw).getTime());
}
function centsToDollars(value: number | null): number | null {
return value === null ? null : value / 100;
}
function readItemTableValue(db: CursorStateDatabase, key: string): string | null {
const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key);
const value = row?.["value"];
if (typeof value === "string") return value;
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
return null;
}
function cursorTokenFromDb(db: CursorStateDatabase): string | null {
const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim();
if (modern) return modern;
const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY);
if (legacy) {
try {
const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy));
if (parsed.accessToken) return parsed.accessToken;
} catch {
// ignore a malformed legacy blob
}
}
return null;
}
async function readCursorTokenFromSqlite(path: string): Promise<string | null> {
// Held in a variable so TypeScript skips module resolution: @types/node@20 has no
// node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it.
const sqliteSpecifier: string = "node:sqlite";
let sqlite: NodeSqliteModule;
try {
sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule;
} catch {
return null; // runtime without node:sqlite
}
if (!existsSync(path)) return null;
let db: CursorStateDatabase | undefined;
try {
db = new sqlite.DatabaseSync(path, { readOnly: true });
return cursorTokenFromDb(db);
} catch {
return null;
} finally {
db?.close();
}
}
async function readCursorTokenFromAuthJson(path: string): Promise<string | null> {
if (!existsSync(path)) return null;
try {
const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8")));
return parsed.accessToken?.trim() || null;
} catch {
return null;
}
}
export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
): Promise<UsageReport> {
const token = await readToken(input);
if (!token) throw new Error("Cursor login store no longer exists");
const res = await fetchApi(
"https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
{
signal: AbortSignal.timeout(15_000),
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Connect-Protocol-Version": "1",
},
body: JSON.stringify({}),
},
);
if (res.status === 401 || res.status === 403)
return unavailable({ kind: "rejected", status: res.status });
if (!res.ok) throw new Error(`Cursor usage API returned ${res.status}`);
const resp = CursorUsageResponseSchema.parse(await res.json());
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
const balances: UsageBalance[] = [];
if (resp.planUsage) {
const totalSpend = centsToDollars(resp.planUsage.totalSpend);
const remaining = centsToDollars(resp.planUsage.remaining);
const limit = centsToDollars(resp.planUsage.limit);
balances.push({
id: "plan_usage",
label: "Plan usage",
used: totalSpend,
remaining,
limit,
unit: "usd",
resetsAt: billingCycleEnd,
tone: toneFromUsedPct(usedPctOf(totalSpend, limit)),
});
}
return {
status: "available",
planLabel: undefined,
windows: [],
balances,
details: [],
};
}
async function readToken(input: UsageInput): Promise<string | undefined> {
if (input.store === "env") return process.env[input.locator];
const token =
input.store === "sqlite"
? await readCursorTokenFromSqlite(input.locator)
: 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) => ({
store: "env",
locator,
}));
if (process.env.APPDATA)
candidates.push({
store: "sqlite",
locator: join(process.env.APPDATA, "Cursor", "User", "globalStorage", "state.vscdb"),
});
candidates.push(
...[
join(
home,
"Library",
"Application Support",
"Cursor",
"User",
"globalStorage",
"state.vscdb",
),
join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb"),
].map((locator) => ({ store: "sqlite" as const, locator })),
{ store: "file", locator: join(home, ".config", "cursor", "auth.json") },
);
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");
}