From 4cba1492d8a9d81a9216bbf1939450908e26ea30 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 6 Sep 2026 22:21:57 +0000 Subject: [PATCH 01/28] feat(usage): read cursor, opencode, and antigravity history --- .../src/features/usage/UsageRouteScreen.tsx | 20 ++ .../src/features/usage/usageProviders.ts | 15 +- apps/server/src/usage/UsageService.test.ts | 48 ++- apps/server/src/usage/UsageService.ts | 119 ++++++- .../src/usage/antigravityUsageReader.ts | 310 ++++++++++++++++++ apps/server/src/usage/cursorUsageReader.ts | 114 +++++++ apps/server/src/usage/opencodeUsageReader.ts | 186 +++++++++++ apps/server/src/usage/usageAggregation.ts | 8 +- .../src/usage/usageTranscriptReader.test.ts | 200 +++++++++++ apps/web/src/components/usage/UsagePage.tsx | 20 ++ .../usage/UsageProviderChart.test.ts | 3 + .../src/components/usage/usageProviders.ts | 13 +- docs/user/usage.md | 10 +- packages/contracts/src/usage.ts | 27 +- packages/shared/src/usageMerge.test.ts | 28 +- packages/shared/src/usageMerge.ts | 8 +- 16 files changed, 1098 insertions(+), 31 deletions(-) create mode 100644 apps/server/src/usage/antigravityUsageReader.ts create mode 100644 apps/server/src/usage/cursorUsageReader.ts create mode 100644 apps/server/src/usage/opencodeUsageReader.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 3e5cd0fc9e3d..d9fd580e9b0f 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -81,6 +81,21 @@ export function UsageRouteScreen() { selectedEnvironmentIds, ); const limits = useRefreshLimits(selectedEnvironmentIds); + const sourceMessages = [ + ...new Set( + selectedEnvironments.flatMap( + (environment) => + environment.summary?.sources.flatMap((source) => + source.message && + (source.status === "partial" || + source.status === "failed" || + source.fingerprint.provider === "cursor") + ? [source.message] + : [], + ) ?? [], + ), + ), + ]; const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -295,6 +310,11 @@ export function UsageRouteScreen() { ) : ( <> + {sourceMessages.map((message) => ( + + {message} + + ))} = { claude: "Claude Code", codex: "Codex", grok: "Grok Build", + cursor: "Cursor", + opencode: "OpenCode", + antigravity: "Antigravity", }; /** @@ -23,5 +33,8 @@ export function useProviderColors(): Record { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", grok: scheme === "dark" ? "#a1a1aa" : "#52525b", + cursor: "#8b8b8b", + opencode: "#5b9bbd", + antigravity: "#8c7bd1", }; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9d728c88cf42..86c8e79b161c 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -6,7 +6,7 @@ import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; @@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -74,6 +75,7 @@ const serviceLayers = (input: { }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -89,7 +91,13 @@ const serviceLayers = (input: { ), ), Layer.provideMerge( - Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), + Layer.succeed(HostProcessEnvironment, { + GROK_HOME: NodePath.join(input.home, "grok"), + OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), + ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"), + XDG_CONFIG_HOME: NodePath.join(input.home, "config"), + APPDATA: NodePath.join(input.home, "config"), + }), ), ); @@ -98,6 +106,42 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("includes OpenCode history with its source and reports Cursor's missing coverage", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const root = NodePath.join(home, "opencode"); + const message = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + id: "msg_1", + sessionID: "session-1", + role: "assistant", + modelID: "example-model", + time: { created: Date.parse("2026-08-01T10:00:00Z") }, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 3 } }, + }); + yield* Effect.promise(async () => { + const directory = NodePath.join(root, "storage", "message", "session-1"); + await NodeFSP.mkdir(directory, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(directory, "msg_1.json"), message); + }); + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-opencode", home, settings })), + ); + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(summary.buckets[0]?.provider, "opencode"); + assert.strictEqual(summary.buckets[0]?.sourcePath, root); + assert.strictEqual(summary.buckets[0]?.totals.outputTokens, 7); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "opencode") + ?.distinctSessions, + 1, + ); + assert.include( + summary.sources.find((source) => source.fingerprint.provider === "cursor")?.message ?? "", + "Cursor CLI does not save token totals", + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0e6b0c1eecd6..e41df0a6fc2b 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,14 +1,14 @@ /** * UsageService - scans provider transcripts and returns priced usage buckets. * - * The scan reads the provider CLIs' own session files (Claude Code, Codex, and - * Grok Build) rather than T3 Code's orchestration projections, so usage covers - * turns driven outside T3 Code too. This is the approach `ccusage` takes. + * The scan reads native session files and databases, including work driven + * outside T3 Code. Cursor's local records provide only partial coverage. * - * Transcripts are append-only, so parsed records are memoised per file by + * JSONL transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm * scans only reparse files that changed, and a file that merely grew resumes * from its cached parse position so only the appended bytes are read. + * SQLite readers query live databases each scan so WAL writes remain visible. * * @module UsageService */ @@ -16,6 +16,7 @@ import * as NodeOS from "node:os"; import { USAGE_CONTRACT_VERSION, + ProviderInstanceId, type ServerSettings as ServerSettingsValue, type UsageProviderKind, type UsageSource, @@ -24,7 +25,7 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -44,6 +45,10 @@ import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { resolveAntigravityProfileDirectory } from "../provider/antigravityAuthSupport.ts"; +import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; +import { readAntigravityUsage } from "./antigravityUsageReader.ts"; +import { readCursorUsage } from "./cursorUsageReader.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -139,6 +144,7 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; + const platform = yield* HostProcessPlatform; const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -372,6 +378,8 @@ export const make = Effect.gen(function* () { readonly provider: UsageProviderKind; readonly dir: string; readonly volumeId: string; + readonly status?: UsageSource["status"]; + readonly message?: string; /** Parsed records per file, or `null` when the directory does not exist. */ readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] @@ -407,6 +415,97 @@ export const make = Effect.gen(function* () { } scanned.push({ provider, dir, volumeId, files: parsedFiles }); } + + const home = NodeOS.homedir(); + const envRoots = (key: string, defaults: readonly string[]) => { + const roots = hostEnvironment[key] + ?.split(",") + .map((value) => value.trim()) + .filter(Boolean); + return [ + ...new Set( + (roots?.length ? roots : defaults).map((root) => path.resolve(expandHomePath(root))), + ), + ]; + }; + const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim(); + for (const dir of envRoots("OPENCODE_DATA_DIR", [ + path.join( + dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"), + "opencode", + ), + ])) { + const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs)); + scanned.push({ + provider: "opencode", + dir, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), + files: result.missing && !result.error ? null : result.files, + status: result.error ? "partial" : "ok", + ...(result.error ? { message: "Some OpenCode history could not be read." } : {}), + }); + } + const antigravityRoots = envRoots("ANTIGRAVITY_DATA_DIR", [ + ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) => + path.join(home, ".gemini", name), + ), + path.join(home, ".config", "antigravity"), + ]); + for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { + if (instance.driver === "antigravity") { + antigravityRoots.push( + path.join( + resolveAntigravityProfileDirectory( + config.stateDir, + ProviderInstanceId.make(instanceId), + ), + "antigravity-acp", + ), + ); + } + } + for (const root of new Set(antigravityRoots)) { + const nested = path.join(root, "conversations"); + const dir = (yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false)))) + ? nested + : root; + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + const result = yield* Effect.promise(() => readAntigravityUsage(dir, windowStartMs)); + scanned.push({ + provider: "antigravity", + dir, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), + files: !exists && result.errors.length === 0 ? null : result.files, + status: result.errors.length > 0 ? "partial" : "ok", + ...(result.errors.length > 0 + ? { message: "Some Antigravity history could not be read." } + : {}), + }); + } + const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); + const cursorHome = + platform === "darwin" + ? path.join(home, "Library", "Application Support") + : platform === "win32" + ? hostEnvironment["APPDATA"] || path.join(home, "AppData", "Roaming") + : configHome && path.isAbsolute(configHome) + ? configHome + : path.join(home, ".config"); + const cursorPath = path.join(cursorHome, "Cursor", "User", "globalStorage", "state.vscdb"); + const cursor = yield* Effect.promise(() => readCursorUsage(cursorPath, windowStartMs)); + scanned.push({ + provider: "cursor", + dir: cursorPath, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorPath)), + files: cursor.missing && !cursor.error ? null : cursor.files, + status: "partial", + message: + "Cursor local history is incomplete. Cursor CLI does not save token totals; check the Cursor Usage Dashboard for complete usage.", + }); return scanned; }); @@ -481,7 +580,7 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir, volumeId, files } of scannedDirs) { + for (const { provider, dir, volumeId, files, status, message } of scannedDirs) { if (files === null) { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, @@ -490,7 +589,7 @@ export const make = Effect.gen(function* () { skippedFiles: 0, malformedRecords: 0, distinctSessions: 0, - message: "No transcript directory on this environment.", + message: message ?? "No transcript directory on this environment.", }); continue; } @@ -512,7 +611,7 @@ export const make = Effect.gen(function* () { for (const record of file.records) { // Only sessions that contributed in-window count: the mtime slack // admits boundary files whose records fall outside the range. - if (aggregator.add(record) && record.sessionId.length > 0) { + if (aggregator.add(record, dir) && record.sessionId.length > 0) { sessionIds.add(record.sessionId); } } @@ -520,12 +619,12 @@ export const make = Effect.gen(function* () { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: "ok", + status: status ?? "ok", scannedFiles, skippedFiles, malformedRecords: 0, distinctSessions: sessionIds.size, - message: null, + message: message ?? null, }); } diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts new file mode 100644 index 000000000000..2c6af17205de --- /dev/null +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -0,0 +1,310 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeTimersPromises from "node:timers/promises"; + +import type { UsageRecord } from "./usageTranscripts.ts"; + +type Fields = Map>; + +/** Antigravity stores usage metadata as protobuf, independently of conversation text. */ +function fields(bytes: Uint8Array): Fields { + let offset = 0; + const result: Fields = new Map(); + const varint = () => { + let value = 0n; + for (let shift = 0n; shift < 70n; shift += 7n) { + const byte = bytes[offset++]; + if (byte === undefined || (shift === 63n && byte > 1)) { + throw new Error("Invalid Antigravity protobuf varint"); + } + value |= BigInt(byte & 127) << shift; + if (byte < 128) { + if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Unsafe protobuf number"); + return Number(value); + } + } + throw new Error("Invalid Antigravity protobuf varint"); + }; + while (offset < bytes.length) { + const tag = varint(); + const number = Math.floor(tag / 8); + const wire = tag % 8; + if (number === 0) throw new Error("Invalid protobuf field"); + let value: number | Uint8Array; + if (wire === 0) { + value = varint(); + } else if (wire === 1 || wire === 5 || wire === 2) { + const length = wire === 2 ? varint() : wire === 1 ? 8 : 4; + if (length > bytes.length - offset) throw new Error("Truncated protobuf field"); + value = bytes.subarray(offset, offset + length); + offset += length; + if (wire !== 2) continue; + } else { + throw new Error("Unsupported protobuf wire type"); + } + const entries = result.get(number) ?? []; + entries.push(value); + result.set(number, entries); + } + return result; +} + +const numberAt = (value: Fields, key: number) => { + const entry = value.get(key)?.[0]; + return typeof entry === "number" ? entry : 0; +}; +const bytesAt = (value: Fields, key: number) => { + const entry = value.get(key)?.[0]; + return entry instanceof Uint8Array ? entry : undefined; +}; +const nested = (value: Fields, key: number) => { + const bytes = bytesAt(value, key); + return bytes === undefined ? new Map>() : fields(bytes); +}; +const textAt = (value: Fields, key: number) => { + const bytes = bytesAt(value, key); + return bytes === undefined ? "" : new TextDecoder("utf-8", { fatal: true }).decode(bytes).trim(); +}; +const timestamp = (value: Fields) => { + const seconds = numberAt(value, 1); + return seconds > 0 ? seconds * 1000 + Math.floor(numberAt(value, 2) / 1_000_000) : null; +}; + +const MODEL_IDS: Record = { + 246: "gemini-2.5-pro", + 312: "gemini-2.5-flash", + 313: "gemini-2.5-flash-thinking", + 329: "gemini-2.5-flash-thinking", + 330: "gemini-2.5-flash-lite", + 281: "claude-sonnet-4", + 282: "claude-sonnet-4", + 290: "claude-opus-4", + 291: "claude-opus-4", + 333: "claude-sonnet-4-5", + 334: "claude-sonnet-4-5", + 340: "claude-haiku-4-5", + 341: "claude-haiku-4-5", + 1026: "claude-opus-4-6", + 1035: "claude-sonnet-4-6", + 1016: "gemini-3.1-pro", + 1036: "gemini-3.1-pro", + 1037: "gemini-3.1-pro", + 1018: "gemini-3-flash-preview", + 1084: "gemini-3-flash-preview", + 1047: "gemini-3-flash-preview", +}; + +function modelName(name: string, id: number): string { + if (name) { + const normalized = name + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, "") + .replaceAll(" ", "-"); + if (normalized.startsWith("claude-")) { + return normalized + .replace(/^claude-(4(?:\.\d+)?)-(sonnet|opus|haiku)/, "claude-$2-$1") + .replaceAll(".", "-"); + } + return normalized; + } + return MODEL_IDS[id] ?? (id > 0 ? `antigravity-model-${id}` : ""); +} + +interface Metadata { + model: string; + timestampMs: number | null; + usages: Fields[]; +} + +function metadata(bytes: Uint8Array, step: boolean): Metadata { + const root = fields(bytes); + if (!step && bytesAt(root, 1) === undefined) { + throw new Error("Missing Antigravity generation metadata"); + } + const data = step ? root : nested(root, 1); + const model = step ? nested(data, 24) : data; + const usage = bytesAt(data, step ? 9 : 4); + const usages = usage === undefined ? [] : [fields(usage)]; + for (const retry of data.get(step ? 28 : 17) ?? []) { + if (!(retry instanceof Uint8Array)) throw new Error("Invalid retry metadata"); + const retryUsage = bytesAt(fields(retry), 2); + if (retryUsage !== undefined) usages.push(fields(retryUsage)); + } + return { + model: modelName( + textAt(model, step ? 12 : 19) || textAt(model, step ? 8 : 21), + numberAt(model, step ? 1 : 3), + ), + timestampMs: step + ? (timestamp(nested(data, 8)) ?? timestamp(nested(data, 1))) + : timestamp(nested(nested(data, 9), 4)), + usages, + }; +} + +function blob(value: unknown): Uint8Array { + if (!(value instanceof Uint8Array)) throw new Error("Invalid Antigravity metadata blob"); + return value; +} + +async function readDatabase(path: string, fallbackTimestamp: number): Promise { + const db = new NodeSqlite.DatabaseSync(path, { readOnly: true }); + try { + db.exec("PRAGMA busy_timeout = 100; BEGIN"); + const tables = new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name), + ); + if (!tables.has("gen_metadata") && !tables.has("steps")) { + throw new Error("Missing Antigravity usage tables"); + } + const readMetadata = async (query: string, column: string, step: boolean) => { + const entries: Metadata[] = []; + for (const row of db.prepare(query).iterate()) { + entries.push(metadata(blob(row[column]), step)); + if (entries.length % 256 === 0) await NodeTimersPromises.setImmediate(); + } + return entries; + }; + const generations = tables.has("gen_metadata") + ? await readMetadata("SELECT data FROM gen_metadata ORDER BY idx", "data", false) + : []; + let trajectoryTimestamp: number | null = null; + if (tables.has("trajectory_metadata_blob")) { + for (const row of db.prepare("SELECT data FROM trajectory_metadata_blob").iterate()) { + trajectoryTimestamp ??= timestamp(nested(fields(blob(row.data)), 2)); + } + } + const steps = tables.has("steps") + ? await readMetadata( + "SELECT metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx", + "metadata", + true, + ) + : []; + const sessionId = NodePath.basename(path, ".db"); + const records: UsageRecord[] = []; + const identities = new Map(); + let currentModel = ""; + const generationModel = generations.findLast((entry) => entry.model)?.model ?? ""; + for (const [source, entries] of [ + ["step", steps], + ["generation", generations], + ] as const) { + for (const [index, entry] of entries.entries()) { + if (entry.model) currentModel = entry.model; + for (const [usageIndex, usage] of entry.usages.entries()) { + const outputTokens = Math.max( + numberAt(usage, 3), + numberAt(usage, 9) + numberAt(usage, 10), + ); + const totals = { + uncachedInputTokens: numberAt(usage, 2), + cachedInputTokens: numberAt(usage, 5), + cacheCreationTokens: numberAt(usage, 4), + outputTokens, + reasoningTokens: Math.min(outputTokens, numberAt(usage, 9)), + }; + if ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + outputTokens === + 0 + ) + continue; + const keys = ([11, 12, 7] as const).flatMap((key) => { + const id = textAt(usage, key); + return id ? [`antigravity:${key}:${id}`] : []; + }); + const existingIndex = keys + .map((key) => identities.get(key)) + .find((value) => value !== undefined); + const record: UsageRecord = { + provider: "antigravity", + sessionId, + timestampMs: entry.timestampMs ?? trajectoryTimestamp ?? fallbackTimestamp, + model: + MODEL_IDS[numberAt(usage, 1)] || + entry.model || + (source === "step" ? generationModel : currentModel) || + modelName("", numberAt(usage, 1)) || + "antigravity-unknown", + totals, + reportedCostUsd: null, + dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`, + }; + if (existingIndex === undefined) { + for (const key of keys) identities.set(key, records.length); + records.push(record); + } else { + const existing = records[existingIndex]!; + records[existingIndex] = { + ...existing, + totals: { + uncachedInputTokens: Math.max( + existing.totals.uncachedInputTokens, + totals.uncachedInputTokens, + ), + cachedInputTokens: Math.max( + existing.totals.cachedInputTokens, + totals.cachedInputTokens, + ), + cacheCreationTokens: Math.max( + existing.totals.cacheCreationTokens, + totals.cacheCreationTokens, + ), + outputTokens: Math.max(existing.totals.outputTokens, totals.outputTokens), + reasoningTokens: Math.max(existing.totals.reasoningTokens, totals.reasoningTokens), + }, + }; + for (const key of keys) identities.set(key, existingIndex); + } + } + } + currentModel = ""; + } + return records; + } finally { + db.close(); + } +} + +/** Reads the SQLite usage metadata described by ccusage's Antigravity adapter. */ +export async function readAntigravityUsage(conversationsDirectory: string, sinceMs: number) { + const files: Array<{ path: string; records: readonly UsageRecord[] }> = []; + const errors: string[] = []; + const walk = async (directory: string): Promise => { + let entries; + try { + entries = await NodeFSP.readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push(directory); + return; + } + for (const entry of entries) { + const path = NodePath.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(path); + } else if (entry.isFile() && entry.name.endsWith(".db")) { + try { + const stat = await NodeFSP.stat(path); + files.push({ + path, + records: (await readDatabase(path, stat.mtimeMs)).filter( + (record) => record.timestampMs >= sinceMs, + ), + }); + } catch { + errors.push(path); + } + } + } + }; + await walk(conversationsDirectory); + return { files, errors }; +} diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts new file mode 100644 index 000000000000..10df00c4d8dd --- /dev/null +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -0,0 +1,114 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeTimersPromises from "node:timers/promises"; + +import type { UsageRecord } from "./usageTranscripts.ts"; + +function object(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function tokens(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +/** Only recorded token counts count as usage; text length and context meters do not. */ +export function parseCursorBubble(key: string, source: string): UsageRecord | null { + const match = /^bubbleId:([^:]+):(.+)$/.exec(key); + if (match === null) return null; + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return null; + } + const bubble = object(parsed); + if (bubble.type !== 2) return null; + const usage = object(bubble.tokenCount); + const inputTokens = tokens(usage.inputTokens); + const outputTokens = tokens(usage.outputTokens); + if (inputTokens + outputTokens === 0) return null; + const timestampMs = + typeof bubble.createdAt === "number" + ? bubble.createdAt + : typeof bubble.createdAt === "string" + ? Date.parse(bubble.createdAt) + : NaN; + if (!Number.isFinite(timestampMs)) return null; + const modelName = object(bubble.modelInfo).modelName; + const model = + typeof modelName === "string" && modelName && modelName !== "default" + ? modelName + : "cursor-auto"; + const requestId = typeof bubble.requestId === "string" ? bubble.requestId : ""; + return { + provider: "cursor", + timestampMs, + model, + sessionId: match[1] ?? "", + totals: { + uncachedInputTokens: inputTokens, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: requestId ? `cursor:request:${requestId}` : `cursor:${key}`, + }; +} + +export interface CursorUsageReadResult { + readonly files: readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]; + readonly missing: boolean; + readonly error: boolean; +} + +/** Cursor's desktop store only exposes counts for some versions and requests. */ +export async function readCursorUsage( + dbPath: string, + sinceMs: number, +): Promise { + try { + await NodeFSP.stat(dbPath); + } catch (cause) { + const missing = object(cause).code === "ENOENT"; + return { files: [], missing, error: !missing }; + } + const records: UsageRecord[] = []; + const files = [{ path: dbPath, records }]; + const seen = new Set(); + let database: NodeSqlite.DatabaseSync | undefined; + try { + database = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); + database.exec("PRAGMA busy_timeout = 100"); + const statement = database.prepare( + "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'", + ); + let count = 0; + for (const row of statement.iterate()) { + const source = + typeof row.value === "string" + ? row.value + : row.value instanceof Uint8Array + ? Buffer.from(row.value).toString("utf8") + : ""; + const record = parseCursorBubble(typeof row.key === "string" ? row.key : "", source); + if (record !== null && record.timestampMs >= sinceMs) { + if (record.dedupeKey === null || !seen.has(record.dedupeKey)) { + if (record.dedupeKey !== null) seen.add(record.dedupeKey); + records.push(record); + } + } + if (++count % 256 === 0) await NodeTimersPromises.setImmediate(); + } + return { files, missing: false, error: false }; + } catch { + return { files, missing: false, error: true }; + } finally { + database?.close(); + } +} diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts new file mode 100644 index 000000000000..8b51924d6327 --- /dev/null +++ b/apps/server/src/usage/opencodeUsageReader.ts @@ -0,0 +1,186 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeTimersPromises from "node:timers/promises"; + +import { totalTokens, type UsageRecord } from "./usageTranscripts.ts"; + +function object(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function tokens(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** OpenCode stores uncached input and reasoning separately from input/output. */ +export function parseOpenCodeMessage( + source: string, + fallback: { + readonly id?: string; + readonly sessionId?: string; + readonly timestampMs?: number; + } = {}, +): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return null; + } + const message = object(parsed); + if (message.role !== undefined && message.role !== "assistant") return null; + const usage = object(message.tokens); + const cache = object(usage.cache); + const modelReference = object(message.model); + const model = text(modelReference.id) || text(modelReference.modelID) || text(message.modelID); + const timestampMs = object(message.time).created ?? fallback.timestampMs; + if (!model || typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return null; + const reasoningTokens = tokens(usage.reasoning); + const totals = { + uncachedInputTokens: tokens(usage.input), + cachedInputTokens: tokens(cache.read), + cacheCreationTokens: tokens(cache.write), + outputTokens: tokens(usage.output) + reasoningTokens, + reasoningTokens, + }; + if (totalTokens(totals) === 0) return null; + const id = fallback.id || text(message.id); + const cost = message.cost; + return { + provider: "opencode", + timestampMs, + model, + sessionId: fallback.sessionId || text(message.sessionID), + totals, + // OpenCode writes zero for models without a known rate, including paid + // subscription models. Let the shared price table estimate those records. + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null, + dedupeKey: id ? `opencode:${id}` : null, + }; +} + +export interface OpenCodeUsageReadResult { + readonly files: readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]; + readonly missing: boolean; + readonly error: boolean; +} + +/** Reads current SQLite and pre-migration JSON stores without modifying either. */ +export async function readOpenCodeUsage( + root: string, + sinceMs: number, +): Promise { + const files: { path: string; records: UsageRecord[] }[] = []; + const seen = new Set(); + let found = false; + let error = false; + const append = (records: UsageRecord[], record: UsageRecord | null) => { + if (record === null || record.timestampMs < sinceMs) return; + if (record.dedupeKey !== null) { + if (seen.has(record.dedupeKey)) return; + seen.add(record.dedupeKey); + } + records.push(record); + }; + + let databases: string[] = []; + try { + databases = (await NodeFSP.readdir(root, { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(entry.name)) + .map((entry) => entry.name) + .sort((a, b) => (a === "opencode.db" ? -1 : b === "opencode.db" ? 1 : a.localeCompare(b))); + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + for (const name of databases) { + found = true; + const file = { path: NodePath.join(root, name), records: [] as UsageRecord[] }; + files.push(file); + let database: NodeSqlite.DatabaseSync | undefined; + try { + database = new NodeSqlite.DatabaseSync(NodePath.join(root, name), { readOnly: true }); + // A busy live provider should fail this source promptly rather than + // stalling the server while SQLite waits for its writer. + database.exec("PRAGMA busy_timeout = 100"); + const tables = new Set( + database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name), + ); + if (!tables.has("message") && !tables.has("session_message")) error = true; + for (const table of ["message", "session_message"] as const) { + if (!tables.has(table)) continue; + const columns = new Set( + database + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((row) => row.name), + ); + const timestamp = columns.has("time_created") ? "time_created" : "NULL"; + const predicates = table === "session_message" ? ["type = 'assistant'"] : []; + if (timestamp !== "NULL") predicates.push("time_created >= ?"); + const where = predicates.length > 0 ? ` WHERE ${predicates.join(" AND ")}` : ""; + const statement = database.prepare( + `SELECT id, session_id, data, ${timestamp} AS created FROM ${table}${where}`, + ); + let count = 0; + for (const row of statement.iterate(...(timestamp === "NULL" ? [] : [sinceMs]))) { + append( + file.records, + parseOpenCodeMessage(text(row.data), { + id: text(row.id), + sessionId: text(row.session_id), + ...(typeof row.created === "number" ? { timestampMs: row.created } : {}), + }), + ); + if (++count % 256 === 0) await NodeTimersPromises.setImmediate(); + } + } + } catch { + error = true; + } finally { + database?.close(); + } + } + + // Do not follow symlinks, including cycles. Database records win over their + // old JSON copies when OpenCode has migrated a store in place. + const directories = [NodePath.join(root, "storage", "message")]; + while (directories.length > 0) { + const directory = directories.pop()!; + try { + for (const entry of await NodeFSP.readdir(directory, { withFileTypes: true })) { + const path = NodePath.join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(path); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + found = true; + const id = entry.name.slice(0, -5); + if (seen.has(`opencode:${id}`)) continue; + const file = { path, records: [] as UsageRecord[] }; + files.push(file); + try { + append( + file.records, + parseOpenCodeMessage(await NodeFSP.readFile(path, "utf8"), { id }), + ); + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + } + } + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + } + return { files, missing: !found && !error, error }; +} diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 01a1195efb60..fdcde3002b4b 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -112,7 +112,7 @@ export class UsageAggregator { * can derive per-window facts (distinct sessions, for one) from the records * that landed rather than everything the mtime prefilter happened to admit. */ - add(record: UsageRecord): boolean { + add(record: UsageRecord, sourcePath?: string): boolean { if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -146,7 +146,7 @@ export class UsageAggregator { this.#hourlyWindow.sinceTimeMs + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`; + const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourcePath ?? ""}`; let bucket = this.#buckets.get(key); if (bucket === undefined) { bucket = { @@ -187,12 +187,14 @@ export class UsageAggregator { finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000"); + const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = + key.split("\u0000"); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), provider: provider as UsageBucket["provider"], model, + ...(sourcePath === "" ? {} : { sourcePath }), totals: bucket.totals, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 5feb68b2ff58..1902731df4f0 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -4,13 +4,40 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; import { readTranscriptRecords } from "./usageTranscriptReader.ts"; +import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; +import { readCursorUsage } from "./cursorUsageReader.ts"; +import { readAntigravityUsage } from "./antigravityUsageReader.ts"; let dir: string; +function protoNumber(field: number, value: number): number[] { + const varint = (number: number) => { + const bytes: number[] = []; + do { + const byte = number % 128; + number = Math.floor(number / 128); + bytes.push(byte + (number > 0 ? 128 : 0)); + } while (number > 0); + return bytes; + }; + return [...varint(field * 8), ...varint(value)]; +} + +function protoBytes(field: number, bytes: readonly number[]): number[] { + const encoded = protoNumber(field, bytes.length); + encoded[0] = encoded[0]! + 2; + return [...encoded, ...bytes]; +} + +function protoText(field: number, value: string): number[] { + return protoBytes(field, [...Buffer.from(value)]); +} + beforeEach(async () => { dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); }); @@ -208,3 +235,176 @@ describe("readTranscriptRecords resume", () => { assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); }); }); + +describe("SQLite usage readers", () => { + it("counts migrated OpenCode messages once and sees subsequent WAL writes", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "opencode.db")); + try { + db.exec( + "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; CREATE TABLE message (id TEXT, session_id TEXT, data TEXT)", + ); + const message = { + id: "msg-1", + sessionID: "session-1", + role: "assistant", + modelID: "claude-sonnet-4-5", + time: { created: 1780000000000 }, + cost: 0.25, + tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 30, write: 10 } }, + }; + const insert = db.prepare("INSERT INTO message VALUES (?, ?, ?)"); + insert.run(message.id, message.sessionID, JSON.stringify(message)); + const legacy = NodePath.join(dir, "storage", "message", message.sessionID); + await NodeFSP.mkdir(legacy, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(legacy, "msg-1.json"), JSON.stringify(message)); + const first = await readOpenCodeUsage(dir, 0); + assert.isFalse(first.error); + const records = first.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 1); + assert.deepStrictEqual(records[0]?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 30, + cacheCreationTokens: 10, + outputTokens: 25, + reasoningTokens: 5, + }); + assert.strictEqual(records[0]?.reportedCostUsd, 0.25); + insert.run( + "msg-2", + message.sessionID, + JSON.stringify({ ...message, id: "msg-2", time: { created: 1780000001000 } }), + ); + const next = await readOpenCodeUsage(dir, 1780000001000); + assert.isFalse(next.error); + assert.deepStrictEqual( + next.files.flatMap((file) => file.records).map((record) => record.dedupeKey), + ["opencode:msg-2"], + ); + assert.isAbove((await NodeFSP.stat(NodePath.join(dir, "opencode.db-wal"))).size, 0); + } finally { + db.close(); + } + }); + + it("counts Cursor assistant usage without estimating zero-count or user bubbles", async () => { + const path = NodePath.join(dir, "state.vscdb"); + const db = new NodeSqlite.DatabaseSync(path); + try { + db.exec("CREATE TABLE cursorDiskKV (key TEXT, value TEXT)"); + const insert = db.prepare("INSERT INTO cursorDiskKV VALUES (?, ?)"); + const bubble = { + type: 2, + createdAt: "2026-08-01T10:00:00Z", + requestId: "request-1", + modelInfo: { modelName: "claude-sonnet-4-5" }, + tokenCount: { inputTokens: 120, outputTokens: 30 }, + }; + insert.run("bubbleId:session-1:assistant", JSON.stringify(bubble)); + insert.run("bubbleId:session-1:copy", JSON.stringify(bubble)); + insert.run( + "bubbleId:session-1:user", + JSON.stringify({ ...bubble, type: 1, requestId: "user-request" }), + ); + insert.run( + "bubbleId:session-1:zero", + JSON.stringify({ + ...bubble, + requestId: "zero", + tokenCount: { inputTokens: 0, outputTokens: 0 }, + text: "real text without recorded usage", + }), + ); + } finally { + db.close(); + } + const result = await readCursorUsage(path, 0); + assert.isFalse(result.error); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 1); + assert.strictEqual(records[0]?.model, "claude-sonnet-4-5"); + assert.strictEqual(records[0]?.sessionId, "session-1"); + assert.strictEqual(records[0]?.totals.uncachedInputTokens, 120); + assert.strictEqual(records[0]?.totals.outputTokens, 30); + assert.strictEqual(records[0]?.timestampMs, Date.parse("2026-08-01T10:00:00Z")); + }); + + it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db")); + const stamp = protoNumber(1, 1780000000); + const usage = [ + ...protoNumber(2, 100), + ...protoNumber(3, 40), + ...protoNumber(4, 5), + ...protoNumber(5, 20), + ...protoNumber(9, 10), + ...protoText(11, "response-1"), + ]; + const retry = [ + ...protoNumber(1, 1026), + ...protoNumber(2, 12), + ...protoNumber(3, 3), + ...protoText(11, "retry-1"), + ]; + const generation = protoBytes(1, [ + ...protoBytes(4, usage), + ...protoText(19, "Gemini 3 Pro"), + ...protoBytes(9, protoBytes(4, stamp)), + ]); + const step = [ + ...protoBytes(9, usage), + ...protoBytes(8, stamp), + ...protoBytes(28, protoBytes(2, retry)), + ]; + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, new Uint8Array(generation)); + db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(step)); + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 2); + const main = records.find((record) => record.model === "gemini-3-pro"); + assert.isDefined(main); + assert.strictEqual(main?.timestampMs, 1780000000000); + assert.strictEqual(main?.sessionId, "session-1"); + assert.deepStrictEqual(main?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 40, + reasoningTokens: 10, + }); + assert.strictEqual( + records.find((record) => record.model === "claude-opus-4-6")?.totals.uncachedInputTokens, + 12, + ); + assert.deepStrictEqual( + (await readAntigravityUsage(dir, 1780000000001)).files.flatMap((file) => file.records), + [], + ); + }); + + it("reads Antigravity step-only stores and reports malformed databases", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "steps.db")); + try { + db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...protoNumber(3, 5)]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run( + 0, + new Uint8Array([...protoBytes(9, usage), ...protoBytes(8, protoNumber(1, 1780000000))]), + ); + } finally { + db.close(); + } + await NodeFSP.writeFile(NodePath.join(dir, "broken.db"), "not a sqlite database"); + const result = await readAntigravityUsage(dir, 0); + assert.strictEqual(result.errors.length, 1); + assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.model, "gemini-2.5-pro"); + assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.totals.outputTokens, 5); + }); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index deb05f266b98..21d50b72aba9 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -114,6 +114,21 @@ export function UsagePage() { selectedEnvironmentIds, ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const sourceMessages = [ + ...new Set( + selectedEnvironments.flatMap( + (environment) => + environment.summary?.sources.flatMap((source) => + source.message && + (source.status === "partial" || + source.status === "failed" || + source.fingerprint.provider === "cursor") + ? [source.message] + : [], + ) ?? [], + ), + ), + ]; const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -354,6 +369,11 @@ export function UsagePage() { ) : ( <> + {sourceMessages.map((message) => ( +

+ {message} +

+ ))}
diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 622d73d13844..e3060dbaefd3 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -89,6 +89,9 @@ describe("buildPeriodColumns", () => { { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, { provider: "grok", value: 0 }, + { provider: "cursor", value: 0 }, + { provider: "opencode", value: 0 }, + { provider: "antigravity", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index efad95e531ad..38b78cbcdb04 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,6 +1,14 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; +import { + AntigravityIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -30,6 +38,9 @@ export const PROVIDER_PRESENTATION = { color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))", mark: GrokIcon, }, + cursor: { label: "Cursor", color: "#8b8b8b", mark: CursorIcon }, + opencode: { label: "OpenCode", color: "#5b9bbd", mark: OpenCodeIcon }, + antigravity: { label: "Antigravity", color: "#8c7bd1", mark: AntigravityIcon }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/docs/user/usage.md b/docs/user/usage.md index 4c4af3299acb..e454a5b218c9 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -2,13 +2,21 @@ ## Understand your usage -**Usage** combines Codex, Claude Code, and Grok Build session history from your connected +**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and available Cursor desktop history from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent cost. These estimates are not your subscription bill. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. +OpenCode reads its SQLite database and older JSON history. Antigravity reads local conversation +databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the +server to read a different data directory; comma-separated paths read multiple directories. + +Cursor coverage is partial: only saved desktop token counts are included. Cursor CLI transcripts +do not contain token totals, and recent desktop versions often omit them too. Use Cursor's Usage +Dashboard for complete usage. T3 does not estimate missing tokens from conversation text. + On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All environments are selected by default. The dropdown shows which environments are still scanning; results appear as each one responds. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index c36a4557c294..ce5ac6045401 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -1,13 +1,10 @@ /** * Usage reporting contract. * - * Each environment scans the provider CLIs' own on-disk session transcripts - * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, - * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own - * orchestration projections, so usage stays complete even for turns that were - * never driven through T3 Code. This mirrors the approach `ccusage` takes. + * Each environment scans native session files and databases, including work + * driven outside T3 Code. Source status describes gaps in local coverage. * - * Environments return pre-aggregated `(day, hourStart?, provider, model)` + * Environments return pre-aggregated `(day, hourStart?, provider, model, sourcePath?)` * buckets. Raw transcript records never cross the wire. * * @module usage @@ -21,18 +18,24 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 5 as const; +export const USAGE_CONTRACT_VERSION = 6 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * - * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets - * remain valid, so mixed-version environments keep those totals instead of - * treating every older server as stale. + * v5/v6 add providers and optional source attribution; v4 Claude/Codex + * buckets remain valid in mixed-version environments. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); +export const UsageProviderKind = Schema.Literals([ + "claude", + "codex", + "grok", + "cursor", + "opencode", + "antigravity", +]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** @@ -93,6 +96,8 @@ export const UsageBucket = Schema.Struct({ hourStart: Schema.optional(TrimmedNonEmptyString), provider: UsageProviderKind, model: TrimmedNonEmptyString, + /** Source directory, so overlapping multi-home environments merge once per source. */ + sourcePath: Schema.optional(TrimmedNonEmptyString), totals: UsageTokenTotals, costUsd: Schema.Number, /** diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 6c706395c6ff..fab145f1da2c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -1,5 +1,6 @@ import { USAGE_CONTRACT_VERSION, + USAGE_MERGE_COMPATIBLE_SINCE, type EnvironmentId, type UsageBucket, type UsageDay, @@ -146,6 +147,31 @@ describe("mergeUsage", () => { ).toEqual({ claude: 1, codex: 1 }); }); + it("counts overlapping provider roots once while keeping each environment's unique root", () => { + const source = (homePath: string) => ({ + provider: "opencode" as const, + hostId: "host", + homePath, + }); + const usage = (sourcePath: string, costUsd: number) => + bucket({ provider: "opencode", sourcePath, costUsd }); + const merged = mergeUsage( + [ + environment( + "env-a", + summary([usage("/shared", 10), usage("/a", 2)], [source("/shared"), source("/a")]), + ), + environment( + "env-b", + summary([usage("/shared", 10), usage("/b", 3)], [source("/shared"), source("/b")]), + ), + ], + USAGE_CONTRACT_VERSION, + ); + expect(merged.costUsd).toBe(15); + expect(merged.sessions).toBe(3); + }); + it("excludes an environment reporting an older contract version", () => { const merged = mergeUsage( [ @@ -158,7 +184,7 @@ describe("mergeUsage", () => { summary( [bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 2, + USAGE_MERGE_COMPATIBLE_SINCE - 1, ), ), ], diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 95982bf507da..c9321f66834b 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -142,6 +142,7 @@ function ownedContribution( readonly sessionsByProvider: ReadonlyMap; } { const ownedProviders = new Set(); + const ownedSources = new Set(); const sessionsByProvider = new Map(); for (const source of environment.summary.sources) { if (source.status === "missing") continue; @@ -149,6 +150,7 @@ function ownedContribution( if (ownerByFingerprint.get(key) === environment.environmentId) { const provider = source.fingerprint.provider; ownedProviders.add(provider); + ownedSources.add(`${provider}\u0000${source.fingerprint.resolvedHomePath}`); // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. sessionsByProvider.set( @@ -158,7 +160,11 @@ function ownedContribution( } } return { - buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + buckets: environment.summary.buckets.filter((bucket) => + bucket.sourcePath === undefined + ? ownedProviders.has(bucket.provider) + : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`), + ), sessionsByProvider, }; } From 2574209557597a9c5d20a5ab3e485270c90a011b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 6 Sep 2026 22:35:52 +0000 Subject: [PATCH 02/28] fix(usage): preserve bubble totals and merge antigravity aliases --- apps/server/src/usage/UsageService.ts | 19 ++- .../src/usage/antigravityUsageReader.ts | 150 +++++++++++++----- apps/server/src/usage/cursorUsageReader.ts | 4 +- .../src/usage/usageTranscriptReader.test.ts | 133 +++++++++++++++- 4 files changed, 253 insertions(+), 53 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index e41df0a6fc2b..2ea476f70f5e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -464,6 +464,7 @@ export const make = Effect.gen(function* () { ); } } + const antigravityDirs = new Set(); for (const root of new Set(antigravityRoots)) { const nested = path.join(root, "conversations"); const dir = (yield* fileSystem @@ -471,19 +472,25 @@ export const make = Effect.gen(function* () { .pipe(Effect.catchCause(() => Effect.succeed(false)))) ? nested : root; + antigravityDirs.add(dir); + } + const antigravity = yield* Effect.promise(() => + readAntigravityUsage([...antigravityDirs], windowStartMs), + ); + for (const dir of antigravityDirs) { const exists = yield* fileSystem .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); - const result = yield* Effect.promise(() => readAntigravityUsage(dir, windowStartMs)); + const failed = antigravity.errors.some( + (error) => error === dir || error.startsWith(`${dir}${path.sep}`), + ); scanned.push({ provider: "antigravity", dir, volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), - files: !exists && result.errors.length === 0 ? null : result.files, - status: result.errors.length > 0 ? "partial" : "ok", - ...(result.errors.length > 0 - ? { message: "Some Antigravity history could not be read." } - : {}), + files: !exists && !failed ? null : antigravity.files.filter((file) => file.root === dir), + status: failed ? "partial" : "ok", + ...(failed ? { message: "Some Antigravity history could not be read." } : {}), }); } const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts index 2c6af17205de..ceaf48d87c86 100644 --- a/apps/server/src/usage/antigravityUsageReader.ts +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -149,7 +149,13 @@ function blob(value: unknown): Uint8Array { return value; } -async function readDatabase(path: string, fallbackTimestamp: number): Promise { +interface UsageCandidate { + record: UsageRecord; + keys: readonly string[]; + timestampQuality: number; +} + +async function readDatabase(path: string, fallbackTimestamp: number): Promise { const db = new NodeSqlite.DatabaseSync(path, { readOnly: true }); try { db.exec("PRAGMA busy_timeout = 100; BEGIN"); @@ -187,8 +193,7 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise(); + const records: UsageCandidate[] = []; let currentModel = ""; const generationModel = generations.findLast((entry) => entry.model)?.model ?? ""; for (const [source, entries] of [ @@ -221,9 +226,6 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise identities.get(key)) - .find((value) => value !== undefined); const record: UsageRecord = { provider: "antigravity", sessionId, @@ -238,32 +240,11 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise = []; +/** Reads and merges aliases across every configured Antigravity store before date filtering. */ +export async function readAntigravityUsage( + conversationsDirectories: string | readonly string[], + sinceMs: number, +) { + const roots = + typeof conversationsDirectories === "string" + ? [conversationsDirectories] + : conversationsDirectories; + const files: Array<{ root: string; path: string; records: UsageRecord[] }> = []; const errors: string[] = []; - const walk = async (directory: string): Promise => { + const identities = new Map(); + const groups: Array< + UsageCandidate & { parent: number; size: number; owner: number; fileIndex: number } + > = []; + const find = (index: number): number => { + let root = index; + while (groups[root]!.parent !== root) root = groups[root]!.parent; + while (index !== root) { + const parent = groups[index]!.parent; + groups[index]!.parent = root; + index = parent; + } + return root; + }; + const merge = (left: number, right: number): number => { + let a = find(left); + let b = find(right); + if (a === b) return a; + if (groups[a]!.size < groups[b]!.size) [a, b] = [b, a]; + const target = groups[a]!; + const source = groups[b]!; + const first = target.owner < source.owner ? target : source; + const bestTime = + source.timestampQuality > target.timestampQuality || + (source.timestampQuality === target.timestampQuality && + source.record.timestampMs < target.record.timestampMs) + ? source + : target; + const x = target.record.totals; + const y = source.record.totals; + target.record = { + ...first.record, + model: + first.record.model === "antigravity-unknown" + ? first === target + ? source.record.model + : target.record.model + : first.record.model, + timestampMs: bestTime.record.timestampMs, + totals: { + uncachedInputTokens: Math.max(x.uncachedInputTokens, y.uncachedInputTokens), + cachedInputTokens: Math.max(x.cachedInputTokens, y.cachedInputTokens), + cacheCreationTokens: Math.max(x.cacheCreationTokens, y.cacheCreationTokens), + outputTokens: Math.max(x.outputTokens, y.outputTokens), + reasoningTokens: Math.max(x.reasoningTokens, y.reasoningTokens), + }, + }; + target.timestampQuality = bestTime.timestampQuality; + target.owner = first.owner; + target.fileIndex = first.fileIndex; + target.size += source.size; + source.parent = a; + return a; + }; + const append = (candidate: UsageCandidate, fileIndex: number) => { + const index = groups.length; + groups.push({ ...candidate, parent: index, size: 1, owner: index, fileIndex }); + for (const key of candidate.keys) { + const existing = identities.get(key); + if (existing !== undefined) merge(index, existing); + identities.set(key, index); + } + }; + const visited = new Set(); + const walk = async (directory: string, root: string): Promise => { let entries; try { entries = await NodeFSP.readdir(directory, { withFileTypes: true }); @@ -286,25 +338,35 @@ export async function readAntigravityUsage(conversationsDirectory: string, since if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push(directory); return; } + entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { const path = NodePath.join(directory, entry.name); if (entry.isDirectory()) { - await walk(path); + await walk(path, root); } else if (entry.isFile() && entry.name.endsWith(".db")) { try { + const canonical = await NodeFSP.realpath(path); + if (visited.has(canonical)) continue; + visited.add(canonical); const stat = await NodeFSP.stat(path); - files.push({ - path, - records: (await readDatabase(path, stat.mtimeMs)).filter( - (record) => record.timestampMs >= sinceMs, - ), - }); + const candidates = await readDatabase(path, stat.mtimeMs); + const fileIndex = files.length; + files.push({ root, path, records: [] }); + for (const [index, candidate] of candidates.entries()) { + append(candidate, fileIndex); + if (index % 256 === 255) await NodeTimersPromises.setImmediate(); + } } catch { errors.push(path); } } } }; - await walk(conversationsDirectory); + for (const root of roots) await walk(root, root); + for (const [index, group] of groups.entries()) { + if (group.parent === index && group.record.timestampMs >= sinceMs) { + files[group.fileIndex]!.records.push(group.record); + } + } return { files, errors }; } diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 10df00c4d8dd..948c5a138be1 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -43,7 +43,6 @@ export function parseCursorBubble(key: string, source: string): UsageRecord | nu typeof modelName === "string" && modelName && modelName !== "default" ? modelName : "cursor-auto"; - const requestId = typeof bubble.requestId === "string" ? bubble.requestId : ""; return { provider: "cursor", timestampMs, @@ -57,7 +56,8 @@ export function parseCursorBubble(key: string, source: string): UsageRecord | nu reasoningTokens: 0, }, reportedCostUsd: null, - dedupeKey: requestId ? `cursor:request:${requestId}` : `cursor:${key}`, + // One request can produce several distinct token-bearing assistant bubbles. + dedupeKey: `cursor:${key}`, }; } diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 1902731df4f0..681b79e8091f 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -300,6 +300,7 @@ describe("SQLite usage readers", () => { tokenCount: { inputTokens: 120, outputTokens: 30 }, }; insert.run("bubbleId:session-1:assistant", JSON.stringify(bubble)); + insert.run("bubbleId:session-1:assistant", JSON.stringify(bubble)); insert.run("bubbleId:session-1:copy", JSON.stringify(bubble)); insert.run( "bubbleId:session-1:user", @@ -320,7 +321,11 @@ describe("SQLite usage readers", () => { const result = await readCursorUsage(path, 0); assert.isFalse(result.error); const records = result.files.flatMap((file) => file.records); - assert.strictEqual(records.length, 1); + assert.strictEqual(records.length, 2); + assert.strictEqual( + records.reduce((sum, record) => sum + record.totals.outputTokens, 0), + 60, + ); assert.strictEqual(records[0]?.model, "claude-sonnet-4-5"); assert.strictEqual(records[0]?.sessionId, "session-1"); assert.strictEqual(records[0]?.totals.uncachedInputTokens, 120); @@ -389,6 +394,132 @@ describe("SQLite usage readers", () => { ); }); + it("merges Antigravity aliases that bridge previously separate step records", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "bridge.db")); + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + const step = db.prepare("INSERT INTO steps VALUES (?, ?)"); + step.run( + 0, + new Uint8Array(protoBytes(9, [...protoNumber(2, 100), ...protoText(11, "response")])), + ); + step.run( + 1, + new Uint8Array(protoBytes(9, [...protoNumber(3, 40), ...protoText(12, "provider")])), + ); + db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run( + 0, + new Uint8Array( + protoBytes(1, [ + ...protoText(19, "Gemini 3 Pro"), + ...protoBytes(4, [ + ...protoNumber(2, 50), + ...protoNumber(5, 20), + ...protoText(11, "response"), + ...protoText(12, "provider"), + ]), + ]), + ), + ); + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 1); + assert.deepStrictEqual(records[0]?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 20, + cacheCreationTokens: 0, + outputTokens: 40, + reasoningTokens: 0, + }); + }); + + it("merges Antigravity provider and message aliases across configured roots while keeping original ownership", async () => { + const roots = [NodePath.join(dir, "first"), NodePath.join(dir, "second")]; + for (const [index, root] of roots.entries()) { + await NodeFSP.mkdir(root); + const db = new NodeSqlite.DatabaseSync(NodePath.join(root, `session-${index}.db`)); + try { + db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + for (const identity of [7, 12]) { + const usage = [ + ...protoNumber(1, 246), + ...protoNumber(2, index === 0 ? 100 : 150), + ...protoText(11, `response-${index}-${identity}`), + ...protoText(identity, `shared-${identity}`), + ]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run( + identity, + new Uint8Array(protoBytes(9, usage)), + ); + } + } finally { + db.close(); + } + } + const result = await readAntigravityUsage(roots, 0); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0]?.root, roots[0]); + assert.strictEqual(result.files[0]?.records.length, 2); + assert.strictEqual(result.files[1]?.records.length, 0); + assert.deepStrictEqual( + result.files[0]?.records.map((record) => record.totals.uncachedInputTokens), + [150, 150], + ); + assert.isTrue(result.files[0]?.records.every((record) => record.sessionId === "session-0")); + }); + + it("upgrades Antigravity fallback timestamps before applying the date window", async () => { + for (const fallback of ["mtime", "trajectory"]) { + const path = NodePath.join(dir, `${fallback}.db`); + const db = new NodeSqlite.DatabaseSync(path); + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + if (fallback === "trajectory") { + db.exec("CREATE TABLE trajectory_metadata_blob (data BLOB)"); + db.prepare("INSERT INTO trajectory_metadata_blob VALUES (?)").run( + new Uint8Array(protoBytes(2, protoNumber(1, 1780000200))), + ); + } + for (const [index, seconds] of [1780000000, 1780000200].entries()) { + const usage = [...protoNumber(2, 10), ...protoText(11, `${fallback}-${index}`)]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run( + index, + new Uint8Array(protoBytes(9, usage)), + ); + db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run( + index, + new Uint8Array( + protoBytes(1, [ + ...protoBytes(4, usage), + ...protoBytes(9, protoBytes(4, protoNumber(1, seconds))), + ]), + ), + ); + } + } finally { + db.close(); + } + await NodeFSP.utimes(path, 1780000000, 1780000000); + } + const result = await readAntigravityUsage(dir, 1780000100000); + assert.deepStrictEqual(result.errors, []); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 2); + assert.deepStrictEqual( + records.map((record) => record.timestampMs), + [1780000200000, 1780000200000], + ); + }); + it("reads Antigravity step-only stores and reports malformed databases", async () => { const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "steps.db")); try { From 6e632a4b5a91dffe4e73b4953ed1ca6ac10c434c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 6 Sep 2026 23:19:24 +0000 Subject: [PATCH 03/28] fix(usage): read headless cursor account history --- apps/server/src/usage/UsageService.test.ts | 2 +- apps/server/src/usage/UsageService.ts | 47 ++++- apps/server/src/usage/cursorUsageReader.ts | 166 ++++++++++++++++++ .../src/usage/usageTranscriptReader.test.ts | 81 ++++++++- docs/user/usage.md | 9 +- packages/shared/src/usageMerge.test.ts | 35 ++++ 6 files changed, 329 insertions(+), 11 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 86c8e79b161c..9a3e5e78cdb4 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -137,7 +137,7 @@ describe("UsageService", () => { ); assert.include( summary.sources.find((source) => source.fingerprint.provider === "cursor")?.message ?? "", - "Cursor CLI does not save token totals", + "Cursor account history needs a Cursor CLI login", ); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 2ea476f70f5e..bf1a7a5c3f13 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -48,7 +48,7 @@ import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { resolveAntigravityProfileDirectory } from "../provider/antigravityAuthSupport.ts"; import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; -import { readCursorUsage } from "./cursorUsageReader.ts"; +import { readCursorAccountUsage, readCursorUsage } from "./cursorUsageReader.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -378,6 +378,7 @@ export const make = Effect.gen(function* () { readonly provider: UsageProviderKind; readonly dir: string; readonly volumeId: string; + readonly hostId?: string; readonly status?: UsageSource["status"]; readonly message?: string; /** Parsed records per file, or `null` when the directory does not exist. */ @@ -503,6 +504,28 @@ export const make = Effect.gen(function* () { ? configHome : path.join(home, ".config"); const cursorPath = path.join(cursorHome, "Cursor", "User", "globalStorage", "state.vscdb"); + const cursorAuthPath = + platform === "darwin" + ? path.join(home, ".cursor", "auth.json") + : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); + const cursorUntilMs = yield* Clock.currentTimeMillis; + const account = yield* Effect.promise(() => + readCursorAccountUsage(cursorAuthPath, windowStartMs, cursorUntilMs), + ); + if (account.accountKey !== null && account.error === null && !account.missing) { + // The same account includes CLI and desktop history from every machine. + // A stable remote fingerprint prevents connected environments counting it twice. + const source = `cursor-account:${account.accountKey}`; + scanned.push({ + provider: "cursor", + dir: source, + hostId: "cursor.com", + volumeId: account.accountKey, + files: [{ path: source, records: account.records }], + status: "ok", + }); + return scanned; + } const cursor = yield* Effect.promise(() => readCursorUsage(cursorPath, windowStartMs)); scanned.push({ provider: "cursor", @@ -511,7 +534,8 @@ export const make = Effect.gen(function* () { files: cursor.missing && !cursor.error ? null : cursor.files, status: "partial", message: - "Cursor local history is incomplete. Cursor CLI does not save token totals; check the Cursor Usage Dashboard for complete usage.", + account.error ?? + "Cursor account history needs a Cursor CLI login saved on this server. Only available desktop token counts are shown.", }); return scanned; }); @@ -587,10 +611,23 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir, volumeId, files, status, message } of scannedDirs) { + for (const { + provider, + dir, + volumeId, + files, + status, + message, + hostId: sourceHostId, + } of scannedDirs) { if (files === null) { sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + fingerprint: { + hostId: sourceHostId ?? hostId, + provider, + resolvedHomePath: dir, + volumeId, + }, status: "missing", scannedFiles: 0, skippedFiles: 0, @@ -625,7 +662,7 @@ export const make = Effect.gen(function* () { } sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId }, status: status ?? "ok", scannedFiles, skippedFiles, diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 948c5a138be1..bfc87c626002 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; +import * as NodeCrypto from "node:crypto"; import * as NodeSqlite from "node:sqlite"; import * as NodeTimersPromises from "node:timers/promises"; @@ -67,6 +68,171 @@ export interface CursorUsageReadResult { readonly error: boolean; } +export interface CursorAccountUsageReadResult { + readonly accountKey: string | null; + readonly records: readonly UsageRecord[]; + readonly missing: boolean; + readonly error: string | null; +} + +const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); + +/** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */ +export async function readCursorAccountUsage( + authPath: string, + sinceMs: number, + endDate: number, + request: (url: string, init: RequestInit) => Promise = globalThis.fetch, +): Promise { + let auth: Record; + try { + auth = object(JSON.parse(await NodeFSP.readFile(authPath, "utf8"))); + } catch (cause) { + const missing = object(cause).code === "ENOENT"; + return { + accountKey: null, + records: [], + missing, + error: missing ? null : "Cursor credentials could not be read.", + }; + } + if (typeof auth.accessToken !== "string" || !auth.accessToken) { + return { accountKey: null, records: [], missing: true, error: null }; + } + let accountKey: string | null = null; + try { + const payload = auth.accessToken.split(".")[1]; + const subject = object( + JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")), + ).sub; + if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication"); + const userId = subject.split("|").at(-1); + if (!userId) throw new Error("Invalid authentication"); + accountKey = accountHash(subject); + if (!Number.isFinite(sinceMs) || sinceMs < 0 || sinceMs > endDate) + throw new Error("Invalid date window"); + const signal = AbortSignal.timeout(10_000); + const records: UsageRecord[] = []; + const occurrences = new Map(); + const pages = new Set(); + const pageSize = 1000; + let total: number | undefined; + let received = 0; + for (let page = 1; page <= 100; page++) { + const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { + method: "POST", + redirect: "error", + signal, + headers: { + "Content-Type": "application/json", + Origin: "https://cursor.com", + Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${auth.accessToken}`)}`, + }, + body: JSON.stringify({ + page, + pageSize, + startDate: String(sinceMs), + endDate: String(endDate), + }), + }); + if (response.status === 401 || response.status === 403) { + return { + accountKey, + records: [], + missing: false, + error: "Sign in to Cursor again to read account usage.", + }; + } + if (!response.ok) throw new Error("Account usage request failed"); + const body = object(await response.json()); + const count = body.totalUsageEventsCount; + const events = body.usageEventsDisplay; + if ( + typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 0 || + count > pageSize * 100 || + (total !== undefined && count !== total) || + !Array.isArray(events) || + events.length !== Math.min(pageSize, count - received) + ) { + throw new Error("Inconsistent account usage page"); + } + total = count; + const signature = accountHash(JSON.stringify(events)); + if (pages.has(signature)) throw new Error("Repeated account usage page"); + pages.add(signature); + for (const raw of events) { + const event = object(raw); + const usage = object(event.tokenUsage); + if (event.tokenUsage === undefined || event.tokenUsage === null) continue; + for (const key of [ + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "totalCents", + ]) { + const value = usage[key]; + if ( + value !== undefined && + (typeof value !== "number" || !Number.isFinite(value) || value < 0) + ) { + throw new Error("Invalid account usage totals"); + } + } + const timestampMs = + typeof event.timestamp === "string" && event.timestamp.trim() !== "" + ? Number(event.timestamp) + : event.timestamp; + if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + typeof event.model !== "string" || + !event.model + ) + throw new Error("Invalid account usage event"); + if (timestampMs < sinceMs || timestampMs > endDate) continue; + const totals = { + uncachedInputTokens: tokens(usage.inputTokens), + cachedInputTokens: tokens(usage.cacheReadTokens), + cacheCreationTokens: tokens(usage.cacheWriteTokens), + outputTokens: tokens(usage.outputTokens), + reasoningTokens: 0, + }; + const reportedCostUsd = + typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; + const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; + // No event ID is provided. Preserve identical billed rows with an occurrence index. + const key = accountHash( + JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]), + ); + const occurrence = occurrences.get(key) ?? 0; + occurrences.set(key, occurrence + 1); + records.push({ + provider: "cursor", + timestampMs, + model: event.model, + sessionId, + totals, + reportedCostUsd, + dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, + }); + } + received += events.length; + if (received === total) return { accountKey, records, missing: false, error: null }; + } + throw new Error("Account usage page limit exceeded"); + } catch { + return { + accountKey, + records: [], + missing: false, + error: "Cursor account usage could not be read.", + }; + } +} + /** Cursor's desktop store only exposes counts for some versions and requests. */ export async function readCursorUsage( dbPath: string, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 681b79e8091f..6e5ba3971975 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -10,7 +10,7 @@ import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; import { readTranscriptRecords } from "./usageTranscriptReader.ts"; import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; -import { readCursorUsage } from "./cursorUsageReader.ts"; +import { readCursorAccountUsage, readCursorUsage } from "./cursorUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; let dir: string; @@ -237,6 +237,85 @@ describe("readTranscriptRecords resume", () => { }); describe("SQLite usage readers", () => { + it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const pages: number[] = []; + const request = async (url: string, init: RequestInit) => { + assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events"); + assert.strictEqual(init?.redirect, "error"); + const headers = new Headers(init?.headers); + assert.strictEqual(headers.get("origin"), "https://cursor.com"); + assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); + const body = JSON.parse(String(init?.body)); + pages.push(body.page); + return Response.json({ + totalUsageEventsCount: 1001, + usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ + timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000), + model: "claude-sonnet-4-5", + conversationId: `conversation-${body.page}`, + isHeadless: body.page === 2, + chargedCents: 0, + tokenUsage: { + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 30, + cacheWriteTokens: 2, + totalCents: 25, + }, + })), + }); + }; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request); + assert.isNull(result.error); + assert.deepStrictEqual(pages, [1, 2]); + assert.strictEqual(result.records.length, 1001); + assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); + assert.deepStrictEqual(result.records[0]?.totals, { + uncachedInputTokens: 10, + cachedInputTokens: 30, + cacheCreationTokens: 2, + outputTokens: 5, + reasoningTokens: 0, + }); + assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); + assert.isFalse(result.accountKey?.includes("demo") ?? true); + }); + + it("does not present truncated Cursor account pages or authentication failures as complete history", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), + ); + assert.isNotNull(truncated.error); + assert.deepStrictEqual(truncated.records, []); + const denied = await readCursorAccountUsage( + authPath, + 0, + 1781000000000, + async () => new Response(accessToken, { status: 401 }), + ); + assert.isNotNull(denied.error); + assert.isFalse(denied.error?.includes(accessToken) ?? true); + assert.deepStrictEqual(denied.records, []); + let requested = false; + const missing = await readCursorAccountUsage( + NodePath.join(dir, "missing.json"), + 0, + 1781000000000, + async () => { + requested = true; + return Response.json({}); + }, + ); + assert.isTrue(missing.missing); + assert.isFalse(requested); + }); + it("counts migrated OpenCode messages once and sees subsequent WAL writes", async () => { const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "opencode.db")); try { diff --git a/docs/user/usage.md b/docs/user/usage.md index e454a5b218c9..b3699361ae6d 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -2,7 +2,7 @@ ## Understand your usage -**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and available Cursor desktop history from your connected +**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent cost. These estimates are not your subscription bill. @@ -13,9 +13,10 @@ OpenCode reads its SQLite database and older JSON history. Antigravity reads loc databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the server to read a different data directory; comma-separated paths read multiple directories. -Cursor coverage is partial: only saved desktop token counts are included. Cursor CLI transcripts -do not contain token totals, and recent desktop versions often omit them too. Use Cursor's Usage -Dashboard for complete usage. T3 does not estimate missing tokens from conversation text. +Cursor reads account usage from Cursor's dashboard API using the CLI login saved on the server. +This includes headless T3 sessions and desktop usage across machines; the same account counts +once across connected environments. Without an accessible file-based CLI login, T3 falls back +to partial desktop history and shows a notice. T3 does not estimate missing tokens from conversation text. On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All environments are selected by default. The dropdown shows which environments are still scanning; diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index fab145f1da2c..d27a6277ef66 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -75,6 +75,41 @@ function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { } describe("mergeUsage", () => { + it("counts a Cursor account once across servers while retaining each server's other providers", () => { + const account = { + provider: "cursor" as const, + hostId: "cursor.com", + homePath: "cursor-account:account-hash", + volumeId: "account-hash", + }; + const merged = mergeUsage( + [ + environment( + "mac", + summary([bucket({ provider: "cursor", sourcePath: account.homePath })], [account]), + ), + environment( + "linux", + summary( + [ + bucket({ provider: "cursor", sourcePath: account.homePath }), + bucket({ provider: "opencode", sourcePath: "/opencode" }), + ], + [account, { provider: "opencode", hostId: "linux", homePath: "/opencode" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + expect( + merged.providers.map((provider) => [provider.provider, provider.costUsd]).sort(), + ).toEqual([ + ["cursor", 10], + ["opencode", 10], + ]); + expect(merged.duplicateSources).toHaveLength(1); + }); + it("sums environments that read different transcript directories", () => { const merged = mergeUsage( [ From 0d1357ff7df8bdff45b6f2a3a9a741d7f10342db Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 6 Sep 2026 23:33:37 +0000 Subject: [PATCH 04/28] fix(usage): reconcile cursor pagination and remove overlapping fallback --- apps/server/src/usage/UsageService.test.ts | 96 +++++---- apps/server/src/usage/UsageService.ts | 15 +- apps/server/src/usage/cursorUsageReader.ts | 189 +++++++----------- .../src/usage/usageTranscriptReader.test.ts | 155 +++++++++----- docs/user/usage.md | 4 +- 5 files changed, 247 insertions(+), 212 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9a3e5e78cdb4..dd7d9350a118 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -3,6 +3,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -24,6 +25,8 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { return `${JSON.stringify({ type: "assistant", @@ -106,40 +109,65 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { - it.live("includes OpenCode history with its source and reports Cursor's missing coverage", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - const root = NodePath.join(home, "opencode"); - const message = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ - id: "msg_1", - sessionID: "session-1", - role: "assistant", - modelID: "example-model", - time: { created: Date.parse("2026-08-01T10:00:00Z") }, - tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 3 } }, - }); - yield* Effect.promise(async () => { - const directory = NodePath.join(root, "storage", "message", "session-1"); - await NodeFSP.mkdir(directory, { recursive: true }); - await NodeFSP.writeFile(NodePath.join(directory, "msg_1.json"), message); - }); - const service = yield* UsageService.make.pipe( - Effect.provide(serviceLayers({ prefix: "usage-service-opencode", home, settings })), - ); - const summary = yield* service.readSummary(WINDOW); - assert.strictEqual(summary.buckets[0]?.provider, "opencode"); - assert.strictEqual(summary.buckets[0]?.sourcePath, root); - assert.strictEqual(summary.buckets[0]?.totals.outputTokens, 7); - assert.strictEqual( - summary.sources.find((source) => source.fingerprint.provider === "opencode") - ?.distinctSessions, - 1, - ); - assert.include( - summary.sources.find((source) => source.fingerprint.provider === "cursor")?.message ?? "", - "Cursor account history needs a Cursor CLI login", - ); - }).pipe(Effect.scoped), + it.live( + "includes OpenCode history but does not substitute desktop usage for an unavailable Cursor account", + () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const root = NodePath.join(home, "opencode"); + const message = yield* encodeUnknownJson({ + id: "msg_1", + sessionID: "session-1", + role: "assistant", + modelID: "example-model", + time: { created: Date.parse("2026-08-01T10:00:00Z") }, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 3 } }, + }); + const bubble = yield* encodeUnknownJson({ + type: 2, + createdAt: "2026-08-01T10:00:00Z", + modelInfo: { modelName: "example-model" }, + tokenCount: { inputTokens: 100, outputTokens: 20 }, + }); + yield* Effect.promise(async () => { + const directory = NodePath.join(root, "storage", "message", "session-1"); + await NodeFSP.mkdir(directory, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(directory, "msg_1.json"), message); + const desktop = NodePath.join(home, "config", "Cursor", "User", "globalStorage"); + await NodeFSP.mkdir(desktop, { recursive: true }); + const db = new NodeSqlite.DatabaseSync(NodePath.join(desktop, "state.vscdb")); + try { + db.exec("CREATE TABLE cursorDiskKV (key TEXT, value TEXT)"); + db.prepare("INSERT INTO cursorDiskKV VALUES (?, ?)").run( + "bubbleId:session:assistant", + bubble, + ); + } finally { + db.close(); + } + }); + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-opencode", home, settings })), + ); + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(summary.buckets[0]?.provider, "opencode"); + assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "cursor")?.status, + "missing", + ); + assert.strictEqual(summary.buckets[0]?.sourcePath, root); + assert.strictEqual(summary.buckets[0]?.totals.outputTokens, 7); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "opencode") + ?.distinctSessions, + 1, + ); + assert.include( + summary.sources.find((source) => source.fingerprint.provider === "cursor")?.message ?? "", + "Cursor account history needs a Cursor CLI login", + ); + }).pipe(Effect.scoped), ); it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index bf1a7a5c3f13..6749f794a93d 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -48,7 +48,7 @@ import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { resolveAntigravityProfileDirectory } from "../provider/antigravityAuthSupport.ts"; import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; -import { readCursorAccountUsage, readCursorUsage } from "./cursorUsageReader.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -503,7 +503,6 @@ export const make = Effect.gen(function* () { : configHome && path.isAbsolute(configHome) ? configHome : path.join(home, ".config"); - const cursorPath = path.join(cursorHome, "Cursor", "User", "globalStorage", "state.vscdb"); const cursorAuthPath = platform === "darwin" ? path.join(home, ".cursor", "auth.json") @@ -526,16 +525,14 @@ export const make = Effect.gen(function* () { }); return scanned; } - const cursor = yield* Effect.promise(() => readCursorUsage(cursorPath, windowStartMs)); scanned.push({ provider: "cursor", - dir: cursorPath, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorPath)), - files: cursor.missing && !cursor.error ? null : cursor.files, - status: "partial", + dir: cursorAuthPath, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), + // Never combine a local fallback with another server's account-wide history. + files: null, message: - account.error ?? - "Cursor account history needs a Cursor CLI login saved on this server. Only available desktop token counts are shown.", + account.error ?? "Cursor account history needs a Cursor CLI login saved on this server.", }); return scanned; }); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index bfc87c626002..0acd81ba8827 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -1,7 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodeCrypto from "node:crypto"; -import * as NodeSqlite from "node:sqlite"; import * as NodeTimersPromises from "node:timers/promises"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -16,58 +15,6 @@ function tokens(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; } -/** Only recorded token counts count as usage; text length and context meters do not. */ -export function parseCursorBubble(key: string, source: string): UsageRecord | null { - const match = /^bubbleId:([^:]+):(.+)$/.exec(key); - if (match === null) return null; - let parsed: unknown; - try { - parsed = JSON.parse(source); - } catch { - return null; - } - const bubble = object(parsed); - if (bubble.type !== 2) return null; - const usage = object(bubble.tokenCount); - const inputTokens = tokens(usage.inputTokens); - const outputTokens = tokens(usage.outputTokens); - if (inputTokens + outputTokens === 0) return null; - const timestampMs = - typeof bubble.createdAt === "number" - ? bubble.createdAt - : typeof bubble.createdAt === "string" - ? Date.parse(bubble.createdAt) - : NaN; - if (!Number.isFinite(timestampMs)) return null; - const modelName = object(bubble.modelInfo).modelName; - const model = - typeof modelName === "string" && modelName && modelName !== "default" - ? modelName - : "cursor-auto"; - return { - provider: "cursor", - timestampMs, - model, - sessionId: match[1] ?? "", - totals: { - uncachedInputTokens: inputTokens, - cachedInputTokens: 0, - cacheCreationTokens: 0, - outputTokens, - reasoningTokens: 0, - }, - reportedCostUsd: null, - // One request can produce several distinct token-bearing assistant bubbles. - dedupeKey: `cursor:${key}`, - }; -} - -export interface CursorUsageReadResult { - readonly files: readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]; - readonly missing: boolean; - readonly error: boolean; -} - export interface CursorAccountUsageReadResult { readonly accountKey: string | null; readonly records: readonly UsageRecord[]; @@ -77,6 +24,30 @@ export interface CursorAccountUsageReadResult { const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** Find the longest exact suffix/prefix overlap in linear time. */ +function boundaryOverlap(previous: readonly string[], current: readonly string[]): number { + const sequence = [...current, "", ...previous]; + const lengths = Array.from({ length: sequence.length }, () => 0); + for (let index = 1; index < sequence.length; index++) { + let length = lengths[index - 1]!; + while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!; + if (sequence[index] === sequence[length]) length++; + lengths[index] = length; + } + return lengths.at(-1) ?? 0; +} + /** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */ export async function readCursorAccountUsage( authPath: string, @@ -109,15 +80,15 @@ export async function readCursorAccountUsage( const userId = subject.split("|").at(-1); if (!userId) throw new Error("Invalid authentication"); accountKey = accountHash(subject); - if (!Number.isFinite(sinceMs) || sinceMs < 0 || sinceMs > endDate) + if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) throw new Error("Invalid date window"); const signal = AbortSignal.timeout(10_000); const records: UsageRecord[] = []; const occurrences = new Map(); - const pages = new Set(); + const pages: unknown[][] = []; + let completed = false; const pageSize = 1000; let total: number | undefined; - let received = 0; for (let page = 1; page <= 100; page++) { const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { method: "POST", @@ -144,25 +115,51 @@ export async function readCursorAccountUsage( }; } if (!response.ok) throw new Error("Account usage request failed"); - const body = object(await response.json()); - const count = body.totalUsageEventsCount; - const events = body.usageEventsDisplay; + const parsed: unknown = await response.json(); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid account usage page"); + } + const body = object(parsed); + const keys = Object.keys(body); + if ("error" in body || "message" in body || "code" in body) + throw new Error("Account usage error response"); + const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; + const events = + keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") + ? [] + : body.usageEventsDisplay; if ( - typeof count !== "number" || - !Number.isSafeInteger(count) || - count < 0 || - count > pageSize * 100 || - (total !== undefined && count !== total) || + (count !== undefined && + (typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 0 || + count > pageSize * 100 || + (total !== undefined && count !== total))) || !Array.isArray(events) || - events.length !== Math.min(pageSize, count - received) + events.length > pageSize || + (count === undefined && !Array.isArray(body.usageEventsDisplay)) ) { throw new Error("Inconsistent account usage page"); } - total = count; - const signature = accountHash(JSON.stringify(events)); - if (pages.has(signature)) throw new Error("Repeated account usage page"); - pages.add(signature); - for (const raw of events) { + if (typeof count === "number") total = count; + pages.push(events); + if (events.length < pageSize) { + completed = true; + break; + } + } + if (!completed) throw new Error("Account usage page limit exceeded"); + const rawCount = pages.reduce((sum, page) => sum + page.length, 0); + if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages"); + let removalsRemaining = total === undefined ? 0 : rawCount - total; + let previousKeys: string[] = []; + for (const events of pages) { + const eventKeys = + removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : []; + const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys)); + removalsRemaining -= removalCount; + previousKeys = eventKeys; + for (const raw of events.slice(removalCount)) { const event = object(raw); const usage = object(event.tokenUsage); if (event.tokenUsage === undefined || event.tokenUsage === null) continue; @@ -219,10 +216,10 @@ export async function readCursorAccountUsage( dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, }); } - received += events.length; - if (received === total) return { accountKey, records, missing: false, error: null }; + await NodeTimersPromises.setImmediate(); } - throw new Error("Account usage page limit exceeded"); + if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries"); + return { accountKey, records, missing: false, error: null }; } catch { return { accountKey, @@ -232,49 +229,3 @@ export async function readCursorAccountUsage( }; } } - -/** Cursor's desktop store only exposes counts for some versions and requests. */ -export async function readCursorUsage( - dbPath: string, - sinceMs: number, -): Promise { - try { - await NodeFSP.stat(dbPath); - } catch (cause) { - const missing = object(cause).code === "ENOENT"; - return { files: [], missing, error: !missing }; - } - const records: UsageRecord[] = []; - const files = [{ path: dbPath, records }]; - const seen = new Set(); - let database: NodeSqlite.DatabaseSync | undefined; - try { - database = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); - database.exec("PRAGMA busy_timeout = 100"); - const statement = database.prepare( - "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'", - ); - let count = 0; - for (const row of statement.iterate()) { - const source = - typeof row.value === "string" - ? row.value - : row.value instanceof Uint8Array - ? Buffer.from(row.value).toString("utf8") - : ""; - const record = parseCursorBubble(typeof row.key === "string" ? row.key : "", source); - if (record !== null && record.timestampMs >= sinceMs) { - if (record.dedupeKey === null || !seen.has(record.dedupeKey)) { - if (record.dedupeKey !== null) seen.add(record.dedupeKey); - records.push(record); - } - } - if (++count % 256 === 0) await NodeTimersPromises.setImmediate(); - } - return { files, missing: false, error: false }; - } catch { - return { files, missing: false, error: true }; - } finally { - database?.close(); - } -} diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 6e5ba3971975..c33a8e85be0e 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -10,7 +10,7 @@ import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; import { readTranscriptRecords } from "./usageTranscriptReader.ts"; import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; -import { readCursorAccountUsage, readCursorUsage } from "./cursorUsageReader.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; let dir: string; @@ -284,6 +284,112 @@ describe("SQLite usage readers", () => { assert.isFalse(result.accountKey?.includes("demo") ?? true); }); + it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + for (const body of [ + {}, + { totalUsageEventsCount: 0 }, + { totalUsageEventsCount: 0, usageEventsDisplay: [] }, + ]) { + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json(body), + ); + assert.isNull(result.error); + assert.deepStrictEqual(result.records, []); + assert.isFalse(result.missing); + } + for (const body of [ + { error: "upstream error" }, + { detail: "unknown error envelope" }, + { totalUsageEventsCount: 0, error: "upstream error" }, + null, + [], + "invalid", + 0, + ]) { + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json(body), + ); + assert.isNotNull(result.error); + assert.deepStrictEqual(result.records, []); + } + }); + + it("requires a terminal Cursor page after a full page reaches the reported count", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json( + requests === 1 + ? { + totalUsageEventsCount: 1000, + usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({ + timestamp: String(1780000000000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 10, outputTokens: 5 }, + })), + } + : { totalUsageEventsCount: 1000 }, + ); + }); + assert.isNull(result.error); + assert.strictEqual(result.records.length, 1000); + assert.strictEqual(requests, 2); + }); + + it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const event = (index: number) => ({ + timestamp: String(1780000000000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, + }); + for (const total of [2000, 2001]) { + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json({ + totalUsageEventsCount: total, + usageEventsDisplay: + requests === 1 + ? Array.from({ length: 1000 }, (_, index) => event(index)) + : requests === 2 + ? Array.from({ length: 1000 }, (_, index) => event(999 + index)) + : [event(1999)], + }); + }); + assert.isNull(result.error); + assert.strictEqual(result.records.length, total); + assert.strictEqual(requests, 3); + assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999); + assert.strictEqual( + result.records.filter((record) => record.timestampMs === 1780000000999).length, + total === 2000 ? 1 : 2, + ); + assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); + } + let requests = 0; + const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json({ + totalUsageEventsCount: 1001, + usageEventsDisplay: + requests === 1 + ? Array.from({ length: 1000 }, (_, index) => event(index)) + : [event(500), event(1000)], + }); + }); + assert.isNotNull(inconsistent.error); + assert.deepStrictEqual(inconsistent.records, []); + }); + it("does not present truncated Cursor account pages or authentication failures as complete history", async () => { const authPath = NodePath.join(dir, "auth.json"); const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; @@ -365,53 +471,6 @@ describe("SQLite usage readers", () => { } }); - it("counts Cursor assistant usage without estimating zero-count or user bubbles", async () => { - const path = NodePath.join(dir, "state.vscdb"); - const db = new NodeSqlite.DatabaseSync(path); - try { - db.exec("CREATE TABLE cursorDiskKV (key TEXT, value TEXT)"); - const insert = db.prepare("INSERT INTO cursorDiskKV VALUES (?, ?)"); - const bubble = { - type: 2, - createdAt: "2026-08-01T10:00:00Z", - requestId: "request-1", - modelInfo: { modelName: "claude-sonnet-4-5" }, - tokenCount: { inputTokens: 120, outputTokens: 30 }, - }; - insert.run("bubbleId:session-1:assistant", JSON.stringify(bubble)); - insert.run("bubbleId:session-1:assistant", JSON.stringify(bubble)); - insert.run("bubbleId:session-1:copy", JSON.stringify(bubble)); - insert.run( - "bubbleId:session-1:user", - JSON.stringify({ ...bubble, type: 1, requestId: "user-request" }), - ); - insert.run( - "bubbleId:session-1:zero", - JSON.stringify({ - ...bubble, - requestId: "zero", - tokenCount: { inputTokens: 0, outputTokens: 0 }, - text: "real text without recorded usage", - }), - ); - } finally { - db.close(); - } - const result = await readCursorUsage(path, 0); - assert.isFalse(result.error); - const records = result.files.flatMap((file) => file.records); - assert.strictEqual(records.length, 2); - assert.strictEqual( - records.reduce((sum, record) => sum + record.totals.outputTokens, 0), - 60, - ); - assert.strictEqual(records[0]?.model, "claude-sonnet-4-5"); - assert.strictEqual(records[0]?.sessionId, "session-1"); - assert.strictEqual(records[0]?.totals.uncachedInputTokens, 120); - assert.strictEqual(records[0]?.totals.outputTokens, 30); - assert.strictEqual(records[0]?.timestampMs, Date.parse("2026-08-01T10:00:00Z")); - }); - it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => { const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db")); const stamp = protoNumber(1, 1780000000); diff --git a/docs/user/usage.md b/docs/user/usage.md index b3699361ae6d..c0bf77606691 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -15,8 +15,8 @@ server to read a different data directory; comma-separated paths read multiple d Cursor reads account usage from Cursor's dashboard API using the CLI login saved on the server. This includes headless T3 sessions and desktop usage across machines; the same account counts -once across connected environments. Without an accessible file-based CLI login, T3 falls back -to partial desktop history and shows a notice. T3 does not estimate missing tokens from conversation text. +once across connected environments. Without an accessible file-based CLI login, T3 shows a +notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All environments are selected by default. The dropdown shows which environments are still scanning; From aa20d4455972bdf0b2de27a55d298395ec1f621b Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 21:17:52 -0500 Subject: [PATCH 05/28] fix(usage): keep OpenCode parser internal --- apps/server/src/usage/opencodeUsageReader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts index 8b51924d6327..1bfc8d1bc597 100644 --- a/apps/server/src/usage/opencodeUsageReader.ts +++ b/apps/server/src/usage/opencodeUsageReader.ts @@ -21,7 +21,7 @@ function text(value: unknown): string { } /** OpenCode stores uncached input and reasoning separately from input/output. */ -export function parseOpenCodeMessage( +function parseOpenCodeMessage( source: string, fallback: { readonly id?: string; From bb6789686b2ae3a56b4186d2db9ee24d88d95287 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 21:21:18 -0500 Subject: [PATCH 06/28] fix(usage): honor active Cursor credential store --- apps/server/src/usage/UsageService.test.ts | 45 +++++++++++++++++++++- apps/server/src/usage/UsageService.ts | 29 ++++++++++---- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 40228896e28c..5cdf37cd9d02 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -84,10 +84,11 @@ const serviceLayers = (input: { /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; readonly environment?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -104,6 +105,7 @@ const serviceLayers = (input: { ), Layer.provideMerge( Layer.succeed(HostProcessEnvironment, { + HOME: input.home, GROK_HOME: NodePath.join(input.home, "grok"), OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"), @@ -119,6 +121,47 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("ignores stale Cursor file logins when the active credential store differs", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + for (const [index, testCase] of [ + { platform: "darwin" as const, environment: {}, authPath: [".cursor", "auth.json"] }, + { + platform: "linux" as const, + environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, + authPath: ["config", "cursor", "auth.json"], + }, + { + platform: "linux" as const, + environment: { CURSOR_API_KEY: "different-account" }, + authPath: ["config", "cursor", "auth.json"], + }, + ].entries()) { + const authPath = NodePath.join(home, ...testCase.authPath); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); + await NodeFSP.writeFile(authPath, encodeUnknownJsonString({ accessToken: "stale-token" })); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: `usage-service-cursor-store-${index}`, + home, + settings, + platform: testCase.platform, + environment: testCase.environment, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.status, "missing"); + assert.include(cursor?.message ?? "", "file-based CLI login"); + assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); + } + }).pipe(Effect.scoped), + ); + it.live( "includes OpenCode history but does not substitute desktop usage for an unavailable Cursor account", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 2882fd8be0c9..87a24c87b554 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -576,23 +576,38 @@ export const make = Effect.gen(function* () { ...(failed ? { message: "Some Antigravity history could not be read." } : {}), }); } + const cursorUserHome = + (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); const cursorHome = platform === "darwin" - ? path.join(home, "Library", "Application Support") + ? path.join(cursorUserHome, "Library", "Application Support") : platform === "win32" - ? hostEnvironment["APPDATA"] || path.join(home, "AppData", "Roaming") + ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming") : configHome && path.isAbsolute(configHome) ? configHome - : path.join(home, ".config"); + : path.join(cursorUserHome, ".config"); const cursorAuthPath = platform === "darwin" - ? path.join(home, ".cursor", "auth.json") + ? path.join(cursorUserHome, ".cursor", "auth.json") : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); + const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; + const fileLoginUnavailable = + Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || + Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || + credentialStore === "memory" || + (platform === "darwin" && credentialStore !== "file"); const cursorUntilMs = yield* Clock.currentTimeMillis; - const account = yield* Effect.promise(() => - readCursorAccountUsage(cursorAuthPath, windowStartMs, cursorUntilMs), - ); + const account = fileLoginUnavailable + ? { + accountKey: null, + records: [], + missing: true, + error: "Cursor account history needs a file-based CLI login on this server.", + } + : yield* Effect.promise(() => + readCursorAccountUsage(cursorAuthPath, windowStartMs, cursorUntilMs), + ); if (account.accountKey !== null && account.error === null && !account.missing) { // The same account includes CLI and desktop history from every machine. // A stable remote fingerprint prevents connected environments counting it twice. From 9e1ed755113645b4b4f53a8afb35ccd0acf2e0ed Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 21:23:54 -0500 Subject: [PATCH 07/28] chore(usage): explain Node reader imports --- apps/server/src/usage/UsageService.test.ts | 5 ++++- apps/server/src/usage/antigravityUsageReader.ts | 2 +- apps/server/src/usage/cursorUsageReader.ts | 2 +- apps/server/src/usage/opencodeUsageReader.ts | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 5cdf37cd9d02..d72d2cdff6d4 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -140,7 +140,10 @@ describe("UsageService", () => { const authPath = NodePath.join(home, ...testCase.authPath); yield* Effect.promise(async () => { await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); - await NodeFSP.writeFile(authPath, encodeUnknownJsonString({ accessToken: "stale-token" })); + await NodeFSP.writeFile( + authPath, + encodeUnknownJsonString({ accessToken: "stale-token" }), + ); }); const service = yield* UsageService.make.pipe( Effect.provide( diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts index ceaf48d87c86..3d5fa81dac8a 100644 --- a/apps/server/src/usage/antigravityUsageReader.ts +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off - node:sqlite reads live conversation databases while Node fs discovers them. import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeSqlite from "node:sqlite"; diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 0acd81ba8827..836591c83548 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off - Node fs reads CLI credentials, and crypto hashes account IDs for deduplication. import * as NodeFSP from "node:fs/promises"; import * as NodeCrypto from "node:crypto"; import * as NodeTimersPromises from "node:timers/promises"; diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts index 1bfc8d1bc597..8800c50f22f4 100644 --- a/apps/server/src/usage/opencodeUsageReader.ts +++ b/apps/server/src/usage/opencodeUsageReader.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off - node:sqlite reads live OpenCode databases; Node fs walks legacy JSON history. import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeSqlite from "node:sqlite"; From f9c6a2860bb30b2877b63aab6ad9e5565dae0915 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 21:29:40 -0500 Subject: [PATCH 08/28] fix(usage): use shared Crypto service for profile lookup --- apps/server/src/usage/UsageService.ts | 5 +++-- apps/server/src/usage/antigravityUsageReader.ts | 3 ++- apps/server/src/usage/cursorUsageReader.ts | 3 ++- apps/server/src/usage/opencodeUsageReader.ts | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 87a24c87b554..e668d2240b20 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -14,7 +14,6 @@ */ import * as NodeOS from "node:os"; -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { ClaudeSettings, CodexSettings, @@ -33,6 +32,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -150,6 +150,7 @@ export const layerTest = Layer.succeed( ); export const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig; @@ -533,8 +534,8 @@ export const make = Effect.gen(function* () { config.stateDir, ProviderInstanceId.make(instanceId), ).pipe( + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(Path.Path, path), - Effect.provide(NodeCrypto.layer), Effect.mapError( (cause) => new UsageReadError({ diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts index 3d5fa81dac8a..ef395f4cd040 100644 --- a/apps/server/src/usage/antigravityUsageReader.ts +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off - node:sqlite reads live conversation databases while Node fs discovers them. +// node:sqlite reads live conversation databases while Node fs discovers them. +// @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeSqlite from "node:sqlite"; diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 836591c83548..b59df76a4d67 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off - Node fs reads CLI credentials, and crypto hashes account IDs for deduplication. +// Node fs reads CLI credentials, and crypto hashes account IDs for deduplication. +// @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodeCrypto from "node:crypto"; import * as NodeTimersPromises from "node:timers/promises"; diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts index 8800c50f22f4..7defb1718e21 100644 --- a/apps/server/src/usage/opencodeUsageReader.ts +++ b/apps/server/src/usage/opencodeUsageReader.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off - node:sqlite reads live OpenCode databases; Node fs walks legacy JSON history. +// node:sqlite reads live OpenCode databases; Node fs walks legacy JSON history. +// @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeSqlite from "node:sqlite"; From 393a8f3a4c64626bee0b8edea9fa8261d3fb8837 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 21:43:30 -0500 Subject: [PATCH 09/28] fix(usage): handle paginated and aliased history --- apps/server/src/usage/UsageService.test.ts | 59 ++++++++++++++++++- apps/server/src/usage/UsageService.ts | 30 ++++++---- apps/server/src/usage/cursorUsageReader.ts | 4 +- .../src/usage/usageTranscriptReader.test.ts | 4 ++ docs/user/usage.md | 2 + 5 files changed, 83 insertions(+), 16 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index d72d2cdff6d4..9d8687665331 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -212,7 +212,10 @@ describe("UsageService", () => { summary.sources.find((source) => source.fingerprint.provider === "cursor")?.status, "missing", ); - assert.strictEqual(summary.buckets[0]?.sourcePath, root); + assert.strictEqual( + summary.buckets[0]?.sourcePath, + yield* Effect.promise(() => NodeFSP.realpath(root)), + ); assert.strictEqual(summary.buckets[0]?.totals.outputTokens, 7); assert.strictEqual( summary.sources.find((source) => source.fingerprint.provider === "opencode") @@ -226,6 +229,60 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("counts aliased OpenCode and Antigravity directories once", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const opencode = NodePath.join(home, "opencode-store"); + const opencodeAlias = NodePath.join(home, "opencode-alias"); + const conversations = NodePath.join(home, "antigravity-conversations"); + const antigravityA = NodePath.join(home, "antigravity-a"); + const antigravityB = NodePath.join(home, "antigravity-b"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(opencode); + await NodeFSP.symlink(opencode, opencodeAlias, "junction"); + await NodeFSP.mkdir(conversations); + await NodeFSP.mkdir(antigravityA); + await NodeFSP.mkdir(antigravityB); + await NodeFSP.symlink( + conversations, + NodePath.join(antigravityA, "conversations"), + "junction", + ); + await NodeFSP.symlink( + conversations, + NodePath.join(antigravityB, "conversations"), + "junction", + ); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-aliased-roots-test", + home, + settings, + environment: { + OPENCODE_DATA_DIR: `${opencode},${opencodeAlias}`, + ANTIGRAVITY_DATA_DIR: `${antigravityA},${antigravityB}`, + }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const sourcesFor = (provider: "opencode" | "antigravity") => + summary.sources.filter((source) => source.fingerprint.provider === provider); + assert.strictEqual(sourcesFor("opencode").length, 1); + assert.strictEqual(sourcesFor("antigravity").length, 1); + assert.strictEqual( + sourcesFor("opencode")[0]?.fingerprint.resolvedHomePath, + yield* Effect.promise(() => NodeFSP.realpath(opencode)), + ); + assert.strictEqual( + sourcesFor("antigravity")[0]?.fingerprint.resolvedHomePath, + yield* Effect.promise(() => NodeFSP.realpath(conversations)), + ); + }).pipe(Effect.scoped), + ); + it.live("reads configured and disabled accounts once across shared and aliased homes", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index e668d2240b20..9cfd13b3d77d 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -494,19 +494,22 @@ export const make = Effect.gen(function* () { } const home = NodeOS.homedir(); - const envRoots = (key: string, defaults: readonly string[]) => { + const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) { const roots = hostEnvironment[key] ?.split(",") .map((value) => value.trim()) .filter(Boolean); - return [ - ...new Set( - (roots?.length ? roots : defaults).map((root) => path.resolve(expandHomePath(root))), - ), - ]; - }; + const canonical = new Set(); + for (const root of roots?.length ? roots : defaults) { + const resolved = path.resolve(expandHomePath(root)); + canonical.add( + yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)), + ); + } + return [...canonical]; + }); const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim(); - for (const dir of envRoots("OPENCODE_DATA_DIR", [ + for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [ path.join( dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"), "opencode", @@ -522,7 +525,7 @@ export const make = Effect.gen(function* () { ...(result.error ? { message: "Some OpenCode history could not be read." } : {}), }); } - const antigravityRoots = envRoots("ANTIGRAVITY_DATA_DIR", [ + const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [ ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) => path.join(home, ".gemini", name), ), @@ -549,14 +552,15 @@ export const make = Effect.gen(function* () { } } const antigravityDirs = new Set(); - for (const root of new Set(antigravityRoots)) { - const nested = path.join(root, "conversations"); + for (const root of antigravityRoots) { + const resolvedRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + const nested = path.join(resolvedRoot, "conversations"); const dir = (yield* fileSystem .exists(nested) .pipe(Effect.catchCause(() => Effect.succeed(false)))) ? nested - : root; - antigravityDirs.add(dir); + : resolvedRoot; + antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir))); } const antigravity = yield* Effect.promise(() => readAntigravityUsage([...antigravityDirs], windowStartMs), diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index b59df76a4d67..5e481c283bfc 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -83,7 +83,7 @@ export async function readCursorAccountUsage( accountKey = accountHash(subject); if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) throw new Error("Invalid date window"); - const signal = AbortSignal.timeout(10_000); + const deadline = AbortSignal.timeout(60_000); const records: UsageRecord[] = []; const occurrences = new Map(); const pages: unknown[][] = []; @@ -94,7 +94,7 @@ export async function readCursorAccountUsage( const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { method: "POST", redirect: "error", - signal, + signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]), headers: { "Content-Type": "application/json", Origin: "https://cursor.com", diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index c33a8e85be0e..ce7f09005561 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -242,6 +242,7 @@ describe("SQLite usage readers", () => { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); const pages: number[] = []; + const signals: AbortSignal[] = []; const request = async (url: string, init: RequestInit) => { assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events"); assert.strictEqual(init?.redirect, "error"); @@ -250,6 +251,7 @@ describe("SQLite usage readers", () => { assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); const body = JSON.parse(String(init?.body)); pages.push(body.page); + if (init.signal) signals.push(init.signal); return Response.json({ totalUsageEventsCount: 1001, usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ @@ -271,6 +273,8 @@ describe("SQLite usage readers", () => { const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request); assert.isNull(result.error); assert.deepStrictEqual(pages, [1, 2]); + assert.lengthOf(signals, 2); + assert.notStrictEqual(signals[0], signals[1]); assert.strictEqual(result.records.length, 1001); assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); assert.deepStrictEqual(result.records[0]?.totals, { diff --git a/docs/user/usage.md b/docs/user/usage.md index 169533c80d28..0120e4428964 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -21,6 +21,8 @@ Cursor reads account usage from Cursor's dashboard API using the CLI login saved This includes headless T3 sessions and desktop usage across machines; the same account counts once across connected environments. Without an accessible file-based CLI login, T3 shows a notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. +On macOS, [set up a file-based login](#track-subscription-limits) and set +`AGENT_CLI_CREDENTIAL_STORE=file` on the server to enable Cursor history. Usage includes each configured account's history, including disabled accounts. Custom homes follow the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment From 62a1eac12cc53200dc170e4ef8bce7146ab368e8 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 21:49:05 -0500 Subject: [PATCH 10/28] fix(usage): keep large and partial histories accurate --- apps/server/src/usage/cursorUsageReader.ts | 8 +++++-- .../src/usage/usageTranscriptReader.test.ts | 18 +++++++++++++++ packages/shared/src/usageMerge.test.ts | 22 +++++++++++++++++++ packages/shared/src/usageMerge.ts | 20 ++++++++++------- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 5e481c283bfc..a263581fb13d 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -90,7 +90,12 @@ export async function readCursorAccountUsage( let completed = false; const pageSize = 1000; let total: number | undefined; - for (let page = 1; page <= 100; page++) { + for (let page = 1; ; page++) { + // A count can include overlapping page boundaries. Allow room to + // reconcile them without imposing a fixed account-size limit. + if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { + throw new Error("Account usage page limit exceeded"); + } const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { method: "POST", redirect: "error", @@ -134,7 +139,6 @@ export async function readCursorAccountUsage( (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0 || - count > pageSize * 100 || (total !== undefined && count !== total))) || !Array.isArray(events) || events.length > pageSize || diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index ce7f09005561..2183b0eaec84 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -288,6 +288,24 @@ describe("SQLite usage readers", () => { assert.isFalse(result.accountKey?.includes("demo") ?? true); }); + it("reads Cursor account history beyond 100 pages", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests += 1; + return Response.json({ + totalUsageEventsCount: 100_001, + usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }], + }); + }); + assert.isNull(result.error); + assert.strictEqual(requests, 101); + assert.deepStrictEqual(result.records, []); + }); + it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => { const authPath = NodePath.join(dir, "auth.json"); const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 0e56463768a6..e843302e66e1 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -227,6 +227,28 @@ describe("mergeUsage", () => { } }); + it("prefers a complete scan over a newer partial scan of the same directory", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const incomplete = summary([bucket({ costUsd: 4, records: 2 })], [source]); + const partial = environment("new", { + ...incomplete, + readAt: "2026-08-07T01:00:00.000Z", + sources: incomplete.sources.map((entry) => ({ ...entry, status: "partial" as const })), + }); + const complete = environment("old", summary([bucket()], [source])); + + for (const ordered of [ + [partial, complete], + [complete, partial], + ]) { + const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.contributingEnvironments).toEqual(["old"]); + expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]); + } + expect(mergeUsage([partial], USAGE_CONTRACT_VERSION).costUsd).toBe(4); + }); + it("excludes an environment reporting an older contract version", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index e28c867f1624..e2ce0ae9fbd6 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -135,15 +135,19 @@ function claimSources(environments: readonly EnvironmentUsage[]): { a.environmentId.localeCompare(b.environmentId), ); - for (const environment of ordered) { - for (const source of environment.summary.sources) { - if (source.status === "missing") continue; - const key = fingerprintKey(source.fingerprint); - if (ownerByFingerprint.has(key)) { - duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); - continue; + // A complete scan takes precedence over a newer partial scan of the same + // directory. Partial history still contributes when no complete copy exists. + for (const status of ["ok", "partial", "failed"] as const) { + for (const environment of ordered) { + for (const source of environment.summary.sources) { + if (source.status !== status) continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.has(key)) { + duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); + continue; + } + ownerByFingerprint.set(key, environment.environmentId); } - ownerByFingerprint.set(key, environment.environmentId); } } From ced8da6acbc2e52391a7442754d27b5aadf78ade Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 22:03:41 -0500 Subject: [PATCH 11/28] fix(usage): retain new cells from partial scans --- packages/shared/src/usageMerge.test.ts | 43 ++++++++++ packages/shared/src/usageMerge.ts | 114 ++++++++++++++++++++++--- 2 files changed, 146 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index e843302e66e1..e243c913eff5 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -249,6 +249,49 @@ describe("mergeUsage", () => { expect(mergeUsage([partial], USAGE_CONTRACT_VERSION).costUsd).toBe(4); }); + it("keeps new cells from a later partial scan without recounting older cells", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const complete = environment( + "old", + summary([bucket({ sourcePath: source.homePath })], [source]), + ); + const partialSummary = summary( + [ + bucket({ sourcePath: source.homePath, costUsd: 4, records: 2 }), + bucket({ + day: "2026-08-08" as UsageDay, + sourcePath: source.homePath, + costUsd: 3, + records: 1, + }), + ], + [{ ...source, distinctSessions: 2 }], + ); + const partial = environment("new", { + ...partialSummary, + readAt: "2026-08-08T01:00:00.000Z", + sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })), + }); + + for (const ordered of [ + [complete, partial], + [partial, complete], + ]) { + const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(13); + expect(merged.records).toBe(6); + expect(merged.sessions).toBe(2); + expect(merged.daily.map(({ day, costUsd }) => [day, costUsd])).toEqual([ + ["2026-08-07", 10], + ["2026-08-08", 3], + ]); + expect(merged.contributingEnvironments).toEqual( + ordered.map(({ environmentId }) => environmentId), + ); + expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]); + } + }); + it("excludes an environment reporting an older contract version", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index e2ce0ae9fbd6..abc95ead1f98 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -11,6 +11,7 @@ import { type EnvironmentId, type UsageBucket, type UsageProviderKind, + type UsageSource, type UsageSourceFingerprint, type UsageSummary, } from "@t3tools/contracts"; @@ -113,20 +114,52 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { ].join(" "); } +function bucketsForSource(summary: UsageSummary, source: UsageSource): readonly UsageBucket[] { + const providerSources = summary.sources.filter( + (entry) => entry.fingerprint.provider === source.fingerprint.provider, + ); + return summary.buckets.filter( + (bucket) => + bucket.provider === source.fingerprint.provider && + (bucket.sourcePath === source.fingerprint.resolvedHomePath || + (bucket.sourcePath === undefined && providerSources.length === 1)), + ); +} + +function bucketKey(bucket: UsageBucket): string { + return JSON.stringify([ + bucket.day, + bucket.hourStart ?? null, + bucket.provider, + bucket.model, + bucket.sourcePath ?? null, + ]); +} + /** * Decides which environment owns each physical transcript directory. * * Several environments on one machine (worktree servers, for instance) resolve * the same provider home and would otherwise double count every token. The - * most recently read summary claims a fingerprint; the rest have that provider's - * buckets dropped. Environment ids break ties so the winner is stable when - * summaries have the same read time. + * Complete scans claim a fingerprint ahead of partial scans, then the most + * recently read scan wins within each status. A newer partial scan can still + * contribute cells absent from an older complete scan. Environment ids break + * ties so the result is stable when summaries have the same read time. */ function claimSources(environments: readonly EnvironmentUsage[]): { readonly ownerByFingerprint: ReadonlyMap; + readonly supplementalBucketsByEnvironment: ReadonlyMap>; + readonly sessionsByFingerprint: ReadonlyMap; readonly duplicates: readonly string[]; } { const ownerByFingerprint = new Map(); + const ownerScanByFingerprint = new Map< + string, + { environment: EnvironmentUsage; source: UsageSource } + >(); + const seenBucketKeysByFingerprint = new Map>(); + const supplementalBucketsByEnvironment = new Map>(); + const sessionsByFingerprint = new Map(); const duplicates: string[] = []; const ordered = [...environments].sort( @@ -147,17 +180,63 @@ function claimSources(environments: readonly EnvironmentUsage[]): { continue; } ownerByFingerprint.set(key, environment.environmentId); + ownerScanByFingerprint.set(key, { environment, source }); + sessionsByFingerprint.set(key, source.distinctSessions); } } } - return { ownerByFingerprint, duplicates }; + // A newer partial scan may contain usage recorded after an older complete + // scan. Keep its cells that the complete scan could not have counted. + for (const environment of ordered) { + for (const source of environment.summary.sources) { + if (source.status !== "partial") continue; + const key = fingerprintKey(source.fingerprint); + const owner = ownerScanByFingerprint.get(key); + if ( + owner?.source.status !== "ok" || + Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt) + ) { + continue; + } + let seen = seenBucketKeysByFingerprint.get(key); + if (seen === undefined) { + seen = new Set(bucketsForSource(owner.environment.summary, owner.source).map(bucketKey)); + seenBucketKeysByFingerprint.set(key, seen); + } + const supplemental = + supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(); + let added = false; + for (const bucket of bucketsForSource(environment.summary, source)) { + const cell = bucketKey(bucket); + if (seen.has(cell)) continue; + seen.add(cell); + supplemental.add(bucket); + added = true; + } + if (!added) continue; + supplementalBucketsByEnvironment.set(environment.environmentId, supplemental); + sessionsByFingerprint.set( + key, + Math.max(sessionsByFingerprint.get(key) ?? 0, source.distinctSessions), + ); + } + } + + return { + ownerByFingerprint, + supplementalBucketsByEnvironment, + sessionsByFingerprint, + duplicates, + }; } /** Sources this environment owns after fingerprint claims, plus their buckets. */ function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, + supplementalBuckets: ReadonlySet, + sessionsByFingerprint: ReadonlyMap, ): { readonly buckets: readonly UsageBucket[]; readonly sessionsByProvider: ReadonlyMap; @@ -176,15 +255,18 @@ function ownedContribution( // would count a session once per day and model it spans. sessionsByProvider.set( provider, - (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, + (sessionsByProvider.get(provider) ?? 0) + + (sessionsByFingerprint.get(key) ?? source.distinctSessions), ); } } return { - buckets: environment.summary.buckets.filter((bucket) => - bucket.sourcePath === undefined - ? ownedProviders.has(bucket.provider) - : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`), + buckets: environment.summary.buckets.filter( + (bucket) => + supplementalBuckets.has(bucket) || + (bucket.sourcePath === undefined + ? ownedProviders.has(bucket.provider) + : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`)), ), sessionsByProvider, }; @@ -256,7 +338,12 @@ export function mergeUsage( } } - const { ownerByFingerprint, duplicates } = claimSources(current); + const { + ownerByFingerprint, + supplementalBucketsByEnvironment, + sessionsByFingerprint, + duplicates, + } = claimSources(current); let costUsd = 0; let uncachedInputTokens = 0; @@ -305,7 +392,12 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); + const { buckets, sessionsByProvider } = ownedContribution( + environment, + ownerByFingerprint, + supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(), + sessionsByFingerprint, + ); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); for (const [providerKind, providerSessions] of sessionsByProvider) { From 25426dff9b600042087cd833f058a3c4f73320af Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 22:09:15 -0500 Subject: [PATCH 12/28] fix(usage): reconcile overlapping partial cells --- packages/shared/src/usageMerge.test.ts | 22 +++++++++- packages/shared/src/usageMerge.ts | 59 +++++++++++++++++--------- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index e243c913eff5..30204d6a7456 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -253,7 +253,7 @@ describe("mergeUsage", () => { const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; const complete = environment( "old", - summary([bucket({ sourcePath: source.homePath })], [source]), + summary([bucket()], [source], USAGE_MERGE_COMPATIBLE_SINCE), ); const partialSummary = summary( [ @@ -292,6 +292,26 @@ describe("mergeUsage", () => { } }); + it("uses a newer partial cell when it contains more records than the complete scan", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const complete = environment("old", summary([bucket()], [source])); + const partialSummary = summary( + [bucket({ costUsd: 12, records: 6 })], + [{ ...source, distinctSessions: 2 }], + ); + const partial = environment("new", { + ...partialSummary, + readAt: "2026-08-07T01:00:00.000Z", + sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })), + }); + + const merged = mergeUsage([complete, partial], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(12); + expect(merged.records).toBe(6); + expect(merged.sessions).toBe(2); + expect(merged.contributingEnvironments).toEqual(["new"]); + }); + it("excludes an environment reporting an older contract version", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index abc95ead1f98..8b98719fce67 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -127,13 +127,7 @@ function bucketsForSource(summary: UsageSummary, source: UsageSource): readonly } function bucketKey(bucket: UsageBucket): string { - return JSON.stringify([ - bucket.day, - bucket.hourStart ?? null, - bucket.provider, - bucket.model, - bucket.sourcePath ?? null, - ]); + return JSON.stringify([bucket.day, bucket.hourStart ?? null, bucket.provider, bucket.model]); } /** @@ -149,6 +143,7 @@ function bucketKey(bucket: UsageBucket): string { function claimSources(environments: readonly EnvironmentUsage[]): { readonly ownerByFingerprint: ReadonlyMap; readonly supplementalBucketsByEnvironment: ReadonlyMap>; + readonly excludedBucketsByEnvironment: ReadonlyMap>; readonly sessionsByFingerprint: ReadonlyMap; readonly duplicates: readonly string[]; } { @@ -157,8 +152,12 @@ function claimSources(environments: readonly EnvironmentUsage[]): { string, { environment: EnvironmentUsage; source: UsageSource } >(); - const seenBucketKeysByFingerprint = new Map>(); + const selectedBucketsByFingerprint = new Map< + string, + Map + >(); const supplementalBucketsByEnvironment = new Map>(); + const excludedBucketsByEnvironment = new Map>(); const sessionsByFingerprint = new Map(); const duplicates: string[] = []; @@ -187,7 +186,8 @@ function claimSources(environments: readonly EnvironmentUsage[]): { } // A newer partial scan may contain usage recorded after an older complete - // scan. Keep its cells that the complete scan could not have counted. + // scan. Keep its new cells, and use its overlapping cell only when it has + // more records. Aggregated cells cannot reveal the exact record overlap. for (const environment of ordered) { for (const source of environment.summary.sources) { if (source.status !== "partial") continue; @@ -199,18 +199,34 @@ function claimSources(environments: readonly EnvironmentUsage[]): { ) { continue; } - let seen = seenBucketKeysByFingerprint.get(key); - if (seen === undefined) { - seen = new Set(bucketsForSource(owner.environment.summary, owner.source).map(bucketKey)); - seenBucketKeysByFingerprint.set(key, seen); + let selected = selectedBucketsByFingerprint.get(key); + if (selected === undefined) { + selected = new Map( + bucketsForSource(owner.environment.summary, owner.source).map((bucket) => [ + bucketKey(bucket), + { bucket, environmentId: owner.environment.environmentId }, + ]), + ); + selectedBucketsByFingerprint.set(key, selected); } const supplemental = supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(); let added = false; for (const bucket of bucketsForSource(environment.summary, source)) { const cell = bucketKey(bucket); - if (seen.has(cell)) continue; - seen.add(cell); + const previous = selected.get(cell); + if (previous !== undefined && previous.bucket.records >= bucket.records) continue; + if (previous !== undefined) { + if (previous.environmentId === owner.environment.environmentId) { + const excluded = + excludedBucketsByEnvironment.get(previous.environmentId) ?? new Set(); + excluded.add(previous.bucket); + excludedBucketsByEnvironment.set(previous.environmentId, excluded); + } else { + supplementalBucketsByEnvironment.get(previous.environmentId)?.delete(previous.bucket); + } + } + selected.set(cell, { bucket, environmentId: environment.environmentId }); supplemental.add(bucket); added = true; } @@ -226,6 +242,7 @@ function claimSources(environments: readonly EnvironmentUsage[]): { return { ownerByFingerprint, supplementalBucketsByEnvironment, + excludedBucketsByEnvironment, sessionsByFingerprint, duplicates, }; @@ -236,6 +253,7 @@ function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, supplementalBuckets: ReadonlySet, + excludedBuckets: ReadonlySet, sessionsByFingerprint: ReadonlyMap, ): { readonly buckets: readonly UsageBucket[]; @@ -263,10 +281,11 @@ function ownedContribution( return { buckets: environment.summary.buckets.filter( (bucket) => - supplementalBuckets.has(bucket) || - (bucket.sourcePath === undefined - ? ownedProviders.has(bucket.provider) - : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`)), + !excludedBuckets.has(bucket) && + (supplementalBuckets.has(bucket) || + (bucket.sourcePath === undefined + ? ownedProviders.has(bucket.provider) + : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`))), ), sessionsByProvider, }; @@ -341,6 +360,7 @@ export function mergeUsage( const { ownerByFingerprint, supplementalBucketsByEnvironment, + excludedBucketsByEnvironment, sessionsByFingerprint, duplicates, } = claimSources(current); @@ -396,6 +416,7 @@ export function mergeUsage( environment, ownerByFingerprint, supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(), + excludedBucketsByEnvironment.get(environment.environmentId) ?? new Set(), sessionsByFingerprint, ); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); From e06cad6e3866e4bd5fb22dcfcee4c74a93d966bf Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 22:20:18 -0500 Subject: [PATCH 13/28] fix(usage): preserve complete overlapping cells --- packages/shared/src/usageMerge.test.ts | 25 ++++++++++--- packages/shared/src/usageMerge.ts | 52 +++++++------------------- 2 files changed, 32 insertions(+), 45 deletions(-) diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 30204d6a7456..178282238482 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -292,11 +292,23 @@ describe("mergeUsage", () => { } }); - it("uses a newer partial cell when it contains more records than the complete scan", () => { + it("retains a complete cell when a larger partial cell may have skipped old records", () => { const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; const complete = environment("old", summary([bucket()], [source])); const partialSummary = summary( - [bucket({ costUsd: 12, records: 6 })], + [ + bucket({ + costUsd: 4, + records: 6, + totals: { + uncachedInputTokens: 80, + cachedInputTokens: 500, + cacheCreationTokens: 10, + outputTokens: 30, + reasoningTokens: 0, + }, + }), + ], [{ ...source, distinctSessions: 2 }], ); const partial = environment("new", { @@ -306,10 +318,11 @@ describe("mergeUsage", () => { }); const merged = mergeUsage([complete, partial], USAGE_CONTRACT_VERSION); - expect(merged.costUsd).toBe(12); - expect(merged.records).toBe(6); - expect(merged.sessions).toBe(2); - expect(merged.contributingEnvironments).toEqual(["new"]); + expect(merged.costUsd).toBe(10); + expect(merged.totalTokens).toBe(1160); + expect(merged.records).toBe(5); + expect(merged.sessions).toBe(1); + expect(merged.contributingEnvironments).toEqual(["old"]); }); it("excludes an environment reporting an older contract version", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 8b98719fce67..c4a56829a420 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -143,7 +143,6 @@ function bucketKey(bucket: UsageBucket): string { function claimSources(environments: readonly EnvironmentUsage[]): { readonly ownerByFingerprint: ReadonlyMap; readonly supplementalBucketsByEnvironment: ReadonlyMap>; - readonly excludedBucketsByEnvironment: ReadonlyMap>; readonly sessionsByFingerprint: ReadonlyMap; readonly duplicates: readonly string[]; } { @@ -152,12 +151,8 @@ function claimSources(environments: readonly EnvironmentUsage[]): { string, { environment: EnvironmentUsage; source: UsageSource } >(); - const selectedBucketsByFingerprint = new Map< - string, - Map - >(); + const seenBucketKeysByFingerprint = new Map>(); const supplementalBucketsByEnvironment = new Map>(); - const excludedBucketsByEnvironment = new Map>(); const sessionsByFingerprint = new Map(); const duplicates: string[] = []; @@ -186,8 +181,8 @@ function claimSources(environments: readonly EnvironmentUsage[]): { } // A newer partial scan may contain usage recorded after an older complete - // scan. Keep its new cells, and use its overlapping cell only when it has - // more records. Aggregated cells cannot reveal the exact record overlap. + // scan. Keep cells absent from the complete scan. Aggregated cells do not + // reveal enough to reconcile overlapping records without double counting. for (const environment of ordered) { for (const source of environment.summary.sources) { if (source.status !== "partial") continue; @@ -199,34 +194,18 @@ function claimSources(environments: readonly EnvironmentUsage[]): { ) { continue; } - let selected = selectedBucketsByFingerprint.get(key); - if (selected === undefined) { - selected = new Map( - bucketsForSource(owner.environment.summary, owner.source).map((bucket) => [ - bucketKey(bucket), - { bucket, environmentId: owner.environment.environmentId }, - ]), - ); - selectedBucketsByFingerprint.set(key, selected); + let seen = seenBucketKeysByFingerprint.get(key); + if (seen === undefined) { + seen = new Set(bucketsForSource(owner.environment.summary, owner.source).map(bucketKey)); + seenBucketKeysByFingerprint.set(key, seen); } const supplemental = supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(); let added = false; for (const bucket of bucketsForSource(environment.summary, source)) { const cell = bucketKey(bucket); - const previous = selected.get(cell); - if (previous !== undefined && previous.bucket.records >= bucket.records) continue; - if (previous !== undefined) { - if (previous.environmentId === owner.environment.environmentId) { - const excluded = - excludedBucketsByEnvironment.get(previous.environmentId) ?? new Set(); - excluded.add(previous.bucket); - excludedBucketsByEnvironment.set(previous.environmentId, excluded); - } else { - supplementalBucketsByEnvironment.get(previous.environmentId)?.delete(previous.bucket); - } - } - selected.set(cell, { bucket, environmentId: environment.environmentId }); + if (seen.has(cell)) continue; + seen.add(cell); supplemental.add(bucket); added = true; } @@ -242,7 +221,6 @@ function claimSources(environments: readonly EnvironmentUsage[]): { return { ownerByFingerprint, supplementalBucketsByEnvironment, - excludedBucketsByEnvironment, sessionsByFingerprint, duplicates, }; @@ -253,7 +231,6 @@ function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, supplementalBuckets: ReadonlySet, - excludedBuckets: ReadonlySet, sessionsByFingerprint: ReadonlyMap, ): { readonly buckets: readonly UsageBucket[]; @@ -281,11 +258,10 @@ function ownedContribution( return { buckets: environment.summary.buckets.filter( (bucket) => - !excludedBuckets.has(bucket) && - (supplementalBuckets.has(bucket) || - (bucket.sourcePath === undefined - ? ownedProviders.has(bucket.provider) - : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`))), + supplementalBuckets.has(bucket) || + (bucket.sourcePath === undefined + ? ownedProviders.has(bucket.provider) + : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`)), ), sessionsByProvider, }; @@ -360,7 +336,6 @@ export function mergeUsage( const { ownerByFingerprint, supplementalBucketsByEnvironment, - excludedBucketsByEnvironment, sessionsByFingerprint, duplicates, } = claimSources(current); @@ -416,7 +391,6 @@ export function mergeUsage( environment, ownerByFingerprint, supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(), - excludedBucketsByEnvironment.get(environment.environmentId) ?? new Set(), sessionsByFingerprint, ); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); From d49bba0b99c312bb5eecba2fb3505d43ba9b65bd Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 01:00:42 -0500 Subject: [PATCH 14/28] feat(usage): add opt-in Cursor Keychain access --- .../src/features/usage/UsageRouteScreen.tsx | 61 +++++++++++++++++++ apps/server/package.json | 1 + .../src/provider/Drivers/CursorDriver.ts | 12 +++- .../provider/Layers/CursorProvider.test.ts | 31 ++++++++++ .../src/provider/Layers/cursorUsageLimits.ts | 22 ++++--- .../provider/cursorCredentialStore.test.ts | 22 +++++++ .../src/provider/cursorCredentialStore.ts | 29 +++++++++ apps/server/src/usage/UsageService.test.ts | 29 ++++++++- apps/server/src/usage/UsageService.ts | 40 +++++++++--- apps/server/src/usage/cursorUsageReader.ts | 35 ++++++++--- .../src/usage/usageTranscriptReader.test.ts | 22 +++++++ .../settings/ProviderSettingsPanel.tsx | 1 + .../settings/UsageProviderSettings.tsx | 47 +++++++++++++- .../src/components/settings/settingsSearch.ts | 7 +++ apps/web/src/components/usage/UsagePage.tsx | 53 ++++++++++++++++ docs/user/usage.md | 14 ++--- packages/client-runtime/src/state/server.ts | 10 ++- packages/contracts/src/settings.ts | 5 ++ packages/contracts/src/usage.ts | 2 + pnpm-lock.yaml | 3 + scripts/lib/cli-external-packages.test.ts | 3 +- scripts/lib/cli-external-packages.ts | 1 + 22 files changed, 412 insertions(+), 38 deletions(-) create mode 100644 apps/server/src/provider/cursorCredentialStore.test.ts create mode 100644 apps/server/src/provider/cursorCredentialStore.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index ea9f336469cf..9026e10041a2 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -28,6 +28,8 @@ import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; @@ -97,12 +99,16 @@ export function UsageRouteScreen() { ); const isFocused = useIsFocused(); const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); + const cursorAccessEnvironments = selectedEnvironments.filter((environment) => + environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), + ); const sourceMessages = [ ...new Set( selectedEnvironments.flatMap( (environment) => environment.summary?.sources.flatMap((source) => source.message && + !source.action && (source.status === "partial" || source.status === "failed" || source.fingerprint.provider === "cursor") @@ -266,6 +272,17 @@ export function UsageRouteScreen() { } > + {cursorAccessEnvironments.map((environment) => ( + { + void refresh(); + void limits.refresh(); + }} + /> + ))} void; +}) { + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "enable Cursor account usage", + }); + const [pending, setPending] = useState(false); + const enable = async () => { + setPending(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { cursorKeychainUsageEnabled: true } }, + }); + if (result._tag === "Success") onEnabled(); + } finally { + setPending(false); + } + }; + return ( + + + To show Cursor history and monthly limits from {label}, T3 needs access to your Cursor CLI + login in macOS Keychain. macOS may ask you to allow access on that Mac. + + void enable()} + className="self-start rounded-full bg-primary px-4 py-2" + > + Enable Cursor usage + + + ); +} + /** Headline figure, the animated daily chart, and its legend, in one card. */ function ChartCard(props: { readonly merged: MergedUsage; diff --git a/apps/server/package.json b/apps/server/package.json index b7f1bce88768..afa2ca18fdfc 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,6 +27,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.9.4", + "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", "diff": "8.0.3", "effect": "catalog:", diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 59e2138c302f..b0821bab50e3 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -142,9 +142,15 @@ export const CursorDriver: ProviderDriver = { ).pipe( Effect.flatMap((snapshot) => effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" - ? readCursorUsageLimits(effectiveConfig, processEnv).pipe( - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ) + ? Effect.gen(function* () { + const settings = yield* serverSettings.getSettings; + const usageLimits = yield* readCursorUsageLimits( + effectiveConfig, + processEnv, + settings.cursorKeychainUsageEnabled, + ); + return { ...snapshot, usageLimits }; + }) : Effect.succeed(snapshot), ), Effect.map(stampIdentity), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 47826fcc8704..514c2cc298b0 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1079,6 +1079,10 @@ describe("Cursor usage limits", () => { AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), }, + false, + async () => { + throw new Error("must not read Keychain before opt-in"); + }, ).pipe( Effect.provideService(HostProcessPlatform, platform), Effect.provideService( @@ -1108,6 +1112,33 @@ describe("Cursor usage limits", () => { } }); + it("reads the default macOS Cursor login from Keychain for limits", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read a stale credential file"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.headers.authorization).toBe("Bearer keychain-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ planUsage: { totalPercentUsed: 42 } }), + ), + ); + }), + ), + ), + ); + expect(limits.windows[0]?.usedPercent).toBe(42); + }); + it("reports failed requests without exposing credentials or response bodies", async () => { const limits = await runNode( readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts index 685378b9d79e..8479ce91771d 100644 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -13,6 +13,7 @@ import { makeUnavailableUsageLimits, makeUsageLimits, } from "../providerUsageLimits.ts"; +import { readMacCursorAccessToken } from "../cursorCredentialStore.ts"; const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); @@ -63,6 +64,8 @@ export function cursorUsageResponseToLimits( export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( settings: Pick, environment: NodeJS.ProcessEnv = process.env, + allowKeychain = false, + keychainToken: () => Promise = readMacCursorAccessToken, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); return yield* Effect.gen(function* () { @@ -75,18 +78,23 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); } const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - if ( - !token && - (credentialStore === "memory" || (platform === "darwin" && credentialStore !== "file")) - ) { - // Cursor's default macOS login lives in the keychain; a leftover file may be another account. + if (!token && credentialStore === "memory") { return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported", - message: "Cursor usage requires a file-based login or CURSOR_AUTH_TOKEN.", + message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", }); } - if (!token) { + if (!token && platform === "darwin" && credentialStore !== "file") { + if (!allowKeychain) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Enable Cursor account usage in T3 Code to read its Keychain login.", + }); + } + token = (yield* Effect.promise(keychainToken))?.trim(); + } else if (!token) { const home = (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); const directory = diff --git a/apps/server/src/provider/cursorCredentialStore.test.ts b/apps/server/src/provider/cursorCredentialStore.test.ts new file mode 100644 index 000000000000..051cb5425c69 --- /dev/null +++ b/apps/server/src/provider/cursorCredentialStore.test.ts @@ -0,0 +1,22 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.ts"; + +describe("Cursor Keychain reader", () => { + it("shares concurrent reads and rechecks after the cache expires", async () => { + let reads = 0; + let time = 0; + const read = makeCachedCursorAccessTokenReader( + async () => { + reads++; + return `token-${reads}`; + }, + () => time, + ); + assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); + assert.strictEqual(await read(), "token-1"); + assert.strictEqual(reads, 1); + time = 5 * 60_000; + assert.strictEqual(await read(), "token-2"); + }); +}); diff --git a/apps/server/src/provider/cursorCredentialStore.ts b/apps/server/src/provider/cursorCredentialStore.ts new file mode 100644 index 000000000000..0efb37c9eebb --- /dev/null +++ b/apps/server/src/provider/cursorCredentialStore.ts @@ -0,0 +1,29 @@ +const CACHE_MS = 5 * 60_000; + +/** Share one Keychain request across usage history and limits in this server process. */ +export function makeCachedCursorAccessTokenReader( + read: () => Promise, + now: () => number = Date.now, +): () => Promise { + let cached: { token: string; until: number } | null = null; + let pending: Promise | null = null; + return () => { + if (cached && cached.until > now()) return Promise.resolve(cached.token); + if (pending) return pending; + pending = read() + .then((token) => { + cached = token ? { token, until: now() + CACHE_MS } : null; + return token; + }) + .finally(() => { + pending = null; + }); + return pending; + }; +} + +/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ +export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { + const { Entry } = await import("@napi-rs/keyring"); + return new Entry("cursor-access-token", "cursor-user").getPassword(); +}); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9d8687665331..a80a31f48ae6 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -121,11 +121,36 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-cursor-keychain-disabled", + home, + settings, + platform: "darwin", + environment: {}, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.status, "missing"); + assert.strictEqual(cursor?.action, "enableCursorKeychain"); + }).pipe(Effect.scoped), + ); + it.live("ignores stale Cursor file logins when the active credential store differs", () => Effect.gen(function* () { const { settings, home } = yield* setup; for (const [index, testCase] of [ - { platform: "darwin" as const, environment: {}, authPath: [".cursor", "auth.json"] }, + { + platform: "darwin" as const, + environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, + authPath: [".cursor", "auth.json"], + }, { platform: "linux" as const, environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, @@ -159,7 +184,7 @@ describe("UsageService", () => { const summary = yield* service.readSummary(WINDOW); const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); assert.strictEqual(cursor?.status, "missing"); - assert.include(cursor?.message ?? "", "file-based CLI login"); + assert.include(cursor?.message ?? "", "Cursor CLI login"); assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); } }).pipe(Effect.scoped), diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 9cfd13b3d77d..fdd16bee7580 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -457,6 +457,7 @@ export const make = Effect.gen(function* () { readonly hostId?: string; readonly status?: UsageSource["status"]; readonly message?: string; + readonly action?: UsageSource["action"]; /** Parsed records per file, or `null` when the directory does not exist. */ readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] @@ -597,21 +598,42 @@ export const make = Effect.gen(function* () { ? path.join(cursorUserHome, ".cursor", "auth.json") : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; - const fileLoginUnavailable = + const loginUnavailable = Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || - credentialStore === "memory" || - (platform === "darwin" && credentialStore !== "file"); + credentialStore === "memory"; + if ( + platform === "darwin" && + credentialStore !== "file" && + !loginUnavailable && + !settings.cursorKeychainUsageEnabled + ) { + scanned.push({ + provider: "cursor", + dir: cursorAuthPath, + volumeId: "", + files: null, + message: "Cursor account usage is off on this environment.", + action: "enableCursorKeychain", + }); + return scanned; + } const cursorUntilMs = yield* Clock.currentTimeMillis; - const account = fileLoginUnavailable + const account = loginUnavailable ? { accountKey: null, records: [], missing: true, - error: "Cursor account history needs a file-based CLI login on this server.", + error: "Cursor account history needs a Cursor CLI login on this server.", } : yield* Effect.promise(() => - readCursorAccountUsage(cursorAuthPath, windowStartMs, cursorUntilMs), + readCursorAccountUsage( + platform === "darwin" && credentialStore !== "file" + ? { kind: "keychain" } + : cursorAuthPath, + windowStartMs, + cursorUntilMs, + ), ); if (account.accountKey !== null && account.error === null && !account.missing) { // The same account includes CLI and desktop history from every machine. @@ -717,6 +739,7 @@ export const make = Effect.gen(function* () { files, status, message, + action, hostId: sourceHostId, } of scannedDirs) { const retainedFiles = [...(files ?? [])]; @@ -780,6 +803,7 @@ export const make = Effect.gen(function* () { distinctSessions: sessionIds.size, message: message ?? (files === null ? "No transcript directory on this environment." : null), + ...(action ? { action } : {}), }); } @@ -814,6 +838,7 @@ export const make = Effect.gen(function* () { const scanKey = ( input: UsageSummaryInput, priceOverrides: ServerSettingsValue["usagePriceOverrides"], + cursorKeychainUsageEnabled: boolean, ): string => JSON.stringify([ input.timeZone, @@ -823,11 +848,12 @@ export const make = Effect.gen(function* () { input.sinceTime ?? null, input.untilTime ?? null, priceOverrides, + cursorKeychainUsageEnabled, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { const settings = yield* readSettings; - const key = scanKey(input, settings.usagePriceOverrides); + const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index a263581fb13d..8631a67b212d 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -5,6 +5,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeTimersPromises from "node:timers/promises"; import type { UsageRecord } from "./usageTranscripts.ts"; +import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -51,29 +52,45 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[] /** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */ export async function readCursorAccountUsage( - authPath: string, + credentialSource: string | { readonly kind: "keychain" }, sinceMs: number, endDate: number, request: (url: string, init: RequestInit) => Promise = globalThis.fetch, + keychainToken: () => Promise = readMacCursorAccessToken, ): Promise { - let auth: Record; + let accessToken: unknown; try { - auth = object(JSON.parse(await NodeFSP.readFile(authPath, "utf8"))); + accessToken = + typeof credentialSource === "string" + ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken + : await keychainToken(); } catch (cause) { - const missing = object(cause).code === "ENOENT"; + const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT"; return { accountKey: null, records: [], missing, - error: missing ? null : "Cursor credentials could not be read.", + error: missing + ? null + : typeof credentialSource === "string" + ? "Cursor credentials could not be read." + : "Cursor Keychain credentials could not be read.", }; } - if (typeof auth.accessToken !== "string" || !auth.accessToken) { - return { accountKey: null, records: [], missing: true, error: null }; + if (typeof accessToken !== "string" || !accessToken) { + return { + accountKey: null, + records: [], + missing: true, + error: + typeof credentialSource === "string" + ? null + : "Cursor account history needs a macOS Keychain CLI login on this server.", + }; } let accountKey: string | null = null; try { - const payload = auth.accessToken.split(".")[1]; + const payload = accessToken.split(".")[1]; const subject = object( JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")), ).sub; @@ -103,7 +120,7 @@ export async function readCursorAccountUsage( headers: { "Content-Type": "application/json", Origin: "https://cursor.com", - Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${auth.accessToken}`)}`, + Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, }, body: JSON.stringify({ page, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 2183b0eaec84..71e775aa0723 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -237,6 +237,28 @@ describe("readTranscriptRecords resume", () => { }); describe("SQLite usage readers", () => { + it("reads Cursor account history with the default macOS Keychain login", async () => { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + let keychainReads = 0; + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1781000000000, + async (_url, init) => { + assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A"); + return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] }); + }, + async () => { + keychainReads++; + return accessToken; + }, + ); + assert.strictEqual(keychainReads, 1); + assert.isNull(result.error); + assert.isFalse(result.missing); + assert.isNotNull(result.accountKey); + }); + it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => { const authPath = NodePath.join(dir, "auth.json"); const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index acec3be8387f..f25d2a2aa0fb 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -1102,6 +1102,7 @@ export function EnvironmentProviderSettings({ environmentId={environmentId} environmentLabel={environmentLabel} sources={settings.usageLimitSources} + cursorKeychainUsageEnabled={settings.cursorKeychainUsageEnabled} readOnly={readOnly} /> diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index cf2887f39684..46ed099b166e 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -1,8 +1,11 @@ import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { AlertDialog, AlertDialogClose, @@ -13,6 +16,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; import { searchableSetting } from "./settingsSearch"; import { SettingsRow, SettingsSection } from "./settingsLayout"; @@ -22,17 +26,43 @@ export function UsageProviderSettings({ environmentId, environmentLabel, sources, + cursorKeychainUsageEnabled, readOnly, }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly sources: UnifiedSettings["usageLimitSources"]; + readonly cursorKeychainUsageEnabled: boolean; readonly readOnly: boolean; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); + const updateCursorSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "update Cursor account usage", + }); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const platform = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.environment + .platform; const [adding, setAdding] = useState(false); + const [updatingCursor, setUpdatingCursor] = useState(false); const entries = Object.entries(sources); + const setCursorUsageEnabled = async (enabled: boolean) => { + setUpdatingCursor(true); + try { + const result = await updateCursorSettings({ + environmentId, + input: { patch: { cursorKeychainUsageEnabled: enabled } }, + }); + if (result._tag === "Success") { + await refreshProviders({ environmentId, input: {} }); + } + } finally { + setUpdatingCursor(false); + } + }; + return ( <> + {platform?.os === "darwin" ? ( + void setCursorUsageEnabled(enabled)} + /> + } + /> + ) : null} {entries.length === 0 ? ( - + ) : ( entries.map(([id, source]) => { const label = source.label?.trim() || source.url; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index acc9b7eb7dbe..cf77516cf9e4 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -546,6 +546,13 @@ export const SETTINGS_SEARCH_ITEMS = [ ], providerSettingsOnly: true, }, + { + id: "cursor-keychain-usage", + title: "Cursor account usage", + to: "/settings/providers", + searchTerms: ["cursor macOS keychain usage tokens cost limits permission"], + providerSettingsOnly: true, + }, { id: "provider-health-check-interval", title: "Health check interval", diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 3149b37bccbb..e02834ca3fc9 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -118,12 +118,16 @@ export function UsagePage() { selectedEnvironmentIds, ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const cursorAccessEnvironments = selectedEnvironments.filter((environment) => + environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), + ); const sourceMessages = [ ...new Set( selectedEnvironments.flatMap( (environment) => environment.summary?.sources.flatMap((source) => source.message && + !source.action && (source.status === "partial" || source.status === "failed" || source.fingerprint.provider === "cursor") @@ -388,6 +392,17 @@ export function UsagePage() { + {cursorAccessEnvironments.map((environment) => ( + { + void refresh(); + void refreshLimits(); + }} + /> + ))} {selectedEnvironments.length === 0 ? (

{environments.length === 0 @@ -656,6 +671,44 @@ export function UsagePage() { ); } +function CursorKeychainAccessNotice({ + environmentId, + label, + onEnabled, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly onEnabled: () => void; +}) { + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "enable Cursor account usage", + }); + const [pending, setPending] = useState(false); + const enable = async () => { + setPending(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { cursorKeychainUsageEnabled: true } }, + }); + if (result._tag === "Success") onEnabled(); + } finally { + setPending(false); + } + }; + return ( +

+

+ To show Cursor history and monthly limits from {label}, T3 needs access to your Cursor CLI + login in macOS Keychain. macOS may ask you to allow it. +

+ +
+ ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/docs/user/usage.md b/docs/user/usage.md index 0120e4428964..cc48c976123b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -19,10 +19,11 @@ server to read a different data directory; comma-separated paths read multiple d Cursor reads account usage from Cursor's dashboard API using the CLI login saved on the server. This includes headless T3 sessions and desktop usage across machines; the same account counts -once across connected environments. Without an accessible file-based CLI login, T3 shows a +once across connected environments. Without an accessible CLI login, T3 shows a notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. -On macOS, [set up a file-based login](#track-subscription-limits) and set -`AGENT_CLI_CREDENTIAL_STORE=file` on the server to enable Cursor history. +On macOS, choose **Enable Cursor usage** on Usage to allow T3 to read your existing CLI login +from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask +you to allow access on the server Mac. Usage includes each configured account's history, including disabled accounts. Custom homes follow the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment @@ -89,10 +90,9 @@ anything. The command is offered only for providers that appear under **Usage OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials belong to the remote server. Cursor reports -its monthly allowance, including separate Auto and API usage, using a file-based CLI login or -`CURSOR_AUTH_TOKEN`. Cursor's default macOS keychain login does not currently report limits. -On macOS, use `AGENT_CLI_CREDENTIAL_STORE=file` when signing in and in the provider's environment -to use a file-based login. +its monthly allowance, including separate Auto and API usage, using the CLI login or +`CURSOR_AUTH_TOKEN`. On macOS, this includes the default Keychain login after you enable Cursor +usage. Grok reports the remaining subscription allowance and reset time for its current billing period after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index a7476ec17157..5919c0af263a 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -933,6 +933,14 @@ export function createServerEnvironmentAtoms( ); }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)), ); + const usageScanSettingsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + JSON.stringify([ + get(usagePricesAtom(environmentId)), + get(settingsValueAtom(environmentId))?.cursorKeychainUsageEnabled ?? false, + ]), + ).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), + ); const providersValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( Atom.withLabel(`environment-data:server:providers:${environmentId}`), @@ -1050,7 +1058,7 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:usage-summary", tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, - refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), + refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId), }), configProjection, welcome, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3e301201910c..ffdd44783bcc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1279,6 +1279,10 @@ export const ServerSettings = Schema.Struct({ usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** Allows this server to read the Cursor CLI's macOS Keychain login for account usage. */ + cursorKeychainUsageEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), /** Exact model IDs, applied to past and future usage on this environment. */ usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -1552,6 +1556,7 @@ export const ServerSettingsPatch = Schema.Struct({ usageLimitSources: Schema.optionalKey( Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), ), + cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), /** Each entry replaces one model's rates; `null` restores automatic pricing. */ usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 211625832763..a6431330558a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -156,6 +156,8 @@ export const UsageSource = Schema.Struct({ */ distinctSessions: NonNegativeInt, message: Schema.NullOr(TrimmedNonEmptyString), + /** An action the client can offer to make this source available. */ + action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")), }); export type UsageSource = typeof UsageSource.Type; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1090902dab8b..d512900e23bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -516,6 +516,9 @@ importers: '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8) + '@napi-rs/keyring': + specifier: ^1.3.0 + version: 1.3.0 '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index ebcc81020bfe..5168e770a114 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -49,6 +49,7 @@ describe("shouldBundleCliDependency", () => { "ffi-rs", "@yuuang/ffi-rs-win32-x64-msvc", "@ff-labs/fff-node", + "@napi-rs/keyring", "@clerk/electron-passkeys", "node-addon-api", ]) { @@ -82,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => { it("selects every external root declared by the server", () => { assert.deepStrictEqual( Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), - ["@ff-labs/fff-node", "node-pty"], + ["@ff-labs/fff-node", "@napi-rs/keyring", "node-pty"], ); }); }); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 5cafee960675..c158ccaff7f4 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -30,6 +30,7 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "ffi-rs", "@yuuang/", "@ff-labs/", + "@napi-rs/keyring", "@clerk/electron-passkeys", "node-gyp-build", "node-addon-api", From 73854af3653ef063b668691f21be89b98c63f4a5 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 01:25:06 -0500 Subject: [PATCH 15/28] fix(usage): keep Cursor Keychain reads responsive --- .../provider/Layers/CursorProvider.test.ts | 15 +++++++++++ .../src/provider/Layers/cursorUsageLimits.ts | 2 +- .../src/provider/cursorCredentialStore.ts | 4 +-- .../settings/ProviderSettingsPanel.tsx | 20 +++++++++++++++ .../settings/SettingsSidebarNav.tsx | 2 +- .../settings/settingsSearch.test.ts | 25 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 4 +++ .../useAvailableSettingsSearchItems.ts | 14 ++++++++++- 8 files changed, 81 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 514c2cc298b0..c76e303b34db 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1139,6 +1139,21 @@ describe("Cursor usage limits", () => { expect(limits.windows[0]?.usedPercent).toBe(42); }); + it("reports a Keychain initialization failure without failing the provider refresh", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { + throw new Error("Keychain initialization failed"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request limits without a login")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("probeFailed"); + }); + it("reports failed requests without exposing credentials or response bodies", async () => { const limits = await runNode( readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts index 2d27470a0675..a18206b53ba5 100644 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -93,7 +93,7 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function message: "Enable Cursor account usage in T3 Code to read its Keychain login.", }); } - token = (yield* Effect.promise(keychainToken))?.trim(); + token = (yield* Effect.tryPromise(keychainToken))?.trim(); } else if (!token) { const home = (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); diff --git a/apps/server/src/provider/cursorCredentialStore.ts b/apps/server/src/provider/cursorCredentialStore.ts index 0efb37c9eebb..c071e2a88578 100644 --- a/apps/server/src/provider/cursorCredentialStore.ts +++ b/apps/server/src/provider/cursorCredentialStore.ts @@ -24,6 +24,6 @@ export function makeCachedCursorAccessTokenReader( /** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { - const { Entry } = await import("@napi-rs/keyring"); - return new Entry("cursor-access-token", "cursor-user").getPassword(); + const { AsyncEntry } = await import("@napi-rs/keyring"); + return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; }); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index f25d2a2aa0fb..6722969075a1 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -314,7 +314,25 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { hasServerConfig: environment.serverConfig !== null, }), )?.environmentId; + const searchableCursorEnvironmentId = options.find( + (environment) => + environment.serverConfig?.environment.platform.os === "darwin" && + isProviderSettingsEnvironmentAvailable({ + connectionPhase: environment.connection.phase, + hasServerConfig: true, + }), + )?.environmentId; useEffect(() => { + if ( + !target.scoped && + searchTargetId === searchableSetting("cursor-keychain-usage").id && + (!selectedEnvironmentCanRenderSettings || + selectedEnvironment?.serverConfig?.environment.platform.os !== "darwin") && + searchableCursorEnvironmentId !== undefined + ) { + setSelectedEnvironmentId(searchableCursorEnvironmentId); + return; + } if ( !target.scoped && (searchTargetId === searchableSetting("provider-health-check-interval").id || @@ -326,7 +344,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { } }, [ searchTargetId, + searchableCursorEnvironmentId, searchableEnvironmentId, + selectedEnvironment, selectedEnvironmentCanRenderSettings, target.scoped, ]); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index e2ea39c761f2..142972cf1ece 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -116,7 +116,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); - const searchableItems = useAvailableSettingsSearchItems(); + const searchableItems = useAvailableSettingsSearchItems(scopeSearch); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 569d736a62de..9bd4f9906acd 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -154,6 +154,7 @@ describe("searchSettings", () => { hasCloudPublicConfig: false, hasEnvironment: false, hasProviderSettingsEnvironment: false, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: false, @@ -165,6 +166,7 @@ describe("searchSettings", () => { "network-access", "publish-agent-activity", "provider-health-check-interval", + "cursor-keychain-usage", "source-control-writer-model", "source-control-writing-style", "t3-connect", @@ -177,11 +179,31 @@ describe("searchSettings", () => { expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("offers Cursor Keychain settings only when a macOS provider environment is available", () => { + const availability = { + hasCloudPublicConfig: false, + hasEnvironment: true, + hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, + }; + const itemIds = (macAvailable: boolean) => + filterAvailableSettingsSearchItems({ + ...availability, + hasMacProviderSettingsEnvironment: macAvailable, + }).map((item) => item.id); + expect(itemIds(false)).not.toContain("cursor-keychain-usage"); + expect(itemIds(true)).toContain("cursor-keychain-usage"); + }); + it("keeps the local toggle searchable without offering hidden host publishing controls", () => { const availability = { hasCloudPublicConfig: true, hasEnvironment: true, hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: false, @@ -204,6 +226,7 @@ describe("searchSettings", () => { hasCloudPublicConfig: false, hasEnvironment: false, hasProviderSettingsEnvironment: false, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: true, @@ -330,6 +353,7 @@ describe("searchSettings", () => { hasCloudPublicConfig: false, hasEnvironment: true, hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: true, @@ -424,6 +448,7 @@ describe("auto-settlement search availability", () => { hasCloudPublicConfig: false, hasEnvironment: true, hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index cf77516cf9e4..539e780baaa8 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -55,6 +55,7 @@ export interface SettingsSearchItem { readonly cloudOnly?: boolean; readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; + readonly macProviderSettingsOnly?: boolean; readonly localBackendManagementOnly?: boolean; readonly localEnvironmentOnly?: boolean; readonly wslAvailableOnly?: boolean; @@ -71,6 +72,7 @@ export interface SettingsSearchAvailability { readonly hasCloudPublicConfig: boolean; readonly hasEnvironment: boolean; readonly hasProviderSettingsEnvironment: boolean; + readonly hasMacProviderSettingsEnvironment: boolean; readonly canManageLocalBackend: boolean; readonly isWslSettingsRowVisible: boolean; readonly hasThreadAutoSettlement: boolean; @@ -552,6 +554,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/providers", searchTerms: ["cursor macOS keychain usage tokens cost limits permission"], providerSettingsOnly: true, + macProviderSettingsOnly: true, }, { id: "provider-health-check-interval", @@ -950,6 +953,7 @@ export function filterAvailableSettingsSearchItems( (!item.cloudOnly || availability.hasCloudPublicConfig) && (!item.environmentOnly || availability.hasEnvironment) && (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && + (!item.macProviderSettingsOnly || availability.hasMacProviderSettingsEnvironment) && (!item.localBackendManagementOnly || availability.canManageLocalBackend) && (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) && (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index a0601e41729f..a5be950b81ab 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -10,12 +10,13 @@ import { useEnvironmentQuery } from "~/state/query"; import { usePrimarySessionState } from "~/environments/primary"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; +import type { SettingsScopeSearch } from "./settingsScope"; import { filterAvailableSettingsSearchItems, getThreadAutoSettlementSearchAvailability, } from "./settingsSearch"; -export function useAvailableSettingsSearchItems() { +export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) { const { environments } = useEnvironments(); const primarySessionState = usePrimarySessionState(); const localEnvironmentDisabled = isLocalEnvironmentDisabled(); @@ -41,6 +42,16 @@ export function useAvailableSettingsSearchItems() { hasServerConfig: environment.serverConfig !== null, }), ), + hasMacProviderSettingsEnvironment: environments.some( + (environment) => + (scopeSearch.machine === undefined || + environment.environmentId === scopeSearch.machine) && + environment.serverConfig?.environment.platform.os === "darwin" && + isProviderSettingsEnvironmentAvailable({ + connectionPhase: environment.connection.phase, + hasServerConfig: true, + }), + ), canManageLocalBackend, isWslSettingsRowVisible: isWslSettingsRowVisible({ state: desktopWsl.data, @@ -55,6 +66,7 @@ export function useAvailableSettingsSearchItems() { desktopWsl.error, environments, localEnvironmentDisabled, + scopeSearch.machine, ], ); } From f91fa8194407f6a433873a44fb483ea0ff060c02 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 01:40:24 -0500 Subject: [PATCH 16/28] fix(usage): require HTTPS for Cursor Keychain limits --- .../provider/Layers/CursorProvider.test.ts | 21 +++++++++++++++++++ .../src/provider/Layers/cursorUsageLimits.ts | 17 ++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index c76e303b34db..01ab1be90cd0 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1154,6 +1154,27 @@ describe("Cursor usage limits", () => { expect(limits.unavailable?.reason).toBe("probeFailed"); }); + it("does not read Keychain or send its token to an HTTP endpoint", async () => { + for (const [apiEndpoint, environment] of [ + ["http://localhost:3000", {}], + ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], + ] as const) { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { + throw new Error("must not read Keychain for an HTTP endpoint"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not send a credential to HTTP")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("unsupported"); + expect(limits.unavailable?.message).toContain("HTTPS endpoint"); + } + }); + it("reports failed requests without exposing credentials or response bodies", async () => { const limits = await runNode( readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts index a18206b53ba5..195f1e81001a 100644 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -72,6 +72,11 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; + const endpoint = ( + settings.apiEndpoint.trim() || + environment.CURSOR_API_ENDPOINT?.trim() || + "https://api2.cursor.sh" + ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); // An explicit API key can name a different account from the stored login. if (!token && environment.CURSOR_API_KEY?.trim()) { @@ -93,6 +98,13 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function message: "Enable Cursor account usage in T3 Code to read its Keychain login.", }); } + if (!endpoint.toLowerCase().startsWith("https://")) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor account usage requires an HTTPS endpoint when using Keychain.", + }); + } token = (yield* Effect.tryPromise(keychainToken))?.trim(); } else if (!token) { const home = @@ -114,11 +126,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function } if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); const client = yield* HttpClient.HttpClient; - const endpoint = ( - settings.apiEndpoint.trim() || - environment.CURSOR_API_ENDPOINT?.trim() || - "https://api2.cursor.sh" - ).replace(/\/$/, ""); const response = yield* client.execute( HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( HttpClientRequest.bearerToken(token), From b5e254454dca0fe06d23492fc63ec2829eaf5205 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 01:51:28 -0500 Subject: [PATCH 17/28] fix(usage): keep Cursor Keychain token on its API origin --- apps/server/src/provider/Layers/CursorProvider.test.ts | 10 ++++++---- apps/server/src/provider/Layers/cursorUsageLimits.ts | 7 ++++--- docs/user/usage.md | 3 ++- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 01ab1be90cd0..11700b56475b 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1154,24 +1154,26 @@ describe("Cursor usage limits", () => { expect(limits.unavailable?.reason).toBe("probeFailed"); }); - it("does not read Keychain or send its token to an HTTP endpoint", async () => { + it("does not read Keychain or send its token to a custom endpoint", async () => { for (const [apiEndpoint, environment] of [ ["http://localhost:3000", {}], ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], + ["https://cursor-proxy.example", {}], + ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], ] as const) { const limits = await runNode( readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { - throw new Error("must not read Keychain for an HTTP endpoint"); + throw new Error("must not read Keychain for a custom endpoint"); }).pipe( Effect.provideService(HostProcessPlatform, "darwin"), Effect.provideService( HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not send a credential to HTTP")), + HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), ), ), ); expect(limits.unavailable?.reason).toBe("unsupported"); - expect(limits.unavailable?.message).toContain("HTTPS endpoint"); + expect(limits.unavailable?.message).toContain("default Cursor endpoint"); } }); diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts index 195f1e81001a..6b34b8c161d5 100644 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -16,6 +16,7 @@ import { import { readMacCursorAccessToken } from "../cursorCredentialStore.ts"; const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); +const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); const CursorUsageResponse = Schema.Struct({ billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), @@ -75,7 +76,7 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function const endpoint = ( settings.apiEndpoint.trim() || environment.CURSOR_API_ENDPOINT?.trim() || - "https://api2.cursor.sh" + DEFAULT_CURSOR_API_ENDPOINT ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); // An explicit API key can name a different account from the stored login. @@ -98,11 +99,11 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function message: "Enable Cursor account usage in T3 Code to read its Keychain login.", }); } - if (!endpoint.toLowerCase().startsWith("https://")) { + if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported", - message: "Cursor account usage requires an HTTPS endpoint when using Keychain.", + message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", }); } token = (yield* Effect.tryPromise(keychainToken))?.trim(); diff --git a/docs/user/usage.md b/docs/user/usage.md index cc48c976123b..ab77f70a6b3a 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -92,7 +92,8 @@ the environment. T3 cannot report limits for external OpenCode servers because t belong to the remote server. Cursor reports its monthly allowance, including separate Auto and API usage, using the CLI login or `CURSOR_AUTH_TOKEN`. On macOS, this includes the default Keychain login after you enable Cursor -usage. +usage. Keychain login is used for limits only with Cursor's default API endpoint. If you configure +a custom Cursor endpoint, use an explicit token or file-based CLI login for limits. Grok reports the remaining subscription allowance and reset time for its current billing period after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication From 40854e1d3596a93444df52e290ebb7729f78937b Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 02:53:53 -0500 Subject: [PATCH 18/28] fix(usage): show Cursor enable action in provider list --- .../src/features/usage/UsageRouteScreen.tsx | 82 ++++++++++++++----- apps/web/src/components/usage/UsagePage.tsx | 75 ++++++++++++----- 2 files changed, 115 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 9026e10041a2..2b8c7f8e772e 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -102,6 +102,10 @@ export function UsageRouteScreen() { const cursorAccessEnvironments = selectedEnvironments.filter((environment) => environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), ); + const refreshAfterCursorEnable = () => { + void refresh(); + void limits.refresh(); + }; const sourceMessages = [ ...new Set( selectedEnvironments.flatMap( @@ -272,17 +276,20 @@ export function UsageRouteScreen() { } > - {cursorAccessEnvironments.map((environment) => ( - { - void refresh(); - void limits.refresh(); - }} - /> - ))} + {showingLimits && cursorAccessEnvironments.length > 0 ? ( + + {cursorAccessEnvironments.map((environment, index) => ( + 1} + bordered={index > 0} + onEnabled={refreshAfterCursorEnable} + /> + ))} + + ) : null} - + 1} + onCursorEnabled={refreshAfterCursorEnable} + /> @@ -361,15 +374,20 @@ export function UsageRouteScreen() { ); } -function CursorKeychainAccessNotice({ +function CursorEnableRow({ environmentId, label, + showEnvironment, + bordered, onEnabled, }: { readonly environmentId: EnvironmentId; readonly label: string; + readonly showEnvironment: boolean; + readonly bordered: boolean; readonly onEnabled: () => void; }) { + const colors = useProviderColors(); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "enable Cursor account usage", }); @@ -387,19 +405,26 @@ function CursorKeychainAccessNotice({ } }; return ( - - - To show Cursor history and monthly limits from {label}, T3 needs access to your Cursor CLI - login in macOS Keychain. macOS may ask you to allow access on that Mac. - + + + + + Cursor{showEnvironment ? ` · ${label}` : ""} + + void enable()} - className="self-start rounded-full bg-primary px-4 py-2" + className="rounded-full bg-primary px-4 py-2" > - Enable Cursor usage + Enable ); @@ -481,10 +506,13 @@ function ChartCard(props: { function ProviderSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric; + readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[]; + readonly showCursorEnvironment: boolean; + readonly onCursorEnabled: () => void; }) { const { merged, metric } = props; const colors = useProviderColors(); - if (merged.providers.length === 0) return null; + if (merged.providers.length === 0 && props.cursorAccessEnvironments.length === 0) return null; // Ranked by whatever the toggle is showing, so the rows always descend. // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. @@ -530,6 +558,16 @@ function ProviderSection(props: { ); })} + {props.cursorAccessEnvironments.map((environment, index) => ( + 0} + onEnabled={props.onCursorEnabled} + /> + ))}
); } diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index e02834ca3fc9..326b9c8ed99b 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -392,17 +392,6 @@ export function UsagePage() { - {cursorAccessEnvironments.map((environment) => ( - { - void refresh(); - void refreshLimits(); - }} - /> - ))} {selectedEnvironments.length === 0 ? (

{environments.length === 0 @@ -410,7 +399,28 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + <> + {cursorAccessEnvironments.length > 0 ? ( +
+ {cursorAccessEnvironments.map((environment) => ( + 1} + onEnabled={() => { + void refresh(); + void refreshLimits(); + }} + /> + ))} +
+ ) : null} + + ) : isPending ? ( ) : ( @@ -482,6 +492,18 @@ export function UsagePage() {
); })} + {cursorAccessEnvironments.map((environment) => ( + 1} + onEnabled={() => { + void refresh(); + void refreshLimits(); + }} + /> + ))}
@@ -671,13 +693,15 @@ export function UsagePage() { ); } -function CursorKeychainAccessNotice({ +function CursorEnableRow({ environmentId, label, + showEnvironment, onEnabled, }: { readonly environmentId: EnvironmentId; readonly label: string; + readonly showEnvironment: boolean; readonly onEnabled: () => void; }) { const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { @@ -697,13 +721,24 @@ function CursorKeychainAccessNotice({ } }; return ( -
-

- To show Cursor history and monthly limits from {label}, T3 needs access to your Cursor CLI - login in macOS Keychain. macOS may ask you to allow it. -

-
); From c06aa017fa5c12cd3fc26388b72928116bc00f8d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 03:26:49 -0500 Subject: [PATCH 19/28] fix(usage): align Cursor opt-in with provider rows --- .../src/features/usage/UsageLimitsPooled.tsx | 51 +++-- .../src/features/usage/UsageRouteScreen.tsx | 145 ++++++++++---- apps/web/src/components/usage/UsageLimits.tsx | 6 +- .../components/usage/UsageLimitsPooled.tsx | 19 +- apps/web/src/components/usage/UsagePage.tsx | 186 +++++++++++++----- 5 files changed, 293 insertions(+), 114 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 4e3e7339765c..681a57c3a584 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -11,7 +11,7 @@ import { type LimitAccount, type LimitPoolWindow, } from "@t3tools/shared/usageLimits"; -import { useId, useState } from "react"; +import { Fragment, type ReactNode, useId, useState } from "react"; import { Pressable, ScrollView, View } from "react-native"; import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -196,10 +196,12 @@ export function UsageLimitsSection({ now, failedLabels, selectedEnvironmentIds, + cursorPrompt, }: { readonly now: number; readonly failedLabels: readonly string[]; readonly selectedEnvironmentIds: ReadonlySet | null; + readonly cursorPrompt?: ReactNode; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const selected = @@ -209,34 +211,45 @@ export function UsageLimitsSection({ const pools = collectLimitPools(collectLimitAccounts(selected), now); const notices = collectLimitNotices(selected); const colors = useProviderColors(); + const cursorPromptAt = + Math.max( + pools.findIndex((pool) => pool.driver === "codex"), + pools.findIndex((pool) => pool.driver === "claudeAgent"), + ) + 1; return ( - {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 && !cursorPrompt ? ( {selected.size === 0 ? "Select an environment to see limits." : "No provider on the selected environments reports subscription limits."} ) : null} - {pools.map((pool) => ( - - - - - {DRIVER_LABEL[pool.driver] ?? pool.driver} - + {pools.map((pool, index) => ( + + {index === cursorPromptAt ? cursorPrompt : null} + + + + + {DRIVER_LABEL[pool.driver] ?? pool.driver} + + + {pool.windows.map((window) => ( + + ))} - {pool.windows.map((window) => ( - - ))} - + ))} + {cursorPromptAt === pools.length ? cursorPrompt : null} {notices.length > 0 || failedLabels.length > 0 ? ( - {showingLimits && cursorAccessEnvironments.length > 0 ? ( - - {cursorAccessEnvironments.map((environment, index) => ( - 1} - bordered={index > 0} - onEnabled={refreshAfterCursorEnable} - /> - ))} - - ) : null} - 0 ? ( + + ) : null + } /> ) : ( <> @@ -374,20 +369,17 @@ export function UsageRouteScreen() { ); } -function CursorEnableRow({ +function CursorEnableAction({ environmentId, label, - showEnvironment, - bordered, onEnabled, + buttonText = "Enable", }: { readonly environmentId: EnvironmentId; readonly label: string; - readonly showEnvironment: boolean; - readonly bordered: boolean; readonly onEnabled: () => void; + readonly buttonText?: string; }) { - const colors = useProviderColors(); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "enable Cursor account usage", }); @@ -404,6 +396,34 @@ function CursorEnableRow({ setPending(false); } }; + return ( + void enable()} + className="rounded-full bg-primary px-4 py-2" + > + {buttonText} + + ); +} + +function CursorEnableRow({ + environmentId, + label, + showEnvironment, + bordered, + onEnabled, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly showEnvironment: boolean; + readonly bordered: boolean; + readonly onEnabled: () => void; +}) { + const colors = useProviderColors(); return ( - void enable()} - className="rounded-full bg-primary px-4 py-2" - > - Enable - + + + ); +} + +function CursorEnableLimits({ + environments, + onEnabled, +}: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly onEnabled: () => void; +}) { + return ( + + + + Cursor + + + {CURSOR_KEYCHAIN_COPY} + + {environments.map((environment) => ( + 1 ? `Enable on ${environment.label}` : "Enable"} + onEnabled={onEnabled} + /> + ))} + + ); } @@ -519,10 +562,40 @@ function ProviderSection(props: { const ordered = [...merged.providers].sort((a, b) => metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, ); + const rows: Array< + | { readonly kind: "usage"; readonly provider: (typeof ordered)[number] } + | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } + > = ordered.map((provider) => ({ kind: "usage", provider })); + const cursorInsertAt = + Math.max( + ordered.findIndex((provider) => provider.provider === "codex"), + ordered.findIndex((provider) => provider.provider === "claude"), + ) + 1; + rows.splice( + cursorInsertAt, + 0, + ...props.cursorAccessEnvironments.map((environment) => ({ + kind: "enable" as const, + environment, + })), + ); return ( - {ordered.map((provider, index) => { + {rows.map((row, index) => { + if (row.kind === "enable") { + return ( + 0} + onEnabled={props.onCursorEnabled} + /> + ); + } + const provider = row.provider; const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( ); })} - {props.cursorAccessEnvironments.map((environment, index) => ( - 0} - onEnabled={props.onCursorEnabled} - /> - ))} ); } diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 05c91739e251..585bd6ccf100 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -17,7 +17,7 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; -import { Fragment, useState } from "react"; +import { Fragment, type ReactNode, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; import { environmentPresentations } from "../../state/presentation"; @@ -322,14 +322,16 @@ export function ResetCredits({ export function UsageLimitsSection({ selectedEnvironmentIds, now, + cursorPrompt, }: { readonly selectedEnvironmentIds: ReadonlySet | null; readonly now: number; + readonly cursorPrompt?: ReactNode; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const selected = selectedEnvironmentIds === null ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); - return ; + return ; } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 70858262b6a3..1e7a32742ad4 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -11,7 +11,7 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { AlertTriangleIcon, TicketIcon } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { Fragment, type ReactNode, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -539,22 +539,33 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu export function UsageLimitsPooled({ presentations, now, + cursorPrompt, }: { readonly presentations: Parameters[0]; readonly now: number; + readonly cursorPrompt?: ReactNode; }) { const pools = collectLimitPools(collectLimitAccounts(presentations), now); const notices = collectLimitNotices(presentations); + const cursorPromptAt = + Math.max( + pools.findIndex((pool) => pool.driver === "codex"), + pools.findIndex((pool) => pool.driver === "claudeAgent"), + ) + 1; return (
- {pools.length === 0 && notices.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && !cursorPrompt ? (

No provider on the selected environments reports subscription limits.

) : null} - {pools.map((pool) => ( - + {pools.map((pool, index) => ( + + {index === cursorPromptAt ? cursorPrompt : null} + + ))} + {cursorPromptAt === pools.length ? cursorPrompt : null}
); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 326b9c8ed99b..997a5f063b72 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,6 +1,7 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { + ProviderDriverKind, USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageProviderKind, @@ -40,6 +41,7 @@ import { makeWindow, } from "@t3tools/shared/usageFormat"; import { Button, InlineButton } from "../ui/button"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { Menu, MenuCheckboxItem, @@ -53,6 +55,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { SidebarInset } from "../ui/sidebar"; import { Skeleton } from "../ui/skeleton"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, @@ -166,6 +169,17 @@ export function UsagePage() { [breakdown, merged.models, metric], ); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); + const summaryRows: Array< + | { readonly kind: "usage"; readonly provider: UsageProviderKind } + | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } + > = activeProviders.map((provider) => ({ kind: "usage", provider })); + const cursorInsertAt = + Math.max(activeProviders.indexOf("codex"), activeProviders.indexOf("claude")) + 1; + summaryRows.splice( + cursorInsertAt, + 0, + ...cursorAccessEnvironments.map((environment) => ({ kind: "enable" as const, environment })), + ); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; const selectWindow = (days: number) => { @@ -399,28 +413,21 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - <> - {cursorAccessEnvironments.length > 0 ? ( -
- {cursorAccessEnvironments.map((environment) => ( - 1} - onEnabled={() => { - void refresh(); - void refreshLimits(); - }} - /> - ))} -
- ) : null} - - + 0 ? ( + { + void refresh(); + void refreshLimits(); + }} + /> + ) : null + } + /> ) : isPending ? ( ) : ( @@ -449,7 +456,22 @@ export function UsagePage() {
- {activeProviders.map((provider) => { + {summaryRows.map((row) => { + if (row.kind === "enable") { + return ( + 1} + onEnabled={() => { + void refresh(); + void refreshLimits(); + }} + /> + ); + } + const provider = row.provider; const totals = merged.providers.find((entry) => entry.provider === provider); const share = metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); @@ -492,18 +514,6 @@ export function UsagePage() { ); })} - {cursorAccessEnvironments.map((environment) => ( - 1} - onEnabled={() => { - void refresh(); - void refreshLimits(); - }} - /> - ))}
@@ -693,16 +703,20 @@ export function UsagePage() { ); } -function CursorEnableRow({ +const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; + +function CursorEnableButton({ environmentId, label, - showEnvironment, onEnabled, + tooltip, + buttonText = "Enable", }: { readonly environmentId: EnvironmentId; readonly label: string; - readonly showEnvironment: boolean; readonly onEnabled: () => void; + readonly tooltip: boolean; + readonly buttonText?: string; }) { const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "enable Cursor account usage", @@ -720,8 +734,49 @@ function CursorEnableRow({ setPending(false); } }; + const button = tooltip ? ( + void enable()} + > + {buttonText} + + ) : ( + + ); + if (!tooltip) return button; + return ( + + + {CURSOR_KEYCHAIN_COPY} + + ); +} + +function CursorEnableRow({ + environmentId, + label, + showEnvironment, + onEnabled, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly showEnvironment: boolean; + readonly onEnabled: () => void; +}) { return ( -
+
Cursor{showEnvironment ? ` · ${label}` : ""} - +
); } +function CursorEnableLimits({ + environments, + onEnabled, +}: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly onEnabled: () => void; +}) { + return ( +
+

+ + Cursor +

+
+

{CURSOR_KEYCHAIN_COPY}

+
+ {environments.map((environment) => ( + 1 ? `Enable on ${environment.label}` : "Enable"} + onEnabled={onEnabled} + tooltip={false} + /> + ))} +
+
+
+ ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, From 0979931ffc47baa02ed3378fe20ab2981dec5e7a Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 03:29:54 -0500 Subject: [PATCH 20/28] fix(usage): mark new provider records as standard speed --- apps/server/src/usage/antigravityUsageReader.ts | 1 + apps/server/src/usage/cursorUsageReader.ts | 1 + apps/server/src/usage/opencodeUsageReader.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts index ef395f4cd040..d8803d10673d 100644 --- a/apps/server/src/usage/antigravityUsageReader.ts +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -239,6 +239,7 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise 0 ? cost : null, + fast: false, dedupeKey: id ? `opencode:${id}` : null, }; } From 6135e1a864f7f150a881d04f98c920c595fda938 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 03:31:36 -0500 Subject: [PATCH 21/28] fix(usage): refresh Cursor limits after opt-in --- .../src/features/usage/UsageLimitsSection.tsx | 11 +++++++++-- apps/mobile/src/features/usage/UsageRouteScreen.tsx | 2 +- apps/web/src/components/usage/UsagePage.tsx | 7 ++++--- packages/client-runtime/src/state/usage.test.ts | 13 +++++++++++++ packages/client-runtime/src/state/usage.ts | 9 +++++++++ 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 5383602869bb..e0f1827aaa5a 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -294,7 +294,7 @@ export function useRefreshLimits( const [failedEnvironments, setFailedEnvironments] = useState< readonly { environmentId: EnvironmentId; label: string }[] >([]); - const refresh = async (automatic = false) => { + const refresh = async (automatic = false, afterPending = false) => { const connected = [...presentations].filter( ([environmentId, presentation]) => presentation.connection.phase === "connected" && @@ -307,6 +307,7 @@ export function useRefreshLimits( environmentId, () => refreshProviders({ environmentId, input: {} }), automatic, + afterPending, ); if (result === undefined) return; setFailedEnvironments((previous) => [ @@ -354,5 +355,11 @@ export function useRefreshLimits( selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), ) .map(({ label }) => label); - return { now, refreshing, failedLabels, refresh: refreshManually }; + return { + now, + refreshing, + failedLabels, + refresh: refreshManually, + refreshAfterEnable: () => refresh(false, true), + }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index d3f0298b2188..2d5e5398bc51 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -106,7 +106,7 @@ export function UsageRouteScreen() { ); const refreshAfterCursorEnable = () => { void refresh(); - void limits.refresh(); + void limits.refreshAfterEnable(); }; const sourceMessages = [ ...new Set( diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 997a5f063b72..1387bb3a9d9e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -198,7 +198,7 @@ export function UsagePage() { setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); }; - const refreshLimits = async (automatic = false) => { + const refreshLimits = async (automatic = false, afterPending = false) => { try { await Promise.all( Array.from(presentations, ([environmentId, presentation]) => { @@ -208,6 +208,7 @@ export function UsagePage() { environmentId, () => refreshProviders({ environmentId, input: {} }), automatic, + afterPending, ); } }), @@ -422,7 +423,7 @@ export function UsagePage() { environments={cursorAccessEnvironments} onEnabled={() => { void refresh(); - void refreshLimits(); + void refreshLimits(false, true); }} /> ) : null @@ -466,7 +467,7 @@ export function UsagePage() { showEnvironment={selectedEnvironments.length > 1} onEnabled={() => { void refresh(); - void refreshLimits(); + void refreshLimits(false, true); }} /> ); diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 98eb46e13ca8..75977d20de74 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -184,6 +184,19 @@ describe("manual usage refresh", () => { }); describe("limits refresh cooldown", () => { + it("runs a fresh check after an in-flight check when settings change", async () => { + const id = EnvironmentId.make("limits-after-enable"); + const oldCheck = Promise.withResolvers(); + const first = refreshUsageLimits(id, () => oldCheck.promise, true); + const newCheck = vi.fn(async () => "new limits"); + const afterEnable = refreshUsageLimits(id, newCheck, false, true); + expect(newCheck).not.toHaveBeenCalled(); + oldCheck.resolve("old limits"); + expect(await first).toBe("old limits"); + expect(await afterEnable).toBe("new limits"); + expect(newCheck).toHaveBeenCalledTimes(1); + }); + it("joins manual calls and gates automatic refreshes after success or failure", async () => { const clock = vi.spyOn(Date, "now").mockReturnValue(1_000); try { diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 8a2b1a44a951..11cfd26fbe6c 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -16,9 +16,18 @@ export async function refreshUsageLimits( environmentId: EnvironmentId, refresh: () => Promise, automatic = false, + afterPending = false, ): Promise { const pending = limitsRefreshes.get(environmentId); if (pending !== undefined) { + if (afterPending) { + try { + await pending; + } catch { + // The new check still needs to run if the earlier one failed. + } + return refreshUsageLimits(environmentId, refresh, false, true); + } // Manual refresh waits for the current check; automatic refresh does not repeat it. return automatic ? undefined : ((await pending) as A); } From 23d8b0e3c2813e37211731104fbf1151047f80eb Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 03:34:09 -0500 Subject: [PATCH 22/28] fix(usage): match Antigravity step models by generation --- .../src/usage/antigravityUsageReader.ts | 18 +++++++-------- .../src/usage/usageTranscriptReader.test.ts | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts index d8803d10673d..2ab13bf5a8d5 100644 --- a/apps/server/src/usage/antigravityUsageReader.ts +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -170,15 +170,16 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise { - const entries: Metadata[] = []; + const entries: Array<{ idx: number; entry: Metadata }> = []; for (const row of db.prepare(query).iterate()) { - entries.push(metadata(blob(row[column]), step)); + if (typeof row.idx !== "number") throw new Error("Invalid Antigravity metadata index"); + entries.push({ idx: row.idx, entry: metadata(blob(row[column]), step) }); if (entries.length % 256 === 0) await NodeTimersPromises.setImmediate(); } return entries; }; const generations = tables.has("gen_metadata") - ? await readMetadata("SELECT data FROM gen_metadata ORDER BY idx", "data", false) + ? await readMetadata("SELECT idx, data FROM gen_metadata ORDER BY idx", "data", false) : []; let trajectoryTimestamp: number | null = null; if (tables.has("trajectory_metadata_blob")) { @@ -188,21 +189,19 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise entry.model)?.model ?? ""; + const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry.model])); for (const [source, entries] of [ ["step", steps], ["generation", generations], ] as const) { - for (const [index, entry] of entries.entries()) { - if (entry.model) currentModel = entry.model; + for (const [index, { idx, entry }] of entries.entries()) { for (const [usageIndex, usage] of entry.usages.entries()) { const outputTokens = Math.max( numberAt(usage, 3), @@ -234,7 +233,7 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise { ); }); + it("uses the matching Antigravity generation model for each model-less step", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "model-switch.db")); + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + const generation = db.prepare("INSERT INTO gen_metadata VALUES (?, ?)"); + const step = db.prepare("INSERT INTO steps VALUES (?, ?)"); + for (const [idx, name] of ["Gemini 3 Pro", "Claude Opus 4.6"].entries()) { + generation.run(idx, new Uint8Array(protoBytes(1, protoText(19, name)))); + step.run(idx, new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx)))); + } + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.deepStrictEqual( + result.files.flatMap((file) => file.records).map((record) => record.model), + ["gemini-3-pro", "claude-opus-4-6"], + ); + }); + it("merges Antigravity aliases that bridge previously separate step records", async () => { const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "bridge.db")); try { From 0bd2f000dcca172837100dbacf10600049ee1603 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 03:45:21 -0500 Subject: [PATCH 23/28] fix(usage): ignore unused large Antigravity varints --- .../server/src/usage/antigravityUsageReader.ts | 12 +++++++----- .../src/usage/usageTranscriptReader.test.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts index 2ab13bf5a8d5..54c00b804b7a 100644 --- a/apps/server/src/usage/antigravityUsageReader.ts +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -7,7 +7,8 @@ import * as NodeTimersPromises from "node:timers/promises"; import type { UsageRecord } from "./usageTranscripts.ts"; -type Fields = Map>; +type FieldValue = number | bigint | Uint8Array; +type Fields = Map; /** Antigravity stores usage metadata as protobuf, independently of conversation text. */ function fields(bytes: Uint8Array): Fields { @@ -22,22 +23,23 @@ function fields(bytes: Uint8Array): Fields { } value |= BigInt(byte & 127) << shift; if (byte < 128) { - if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Unsafe protobuf number"); - return Number(value); + return value > BigInt(Number.MAX_SAFE_INTEGER) ? value : Number(value); } } throw new Error("Invalid Antigravity protobuf varint"); }; while (offset < bytes.length) { const tag = varint(); + if (typeof tag !== "number") throw new Error("Invalid protobuf field"); const number = Math.floor(tag / 8); const wire = tag % 8; if (number === 0) throw new Error("Invalid protobuf field"); - let value: number | Uint8Array; + let value: FieldValue; if (wire === 0) { value = varint(); } else if (wire === 1 || wire === 5 || wire === 2) { const length = wire === 2 ? varint() : wire === 1 ? 8 : 4; + if (typeof length !== "number") throw new Error("Invalid protobuf field length"); if (length > bytes.length - offset) throw new Error("Truncated protobuf field"); value = bytes.subarray(offset, offset + length); offset += length; @@ -62,7 +64,7 @@ const bytesAt = (value: Fields, key: number) => { }; const nested = (value: Fields, key: number) => { const bytes = bytesAt(value, key); - return bytes === undefined ? new Map>() : fields(bytes); + return bytes === undefined ? new Map() : fields(bytes); }; const textAt = (value: Fields, key: number) => { const bytes = bytesAt(value, key); diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index a6374fa5e29d..6c95a36df19b 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -743,4 +743,22 @@ describe("SQLite usage readers", () => { assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.model, "gemini-2.5-pro"); assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.totals.outputTokens, 5); }); + + it("ignores large values in unused Antigravity protobuf fields", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "large-varint.db")); + try { + db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + const unusedField = [...protoNumber(99, 0).slice(0, -1), ...Array(9).fill(0xff), 0x01]; + const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...unusedField]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(protoBytes(9, usage))); + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual( + result.files.flatMap((file) => file.records)[0]?.totals.uncachedInputTokens, + 10, + ); + }); }); From a60e3bc23a69cd7127c3952975e67c18c5906de5 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 04:17:35 -0500 Subject: [PATCH 24/28] fix(usage): explain Cursor monthly allowances --- .../src/features/usage/UsageLimitsPooled.tsx | 72 ++++++++++++------- .../provider/Layers/CursorProvider.test.ts | 10 +-- .../src/provider/Layers/cursorUsageLimits.ts | 11 ++- .../components/usage/UsageLimitsPooled.tsx | 36 ++++++++-- packages/shared/src/usageLimits.test.ts | 56 +++++++++++++++ packages/shared/src/usageLimits.ts | 42 +++++++++++ 6 files changed, 187 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 681a57c3a584..c6417e12ca35 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -5,6 +5,8 @@ import { collectLimitAccounts, collectLimitNotices, collectLimitPools, + cursorUsageWindowDetails, + displayLimitWindows, formatDuration, formatResetsIn, remainingPercent, @@ -70,11 +72,15 @@ function PoolWindowCard({ color, now, environmentIds, + label, + description, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; readonly environmentIds: readonly string[] | null; + readonly label?: string; + readonly description?: string; }) { const navigation = useNavigation(); const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); @@ -96,7 +102,7 @@ function PoolWindowCard({ - {pool.label} + {label ?? pool.label} {pool.remainingPercent}% @@ -108,6 +114,7 @@ function PoolWindowCard({ {PACE_LABEL[pool.pace]} ) : null} + {description ? {description} : null} {nextRefill ? ( ↻ +{nextRefill.restoresPercent}%{" "} @@ -225,30 +232,47 @@ export function UsageLimitsSection({ : "No provider on the selected environments reports subscription limits."} ) : null} - {pools.map((pool, index) => ( - - {index === cursorPromptAt ? cursorPrompt : null} - - - - - {DRIVER_LABEL[pool.driver] ?? pool.driver} - + {pools.map((pool, index) => { + const { overall, windows } = displayLimitWindows(pool); + return ( + + {index === cursorPromptAt ? cursorPrompt : null} + + + + + {DRIVER_LABEL[pool.driver] ?? pool.driver} + + + {overall ? ( + + + {overall.remainingPercent}% overall left + {" "} + across both allowances, weighted by their size. + + ) : null} + {windows.map((window) => { + const details = + pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; + return ( + + ); + })} - {pool.windows.map((window) => ( - - ))} - - - ))} + + ); + })} {cursorPromptAt === pools.length ? cursorPrompt : null} {notices.length > 0 || failedLabels.length > 0 ? ( { { id: "totalPercentUsed", kind: "monthly", - label: "Monthly", + label: "Overall", usedPercent: 72.4, resetsAt: "2026-09-20T03:53:06.000Z", }, { id: "autoPercentUsed", kind: "monthly", - label: "Monthly · Auto", + label: "Cursor Models", usedPercent: 69.5, resetsAt: "2026-09-20T03:53:06.000Z", }, { id: "apiPercentUsed", kind: "monthly", - label: "Monthly · API", + label: "Other Models", usedPercent: 100, resetsAt: "2026-09-20T03:53:06.000Z", }, @@ -1019,10 +1019,10 @@ describe("Cursor usage limits", () => { ); expect( cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 0 }]); + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); expect( cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 100 }]); + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); }); it("reads the instance's credentials and endpoint even when usage enabled is false", async () => { diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts index 6b34b8c161d5..e0611e0c50ab 100644 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -41,15 +42,11 @@ export function cursorUsageResponseToLimits( : undefined; const windows: ServerProviderUsageWindow[] = []; if (response.planUsage) { - for (const [key, label] of [ - ["totalPercentUsed", "Monthly"], - ["autoPercentUsed", "Monthly · Auto"], - ["apiPercentUsed", "Monthly · API"], - ] as const) { - const usedPercent = response.planUsage[key]; + for (const { id, label } of CURSOR_USAGE_WINDOWS) { + const usedPercent = response.planUsage[id]; if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; windows.push({ - id: key, + id, kind: "monthly", label, usedPercent: clampPercent(usedPercent), diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 1e7a32742ad4..44119a7a42de 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -2,6 +2,8 @@ import { collectLimitAccounts, collectLimitNotices, collectLimitPools, + cursorUsageWindowDetails, + displayLimitWindows, formatDuration, formatResetsIn, type LimitAccount, @@ -479,17 +481,21 @@ function PoolWindowCard({ pool, color, now, + label, + description, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; + readonly label?: string | undefined; + readonly description?: string | undefined; }) { // The soonest reset that hands anything back; an untouched account resets to no effect. const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); return (
- {pool.label} + {label ?? pool.label} {pool.remainingPercent}% @@ -505,6 +511,9 @@ function PoolWindowCard({ ) : null}
+ {description ? ( +

{description}

+ ) : null}
); } @@ -512,6 +521,7 @@ function PoolWindowCard({ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { const color = barColor(pool.driver); const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); + const { overall, windows } = displayLimitWindows(pool); return (

@@ -524,9 +534,27 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu /> {label}

- {pool.windows.map((window) => ( - - ))} + {overall ? ( +

+ + {overall.remainingPercent}% overall left + {" "} + across both allowances, weighted by their size. +

+ ) : null} + {windows.map((window) => { + const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; + return ( + + ); + })}
); } diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0ef53b1e7f55..5bea33af9f6b 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -16,6 +16,7 @@ import { collectLimitAccounts, collectLimitNotices, collectLimitPools, + displayLimitWindows, elapsedShare, formatResetsIn, limitsNotice, @@ -687,6 +688,61 @@ describe("pooled account columns", () => { }); }); +describe("Cursor limit presentation", () => { + const cursorAccount: LimitAccount = { + key: "cursor", + driver: ProviderDriverKind.make("cursor"), + displayName: "Cursor", + email: undefined, + plan: undefined, + accentColor: undefined, + environments: [], + sourceLabel: "Cursor", + redeem: null, + limits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [ + { id: "apiPercentUsed", kind: "monthly", label: "Other Models", usedPercent: 49 }, + { id: "autoPercentUsed", kind: "monthly", label: "Cursor Models", usedPercent: 9 }, + { id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 15 }, + ], + }, + }; + + it("separates the combined percentage and orders the two allowances", () => { + const [pool] = collectLimitPools([cursorAccount], now); + const display = displayLimitWindows(pool!); + expect(display.overall?.remainingPercent).toBe(85); + expect(display.windows.map((window) => window.id)).toEqual([ + "autoPercentUsed", + "apiPercentUsed", + ]); + }); + + it("keeps the combined percentage as a card if either allowance is missing", () => { + const [pool] = collectLimitPools( + [ + { + ...cursorAccount, + limits: { + ...cursorAccount.limits, + windows: cursorAccount.limits.windows.filter( + (window) => window.id !== "apiPercentUsed", + ), + }, + }, + ], + now, + ); + const display = displayLimitWindows(pool!); + expect(display.overall).toBeUndefined(); + expect(display.windows.map((window) => window.id)).toEqual([ + "totalPercentUsed", + "autoPercentUsed", + ]); + }); +}); + describe("collectLimitNotices", () => { const checkedAt = "2026-09-03T11:00:00.000Z"; const claude = ProviderDriverKind.make("claudeAgent"); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 17ba1feab5d6..a9453900c5a8 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -24,6 +24,33 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export const CURSOR_USAGE_WINDOWS = [ + { + id: "totalPercentUsed", + label: "Overall", + description: "Combined usage across both allowances, not a third quota.", + }, + { + id: "autoPercentUsed", + label: "Cursor Models", + description: "Grok and Composer use this allowance. Auto can draw from it too.", + }, + { + id: "apiPercentUsed", + label: "Other Models", + description: "Claude, GPT, and Gemini use this allowance. Auto can draw from it too.", + }, +] as const; + +export function cursorUsageWindowDetails(id: string) { + return CURSOR_USAGE_WINDOWS.find((window) => window.id === id); +} + +export function cursorUsageWindowRank(id: string): number { + const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id); + return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank; +} + /** * Providers that belong on the Limits view: enabled, installed, and one whose * driver reports subscription usage at all. A driver with no notion of usage @@ -282,6 +309,21 @@ export interface LimitPool { readonly windows: readonly LimitPoolWindow[]; } +/** Show Cursor's combined figure separately when both actual allowances are available. */ +export function displayLimitWindows(pool: LimitPool) { + if (pool.driver !== "cursor") return { overall: undefined, windows: pool.windows }; + const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed"); + const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed"); + const overall = + hasAuto && hasApi ? pool.windows.find((window) => window.id === "totalPercentUsed") : undefined; + return { + overall, + windows: pool.windows + .filter((window) => window !== overall) + .sort((left, right) => cursorUsageWindowRank(left.id) - cursorUsageWindowRank(right.id)), + }; +} + const WINDOW_KIND_ORDER: Record = { session: 0, weekly: 1, From fe90238058f56a0c10909ccfe6afd8eb1a8a5caa Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 04:19:46 -0500 Subject: [PATCH 25/28] fix(usage): keep Cursor window ranking private --- packages/shared/src/usageLimits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index a9453900c5a8..78124eb75092 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -46,7 +46,7 @@ export function cursorUsageWindowDetails(id: string) { return CURSOR_USAGE_WINDOWS.find((window) => window.id === id); } -export function cursorUsageWindowRank(id: string): number { +function cursorUsageWindowRank(id: string): number { const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id); return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank; } From f24f90fb0e4b6437ff4e161fea41a0b0f32bfe11 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 05:16:22 -0500 Subject: [PATCH 26/28] fix(usage): focus Cursor limits on usable pools --- .../src/features/usage/UsageLimitsPooled.tsx | 10 +--------- .../components/usage/UsageLimitsPooled.tsx | 10 +--------- packages/shared/src/usageLimits.test.ts | 14 +++---------- packages/shared/src/usageLimits.ts | 20 ++++++++----------- 4 files changed, 13 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index c6417e12ca35..0bcc0cd74958 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -233,7 +233,7 @@ export function UsageLimitsSection({ ) : null} {pools.map((pool, index) => { - const { overall, windows } = displayLimitWindows(pool); + const windows = displayLimitWindows(pool); return ( {index === cursorPromptAt ? cursorPrompt : null} @@ -244,14 +244,6 @@ export function UsageLimitsSection({ {DRIVER_LABEL[pool.driver] ?? pool.driver}
- {overall ? ( - - - {overall.remainingPercent}% overall left - {" "} - across both allowances, weighted by their size. - - ) : null} {windows.map((window) => { const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 44119a7a42de..056fb6138767 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -521,7 +521,7 @@ function PoolWindowCard({ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { const color = barColor(pool.driver); const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); - const { overall, windows } = displayLimitWindows(pool); + const windows = displayLimitWindows(pool); return (

@@ -534,14 +534,6 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu /> {label}

- {overall ? ( -

- - {overall.remainingPercent}% overall left - {" "} - across both allowances, weighted by their size. -

- ) : null} {windows.map((window) => { const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; return ( diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 5bea33af9f6b..0646953a5f08 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -709,14 +709,10 @@ describe("Cursor limit presentation", () => { }, }; - it("separates the combined percentage and orders the two allowances", () => { + it("hides the combined percentage and orders the two pools", () => { const [pool] = collectLimitPools([cursorAccount], now); const display = displayLimitWindows(pool!); - expect(display.overall?.remainingPercent).toBe(85); - expect(display.windows.map((window) => window.id)).toEqual([ - "autoPercentUsed", - "apiPercentUsed", - ]); + expect(display.map((window) => window.id)).toEqual(["autoPercentUsed", "apiPercentUsed"]); }); it("keeps the combined percentage as a card if either allowance is missing", () => { @@ -735,11 +731,7 @@ describe("Cursor limit presentation", () => { now, ); const display = displayLimitWindows(pool!); - expect(display.overall).toBeUndefined(); - expect(display.windows.map((window) => window.id)).toEqual([ - "totalPercentUsed", - "autoPercentUsed", - ]); + expect(display.map((window) => window.id)).toEqual(["totalPercentUsed", "autoPercentUsed"]); }); }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 78124eb75092..f2b5cfe53b84 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -33,12 +33,12 @@ export const CURSOR_USAGE_WINDOWS = [ { id: "autoPercentUsed", label: "Cursor Models", - description: "Grok and Composer use this allowance. Auto can draw from it too.", + description: "Grok and Composer use this first. Auto can use either pool.", }, { id: "apiPercentUsed", label: "Other Models", - description: "Claude, GPT, and Gemini use this allowance. Auto can draw from it too.", + description: "Claude, GPT, and Gemini use this pool. Grok and Composer fall back here.", }, ] as const; @@ -309,19 +309,15 @@ export interface LimitPool { readonly windows: readonly LimitPoolWindow[]; } -/** Show Cursor's combined figure separately when both actual allowances are available. */ +/** Show Cursor's two usable pools instead of a combined percentage when both are available. */ export function displayLimitWindows(pool: LimitPool) { - if (pool.driver !== "cursor") return { overall: undefined, windows: pool.windows }; + if (pool.driver !== "cursor") return pool.windows; const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed"); const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed"); - const overall = - hasAuto && hasApi ? pool.windows.find((window) => window.id === "totalPercentUsed") : undefined; - return { - overall, - windows: pool.windows - .filter((window) => window !== overall) - .sort((left, right) => cursorUsageWindowRank(left.id) - cursorUsageWindowRank(right.id)), - }; + const hasBothPools = hasAuto && hasApi; + return pool.windows + .filter((window) => !hasBothPools || window.id !== "totalPercentUsed") + .sort((left, right) => cursorUsageWindowRank(left.id) - cursorUsageWindowRank(right.id)); } const WINDOW_KIND_ORDER: Record = { From 3f510e00231411a71513cf80846c91acb15df413 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 05:35:41 -0500 Subject: [PATCH 27/28] fix(usage): simplify single-account limit bars --- .../src/components/usage/UsageLimitsPooled.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 056fb6138767..bdb291836328 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -4,7 +4,6 @@ import { collectLimitPools, cursorUsageWindowDetails, displayLimitWindows, - formatDuration, formatResetsIn, type LimitAccount, type LimitPool, @@ -226,6 +225,7 @@ function PoolSegment({ color, now, index, + showAccountName, }: { readonly account: LimitAccount; readonly window: LimitPoolMember["window"]; @@ -234,6 +234,7 @@ function PoolSegment({ readonly now: number; /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */ readonly index: number; + readonly showAccountName: boolean; }) { const [open, setOpen] = useState(false); const remaining = remainingPercent(window); @@ -276,7 +277,12 @@ function PoolSegment({ {index}
- + {showAccountName ? ( + + ) : null} {remaining}% {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} @@ -465,6 +471,7 @@ function PoolBar({ color={color} now={now} index={position + 1} + showAccountName={pool.columns.length > 1} /> ) : null, )} @@ -503,10 +510,9 @@ function PoolWindowCard({ left {pool.pace ? : null} - {nextRefill ? ( - - ↻ +{nextRefill.restoresPercent}%{" "} - {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + {nextRefill && pool.columns.length > 1 ? ( + + ↻ +{nextRefill.restoresPercent}% ) : null}
From 003f3c8a48f47c6941131e2da67b466b81209fe3 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 06:04:30 -0500 Subject: [PATCH 28/28] fix(usage): show tiny positive shares as less than 0.1 percent --- packages/shared/src/usageFormat.test.ts | 12 ++++++++++++ packages/shared/src/usageFormat.ts | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 3e92d0b3cb85..3a47ede3bcc8 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -5,10 +5,22 @@ import { enumerateHourStarts, formatDateTimeShort, formatHourShort, + formatPercent, formatRelativeHourShort, makeWindow, } from "./usageFormat.ts"; +describe("formatPercent", () => { + it("distinguishes a small positive share from zero", () => { + expect(formatPercent(0)).toBe("0.0%"); + expect(formatPercent(0.0004)).toBe("<0.1%"); + expect(formatPercent(0.0009)).toBe("<0.1%"); + expect(formatPercent(0.001)).toBe("0.1%"); + expect(formatPercent(0.023)).toBe("2.3%"); + expect(formatPercent(0.00004, 2)).toBe("<0.01%"); + }); +}); + describe("hourly usage formatting", () => { it("keeps requested zones separate when formatting repeated calls", () => { const instant = "2026-08-11T12:37:00.000Z"; diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 442687308323..328e0cf814e7 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -43,7 +43,10 @@ function trim(value: number): string { } export function formatPercent(share: number, digits = 1): string { - return `${(share * 100).toFixed(digits)}%`; + const percent = share * 100; + const smallest = 10 ** -digits; + if (percent > 0 && percent < smallest) return `<${smallest.toFixed(digits)}%`; + return `${percent.toFixed(digits)}%`; } /** `2026-08-07` to `Aug 7`. */