authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 15:26:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 15:26:19-07:00
log5a9c664b3ec8214cc5847d561efa130ba213eb38
tree58910919f6b007f6c06fdf9f8576e1839ff6ec97
parent23957c704b43c25a253baf2644829e88cf45917f
signature Signed by PGP key B5690EEEBB952194

refactor(usage): Cursor account usage comes from provider-cursor (#17578)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

28 files changed, 1706 insertions(+), 1405 deletions(-)

apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts+3
...@@ -4,6 +4,7 @@ import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.t...@@ -4,6 +4,7 @@ import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.t
4import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts";4import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
5import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk";5import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk";
6import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk";6import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk";
7import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain";
7import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";8import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
8import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";9import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
910
...@@ -12,6 +13,7 @@ export type ProviderOrchestrationAdapterInfrastructure =...@@ -12,6 +13,7 @@ export type ProviderOrchestrationAdapterInfrastructure =
12 | CodexAdapterV2.CodexAppServerClientFactory13 | CodexAdapterV2.CodexAppServerClientFactory
13 | CursorAgentSdk.CursorAgentSdkRunner14 | CursorAgentSdk.CursorAgentSdkRunner
14 | CursorSdk.CursorSdk15 | CursorSdk.CursorSdk
16 | CursorKeychain.CursorKeychain
15 | IdAllocator.IdAllocatorV2;17 | IdAllocator.IdAllocatorV2;
1618
17/**19/**
...@@ -24,6 +26,7 @@ export const layer = Layer.mergeAll(...@@ -24,6 +26,7 @@ export const layer = Layer.mergeAll(
24 ClaudeAdapterV2.layerQueryRunner,26 ClaudeAdapterV2.layerQueryRunner,
25 CodexAdapterV2.layerAppServerClientFactory,27 CodexAdapterV2.layerAppServerClientFactory,
26 CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)),28 CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)),
29 CursorKeychain.layer,
27 IdAllocator.layer,30 IdAllocator.layer,
28 ProviderContinuationRequests.layer,31 ProviderContinuationRequests.layer,
29);32);
apps/server/src/provider/builtInDrivers.ts+3-2
...@@ -75,7 +75,8 @@ export type BuiltInUsageReadersEnv =...@@ -75,7 +75,8 @@ export type BuiltInUsageReadersEnv =
75 | ProviderUsageReaderEnv<typeof ClaudeDriver>75 | ProviderUsageReaderEnv<typeof ClaudeDriver>
76 | ProviderUsageReaderEnv<typeof CodexDriver>76 | ProviderUsageReaderEnv<typeof CodexDriver>
77 | ProviderUsageReaderEnv<typeof GrokDriver>77 | ProviderUsageReaderEnv<typeof GrokDriver>
78 | ProviderUsageReaderEnv<typeof OpenCodeDriver>;78 | ProviderUsageReaderEnv<typeof OpenCodeDriver>
79 | ProviderUsageReaderEnv<typeof CursorDriver>;
7980
80/**81/**
81 * The drivers that keep usage history, in the order the usage page reads82 * The drivers that keep usage history, in the order the usage page reads
...@@ -84,4 +85,4 @@ export type BuiltInUsageReadersEnv =...@@ -84,4 +85,4 @@ export type BuiltInUsageReadersEnv =
84 */85 */
85export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray<86export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray<
86 AnyProviderDriver<BuiltInDriversEnv, BuiltInUsageReadersEnv>87 AnyProviderDriver<BuiltInDriversEnv, BuiltInUsageReadersEnv>
87> = [ClaudeDriver, CodexDriver, GrokDriver, OpenCodeDriver];88> = [ClaudeDriver, CodexDriver, GrokDriver, OpenCodeDriver, CursorDriver];
apps/server/src/server.ts+12-3
...@@ -154,7 +154,9 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien...@@ -154,7 +154,9 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien
154import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";154import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
155import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";155import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
156import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";156import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
157import * as CursorUsageReader from "./usage/cursorUsageReader.ts";157import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader";
158import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain";
159import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts";
158import * as UsageService from "./usage/UsageService.ts";160import * as UsageService from "./usage/UsageService.ts";
159import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts";161import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts";
160import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";162import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
...@@ -252,8 +254,13 @@ const layerBackground = BackgroundPolicy.layer.pipe(...@@ -252,8 +254,13 @@ const layerBackground = BackgroundPolicy.layer.pipe(
252);254);
253255
254const layerUsage = UsageService.layer.pipe(256const layerUsage = UsageService.layer.pipe(
257 Layer.provide(
258 CursorUsageAccounts.layer.pipe(
259 Layer.provide(CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer))),
260 Layer.provide(ProviderHostLive.layer.pipe(Layer.provide(ServerSecretStore.layer))),
261 ),
262 ),
255 Layer.provide(layerServerSettings),263 Layer.provide(layerServerSettings),
256 Layer.provide(CursorUsageReader.layer),
257);264);
258265
259const layerResourceDiagnostics = Layer.mergeAll(266const layerResourceDiagnostics = Layer.mergeAll(
...@@ -666,9 +673,11 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(...@@ -666,9 +673,11 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
666673
667const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe(674const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe(
668 // Misc.675 // Misc.
676 // Usage reads provider history through the ProviderHost, which needs the
677 // background policy below it.
678 Layer.provideMerge(layerUsage),
669 Layer.provideMerge(layerBackground),679 Layer.provideMerge(layerBackground),
670 Layer.provideMerge(layerResourceDiagnostics),680 Layer.provideMerge(layerResourceDiagnostics),
671 Layer.provideMerge(layerUsage),
672 Layer.provideMerge(TraceDiagnostics.layer),681 Layer.provideMerge(TraceDiagnostics.layer),
673 Layer.provideMerge(AnalyticsService.layer),682 Layer.provideMerge(AnalyticsService.layer),
674 Layer.provideMerge(ExternalLauncher.layer),683 Layer.provideMerge(ExternalLauncher.layer),
apps/server/src/usage/UsageService.test.ts+40-18
...@@ -24,6 +24,7 @@ import * as Effect from "effect/Effect";...@@ -24,6 +24,7 @@ import * as Effect from "effect/Effect";
24import * as Exit from "effect/Exit";24import * as Exit from "effect/Exit";
25import * as Fiber from "effect/Fiber";25import * as Fiber from "effect/Fiber";
26import * as FileSystem from "effect/FileSystem";26import * as FileSystem from "effect/FileSystem";
27import * as Context from "effect/Context";
27import * as Layer from "effect/Layer";28import * as Layer from "effect/Layer";
28import * as Scheduler from "effect/Scheduler";29import * as Scheduler from "effect/Scheduler";
29import * as Schema from "effect/Schema";30import * as Schema from "effect/Schema";
...@@ -32,7 +33,12 @@ import { HttpClient, HttpClientResponse } from "effect/http";...@@ -32,7 +33,12 @@ import { HttpClient, HttpClientResponse } from "effect/http";
3233
33import * as ServerConfig from "../config.ts";34import * as ServerConfig from "../config.ts";
34import * as ServerSettings from "../serverSettings.ts";35import * as ServerSettings from "../serverSettings.ts";
35import * as CursorUsageReader from "./cursorUsageReader.ts";36import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader";
37import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain";
38import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts";
39import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
40import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
41import * as ProviderHostLive from "../provider/ProviderHostLive.ts";
36import type { UsageRecord } from "@t3tools/provider-core/server/usage";42import type { UsageRecord } from "@t3tools/provider-core/server/usage";
37import * as UsageService from "./UsageService.ts";43import * as UsageService from "./UsageService.ts";
3844
...@@ -87,6 +93,27 @@ const setup = Effect.gen(function* () {...@@ -87,6 +93,27 @@ const setup = Effect.gen(function* () {
87 };93 };
88});94});
8995
96const layerCursorUsageAccounts = <E, R>(
97 reader: Layer.Layer<CursorAccountReader.CursorAccountReader, E, R>,
98) => Layer.fresh(CursorUsageAccounts.layer).pipe(Layer.provide(reader));
99
100/**
101 * A service with its own Cursor account caches over `read`, as a fresh server
102 * process has. `awaitPersisted` also waits for the account cache writes.
103 */
104const makeWithCursor = (read: CursorAccountReader.CursorAccountReader["Service"]["read"]) =>
105 Effect.gen(function* () {
106 const context = yield* Layer.build(
107 layerCursorUsageAccounts(Layer.succeed(CursorAccountReader.CursorAccountReader, { read })),
108 );
109 const accounts = Context.get(context, CursorUsageAccounts.CursorUsageAccounts);
110 const service = yield* UsageService.make.pipe(Effect.provideContext(context));
111 return {
112 ...service,
113 awaitPersisted: Effect.andThen(service.awaitPersisted, accounts.awaitPersisted),
114 };
115 });
116
90const layerService = (input: {117const layerService = (input: {
91 readonly prefix: string;118 readonly prefix: string;
92 readonly home: string;119 readonly home: string;
...@@ -97,9 +124,14 @@ const layerService = (input: {...@@ -97,9 +124,14 @@ const layerService = (input: {
97 readonly environment?: NodeJS.ProcessEnv;124 readonly environment?: NodeJS.ProcessEnv;
98 readonly platform?: NodeJS.Platform;125 readonly platform?: NodeJS.Platform;
99}) =>126}) =>
100 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(127 layerCursorUsageAccounts(
128 CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)),
129 ).pipe(
130 Layer.provideMerge(ProviderHostLive.layer),
131 Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})),
132 Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})),
133 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: input.prefix })),
101 Layer.provideMerge(NodeServices.layer),134 Layer.provideMerge(NodeServices.layer),
102 Layer.provideMerge(CursorUsageReader.layer),
103 Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")),135 Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")),
104 Layer.provideMerge(ServerSettings.layerTest(input.settings)),136 Layer.provideMerge(ServerSettings.layerTest(input.settings)),
105 Layer.provideMerge(137 Layer.provideMerge(
...@@ -364,9 +396,7 @@ describe("UsageService", () => {...@@ -364,9 +396,7 @@ describe("UsageService", () => {
364 const gate = yield* Deferred.make<void>();396 const gate = yield* Deferred.make<void>();
365 cursor.state.gate = gate;397 cursor.state.gate = gate;
366 yield* Effect.gen(function* () {398 yield* Effect.gen(function* () {
367 const service = yield* UsageService.make.pipe(399 const service = yield* makeWithCursor(cursor.read);
368 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }),
369 );
370 // Cold: nothing cached yet, so Cursor answers empty while it refreshes.400 // Cold: nothing cached yet, so Cursor answers empty while it refreshes.
371 const cold = yield* service.readSummary(WINDOW);401 const cold = yield* service.readSummary(WINDOW);
372 assert.strictEqual(cursorSource(cold)?.refreshing, true);402 assert.strictEqual(cursorSource(cold)?.refreshing, true);
...@@ -421,9 +451,7 @@ describe("UsageService", () => {...@@ -421,9 +451,7 @@ describe("UsageService", () => {
421 { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 },451 { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 },
422 ];452 ];
423 yield* Effect.gen(function* () {453 yield* Effect.gen(function* () {
424 const service = yield* UsageService.make.pipe(454 const service = yield* makeWithCursor(cursor.read);
425 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }),
426 );
427 const read = (input: UsageSummaryInput) =>455 const read = (input: UsageSummaryInput) =>
428 service.readSummary({ ...input, awaitRefresh: true });456 service.readSummary({ ...input, awaitRefresh: true });
429 assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19);457 assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19);
...@@ -470,9 +498,7 @@ describe("UsageService", () => {...@@ -470,9 +498,7 @@ describe("UsageService", () => {
470 const cursor = makeFakeCursor();498 const cursor = makeFakeCursor();
471 cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }];499 cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }];
472 yield* Effect.gen(function* () {500 yield* Effect.gen(function* () {
473 const service = yield* UsageService.make.pipe(501 const service = yield* makeWithCursor(cursor.read);
474 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }),
475 );
476 yield* service.readSummary({ ...WINDOW, awaitRefresh: true });502 yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
477503
478 yield* TestClock.adjust(Duration.minutes(2));504 yield* TestClock.adjust(Duration.minutes(2));
...@@ -513,17 +539,13 @@ describe("UsageService", () => {...@@ -513,17 +539,13 @@ describe("UsageService", () => {
513 { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 },539 { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 },
514 ];540 ];
515 yield* Effect.gen(function* () {541 yield* Effect.gen(function* () {
516 const first = yield* UsageService.make.pipe(542 const first = yield* makeWithCursor(before.read);
517 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: before.read }),
518 );
519 const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true });543 const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true });
520 yield* first.awaitPersisted;544 yield* first.awaitPersisted;
521545
522 const after = makeFakeCursor();546 const after = makeFakeCursor();
523 after.state.events = before.state.events;547 after.state.events = before.state.events;
524 const restarted = yield* UsageService.make.pipe(548 const restarted = yield* makeWithCursor(after.read);
525 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: after.read }),
526 );
527 const restored = yield* restarted.readSummary(WINDOW);549 const restored = yield* restarted.readSummary(WINDOW);
528 assert.isUndefined(cursorSource(restored)?.refreshing);550 assert.isUndefined(cursorSource(restored)?.refreshing);
529 assert.deepStrictEqual(restored.buckets, original.buckets);551 assert.deepStrictEqual(restored.buckets, original.buckets);
apps/server/src/usage/UsageService.ts+13-272
...@@ -12,9 +12,9 @@...@@ -12,9 +12,9 @@
12 * remain visible. Antigravity databases are memoised in memory while the12 * remain visible. Antigravity databases are memoised in memory while the
13 * database and its WAL keep the same `(size, mtime, ctime)`.13 * database and its WAL keep the same `(size, mtime, ctime)`.
14 *14 *
15 * Cursor's account API is slow, so its source answers from a cache and marks15 * A scan reader with a slow source (an account API) may answer from its own
16 * itself `refreshing` while a background refresh runs; `awaitRefresh` waits16 * cache and mark itself `refreshing` while a background refresh runs;
17 * for that refresh instead. See `cursorAccountCache`.17 * `awaitRefresh` waits for that refresh instead.
18 *18 *
19 * @module UsageService19 * @module UsageService
20 */20 */
...@@ -32,7 +32,7 @@ import {...@@ -32,7 +32,7 @@ import {
32 type UsageSummaryInput,32 type UsageSummaryInput,
33 UsageReadError,33 UsageReadError,
34} from "@t3tools/contracts";34} from "@t3tools/contracts";
35import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";35import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
36import * as Cause from "effect/Cause";36import * as Cause from "effect/Cause";
37import * as Clock from "effect/Clock";37import * as Clock from "effect/Clock";
38import * as Context from "effect/Context";38import * as Context from "effect/Context";
...@@ -64,18 +64,6 @@ import type {...@@ -64,18 +64,6 @@ import type {
64 UsageRecord,64 UsageRecord,
65} from "@t3tools/provider-core/server/usage";65} from "@t3tools/provider-core/server/usage";
66import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";66import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";
67import {
68 CURSOR_ACCOUNT_CACHE_FILE_NAME,
69 CURSOR_ACCOUNT_TTL_MS,
70 cursorFetchRange,
71 decodeCursorAccountCaches,
72 encodeCursorAccountCaches,
73 isCursorCacheFresh,
74 mergeCursorFetch,
75 type CursorAccountCache,
76 type CursorCredentialSource,
77} from "./cursorAccountCache.ts";
78import * as CursorUsageReader from "./cursorUsageReader.ts";
79import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";67import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
80import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";68import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
81import {69import {
...@@ -116,8 +104,6 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;...@@ -116,8 +104,6 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;
116 */104 */
117const CACHE_RETENTION_DAYS = 92;105const CACHE_RETENTION_DAYS = 92;
118106
119const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read.";
120
121/** Transcripts parsed at once. More gains little once the disk stays busy. */107/** Transcripts parsed at once. More gains little once the disk stays busy. */
122const TRANSCRIPT_READ_CONCURRENCY = 4;108const TRANSCRIPT_READ_CONCURRENCY = 4;
123109
...@@ -248,8 +234,6 @@ export const make = Effect.gen(function* () {...@@ -248,8 +234,6 @@ export const make = Effect.gen(function* () {
248 const settingsService = yield* ServerSettings.ServerSettingsService;234 const settingsService = yield* ServerSettings.ServerSettingsService;
249 const httpClient = yield* HttpClient.HttpClient;235 const httpClient = yield* HttpClient.HttpClient;
250 const hostEnvironment = yield* HostProcessEnvironment;236 const hostEnvironment = yield* HostProcessEnvironment;
251 const platform = yield* HostProcessPlatform;
252 const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader;
253 // The readers yield their own services; scans run them against this context.237 // The readers yield their own services; scans run them against this context.
254 const readerContext = yield* Effect.context<BuiltInUsageReadersEnv>();238 const readerContext = yield* Effect.context<BuiltInUsageReadersEnv>();
255239
...@@ -257,20 +241,6 @@ export const make = Effect.gen(function* () {...@@ -257,20 +241,6 @@ export const make = Effect.gen(function* () {
257 const antigravityCache = makeAntigravityUsageCache();241 const antigravityCache = makeAntigravityUsageCache();
258 const sourceCache = new Map<string, typeof CachedSource.Type>();242 const sourceCache = new Map<string, typeof CachedSource.Type>();
259 let cacheDirty = false;243 let cacheDirty = false;
260 /** Cursor account caches by credential source. */
261 const cursorCaches = new Map<string, CursorAccountCache>();
262 let cursorCacheDirty = false;
263 /**
264 * The last failed refresh per credential source, standing for a TTL so a
265 * client refetching a broken login does not refetch Cursor each time. A
266 * `null` error means there is no login: no source to report.
267 */
268 const cursorFailures = new Map<
269 string,
270 { readonly atMs: number; readonly error: string | null }
271 >();
272 /** The refresh in flight per credential source, which every read joins. */
273 const cursorRefreshes = new Map<string, Deferred.Deferred<void>>();
274 const isWithinDirectory = (filePath: string, dir: string) => {244 const isWithinDirectory = (filePath: string, dir: string) => {
275 const relative = path.relative(dir, filePath);245 const relative = path.relative(dir, filePath);
276 return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);246 return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
...@@ -279,7 +249,6 @@ export const make = Effect.gen(function* () {...@@ -279,7 +249,6 @@ export const make = Effect.gen(function* () {
279 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");249 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
280 const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME);250 const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME);
281 const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME);251 const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME);
282 const cursorCachePath = path.join(config.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME);
283 const writeCacheFile = (filePath: string, contents: string) =>252 const writeCacheFile = (filePath: string, contents: string) =>
284 writeFileStringAtomically({ filePath, contents }).pipe(253 writeFileStringAtomically({ filePath, contents }).pipe(
285 Effect.provideService(FileSystem.FileSystem, fileSystem),254 Effect.provideService(FileSystem.FileSystem, fileSystem),
...@@ -453,7 +422,7 @@ export const make = Effect.gen(function* () {...@@ -453,7 +422,7 @@ export const make = Effect.gen(function* () {
453 });422 });
454423
455 /**424 /**
456 * Loads the persisted scan and Cursor account caches exactly once per process.425 * Loads the persisted scan cache exactly once per process.
457 *426 *
458 * `Effect.cached` makes concurrent first readers await the same load rather427 * `Effect.cached` makes concurrent first readers await the same load rather
459 * than each seeing a "loaded" flag set before the read finished and cold428 * than each seeing a "loaded" flag set before the read finished and cold
...@@ -466,9 +435,6 @@ export const make = Effect.gen(function* () {...@@ -466,9 +435,6 @@ export const make = Effect.gen(function* () {
466 Effect.flatMap((raw) => decodeScanCacheFile(raw)),435 Effect.flatMap((raw) => decodeScanCacheFile(raw)),
467 Effect.catchCause(() => Effect.succeed(null)),436 Effect.catchCause(() => Effect.succeed(null)),
468 );437 );
469 for (const [key, cache] of decodeCursorAccountCaches(yield* readDocument(cursorCachePath))) {
470 cursorCaches.set(key, cache);
471 }
472 let document = yield* readDocument(scanCachePath);438 let document = yield* readDocument(scanCachePath);
473 if (document === null) {439 if (document === null) {
474 document = yield* readDocument(legacyScanCachePath);440 document = yield* readDocument(legacyScanCachePath);
...@@ -492,7 +458,7 @@ export const make = Effect.gen(function* () {...@@ -492,7 +458,7 @@ export const make = Effect.gen(function* () {
492 // keeps an older snapshot from landing after a newer one.458 // keeps an older snapshot from landing after a newer one.
493 const persistLock = yield* Semaphore.make(1);459 const persistLock = yield* Semaphore.make(1);
494460
495 // Each dirty flag is cleared before encoding, so a change while the write is461 // The dirty flag is cleared before encoding, so a change while the write is
496 // in flight marks it dirty again. A failed write restores the flag, so the462 // in flight marks it dirty again. A failed write restores the flag, so the
497 // next persist retries instead of leaving disk stale. A cache we cannot463 // next persist retries instead of leaving disk stale. A cache we cannot
498 // write is a slower next start, not a failed read.464 // write is a slower next start, not a failed read.
...@@ -510,17 +476,6 @@ export const make = Effect.gen(function* () {...@@ -510,17 +476,6 @@ export const make = Effect.gen(function* () {
510 ),476 ),
511 );477 );
512 }478 }
513 if (cursorCacheDirty) {
514 cursorCacheDirty = false;
515 yield* Effect.sync(() => encodeCursorAccountCaches(cursorCaches)).pipe(
516 Effect.flatMap((contents) => writeCacheFile(cursorCachePath, contents)),
517 Effect.catchCause(() =>
518 Effect.sync(() => {
519 cursorCacheDirty = true;
520 }),
521 ),
522 );
523 }
524 }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches"));479 }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches"));
525480
526 const pendingPersists = new Set<Fiber.Fiber<void>>();481 const pendingPersists = new Set<Fiber.Fiber<void>>();
...@@ -674,162 +629,6 @@ export const make = Effect.gen(function* () {...@@ -674,162 +629,6 @@ export const make = Effect.gen(function* () {
674 return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir;629 return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir;
675 });630 });
676631
677 /** Fetches what one account cache is missing, then persists it if anything changed. */
678 const refreshCursorAccount = Effect.fn("UsageService.refreshCursorAccount")(function* (
679 credential: CursorCredentialSource,
680 credentialKey: string,
681 retentionStartMs: number,
682 ) {
683 const nowMs = yield* Clock.currentTimeMillis;
684 const fetchMissing = (cache: CursorAccountCache | undefined) => {
685 const range = cursorFetchRange(cache, retentionStartMs, nowMs);
686 return cursorAccountReader
687 .read(credential, range.sinceMs, range.untilMs)
688 .pipe(Effect.map((result) => ({ range, result })));
689 };
690 let base = cursorCaches.get(credentialKey);
691 let fetched = yield* fetchMissing(base);
692 // Another login's history replaces the cached account's, even if reading it fails.
693 if (
694 base !== undefined &&
695 fetched.result.accountKey !== null &&
696 fetched.result.accountKey !== base.accountKey
697 ) {
698 cursorCaches.delete(credentialKey);
699 cursorCacheDirty = true;
700 base = undefined;
701 fetched = yield* fetchMissing(base);
702 }
703 const { range, result } = fetched;
704 if (result.missing || result.error !== null || result.accountKey === null) {
705 cursorFailures.set(credentialKey, {
706 atMs: nowMs,
707 error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR,
708 });
709 yield* schedulePersist;
710 return;
711 }
712 const merged = mergeCursorFetch(
713 base,
714 result.accountKey,
715 range,
716 result.records,
717 nowMs,
718 retentionStartMs,
719 );
720 cursorCaches.set(credentialKey, merged.cache);
721 cursorFailures.delete(credentialKey);
722 // An unchanged edge is not worth rewriting the file for: after a restart
723 // the cache just refetches a slightly wider edge.
724 if (merged.changed) {
725 cursorCacheDirty = true;
726 yield* schedulePersist;
727 }
728 });
729
730 /** Joins the refresh in flight, or starts one. */
731 const startCursorRefresh = (
732 credential: CursorCredentialSource,
733 credentialKey: string,
734 retentionStartMs: number,
735 ) =>
736 // Enrollment and fork are atomic, so an interrupted caller cannot leave a
737 // registered refresh that nothing will finish.
738 Effect.uninterruptible(
739 Effect.gen(function* () {
740 const current = cursorRefreshes.get(credentialKey);
741 if (current !== undefined) return current;
742 const done = Deferred.makeUnsafe<void>();
743 cursorRefreshes.set(credentialKey, done);
744 // Detached: a departing client must not cancel a fetch later reads reuse.
745 yield* refreshCursorAccount(credential, credentialKey, retentionStartMs).pipe(
746 Effect.catchCause(() =>
747 Clock.currentTimeMillis.pipe(
748 Effect.map((atMs) =>
749 cursorFailures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }),
750 ),
751 ),
752 ),
753 Effect.ensuring(
754 Effect.suspend(() => {
755 cursorRefreshes.delete(credentialKey);
756 return Deferred.succeed(done, undefined);
757 }),
758 ),
759 Effect.forkDetach,
760 );
761 return done;
762 }),
763 );
764
765 /**
766 * The Cursor account source. A cache inside its TTL, or a refresh that failed
767 * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh`
768 * waits for it, and anything else answers from the cache marked `refreshing`.
769 */
770 const cursorAccountSource = Effect.fn("UsageService.cursorAccountSource")(function* (
771 credential: CursorCredentialSource,
772 authPath: string,
773 windowStartMs: number,
774 retentionStartMs: number,
775 awaitRefresh: boolean,
776 ) {
777 // No saved login means there is no account source to report, not a setup error.
778 if (
779 typeof credential === "string" &&
780 !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true)))
781 ) {
782 return null;
783 }
784 const credentialKey = typeof credential === "string" ? credential : "keychain";
785 const nowMs = yield* Clock.currentTimeMillis;
786 const recentFailure = cursorFailures.get(credentialKey);
787 let refreshing = false;
788 if (
789 (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) &&
790 !isCursorCacheFresh(cursorCaches.get(credentialKey), nowMs)
791 ) {
792 const refresh = yield* startCursorRefresh(credential, credentialKey, retentionStartMs);
793 if (awaitRefresh) yield* Deferred.await(refresh);
794 else refreshing = true;
795 }
796
797 const cache = cursorCaches.get(credentialKey);
798 const failure = refreshing ? undefined : cursorFailures.get(credentialKey);
799 const failureMessage = failure === undefined ? undefined : failure.error;
800 if (failureMessage === null) return null;
801 if (cache === undefined) {
802 return {
803 provider: "cursor",
804 dir: authPath,
805 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(authPath)),
806 // Never combine a local fallback with another server's account-wide history.
807 ...(refreshing
808 ? { files: [], refreshing: true }
809 : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }),
810 } satisfies ScannedDir;
811 }
812 // The same account includes CLI and desktop history from every machine.
813 // A stable remote fingerprint prevents connected environments counting it twice.
814 const source = `cursor-account:${cache.accountKey}`;
815 return {
816 provider: "cursor",
817 dir: source,
818 hostId: "cursor.com",
819 volumeId: cache.accountKey,
820 files: [
821 {
822 path: source,
823 records: cache.records.filter((record) => record.timestampMs >= windowStartMs),
824 },
825 ],
826 ...(failureMessage === undefined
827 ? { status: "ok" }
828 : { status: "partial", message: failureMessage }),
829 ...(refreshing ? { refreshing: true } : {}),
830 } satisfies ScannedDir;
831 });
832
833 const collectDirs = Effect.fn("UsageService.collectDirs")(function* (632 const collectDirs = Effect.fn("UsageService.collectDirs")(function* (
834 windowStartMs: number,633 windowStartMs: number,
835 settings: ServerSettingsValue,634 settings: ServerSettingsValue,
...@@ -862,6 +661,7 @@ export const make = Effect.gen(function* () {...@@ -862,6 +661,7 @@ export const make = Effect.gen(function* () {
862 reader661 reader
863 .scan({662 .scan({
864 instances: usageInstances(driver, settings),663 instances: usageInstances(driver, settings),
664 settings,
865 windowStartMs,665 windowStartMs,
866 retentionCutoffMs,666 retentionCutoffMs,
867 awaitRefresh,667 awaitRefresh,
...@@ -884,7 +684,7 @@ export const make = Effect.gen(function* () {...@@ -884,7 +684,7 @@ export const make = Effect.gen(function* () {
884 Effect.provideContext(readerContext),684 Effect.provideContext(readerContext),
885 ),685 ),
886 { concurrency: "unbounded" },686 { concurrency: "unbounded" },
887 ).pipe(Effect.map((sources) => sources.flat()));687 );
888688
889 const antigravity = Effect.gen(function* () {689 const antigravity = Effect.gen(function* () {
890 const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [690 const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [
...@@ -949,78 +749,19 @@ export const make = Effect.gen(function* () {...@@ -949,78 +749,19 @@ export const make = Effect.gen(function* () {
949 return scanned;749 return scanned;
950 });750 });
951751
952 const cursor = Effect.gen(function* () {
953 const cursorUserHome =
954 (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home;
955 const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim();
956 const cursorHome =
957 platform === "darwin"
958 ? path.join(cursorUserHome, "Library", "Application Support")
959 : platform === "win32"
960 ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming")
961 : configHome && path.isAbsolute(configHome)
962 ? configHome
963 : path.join(cursorUserHome, ".config");
964 const cursorAuthPath =
965 platform === "darwin"
966 ? path.join(cursorUserHome, ".cursor", "auth.json")
967 : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json");
968 const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"];
969 const loginUnavailable =
970 Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) ||
971 Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) ||
972 credentialStore === "memory";
973 const useKeychain = platform === "darwin" && credentialStore !== "file";
974 if (useKeychain && !loginUnavailable && !settings.cursorKeychainUsageEnabled) {
975 return [
976 {
977 provider: "cursor",
978 dir: cursorAuthPath,
979 volumeId: "",
980 files: null,
981 message: "Cursor account usage is off on this environment.",
982 action: "enableCursorKeychain",
983 } satisfies ScannedDir,
984 ];
985 }
986 if (loginUnavailable) {
987 return [
988 {
989 provider: "cursor",
990 dir: cursorAuthPath,
991 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)),
992 files: null,
993 message: "Cursor account history needs a Cursor CLI login on this server.",
994 } satisfies ScannedDir,
995 ];
996 }
997 const source = yield* cursorAccountSource(
998 useKeychain ? { kind: "keychain" } : cursorAuthPath,
999 cursorAuthPath,
1000 windowStartMs,
1001 retentionCutoffMs,
1002 awaitRefresh,
1003 );
1004 return source === null ? [] : [source];
1005 });
1006
1007 // Independent sources scan together. Transcript directories go one at a752 // Independent sources scan together. Transcript directories go one at a
1008 // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result753 // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result
1009 // keeps this order, since aggregation keeps the first copy of a duplicate.754 // keeps this order, since aggregation keeps the first copy of a duplicate.
1010 const [transcripts, scanDirs, antigravityDirs, cursorDirs] = yield* Effect.all(755 const [transcripts, scanDirs, antigravityDirs] = yield* Effect.all(
1011 [756 [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans, antigravity],
1012 Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)),
1013 scans,
1014 antigravity,
1015 cursor,
1016 ],
1017 { concurrency: "unbounded" },757 { concurrency: "unbounded" },
1018 );758 );
759 // Antigravity reads between the other scan readers until it has a reader of its own.
1019 const scanned: readonly ScannedDir[] = [760 const scanned: readonly ScannedDir[] = [
1020 ...transcripts,761 ...transcripts,
1021 ...scanDirs,762 ...scanDirs.slice(0, -1).flat(),
1022 ...antigravityDirs,763 ...antigravityDirs,
1023 ...cursorDirs,764 ...(scanDirs.at(-1) ?? []),
1024 ];765 ];
1025 return scanned;766 return scanned;
1026 });767 });
apps/server/src/usage/cursorAccountCache.test.ts deleted-80
...@@ -1,80 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts";
4import type { UsageRecord } from "@t3tools/provider-core/server/usage";
5
6const DAY_MS = 24 * 60 * 60 * 1000;
7
8const record = (timestampMs: number): UsageRecord => ({
9 provider: "cursor",
10 timestampMs,
11 model: "claude-fable-5",
12 sessionId: "conversation-1",
13 totals: {
14 uncachedInputTokens: 0,
15 cachedInputTokens: 0,
16 cacheCreationTokens: 0,
17 outputTokens: 1,
18 reasoningTokens: 0,
19 },
20 reportedCostUsd: null,
21 speed: "standard",
22 dedupeKey: `cursor-account:a:${timestampMs}:0`,
23});
24
25describe("cursorAccountCache", () => {
26 it("refetches everything once the cache no longer reaches the retention start", () => {
27 const cache = {
28 accountKey: "a",
29 sinceMs: 0,
30 untilMs: 10 * DAY_MS,
31 fetchedAtMs: 10 * DAY_MS,
32 records: [record(5 * DAY_MS)],
33 };
34 const nowMs = 100 * DAY_MS;
35 const range = cursorFetchRange(cache, 10 * DAY_MS, nowMs);
36 assert.deepStrictEqual(range, { sinceMs: 10 * DAY_MS, untilMs: nowMs });
37
38 const { cache: merged } = mergeCursorFetch(
39 cache,
40 "a",
41 range,
42 [record(50 * DAY_MS)],
43 nowMs,
44 10 * DAY_MS,
45 );
46 assert.deepStrictEqual(
47 merged.records.map((entry) => entry.timestampMs),
48 [50 * DAY_MS],
49 );
50 // Then only the newest edge.
51 assert.deepStrictEqual(cursorFetchRange(merged, 11 * DAY_MS, nowMs + DAY_MS), {
52 sinceMs: nowMs - 60 * 60 * 1000,
53 untilMs: nowMs + DAY_MS,
54 });
55 });
56
57 it("reports an edge that only confirms the cache as unchanged", () => {
58 const cache = {
59 accountKey: "a",
60 sinceMs: 0,
61 untilMs: 10 * DAY_MS,
62 fetchedAtMs: 10 * DAY_MS,
63 records: [record(DAY_MS), record(10 * DAY_MS)],
64 };
65 const range = cursorFetchRange(cache, 0, 11 * DAY_MS);
66 assert.isFalse(
67 mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 0).changed,
68 );
69 assert.isTrue(
70 mergeCursorFetch(
71 cache,
72 "a",
73 range,
74 [record(10 * DAY_MS), record(10.5 * DAY_MS)],
75 11 * DAY_MS,
76 0,
77 ).changed,
78 );
79 });
80});
apps/server/src/usage/cursorAccountCache.ts deleted-271
...@@ -1,271 +0,0 @@
1/**
2 * Cursor account usage, cached per credential source.
3 *
4 * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched
5 * records are kept for the whole retention period, whatever window was asked
6 * for. After one full fetch, a refresh fetches only the newest edge.
7 *
8 * A record's dedupe key carries its timestamp and an occurrence index counted
9 * within one fetch. A refetched range therefore replaces every cached record in
10 * that range rather than adding to it, so each timestamp's records always come
11 * from a single fetch and their occurrence indexes cannot collide.
12 *
13 * @module cursorAccountCache
14 */
15import { cursorRateModel } from "./cursorUsageReader.ts";
16import type { UsageRecord } from "@t3tools/provider-core/server/usage";
17
18export type CursorCredentialSource = string | { readonly kind: "keychain" };
19
20/** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */
21export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000;
22
23/** Dashboard events can be finalized after they were first read. */
24const REFETCH_OVERLAP_MS = 60 * 60 * 1000;
25
26export interface CursorAccountCache {
27 readonly accountKey: string;
28 /** Inclusive range of event timestamps the records are complete for. */
29 readonly sinceMs: number;
30 readonly untilMs: number;
31 /** When the newest edge was fetched. */
32 readonly fetchedAtMs: number;
33 readonly records: readonly UsageRecord[];
34}
35
36interface CursorFetchRange {
37 readonly sinceMs: number;
38 readonly untilMs: number;
39}
40
41/** Whether the cache answers without a refresh. */
42export function isCursorCacheFresh(
43 cache: CursorAccountCache | undefined,
44 nowMs: number,
45): cache is CursorAccountCache {
46 return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS;
47}
48
49/**
50 * The range to fetch so the cache covers `[retentionStartMs, nowMs]`: the
51 * newest edge, or everything when the cache does not reach back that far.
52 */
53export function cursorFetchRange(
54 cache: CursorAccountCache | undefined,
55 retentionStartMs: number,
56 nowMs: number,
57): CursorFetchRange {
58 return cache === undefined ||
59 cache.sinceMs > retentionStartMs ||
60 cache.untilMs - REFETCH_OVERLAP_MS < retentionStartMs
61 ? { sinceMs: retentionStartMs, untilMs: nowMs }
62 : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs };
63}
64
65/**
66 * Applies a fetched range to the cache, replacing the cached records it covers
67 * and dropping those before the retention start. Pass `undefined` for a cache
68 * that belongs to another account. `changed` is false when the fetch only
69 * confirmed the cached records, so there is nothing new to persist.
70 */
71export function mergeCursorFetch(
72 cache: CursorAccountCache | undefined,
73 accountKey: string,
74 range: CursorFetchRange,
75 fetched: readonly UsageRecord[],
76 nowMs: number,
77 retentionStartMs: number,
78) {
79 const inRange = (record: UsageRecord) =>
80 record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs;
81 const previous = cache?.records ?? [];
82 const added = fetched.filter(inRange);
83 const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey));
84 const changed =
85 cache === undefined ||
86 replacedKeys.size !== added.length ||
87 added.some((record) => !replacedKeys.has(record.dedupeKey));
88 return {
89 changed,
90 cache: {
91 accountKey,
92 sinceMs: retentionStartMs,
93 untilMs: range.untilMs,
94 fetchedAtMs: nowMs,
95 records: [
96 ...previous.filter((record) => !inRange(record) && record.timestampMs >= retentionStartMs),
97 ...added,
98 ],
99 } satisfies CursorAccountCache,
100 };
101}
102
103/**
104 * Each version writes its own file, as the scan cache does, so servers of
105 * different versions sharing a state directory keep their own caches.
106 */
107export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json";
108const CURSOR_ACCOUNT_CACHE_VERSION = 1;
109
110/** Positional rows with interned strings, as in the scan cache. */
111type SerializedRecord = readonly [
112 timestampMs: number,
113 modelIndex: number,
114 sessionIndex: number,
115 uncachedInputTokens: number,
116 cachedInputTokens: number,
117 cacheCreationTokens: number,
118 outputTokens: number,
119 reportedCostUsd: number | null,
120 dedupeKey: string | null,
121];
122
123interface SerializedAccount {
124 readonly accountKey: string;
125 readonly sinceMs: number;
126 readonly untilMs: number;
127 readonly fetchedAtMs: number;
128 readonly records: readonly SerializedRecord[];
129}
130
131/** Serialises the caches by credential source. */
132export function encodeCursorAccountCaches(caches: ReadonlyMap<string, CursorAccountCache>): string {
133 const strings: string[] = [];
134 const indexes = new Map<string, number>();
135 const intern = (value: string) => {
136 let index = indexes.get(value);
137 if (index === undefined) {
138 index = strings.length;
139 strings.push(value);
140 indexes.set(value, index);
141 }
142 return index;
143 };
144 const accounts: Record<string, SerializedAccount> = {};
145 for (const [credentialKey, cache] of caches) {
146 accounts[credentialKey] = {
147 accountKey: cache.accountKey,
148 sinceMs: cache.sinceMs,
149 untilMs: cache.untilMs,
150 fetchedAtMs: cache.fetchedAtMs,
151 records: cache.records.map((record) => [
152 record.timestampMs,
153 intern(record.model),
154 intern(record.sessionId),
155 record.totals.uncachedInputTokens,
156 record.totals.cachedInputTokens,
157 record.totals.cacheCreationTokens,
158 record.totals.outputTokens,
159 record.reportedCostUsd,
160 record.dedupeKey,
161 ]),
162 };
163 }
164 return JSON.stringify({ version: CURSOR_ACCOUNT_CACHE_VERSION, strings, accounts });
165}
166
167const isFiniteNumber = (value: unknown): value is number =>
168 typeof value === "number" && Number.isFinite(value);
169
170/**
171 * Rebuilds the caches from a parsed document. A malformed account is dropped
172 * whole, costing one refetch; keeping its survivors would leave a gap the
173 * covered range claims is complete.
174 */
175export function decodeCursorAccountCaches(document: unknown): Map<string, CursorAccountCache> {
176 const caches = new Map<string, CursorAccountCache>();
177 if (typeof document !== "object" || document === null) return caches;
178 const root = document as {
179 readonly version?: unknown;
180 readonly strings?: unknown;
181 readonly accounts?: unknown;
182 };
183 const strings = root.strings;
184 if (
185 root.version !== CURSOR_ACCOUNT_CACHE_VERSION ||
186 !Array.isArray(strings) ||
187 !strings.every((value): value is string => typeof value === "string") ||
188 typeof root.accounts !== "object" ||
189 root.accounts === null
190 ) {
191 return caches;
192 }
193 const stringAt = (index: unknown): string | undefined =>
194 typeof index === "number" ? strings[index] : undefined;
195
196 const decodeRecord = (row: unknown): UsageRecord | null => {
197 if (!Array.isArray(row)) return null;
198 const [
199 timestampMs,
200 modelIndex,
201 sessionIndex,
202 uncached,
203 cached,
204 creation,
205 output,
206 cost,
207 key,
208 ]: readonly unknown[] = row;
209 const model = stringAt(modelIndex);
210 const sessionId = stringAt(sessionIndex);
211 if (
212 !isFiniteNumber(timestampMs) ||
213 model === undefined ||
214 sessionId === undefined ||
215 !isFiniteNumber(uncached) ||
216 !isFiniteNumber(cached) ||
217 !isFiniteNumber(creation) ||
218 !isFiniteNumber(output) ||
219 (cost !== null && !isFiniteNumber(cost)) ||
220 (key !== null && typeof key !== "string")
221 ) {
222 return null;
223 }
224 return {
225 provider: "cursor",
226 timestampMs,
227 model,
228 rateModel: cursorRateModel(model),
229 sessionId,
230 totals: {
231 uncachedInputTokens: uncached,
232 cachedInputTokens: cached,
233 cacheCreationTokens: creation,
234 outputTokens: output,
235 reasoningTokens: 0,
236 },
237 reportedCostUsd: cost,
238 speed: "standard",
239 dedupeKey: key,
240 };
241 };
242
243 for (const [credentialKey, raw] of Object.entries(root.accounts)) {
244 if (typeof raw !== "object" || raw === null) continue;
245 const account = raw as Partial<Record<keyof SerializedAccount, unknown>>;
246 if (
247 typeof account.accountKey !== "string" ||
248 !isFiniteNumber(account.sinceMs) ||
249 !isFiniteNumber(account.untilMs) ||
250 !isFiniteNumber(account.fetchedAtMs) ||
251 !Array.isArray(account.records)
252 ) {
253 continue;
254 }
255 const records: UsageRecord[] = [];
256 for (const row of account.records) {
257 const record = decodeRecord(row);
258 if (record === null) break;
259 records.push(record);
260 }
261 if (records.length !== account.records.length) continue;
262 caches.set(credentialKey, {
263 accountKey: account.accountKey,
264 sinceMs: account.sinceMs,
265 untilMs: account.untilMs,
266 fetchedAtMs: account.fetchedAtMs,
267 records,
268 });
269 }
270 return caches;
271}
apps/server/src/usage/cursorUsageReader.test.ts deleted-93
...@@ -1,93 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { CursorKeychainTimeoutError } from "@t3tools/provider-cursor/server";
4import { readCursorAccountUsage } from "./cursorUsageReader.ts";
5
6describe("readCursorAccountUsage", () => {
7 it("asks for Keychain approval when the prompt goes unanswered", async () => {
8 const result = await readCursorAccountUsage(
9 { kind: "keychain" },
10 0,
11 1,
12 () => Promise.reject(new Error("no network expected")),
13 () => Promise.reject(new CursorKeychainTimeoutError()),
14 );
15 assert.deepStrictEqual(result, {
16 accountKey: null,
17 records: [],
18 missing: false,
19 error: "Allow Keychain access on the Mac running T3 Code, then refresh.",
20 });
21 });
22
23 it("reads the pages behind the first together and keeps them in page order", async () => {
24 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
25 let inFlight = 0;
26 let mostInFlight = 0;
27 const result = await readCursorAccountUsage(
28 { kind: "keychain" },
29 0,
30 1781000000000,
31 async (_url, init) => {
32 const page: number = JSON.parse(String(init.body)).page;
33 inFlight++;
34 mostInFlight = Math.max(mostInFlight, inFlight);
35 // Later pages answer first, so record order cannot come from arrival order.
36 for (let turn = page; turn < 9; turn++) await Promise.resolve();
37 inFlight--;
38 return Response.json({
39 totalUsageEventsCount: 8001,
40 usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({
41 timestamp: String(1780000000000 + (page - 1) * 1000 + index),
42 model: "gpt-5",
43 tokenUsage: { inputTokens: 1 },
44 })),
45 });
46 },
47 async () => accessToken,
48 );
49 assert.isNull(result.error);
50 assert.strictEqual(mostInFlight, 6);
51 assert.deepStrictEqual(
52 result.records.map((record) => record.timestampMs),
53 Array.from({ length: 8001 }, (_, index) => 1780000000000 + index),
54 );
55 });
56
57 it("ends like a page-by-page read when a page fails, leaving no request open", async () => {
58 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
59 const fullPage = {
60 totalUsageEventsCount: 8001,
61 usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })),
62 };
63 // Page 3 fails while the pages after it are still waiting on Cursor.
64 for (const [secondPage, error] of [
65 [() => Response.json(fullPage), "Cursor account usage could not be read."],
66 [() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."],
67 ] as const) {
68 let open = 0;
69 const result = await readCursorAccountUsage(
70 { kind: "keychain" },
71 0,
72 1781000000000,
73 (_url, init) => {
74 const page: number = JSON.parse(String(init.body)).page;
75 if (page === 1) return Promise.resolve(Response.json(fullPage));
76 if (page === 2) return Promise.resolve(secondPage());
77 if (page === 3) return Promise.reject(new Error("connection reset"));
78 open++;
79 return new Promise((_resolve, reject) => {
80 init.signal?.addEventListener("abort", () => {
81 open--;
82 reject(init.signal?.reason);
83 });
84 });
85 },
86 async () => accessToken,
87 );
88 assert.strictEqual(result.error, error);
89 assert.deepStrictEqual(result.records, []);
90 assert.strictEqual(open, 0);
91 }
92 });
93});
apps/server/src/usage/cursorUsageReader.ts deleted-316
...@@ -1,316 +0,0 @@
1// Node fs reads CLI credentials, and crypto hashes account IDs for deduplication.
2// @effect-diagnostics nodeBuiltinImport:off
3import * as NodeFSP from "node:fs/promises";
4import * as NodeCrypto from "node:crypto";
5import * as NodeTimersPromises from "node:timers/promises";
6
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10
11import type { UsageRecord } from "@t3tools/provider-core/server/usage";
12import {
13 CursorKeychainTimeoutError,
14 readMacCursorAccessToken,
15} from "@t3tools/provider-cursor/server";
16
17function object(value: unknown): Record<string, unknown> {
18 return typeof value === "object" && value !== null && !Array.isArray(value)
19 ? (value as Record<string, unknown>)
20 : {};
21}
22
23function tokens(value: unknown): number {
24 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
25}
26
27/**
28 * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`,
29 * `claude-fable-5-1-thinking-high`) to the base model's rate-table key.
30 * Grok resolves through xAI's first-party entry, which has no bare alias.
31 */
32export function cursorRateModel(model: string): string {
33 const base = model
34 .replace(/^cursor-/, "")
35 .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, "");
36 return base.startsWith("grok-") ? `xai/${base}` : base;
37}
38
39export interface CursorAccountUsageReadResult {
40 readonly accountKey: string | null;
41 readonly records: readonly UsageRecord[];
42 readonly missing: boolean;
43 readonly error: string | null;
44}
45
46const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex");
47
48function canonicalJson(value: unknown): string {
49 if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
50 if (value !== null && typeof value === "object") {
51 return `{${Object.entries(value)
52 .sort(([a], [b]) => a.localeCompare(b))
53 .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
54 .join(",")}}`;
55 }
56 return JSON.stringify(value) ?? "null";
57}
58
59/** Find the longest exact suffix/prefix overlap in linear time. */
60function boundaryOverlap(previous: readonly string[], current: readonly string[]): number {
61 const sequence = [...current, "", ...previous];
62 const lengths = Array.from({ length: sequence.length }, () => 0);
63 for (let index = 1; index < sequence.length; index++) {
64 let length = lengths[index - 1]!;
65 while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!;
66 if (sequence[index] === sequence[length]) length++;
67 lengths[index] = length;
68 }
69 return lengths.at(-1) ?? 0;
70}
71
72/** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */
73export async function readCursorAccountUsage(
74 credentialSource: string | { readonly kind: "keychain" },
75 sinceMs: number,
76 endDate: number,
77 request: (url: string, init: RequestInit) => Promise<Response> = globalThis.fetch,
78 keychainToken: () => Promise<string | null> = readMacCursorAccessToken,
79): Promise<CursorAccountUsageReadResult> {
80 let accessToken: unknown;
81 try {
82 accessToken =
83 typeof credentialSource === "string"
84 ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken
85 : await keychainToken();
86 } catch (cause) {
87 const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT";
88 return {
89 accountKey: null,
90 records: [],
91 missing,
92 error: missing
93 ? null
94 : typeof credentialSource === "string"
95 ? "Cursor credentials could not be read."
96 : cause instanceof CursorKeychainTimeoutError
97 ? "Allow Keychain access on the Mac running T3 Code, then refresh."
98 : "Cursor Keychain credentials could not be read.",
99 };
100 }
101 if (typeof accessToken !== "string" || !accessToken) {
102 return {
103 accountKey: null,
104 records: [],
105 missing: true,
106 error:
107 typeof credentialSource === "string"
108 ? null
109 : "Cursor account history needs a macOS Keychain CLI login on this server.",
110 };
111 }
112 let accountKey: string | null = null;
113 const cancel = new AbortController();
114 try {
115 const payload = accessToken.split(".")[1];
116 const subject = object(
117 JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")),
118 ).sub;
119 if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication");
120 const userId = subject.split("|").at(-1);
121 if (!userId) throw new Error("Invalid authentication");
122 accountKey = accountHash(subject);
123 if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate)
124 throw new Error("Invalid date window");
125 const deadline = AbortSignal.any([cancel.signal, AbortSignal.timeout(60_000)]);
126 const records: UsageRecord[] = [];
127 const occurrences = new Map<string, number>();
128 const pages: unknown[][] = [];
129 let completed = false;
130 const pageSize = 1000;
131 let total: number | undefined;
132 const readPage = async (page: number) => {
133 // A count can include overlapping page boundaries. Allow room to
134 // reconcile them without imposing a fixed account-size limit.
135 if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) {
136 throw new Error("Account usage page limit exceeded");
137 }
138 const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", {
139 method: "POST",
140 redirect: "error",
141 signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]),
142 headers: {
143 "Content-Type": "application/json",
144 Origin: "https://cursor.com",
145 Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`,
146 },
147 body: JSON.stringify({
148 page,
149 pageSize,
150 startDate: String(sinceMs),
151 endDate: String(endDate),
152 }),
153 });
154 if (response.status === 401 || response.status === 403) {
155 return {
156 accountKey,
157 records: [],
158 missing: false,
159 error: "Sign in to Cursor again to read account usage.",
160 };
161 }
162 if (!response.ok) throw new Error("Account usage request failed");
163 const parsed: unknown = await response.json();
164 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
165 throw new Error("Invalid account usage page");
166 }
167 const body = object(parsed);
168 const keys = Object.keys(body);
169 if ("error" in body || "message" in body || "code" in body)
170 throw new Error("Account usage error response");
171 const count = keys.length === 0 ? 0 : body.totalUsageEventsCount;
172 const events =
173 keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount")
174 ? []
175 : body.usageEventsDisplay;
176 if (
177 (count !== undefined &&
178 (typeof count !== "number" ||
179 !Number.isSafeInteger(count) ||
180 count < 0 ||
181 (total !== undefined && count !== total))) ||
182 !Array.isArray(events) ||
183 events.length > pageSize ||
184 (count === undefined && !Array.isArray(body.usageEventsDisplay))
185 ) {
186 throw new Error("Inconsistent account usage page");
187 }
188 if (typeof count === "number") total = count;
189 return events;
190 };
191 // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages
192 // the first one's count implies are requested up to six ahead and consumed in page order.
193 const ahead: ReturnType<typeof readPage>[] = [];
194 for (let page = 1; ; page++) {
195 while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) {
196 const pending = readPage(page + ahead.length);
197 // Handled when its turn comes, or cancelled if the read ends first.
198 pending.catch(() => undefined);
199 ahead.push(pending);
200 }
201 const events = await (ahead.shift() ?? readPage(page));
202 // A rejected login comes back as the finished result instead of a page.
203 if (!Array.isArray(events)) return events;
204 pages.push(events);
205 if (events.length < pageSize) {
206 completed = true;
207 break;
208 }
209 }
210 if (!completed) throw new Error("Account usage page limit exceeded");
211 const rawCount = pages.reduce((sum, page) => sum + page.length, 0);
212 if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages");
213 let removalsRemaining = total === undefined ? 0 : rawCount - total;
214 let previousKeys: string[] = [];
215 for (const events of pages) {
216 const eventKeys =
217 removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : [];
218 const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys));
219 removalsRemaining -= removalCount;
220 previousKeys = eventKeys;
221 for (const raw of events.slice(removalCount)) {
222 const event = object(raw);
223 const usage = object(event.tokenUsage);
224 if (event.tokenUsage === undefined || event.tokenUsage === null) continue;
225 for (const key of [
226 "inputTokens",
227 "outputTokens",
228 "cacheReadTokens",
229 "cacheWriteTokens",
230 "totalCents",
231 ]) {
232 const value = usage[key];
233 if (
234 value !== undefined &&
235 (typeof value !== "number" || !Number.isFinite(value) || value < 0)
236 ) {
237 throw new Error("Invalid account usage totals");
238 }
239 }
240 const timestampMs =
241 typeof event.timestamp === "string" && event.timestamp.trim() !== ""
242 ? Number(event.timestamp)
243 : event.timestamp;
244 if (
245 typeof timestampMs !== "number" ||
246 !Number.isFinite(timestampMs) ||
247 typeof event.model !== "string" ||
248 !event.model
249 )
250 throw new Error("Invalid account usage event");
251 if (timestampMs < sinceMs || timestampMs > endDate) continue;
252 const totals = {
253 uncachedInputTokens: tokens(usage.inputTokens),
254 cachedInputTokens: tokens(usage.cacheReadTokens),
255 cacheCreationTokens: tokens(usage.cacheWriteTokens),
256 outputTokens: tokens(usage.outputTokens),
257 reasoningTokens: 0,
258 };
259 const reportedCostUsd =
260 typeof usage.totalCents === "number" ? usage.totalCents / 100 : null;
261 const sessionId = typeof event.conversationId === "string" ? event.conversationId : "";
262 // No event ID is provided. Preserve identical billed rows with an occurrence index.
263 const key = accountHash(
264 JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]),
265 );
266 const occurrence = occurrences.get(key) ?? 0;
267 occurrences.set(key, occurrence + 1);
268 records.push({
269 provider: "cursor",
270 timestampMs,
271 model: event.model,
272 rateModel: cursorRateModel(event.model),
273 sessionId,
274 totals,
275 reportedCostUsd,
276 speed: "standard",
277 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
278 });
279 }
280 await NodeTimersPromises.setImmediate();
281 }
282 if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries");
283 return { accountKey, records, missing: false, error: null };
284 } catch {
285 return {
286 accountKey,
287 records: [],
288 missing: false,
289 error: "Cursor account usage could not be read.",
290 };
291 } finally {
292 // An early exit leaves the pages requested ahead of it in flight.
293 cancel.abort();
294 }
295}
296
297/** Reads one range of Cursor account usage. A service so tests can stand in for Cursor's API. */
298export class CursorAccountReader extends Context.Service<
299 CursorAccountReader,
300 {
301 readonly read: (
302 credentialSource: string | { readonly kind: "keychain" },
303 sinceMs: number,
304 untilMs: number,
305 ) => Effect.Effect<CursorAccountUsageReadResult>;
306 }
307>()("t3/usage/cursorUsageReader/CursorAccountReader") {}
308
309/** Reads Cursor's dashboard API with the saved CLI or Keychain login. */
310export const layer = Layer.succeed(
311 CursorAccountReader,
312 CursorAccountReader.of({
313 read: (credentialSource, sinceMs, untilMs) =>
314 Effect.promise(() => readCursorAccountUsage(credentialSource, sinceMs, untilMs)),
315 }),
316);
apps/server/src/usage/usagePricing.test.ts+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
22
3import { cursorRateModel } from "./cursorUsageReader.ts";3import { cursorRateModel } from "@t3tools/provider-cursor/server/accountUsage";
4import type { UsageSpeed } from "@t3tools/provider-core/server/usage";4import type { UsageSpeed } from "@t3tools/provider-core/server/usage";
5import {5import {
6 cacheSavingsUsd,6 cacheSavingsUsd,
apps/server/src/usage/usageTranscriptReader.test.ts-230
...@@ -10,7 +10,6 @@ import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";...@@ -10,7 +10,6 @@ import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";
1010
11import { TEST_FORMATS } from "./usageTestFormats.ts";11import { TEST_FORMATS } from "./usageTestFormats.ts";
12import { readTranscriptRecords } from "./usageTranscriptReader.ts";12import { readTranscriptRecords } from "./usageTranscriptReader.ts";
13import { readCursorAccountUsage } from "./cursorUsageReader.ts";
14import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";13import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";
1514
16let dir: string;15let dir: string;
...@@ -252,235 +251,6 @@ describe("readTranscriptRecords resume", () => {...@@ -252,235 +251,6 @@ describe("readTranscriptRecords resume", () => {
252});251});
253252
254describe("SQLite usage readers", () => {253describe("SQLite usage readers", () => {
255 it("reads Cursor account history with the default macOS Keychain login", async () => {
256 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
257 let keychainReads = 0;
258 const result = await readCursorAccountUsage(
259 { kind: "keychain" },
260 0,
261 1781000000000,
262 async (_url, init) => {
263 assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A");
264 return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] });
265 },
266 async () => {
267 keychainReads++;
268 return accessToken;
269 },
270 );
271 assert.strictEqual(keychainReads, 1);
272 assert.isNull(result.error);
273 assert.isFalse(result.missing);
274 assert.isNotNull(result.accountKey);
275 });
276
277 it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => {
278 const authPath = NodePath.join(dir, "auth.json");
279 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
280 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
281 const pages: number[] = [];
282 const signals: AbortSignal[] = [];
283 const request = async (url: string, init: RequestInit) => {
284 assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events");
285 assert.strictEqual(init?.redirect, "error");
286 const headers = new Headers(init?.headers);
287 assert.strictEqual(headers.get("origin"), "https://cursor.com");
288 assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A");
289 const body = JSON.parse(String(init?.body));
290 pages.push(body.page);
291 if (init.signal) signals.push(init.signal);
292 return Response.json({
293 totalUsageEventsCount: 1001,
294 usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({
295 timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000),
296 model: "claude-sonnet-4-5",
297 conversationId: `conversation-${body.page}`,
298 isHeadless: body.page === 2,
299 chargedCents: 0,
300 tokenUsage: {
301 inputTokens: 10,
302 outputTokens: 5,
303 cacheReadTokens: 30,
304 cacheWriteTokens: 2,
305 totalCents: 25,
306 },
307 })),
308 });
309 };
310 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request);
311 assert.isNull(result.error);
312 assert.deepStrictEqual(pages, [1, 2]);
313 assert.lengthOf(signals, 2);
314 assert.notStrictEqual(signals[0], signals[1]);
315 assert.strictEqual(result.records.length, 1001);
316 assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2");
317 assert.deepStrictEqual(result.records[0]?.totals, {
318 uncachedInputTokens: 10,
319 cachedInputTokens: 30,
320 cacheCreationTokens: 2,
321 outputTokens: 5,
322 reasoningTokens: 0,
323 });
324 assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25);
325 assert.isFalse(result.accountKey?.includes("demo") ?? true);
326 });
327
328 it("reads Cursor account history beyond 100 pages", async () => {
329 const authPath = NodePath.join(dir, "auth.json");
330 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
331 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
332 const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null }));
333 let requests = 0;
334 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
335 requests += 1;
336 return Response.json({
337 totalUsageEventsCount: 100_001,
338 usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }],
339 });
340 });
341 assert.isNull(result.error);
342 assert.strictEqual(requests, 101);
343 assert.deepStrictEqual(result.records, []);
344 });
345
346 it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => {
347 const authPath = NodePath.join(dir, "auth.json");
348 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
349 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
350 for (const body of [
351 {},
352 { totalUsageEventsCount: 0 },
353 { totalUsageEventsCount: 0, usageEventsDisplay: [] },
354 ]) {
355 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
356 Response.json(body),
357 );
358 assert.isNull(result.error);
359 assert.deepStrictEqual(result.records, []);
360 assert.isFalse(result.missing);
361 }
362 for (const body of [
363 { error: "upstream error" },
364 { detail: "unknown error envelope" },
365 { totalUsageEventsCount: 0, error: "upstream error" },
366 null,
367 [],
368 "invalid",
369 0,
370 ]) {
371 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
372 Response.json(body),
373 );
374 assert.isNotNull(result.error);
375 assert.deepStrictEqual(result.records, []);
376 }
377 });
378
379 it("requires a terminal Cursor page after a full page reaches the reported count", async () => {
380 const authPath = NodePath.join(dir, "auth.json");
381 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
382 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
383 let requests = 0;
384 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
385 requests++;
386 return Response.json(
387 requests === 1
388 ? {
389 totalUsageEventsCount: 1000,
390 usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({
391 timestamp: String(1780000000000 + index),
392 model: "gpt-5",
393 tokenUsage: { inputTokens: 10, outputTokens: 5 },
394 })),
395 }
396 : { totalUsageEventsCount: 1000 },
397 );
398 });
399 assert.isNull(result.error);
400 assert.strictEqual(result.records.length, 1000);
401 assert.strictEqual(requests, 2);
402 });
403
404 it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => {
405 const authPath = NodePath.join(dir, "auth.json");
406 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
407 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
408 const event = (index: number) => ({
409 timestamp: String(1780000000000 + index),
410 model: "gpt-5",
411 tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 },
412 });
413 for (const total of [2000, 2001]) {
414 let requests = 0;
415 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
416 requests++;
417 return Response.json({
418 totalUsageEventsCount: total,
419 usageEventsDisplay:
420 requests === 1
421 ? Array.from({ length: 1000 }, (_, index) => event(index))
422 : requests === 2
423 ? Array.from({ length: 1000 }, (_, index) => event(999 + index))
424 : [event(1999)],
425 });
426 });
427 assert.isNull(result.error);
428 assert.strictEqual(result.records.length, total);
429 assert.strictEqual(requests, 3);
430 assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999);
431 assert.strictEqual(
432 result.records.filter((record) => record.timestampMs === 1780000000999).length,
433 total === 2000 ? 1 : 2,
434 );
435 assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total);
436 }
437 let requests = 0;
438 const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
439 requests++;
440 return Response.json({
441 totalUsageEventsCount: 1001,
442 usageEventsDisplay:
443 requests === 1
444 ? Array.from({ length: 1000 }, (_, index) => event(index))
445 : [event(500), event(1000)],
446 });
447 });
448 assert.isNotNull(inconsistent.error);
449 assert.deepStrictEqual(inconsistent.records, []);
450 });
451
452 it("does not present truncated Cursor account pages or authentication failures as complete history", async () => {
453 const authPath = NodePath.join(dir, "auth.json");
454 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
455 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
456 const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
457 Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }),
458 );
459 assert.isNotNull(truncated.error);
460 assert.deepStrictEqual(truncated.records, []);
461 const denied = await readCursorAccountUsage(
462 authPath,
463 0,
464 1781000000000,
465 async () => new Response(accessToken, { status: 401 }),
466 );
467 assert.isNotNull(denied.error);
468 assert.isFalse(denied.error?.includes(accessToken) ?? true);
469 assert.deepStrictEqual(denied.records, []);
470 let requested = false;
471 const missing = await readCursorAccountUsage(
472 NodePath.join(dir, "missing.json"),
473 0,
474 1781000000000,
475 async () => {
476 requested = true;
477 return Response.json({});
478 },
479 );
480 assert.isTrue(missing.missing);
481 assert.isFalse(requested);
482 });
483
484 it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => {254 it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => {
485 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db"));255 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db"));
486 const stamp = protoNumber(1, 1780000000);256 const stamp = protoNumber(1, 1780000000);
packages/provider-core/src/server/usage.ts+3
...@@ -11,6 +11,7 @@...@@ -11,6 +11,7 @@
11 */11 */
12import type {12import type {
13 ProviderInstanceId,13 ProviderInstanceId,
14 ServerSettings,
14 UsageProviderKind,15 UsageProviderKind,
15 UsageSource,16 UsageSource,
16 UsageTokenTotals,17 UsageTokenTotals,
...@@ -146,6 +147,8 @@ export interface ProviderUsageScan {...@@ -146,6 +147,8 @@ export interface ProviderUsageScan {
146147
147export interface ProviderUsageScanInput<Config> {148export interface ProviderUsageScanInput<Config> {
148 readonly instances: ReadonlyArray<ProviderUsageInstance<Config>>;149 readonly instances: ReadonlyArray<ProviderUsageInstance<Config>>;
150 /** The settings snapshot the whole read runs against. */
151 readonly settings: ServerSettings;
149 /** Files last written before this cannot hold records inside the window. */152 /** Files last written before this cannot hold records inside the window. */
150 readonly windowStartMs: number;153 readonly windowStartMs: number;
151 /** The oldest usage the server keeps; account caches cover back to here. */154 /** The oldest usage the server keeps; account caches cover back to here. */
packages/provider-cursor/package.json+16
...@@ -23,6 +23,22 @@...@@ -23,6 +23,22 @@
23 "types": "./src/server/CursorAgentSdk.ts",23 "types": "./src/server/CursorAgentSdk.ts",
24 "import": "./src/server/CursorAgentSdk.ts"24 "import": "./src/server/CursorAgentSdk.ts"
25 },25 },
26 "./server/accountUsage": {
27 "types": "./src/server/accountUsage.ts",
28 "import": "./src/server/accountUsage.ts"
29 },
30 "./server/CursorAccountReader": {
31 "types": "./src/server/CursorAccountReader.ts",
32 "import": "./src/server/CursorAccountReader.ts"
33 },
34 "./server/CursorKeychain": {
35 "types": "./src/server/CursorKeychain.ts",
36 "import": "./src/server/CursorKeychain.ts"
37 },
38 "./server/CursorUsageAccounts": {
39 "types": "./src/server/CursorUsageAccounts.ts",
40 "import": "./src/server/CursorUsageAccounts.ts"
41 },
26 "./server/CursorSdk": {42 "./server/CursorSdk": {
27 "types": "./src/server/CursorSdk.ts",43 "types": "./src/server/CursorSdk.ts",
28 "import": "./src/server/CursorSdk.ts"44 "import": "./src/server/CursorSdk.ts"
packages/provider-cursor/src/server.ts+2-4
...@@ -1,10 +1,8 @@...@@ -1,10 +1,8 @@
1/**1/**
2 * Cursor's server entry: the driver the server registers, its adapter2 * Cursor's server entry: the driver the server registers and its adapter
3 * driver, the SDK runner layer the server provides once, and the keychain3 * driver.
4 * token reader the usage scanner shares.
5 *4 *
6 * @module provider-cursor/server5 * @module provider-cursor/server
7 */6 */
8export { CursorDriver, type CursorDriverEnv } from "./server/driver.ts";7export { CursorDriver, type CursorDriverEnv } from "./server/driver.ts";
9export { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./server/adapter.ts";8export { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./server/adapter.ts";
10export { CursorKeychainTimeoutError, readMacCursorAccessToken } from "./server/keychainToken.ts";
packages/provider-cursor/src/server/CursorAccountReader.ts created+51
...@@ -0,0 +1,51 @@
1/**
2 * CursorAccountReader - reads one range of Cursor account usage from its
3 * dashboard API. A service so tests can stand in for Cursor's API.
4 *
5 * @module provider-cursor/server/CursorAccountReader
6 */
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Result from "effect/Result";
11
12import type { CursorCredentialSource } from "./accountCache.ts";
13import { readCursorAccountUsage, type CursorAccountUsageReadResult } from "./accountUsage.ts";
14import * as CursorKeychain from "./CursorKeychain.ts";
15
16export class CursorAccountReader extends Context.Service<
17 CursorAccountReader,
18 {
19 readonly read: (
20 credentialSource: CursorCredentialSource,
21 sinceMs: number,
22 untilMs: number,
23 ) => Effect.Effect<CursorAccountUsageReadResult>;
24 }
25>()("@t3tools/provider-cursor/server/CursorAccountReader") {}
26
27/** Reads Cursor's dashboard API with the saved CLI or Keychain login. */
28export const layer = Layer.effect(
29 CursorAccountReader,
30 Effect.gen(function* () {
31 const keychain = yield* CursorKeychain.CursorKeychain;
32 return CursorAccountReader.of({
33 read: (credentialSource, sinceMs, untilMs) =>
34 Effect.gen(function* () {
35 // Only a Keychain login asks the Keychain. The dashboard reader
36 // turns its outcome, a failure included, into the source's message.
37 const token =
38 typeof credentialSource === "string"
39 ? Result.succeed(null)
40 : yield* Effect.result(keychain.accessToken);
41 return yield* Effect.promise(() =>
42 readCursorAccountUsage(credentialSource, sinceMs, untilMs, globalThis.fetch, () =>
43 Result.isSuccess(token)
44 ? Promise.resolve(token.success)
45 : Promise.reject(token.failure),
46 ),
47 );
48 }),
49 });
50 }),
51);
packages/provider-cursor/src/server/CursorKeychain.test.ts created+54
...@@ -0,0 +1,54 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as TestClock from "effect/testing/TestClock";
7
8import * as CursorKeychain from "./CursorKeychain.ts";
9
10describe("CursorKeychain", () => {
11 it.effect("shares concurrent reads and rechecks after the cache expires", () =>
12 Effect.gen(function* () {
13 let reads = 0;
14 const keychain = CursorKeychain.make(
15 Effect.sync(() => {
16 reads++;
17 return `token-${reads}`;
18 }),
19 );
20 assert.deepStrictEqual(
21 yield* Effect.all([keychain.accessToken, keychain.accessToken], {
22 concurrency: "unbounded",
23 }),
24 ["token-1", "token-1"],
25 );
26 assert.strictEqual(yield* keychain.accessToken, "token-1");
27 assert.strictEqual(reads, 1);
28 yield* TestClock.adjust(Duration.minutes(5));
29 assert.strictEqual(yield* keychain.accessToken, "token-2");
30 }),
31 );
32
33 it.effect("gives up on an unanswered prompt and reuses it on the next read", () =>
34 Effect.gen(function* () {
35 let reads = 0;
36 const allow = yield* Deferred.make<string>();
37 const keychain = CursorKeychain.make(
38 Effect.suspend(() => {
39 reads++;
40 return Deferred.await(allow);
41 }),
42 Duration.millis(1),
43 );
44 const timedOut = yield* keychain.accessToken.pipe(Effect.forkChild);
45 yield* TestClock.adjust(Duration.millis(1));
46 const error = yield* Fiber.join(timedOut).pipe(Effect.flip);
47 assert.isTrue(CursorKeychain.isCursorKeychainTimeoutError(error));
48 const retry = yield* keychain.accessToken.pipe(Effect.forkChild);
49 yield* Deferred.succeed(allow, "token");
50 assert.strictEqual(yield* Fiber.join(retry), "token");
51 assert.strictEqual(reads, 1);
52 }),
53 );
54});
packages/provider-cursor/src/server/CursorKeychain.ts created+118
...@@ -0,0 +1,118 @@
1/**
2 * CursorKeychain - the Cursor CLI's macOS Keychain login, shared by usage
3 * history and usage limits in one server process.
4 *
5 * macOS shows the access prompt on the server's own screen, which a remote
6 * client cannot answer, so a read gives up after a timeout. The Keychain read
7 * stays in flight: the next read joins it instead of stacking a second prompt,
8 * and picks up the token once someone allows access.
9 *
10 * @module provider-cursor/server/CursorKeychain
11 */
12import * as NodeModule from "node:module";
13
14import * as Clock from "effect/Clock";
15import * as Context from "effect/Context";
16import * as Deferred from "effect/Deferred";
17import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Schema from "effect/Schema";
22
23const CACHE_MS = 5 * 60_000;
24const DEFAULT_TIMEOUT = Duration.seconds(30);
25
26const requireForKeyring = NodeModule.createRequire(import.meta.url);
27
28/** Nobody answered the macOS Keychain prompt in time. */
29export class CursorKeychainTimeoutError extends Schema.TaggedError<CursorKeychainTimeoutError>()(
30 "CursorKeychainTimeoutError",
31 {},
32) {
33 override get message(): string {
34 return "Timed out waiting for Keychain access.";
35 }
36}
37
38export const isCursorKeychainTimeoutError = Schema.is(CursorKeychainTimeoutError);
39
40/** The Keychain could not be read. */
41export class CursorKeychainReadError extends Schema.TaggedError<CursorKeychainReadError>()(
42 "CursorKeychainReadError",
43 { cause: Schema.Defect() },
44) {
45 override get message(): string {
46 return "Cursor Keychain credentials could not be read.";
47 }
48}
49
50export class CursorKeychain extends Context.Service<
51 CursorKeychain,
52 {
53 /** The access token, or `null` when the Keychain holds no Cursor login. */
54 readonly accessToken: Effect.Effect<
55 string | null,
56 CursorKeychainTimeoutError | CursorKeychainReadError
57 >;
58 }
59>()("@t3tools/provider-cursor/server/CursorKeychain") {}
60
61/** Reads the Cursor CLI's default macOS credential without invoking the shared security binary. */
62const readKeychainEntry = Effect.tryPromise({
63 try: async () => {
64 const { AsyncEntry } = requireForKeyring(
65 "@napi-rs/keyring",
66 ) as typeof import("@napi-rs/keyring");
67 return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null;
68 },
69 catch: (cause) => new CursorKeychainReadError({ cause }),
70});
71
72/** A Keychain backed by `read`, which tests replace. */
73export const make = (
74 read: Effect.Effect<string | null, CursorKeychainReadError> = readKeychainEntry,
75 timeout: Duration.Input = DEFAULT_TIMEOUT,
76): CursorKeychain["Service"] => {
77 let cached: { readonly token: string; readonly untilMs: number } | null = null;
78 let pending: Deferred.Deferred<string | null, CursorKeychainReadError> | null = null;
79
80 /** Joins the read in flight, or starts one that outlives the caller. */
81 const startRead = Effect.uninterruptible(
82 Effect.gen(function* () {
83 if (pending !== null) return pending;
84 const done = Deferred.makeUnsafe<string | null, CursorKeychainReadError>();
85 pending = done;
86 yield* read.pipe(
87 Effect.tap((token) =>
88 Clock.currentTimeMillis.pipe(
89 Effect.map((nowMs) => {
90 cached = token ? { token, untilMs: nowMs + CACHE_MS } : null;
91 }),
92 ),
93 ),
94 Effect.onExit((exit) =>
95 Effect.suspend(() => {
96 pending = null;
97 return Deferred.done(done, exit);
98 }),
99 ),
100 Effect.forkDetach,
101 );
102 return done;
103 }),
104 );
105
106 const accessToken = Effect.gen(function* () {
107 const nowMs = yield* Clock.currentTimeMillis;
108 if (cached !== null && cached.untilMs > nowMs) return cached.token;
109 const done = yield* startRead;
110 const token = yield* Deferred.await(done).pipe(Effect.timeoutOption(timeout));
111 if (Option.isNone(token)) return yield* new CursorKeychainTimeoutError();
112 return token.value;
113 });
114
115 return CursorKeychain.of({ accessToken });
116};
117
118export const layer = Layer.sync(CursorKeychain, () => make());
packages/provider-cursor/src/server/CursorUsageAccounts.ts created+342
...@@ -0,0 +1,342 @@
1/**
2 * CursorUsageAccounts - Cursor's account usage as a usage source, answered
3 * from a cache.
4 *
5 * Cursor's account API is slow, so a read answers from the cached account
6 * history and marks itself `refreshing` while a background refresh runs;
7 * `awaitRefresh` waits for that refresh instead. The caches persist in the
8 * server's state directory, so a restart only refetches the newest edge. See
9 * `accountCache`.
10 *
11 * @module provider-cursor/server/CursorUsageAccounts
12 */
13import * as NodeOS from "node:os";
14
15import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
16import type { ProviderUsageScan } from "@t3tools/provider-core/server/usage";
17import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
18import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
19import * as Clock from "effect/Clock";
20import * as Context from "effect/Context";
21import * as Deferred from "effect/Deferred";
22import * as Effect from "effect/Effect";
23import * as Fiber from "effect/Fiber";
24import * as FileSystem from "effect/FileSystem";
25import * as Layer from "effect/Layer";
26import * as Path from "effect/Path";
27import * as Schema from "effect/Schema";
28import * as Semaphore from "effect/Semaphore";
29
30import {
31 CURSOR_ACCOUNT_CACHE_FILE_NAME,
32 CURSOR_ACCOUNT_TTL_MS,
33 cursorFetchRange,
34 decodeCursorAccountCaches,
35 encodeCursorAccountCaches,
36 isCursorCacheFresh,
37 mergeCursorFetch,
38 type CursorAccountCache,
39 type CursorCredentialSource,
40} from "./accountCache.ts";
41import * as CursorAccountReader from "./CursorAccountReader.ts";
42
43const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read.";
44
45/** The cache file is narrowed by hand in `accountCache`, so JSON is enough here. */
46const decodeCacheFile = Schema.decodeUnknownEffect(
47 Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec<unknown>),
48);
49
50export class CursorUsageAccounts extends Context.Service<
51 CursorUsageAccounts,
52 {
53 /** This environment's Cursor account usage source, if it has one to report. */
54 readonly scan: (input: {
55 /** From the same settings snapshot as the rest of the usage read. */
56 readonly keychainUsageEnabled: boolean;
57 readonly windowStartMs: number;
58 readonly retentionCutoffMs: number;
59 readonly awaitRefresh: boolean;
60 }) => Effect.Effect<ReadonlyArray<ProviderUsageScan>>;
61 /** Waits for every cache write scheduled so far, as a restart would need. */
62 readonly awaitPersisted: Effect.Effect<void>;
63 }
64>()("@t3tools/provider-cursor/server/CursorUsageAccounts") {}
65
66const make = Effect.gen(function* () {
67 const fileSystem = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const host = yield* ProviderHost.ProviderHost;
70 const accountReader = yield* CursorAccountReader.CursorAccountReader;
71 const hostEnvironment = yield* HostProcessEnvironment;
72 const platform = yield* HostProcessPlatform;
73
74 const cachePath = path.join(host.paths.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME);
75 /** Account caches by credential source. */
76 const caches = new Map<string, CursorAccountCache>();
77 let cacheDirty = false;
78 /**
79 * The last failed refresh per credential source, standing for a TTL so a
80 * client refetching a broken login does not refetch Cursor each time. A
81 * `null` error means there is no login: no source to report.
82 */
83 const failures = new Map<string, { readonly atMs: number; readonly error: string | null }>();
84 /** The refresh in flight per credential source, which every read joins. */
85 const refreshes = new Map<string, Deferred.Deferred<void>>();
86
87 /** Loads the persisted caches once; concurrent first readers await the same load. */
88 const ensureLoaded = yield* Effect.cached(
89 fileSystem.readFileString(cachePath).pipe(
90 Effect.flatMap((raw) => decodeCacheFile(raw)),
91 Effect.catchCause(() => Effect.succeed(null)),
92 Effect.map((document) => {
93 for (const [key, cache] of decodeCursorAccountCaches(document)) caches.set(key, cache);
94 }),
95 ),
96 );
97
98 // Serialized so an older snapshot never lands after a newer one. The dirty
99 // flag is cleared before encoding, so a change while the write is in flight
100 // marks it dirty again; a failed write restores it so the next persist retries.
101 const persistLock = yield* Semaphore.make(1);
102 const persist = Effect.gen(function* () {
103 if (!cacheDirty) return;
104 cacheDirty = false;
105 yield* Effect.sync(() => encodeCursorAccountCaches(caches)).pipe(
106 Effect.flatMap((contents) => writeFileStringAtomically({ filePath: cachePath, contents })),
107 Effect.provideService(FileSystem.FileSystem, fileSystem),
108 Effect.provideService(Path.Path, path),
109 Effect.catchCause(() =>
110 Effect.sync(() => {
111 cacheDirty = true;
112 }),
113 ),
114 );
115 }).pipe(persistLock.withPermit, Effect.withSpan("CursorUsageAccounts.persist"));
116
117 const pendingPersists = new Set<Fiber.Fiber<void>>();
118 /** Writes the caches in the background, after the read that dirtied them answers. */
119 const schedulePersist = Effect.forkDetach(persist).pipe(
120 Effect.map((fiber) => {
121 pendingPersists.add(fiber);
122 fiber.addObserver(() => pendingPersists.delete(fiber));
123 }),
124 );
125 const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe(
126 Effect.asVoid,
127 );
128 // A write still running at shutdown finishes first, so the next start keeps it.
129 yield* Effect.addFinalizer(() => awaitPersisted);
130
131 /** Fetches what one account cache is missing, then persists it if anything changed. */
132 const refreshAccount = Effect.fn("CursorUsageAccounts.refreshAccount")(function* (
133 credential: CursorCredentialSource,
134 credentialKey: string,
135 retentionStartMs: number,
136 ) {
137 const nowMs = yield* Clock.currentTimeMillis;
138 const fetchMissing = (cache: CursorAccountCache | undefined) => {
139 const range = cursorFetchRange(cache, retentionStartMs, nowMs);
140 return accountReader
141 .read(credential, range.sinceMs, range.untilMs)
142 .pipe(Effect.map((result) => ({ range, result })));
143 };
144 let base = caches.get(credentialKey);
145 let fetched = yield* fetchMissing(base);
146 // Another login's history replaces the cached account's, even if reading it fails.
147 if (
148 base !== undefined &&
149 fetched.result.accountKey !== null &&
150 fetched.result.accountKey !== base.accountKey
151 ) {
152 caches.delete(credentialKey);
153 cacheDirty = true;
154 base = undefined;
155 fetched = yield* fetchMissing(base);
156 }
157 const { range, result } = fetched;
158 if (result.missing || result.error !== null || result.accountKey === null) {
159 failures.set(credentialKey, {
160 atMs: nowMs,
161 error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR,
162 });
163 yield* schedulePersist;
164 return;
165 }
166 const merged = mergeCursorFetch(
167 base,
168 result.accountKey,
169 range,
170 result.records,
171 nowMs,
172 retentionStartMs,
173 );
174 caches.set(credentialKey, merged.cache);
175 failures.delete(credentialKey);
176 // An unchanged edge is not worth rewriting the file for: after a restart
177 // the cache just refetches a slightly wider edge. Persisting anyway retries
178 // an earlier failed write; it is a no-op when nothing is dirty.
179 if (merged.changed) cacheDirty = true;
180 yield* schedulePersist;
181 });
182
183 /** Joins the refresh in flight, or starts one. */
184 const startRefresh = (
185 credential: CursorCredentialSource,
186 credentialKey: string,
187 retentionStartMs: number,
188 ) =>
189 // Enrollment and fork are atomic, so an interrupted caller cannot leave a
190 // registered refresh that nothing will finish.
191 Effect.uninterruptible(
192 Effect.gen(function* () {
193 const current = refreshes.get(credentialKey);
194 if (current !== undefined) return current;
195 const done = Deferred.makeUnsafe<void>();
196 refreshes.set(credentialKey, done);
197 // Detached: a departing client must not cancel a fetch later reads reuse.
198 yield* refreshAccount(credential, credentialKey, retentionStartMs).pipe(
199 Effect.catchCause(() =>
200 Clock.currentTimeMillis.pipe(
201 Effect.map((atMs) =>
202 failures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }),
203 ),
204 ),
205 ),
206 Effect.ensuring(
207 Effect.suspend(() => {
208 refreshes.delete(credentialKey);
209 return Deferred.succeed(done, undefined);
210 }),
211 ),
212 Effect.forkDetach,
213 );
214 return done;
215 }),
216 );
217
218 /**
219 * The account source. A cache inside its TTL, or a refresh that failed
220 * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh`
221 * waits for it, and anything else answers from the cache marked `refreshing`.
222 */
223 const accountSource = Effect.fn("CursorUsageAccounts.accountSource")(function* (
224 credential: CursorCredentialSource,
225 authPath: string,
226 windowStartMs: number,
227 retentionStartMs: number,
228 awaitRefresh: boolean,
229 ) {
230 // No saved login means there is no account source to report, not a setup error.
231 if (
232 typeof credential === "string" &&
233 !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true)))
234 ) {
235 return null;
236 }
237 yield* ensureLoaded;
238 const credentialKey = typeof credential === "string" ? credential : "keychain";
239 const nowMs = yield* Clock.currentTimeMillis;
240 const recentFailure = failures.get(credentialKey);
241 let refreshing = false;
242 if (
243 (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) &&
244 !isCursorCacheFresh(caches.get(credentialKey), nowMs)
245 ) {
246 const refresh = yield* startRefresh(credential, credentialKey, retentionStartMs);
247 if (awaitRefresh) yield* Deferred.await(refresh);
248 else refreshing = true;
249 }
250
251 const cache = caches.get(credentialKey);
252 const failure = refreshing ? undefined : failures.get(credentialKey);
253 const failureMessage = failure === undefined ? undefined : failure.error;
254 if (failureMessage === null) return null;
255 if (cache === undefined) {
256 return {
257 dir: authPath,
258 // Never combine a local fallback with another server's account-wide history.
259 ...(refreshing
260 ? { files: [], refreshing: true }
261 : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }),
262 } satisfies ProviderUsageScan;
263 }
264 // The same account includes CLI and desktop history from every machine.
265 // A stable remote fingerprint prevents connected environments counting it twice.
266 const source = `cursor-account:${cache.accountKey}`;
267 return {
268 dir: source,
269 hostId: "cursor.com",
270 volumeId: cache.accountKey,
271 files: [
272 {
273 path: source,
274 records: cache.records.filter((record) => record.timestampMs >= windowStartMs),
275 },
276 ],
277 ...(failureMessage === undefined
278 ? { status: "ok" }
279 : { status: "partial", message: failureMessage }),
280 ...(refreshing ? { refreshing: true } : {}),
281 } satisfies ProviderUsageScan;
282 });
283
284 const scan: CursorUsageAccounts["Service"]["scan"] = Effect.fn("CursorUsageAccounts.scan")(
285 function* ({ keychainUsageEnabled, windowStartMs, retentionCutoffMs, awaitRefresh }) {
286 const home = NodeOS.homedir();
287 const userHome =
288 (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home;
289 const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim();
290 const cursorHome =
291 platform === "darwin"
292 ? path.join(userHome, "Library", "Application Support")
293 : platform === "win32"
294 ? hostEnvironment["APPDATA"] || path.join(userHome, "AppData", "Roaming")
295 : configHome && path.isAbsolute(configHome)
296 ? configHome
297 : path.join(userHome, ".config");
298 const authPath =
299 platform === "darwin"
300 ? path.join(userHome, ".cursor", "auth.json")
301 : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json");
302 const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"];
303 const loginUnavailable =
304 Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) ||
305 Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) ||
306 credentialStore === "memory";
307 const useKeychain = platform === "darwin" && credentialStore !== "file";
308 if (useKeychain && !loginUnavailable && !keychainUsageEnabled) {
309 return [
310 {
311 dir: authPath,
312 volumeId: "",
313 files: null,
314 message: "Cursor account usage is off on this environment.",
315 action: "enableCursorKeychain",
316 },
317 ];
318 }
319 if (loginUnavailable) {
320 return [
321 {
322 dir: authPath,
323 files: null,
324 message: "Cursor account history needs a Cursor CLI login on this server.",
325 },
326 ];
327 }
328 const source = yield* accountSource(
329 useKeychain ? { kind: "keychain" } : authPath,
330 authPath,
331 windowStartMs,
332 retentionCutoffMs,
333 awaitRefresh,
334 );
335 return source === null ? [] : [source];
336 },
337 );
338
339 return CursorUsageAccounts.of({ scan, awaitPersisted });
340});
341
342export const layer = Layer.effect(CursorUsageAccounts, make);
packages/provider-cursor/src/server/accountCache.test.ts created+80
...@@ -0,0 +1,80 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { cursorFetchRange, mergeCursorFetch } from "./accountCache.ts";
4import type { UsageRecord } from "@t3tools/provider-core/server/usage";
5
6const DAY_MS = 24 * 60 * 60 * 1000;
7
8const record = (timestampMs: number): UsageRecord => ({
9 provider: "cursor",
10 timestampMs,
11 model: "claude-fable-5",
12 sessionId: "conversation-1",
13 totals: {
14 uncachedInputTokens: 0,
15 cachedInputTokens: 0,
16 cacheCreationTokens: 0,
17 outputTokens: 1,
18 reasoningTokens: 0,
19 },
20 reportedCostUsd: null,
21 speed: "standard",
22 dedupeKey: `cursor-account:a:${timestampMs}:0`,
23});
24
25describe("cursorAccountCache", () => {
26 it("refetches everything once the cache no longer reaches the retention start", () => {
27 const cache = {
28 accountKey: "a",
29 sinceMs: 0,
30 untilMs: 10 * DAY_MS,
31 fetchedAtMs: 10 * DAY_MS,
32 records: [record(5 * DAY_MS)],
33 };
34 const nowMs = 100 * DAY_MS;
35 const range = cursorFetchRange(cache, 10 * DAY_MS, nowMs);
36 assert.deepStrictEqual(range, { sinceMs: 10 * DAY_MS, untilMs: nowMs });
37
38 const { cache: merged } = mergeCursorFetch(
39 cache,
40 "a",
41 range,
42 [record(50 * DAY_MS)],
43 nowMs,
44 10 * DAY_MS,
45 );
46 assert.deepStrictEqual(
47 merged.records.map((entry) => entry.timestampMs),
48 [50 * DAY_MS],
49 );
50 // Then only the newest edge.
51 assert.deepStrictEqual(cursorFetchRange(merged, 11 * DAY_MS, nowMs + DAY_MS), {
52 sinceMs: nowMs - 60 * 60 * 1000,
53 untilMs: nowMs + DAY_MS,
54 });
55 });
56
57 it("reports an edge that only confirms the cache as unchanged", () => {
58 const cache = {
59 accountKey: "a",
60 sinceMs: 0,
61 untilMs: 10 * DAY_MS,
62 fetchedAtMs: 10 * DAY_MS,
63 records: [record(DAY_MS), record(10 * DAY_MS)],
64 };
65 const range = cursorFetchRange(cache, 0, 11 * DAY_MS);
66 assert.isFalse(
67 mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 0).changed,
68 );
69 assert.isTrue(
70 mergeCursorFetch(
71 cache,
72 "a",
73 range,
74 [record(10 * DAY_MS), record(10.5 * DAY_MS)],
75 11 * DAY_MS,
76 0,
77 ).changed,
78 );
79 });
80});
packages/provider-cursor/src/server/accountCache.ts created+271
...@@ -0,0 +1,271 @@
1/**
2 * Cursor account usage, cached per credential source.
3 *
4 * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched
5 * records are kept for the whole retention period, whatever window was asked
6 * for. After one full fetch, a refresh fetches only the newest edge.
7 *
8 * A record's dedupe key carries its timestamp and an occurrence index counted
9 * within one fetch. A refetched range therefore replaces every cached record in
10 * that range rather than adding to it, so each timestamp's records always come
11 * from a single fetch and their occurrence indexes cannot collide.
12 *
13 * @module provider-cursor/server/accountCache
14 */
15import { cursorRateModel } from "./accountUsage.ts";
16import type { UsageRecord } from "@t3tools/provider-core/server/usage";
17
18export type CursorCredentialSource = string | { readonly kind: "keychain" };
19
20/** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */
21export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000;
22
23/** Dashboard events can be finalized after they were first read. */
24const REFETCH_OVERLAP_MS = 60 * 60 * 1000;
25
26export interface CursorAccountCache {
27 readonly accountKey: string;
28 /** Inclusive range of event timestamps the records are complete for. */
29 readonly sinceMs: number;
30 readonly untilMs: number;
31 /** When the newest edge was fetched. */
32 readonly fetchedAtMs: number;
33 readonly records: readonly UsageRecord[];
34}
35
36interface CursorFetchRange {
37 readonly sinceMs: number;
38 readonly untilMs: number;
39}
40
41/** Whether the cache answers without a refresh. */
42export function isCursorCacheFresh(
43 cache: CursorAccountCache | undefined,
44 nowMs: number,
45): cache is CursorAccountCache {
46 return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS;
47}
48
49/**
50 * The range to fetch so the cache covers `[retentionStartMs, nowMs]`: the
51 * newest edge, or everything when the cache does not reach back that far.
52 */
53export function cursorFetchRange(
54 cache: CursorAccountCache | undefined,
55 retentionStartMs: number,
56 nowMs: number,
57): CursorFetchRange {
58 return cache === undefined ||
59 cache.sinceMs > retentionStartMs ||
60 cache.untilMs - REFETCH_OVERLAP_MS < retentionStartMs
61 ? { sinceMs: retentionStartMs, untilMs: nowMs }
62 : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs };
63}
64
65/**
66 * Applies a fetched range to the cache, replacing the cached records it covers
67 * and dropping those before the retention start. Pass `undefined` for a cache
68 * that belongs to another account. `changed` is false when the fetch only
69 * confirmed the cached records, so there is nothing new to persist.
70 */
71export function mergeCursorFetch(
72 cache: CursorAccountCache | undefined,
73 accountKey: string,
74 range: CursorFetchRange,
75 fetched: readonly UsageRecord[],
76 nowMs: number,
77 retentionStartMs: number,
78) {
79 const inRange = (record: UsageRecord) =>
80 record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs;
81 const previous = cache?.records ?? [];
82 const added = fetched.filter(inRange);
83 const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey));
84 const changed =
85 cache === undefined ||
86 replacedKeys.size !== added.length ||
87 added.some((record) => !replacedKeys.has(record.dedupeKey));
88 return {
89 changed,
90 cache: {
91 accountKey,
92 sinceMs: retentionStartMs,
93 untilMs: range.untilMs,
94 fetchedAtMs: nowMs,
95 records: [
96 ...previous.filter((record) => !inRange(record) && record.timestampMs >= retentionStartMs),
97 ...added,
98 ],
99 } satisfies CursorAccountCache,
100 };
101}
102
103/**
104 * Each version writes its own file, as the scan cache does, so servers of
105 * different versions sharing a state directory keep their own caches.
106 */
107export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json";
108const CURSOR_ACCOUNT_CACHE_VERSION = 1;
109
110/** Positional rows with interned strings, as in the scan cache. */
111type SerializedRecord = readonly [
112 timestampMs: number,
113 modelIndex: number,
114 sessionIndex: number,
115 uncachedInputTokens: number,
116 cachedInputTokens: number,
117 cacheCreationTokens: number,
118 outputTokens: number,
119 reportedCostUsd: number | null,
120 dedupeKey: string | null,
121];
122
123interface SerializedAccount {
124 readonly accountKey: string;
125 readonly sinceMs: number;
126 readonly untilMs: number;
127 readonly fetchedAtMs: number;
128 readonly records: readonly SerializedRecord[];
129}
130
131/** Serialises the caches by credential source. */
132export function encodeCursorAccountCaches(caches: ReadonlyMap<string, CursorAccountCache>): string {
133 const strings: string[] = [];
134 const indexes = new Map<string, number>();
135 const intern = (value: string) => {
136 let index = indexes.get(value);
137 if (index === undefined) {
138 index = strings.length;
139 strings.push(value);
140 indexes.set(value, index);
141 }
142 return index;
143 };
144 const accounts: Record<string, SerializedAccount> = {};
145 for (const [credentialKey, cache] of caches) {
146 accounts[credentialKey] = {
147 accountKey: cache.accountKey,
148 sinceMs: cache.sinceMs,
149 untilMs: cache.untilMs,
150 fetchedAtMs: cache.fetchedAtMs,
151 records: cache.records.map((record) => [
152 record.timestampMs,
153 intern(record.model),
154 intern(record.sessionId),
155 record.totals.uncachedInputTokens,
156 record.totals.cachedInputTokens,
157 record.totals.cacheCreationTokens,
158 record.totals.outputTokens,
159 record.reportedCostUsd,
160 record.dedupeKey,
161 ]),
162 };
163 }
164 return JSON.stringify({ version: CURSOR_ACCOUNT_CACHE_VERSION, strings, accounts });
165}
166
167const isFiniteNumber = (value: unknown): value is number =>
168 typeof value === "number" && Number.isFinite(value);
169
170/**
171 * Rebuilds the caches from a parsed document. A malformed account is dropped
172 * whole, costing one refetch; keeping its survivors would leave a gap the
173 * covered range claims is complete.
174 */
175export function decodeCursorAccountCaches(document: unknown): Map<string, CursorAccountCache> {
176 const caches = new Map<string, CursorAccountCache>();
177 if (typeof document !== "object" || document === null) return caches;
178 const root = document as {
179 readonly version?: unknown;
180 readonly strings?: unknown;
181 readonly accounts?: unknown;
182 };
183 const strings = root.strings;
184 if (
185 root.version !== CURSOR_ACCOUNT_CACHE_VERSION ||
186 !Array.isArray(strings) ||
187 !strings.every((value): value is string => typeof value === "string") ||
188 typeof root.accounts !== "object" ||
189 root.accounts === null
190 ) {
191 return caches;
192 }
193 const stringAt = (index: unknown): string | undefined =>
194 typeof index === "number" ? strings[index] : undefined;
195
196 const decodeRecord = (row: unknown): UsageRecord | null => {
197 if (!Array.isArray(row)) return null;
198 const [
199 timestampMs,
200 modelIndex,
201 sessionIndex,
202 uncached,
203 cached,
204 creation,
205 output,
206 cost,
207 key,
208 ]: readonly unknown[] = row;
209 const model = stringAt(modelIndex);
210 const sessionId = stringAt(sessionIndex);
211 if (
212 !isFiniteNumber(timestampMs) ||
213 model === undefined ||
214 sessionId === undefined ||
215 !isFiniteNumber(uncached) ||
216 !isFiniteNumber(cached) ||
217 !isFiniteNumber(creation) ||
218 !isFiniteNumber(output) ||
219 (cost !== null && !isFiniteNumber(cost)) ||
220 (key !== null && typeof key !== "string")
221 ) {
222 return null;
223 }
224 return {
225 provider: "cursor",
226 timestampMs,
227 model,
228 rateModel: cursorRateModel(model),
229 sessionId,
230 totals: {
231 uncachedInputTokens: uncached,
232 cachedInputTokens: cached,
233 cacheCreationTokens: creation,
234 outputTokens: output,
235 reasoningTokens: 0,
236 },
237 reportedCostUsd: cost,
238 speed: "standard",
239 dedupeKey: key,
240 };
241 };
242
243 for (const [credentialKey, raw] of Object.entries(root.accounts)) {
244 if (typeof raw !== "object" || raw === null) continue;
245 const account = raw as Partial<Record<keyof SerializedAccount, unknown>>;
246 if (
247 typeof account.accountKey !== "string" ||
248 !isFiniteNumber(account.sinceMs) ||
249 !isFiniteNumber(account.untilMs) ||
250 !isFiniteNumber(account.fetchedAtMs) ||
251 !Array.isArray(account.records)
252 ) {
253 continue;
254 }
255 const records: UsageRecord[] = [];
256 for (const row of account.records) {
257 const record = decodeRecord(row);
258 if (record === null) break;
259 records.push(record);
260 }
261 if (records.length !== account.records.length) continue;
262 caches.set(credentialKey, {
263 accountKey: account.accountKey,
264 sinceMs: account.sinceMs,
265 untilMs: account.untilMs,
266 fetchedAtMs: account.fetchedAtMs,
267 records,
268 });
269 }
270 return caches;
271}
packages/provider-cursor/src/server/accountUsage.test.ts created+340
...@@ -0,0 +1,340 @@
1// @effect-diagnostics nodeBuiltinImport:off - the reader under test reads a
2// real CLI credential file from disk.
3import * as NodeFSP from "node:fs/promises";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";
8
9import { CursorKeychainTimeoutError } from "./CursorKeychain.ts";
10import { readCursorAccountUsage } from "./accountUsage.ts";
11
12let dir: string;
13
14beforeEach(async () => {
15 dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-"));
16});
17
18afterEach(async () => {
19 await NodeFSP.rm(dir, { recursive: true, force: true });
20});
21
22describe("Cursor account history", () => {
23 it("reads Cursor account history with the default macOS Keychain login", async () => {
24 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
25 let keychainReads = 0;
26 const result = await readCursorAccountUsage(
27 { kind: "keychain" },
28 0,
29 1781000000000,
30 async (_url, init) => {
31 assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A");
32 return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] });
33 },
34 async () => {
35 keychainReads++;
36 return accessToken;
37 },
38 );
39 assert.strictEqual(keychainReads, 1);
40 assert.isNull(result.error);
41 assert.isFalse(result.missing);
42 assert.isNotNull(result.accountKey);
43 });
44
45 it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => {
46 const authPath = NodePath.join(dir, "auth.json");
47 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
48 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
49 const pages: number[] = [];
50 const signals: AbortSignal[] = [];
51 const request = async (url: string, init: RequestInit) => {
52 assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events");
53 assert.strictEqual(init?.redirect, "error");
54 const headers = new Headers(init?.headers);
55 assert.strictEqual(headers.get("origin"), "https://cursor.com");
56 assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A");
57 const body = JSON.parse(String(init?.body));
58 pages.push(body.page);
59 if (init.signal) signals.push(init.signal);
60 return Response.json({
61 totalUsageEventsCount: 1001,
62 usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({
63 timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000),
64 model: "claude-sonnet-4-5",
65 conversationId: `conversation-${body.page}`,
66 isHeadless: body.page === 2,
67 chargedCents: 0,
68 tokenUsage: {
69 inputTokens: 10,
70 outputTokens: 5,
71 cacheReadTokens: 30,
72 cacheWriteTokens: 2,
73 totalCents: 25,
74 },
75 })),
76 });
77 };
78 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request);
79 assert.isNull(result.error);
80 assert.deepStrictEqual(pages, [1, 2]);
81 assert.lengthOf(signals, 2);
82 assert.notStrictEqual(signals[0], signals[1]);
83 assert.strictEqual(result.records.length, 1001);
84 assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2");
85 assert.deepStrictEqual(result.records[0]?.totals, {
86 uncachedInputTokens: 10,
87 cachedInputTokens: 30,
88 cacheCreationTokens: 2,
89 outputTokens: 5,
90 reasoningTokens: 0,
91 });
92 assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25);
93 assert.isFalse(result.accountKey?.includes("demo") ?? true);
94 });
95
96 it("reads Cursor account history beyond 100 pages", async () => {
97 const authPath = NodePath.join(dir, "auth.json");
98 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
99 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
100 const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null }));
101 let requests = 0;
102 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
103 requests += 1;
104 return Response.json({
105 totalUsageEventsCount: 100_001,
106 usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }],
107 });
108 });
109 assert.isNull(result.error);
110 assert.strictEqual(requests, 101);
111 assert.deepStrictEqual(result.records, []);
112 });
113
114 it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => {
115 const authPath = NodePath.join(dir, "auth.json");
116 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
117 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
118 for (const body of [
119 {},
120 { totalUsageEventsCount: 0 },
121 { totalUsageEventsCount: 0, usageEventsDisplay: [] },
122 ]) {
123 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
124 Response.json(body),
125 );
126 assert.isNull(result.error);
127 assert.deepStrictEqual(result.records, []);
128 assert.isFalse(result.missing);
129 }
130 for (const body of [
131 { error: "upstream error" },
132 { detail: "unknown error envelope" },
133 { totalUsageEventsCount: 0, error: "upstream error" },
134 null,
135 [],
136 "invalid",
137 0,
138 ]) {
139 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
140 Response.json(body),
141 );
142 assert.isNotNull(result.error);
143 assert.deepStrictEqual(result.records, []);
144 }
145 });
146
147 it("requires a terminal Cursor page after a full page reaches the reported count", async () => {
148 const authPath = NodePath.join(dir, "auth.json");
149 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
150 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
151 let requests = 0;
152 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
153 requests++;
154 return Response.json(
155 requests === 1
156 ? {
157 totalUsageEventsCount: 1000,
158 usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({
159 timestamp: String(1780000000000 + index),
160 model: "gpt-5",
161 tokenUsage: { inputTokens: 10, outputTokens: 5 },
162 })),
163 }
164 : { totalUsageEventsCount: 1000 },
165 );
166 });
167 assert.isNull(result.error);
168 assert.strictEqual(result.records.length, 1000);
169 assert.strictEqual(requests, 2);
170 });
171
172 it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => {
173 const authPath = NodePath.join(dir, "auth.json");
174 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
175 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
176 const event = (index: number) => ({
177 timestamp: String(1780000000000 + index),
178 model: "gpt-5",
179 tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 },
180 });
181 for (const total of [2000, 2001]) {
182 let requests = 0;
183 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
184 requests++;
185 return Response.json({
186 totalUsageEventsCount: total,
187 usageEventsDisplay:
188 requests === 1
189 ? Array.from({ length: 1000 }, (_, index) => event(index))
190 : requests === 2
191 ? Array.from({ length: 1000 }, (_, index) => event(999 + index))
192 : [event(1999)],
193 });
194 });
195 assert.isNull(result.error);
196 assert.strictEqual(result.records.length, total);
197 assert.strictEqual(requests, 3);
198 assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999);
199 assert.strictEqual(
200 result.records.filter((record) => record.timestampMs === 1780000000999).length,
201 total === 2000 ? 1 : 2,
202 );
203 assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total);
204 }
205 let requests = 0;
206 const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
207 requests++;
208 return Response.json({
209 totalUsageEventsCount: 1001,
210 usageEventsDisplay:
211 requests === 1
212 ? Array.from({ length: 1000 }, (_, index) => event(index))
213 : [event(500), event(1000)],
214 });
215 });
216 assert.isNotNull(inconsistent.error);
217 assert.deepStrictEqual(inconsistent.records, []);
218 });
219
220 it("does not present truncated Cursor account pages or authentication failures as complete history", async () => {
221 const authPath = NodePath.join(dir, "auth.json");
222 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
223 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
224 const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
225 Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }),
226 );
227 assert.isNotNull(truncated.error);
228 assert.deepStrictEqual(truncated.records, []);
229 const denied = await readCursorAccountUsage(
230 authPath,
231 0,
232 1781000000000,
233 async () => new Response(accessToken, { status: 401 }),
234 );
235 assert.isNotNull(denied.error);
236 assert.isFalse(denied.error?.includes(accessToken) ?? true);
237 assert.deepStrictEqual(denied.records, []);
238 let requested = false;
239 const missing = await readCursorAccountUsage(
240 NodePath.join(dir, "missing.json"),
241 0,
242 1781000000000,
243 async () => {
244 requested = true;
245 return Response.json({});
246 },
247 );
248 assert.isTrue(missing.missing);
249 assert.isFalse(requested);
250 });
251});
252
253describe("readCursorAccountUsage", () => {
254 it("asks for Keychain approval when the prompt goes unanswered", async () => {
255 const result = await readCursorAccountUsage(
256 { kind: "keychain" },
257 0,
258 1,
259 () => Promise.reject(new Error("no network expected")),
260 () => Promise.reject(new CursorKeychainTimeoutError()),
261 );
262 assert.deepStrictEqual(result, {
263 accountKey: null,
264 records: [],
265 missing: false,
266 error: "Allow Keychain access on the Mac running T3 Code, then refresh.",
267 });
268 });
269
270 it("reads the pages behind the first together and keeps them in page order", async () => {
271 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
272 let inFlight = 0;
273 let mostInFlight = 0;
274 const result = await readCursorAccountUsage(
275 { kind: "keychain" },
276 0,
277 1781000000000,
278 async (_url, init) => {
279 const page: number = JSON.parse(String(init.body)).page;
280 inFlight++;
281 mostInFlight = Math.max(mostInFlight, inFlight);
282 // Later pages answer first, so record order cannot come from arrival order.
283 for (let turn = page; turn < 9; turn++) await Promise.resolve();
284 inFlight--;
285 return Response.json({
286 totalUsageEventsCount: 8001,
287 usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({
288 timestamp: String(1780000000000 + (page - 1) * 1000 + index),
289 model: "gpt-5",
290 tokenUsage: { inputTokens: 1 },
291 })),
292 });
293 },
294 async () => accessToken,
295 );
296 assert.isNull(result.error);
297 assert.strictEqual(mostInFlight, 6);
298 assert.deepStrictEqual(
299 result.records.map((record) => record.timestampMs),
300 Array.from({ length: 8001 }, (_, index) => 1780000000000 + index),
301 );
302 });
303
304 it("ends like a page-by-page read when a page fails, leaving no request open", async () => {
305 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
306 const fullPage = {
307 totalUsageEventsCount: 8001,
308 usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })),
309 };
310 // Page 3 fails while the pages after it are still waiting on Cursor.
311 for (const [secondPage, error] of [
312 [() => Response.json(fullPage), "Cursor account usage could not be read."],
313 [() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."],
314 ] as const) {
315 let open = 0;
316 const result = await readCursorAccountUsage(
317 { kind: "keychain" },
318 0,
319 1781000000000,
320 (_url, init) => {
321 const page: number = JSON.parse(String(init.body)).page;
322 if (page === 1) return Promise.resolve(Response.json(fullPage));
323 if (page === 2) return Promise.resolve(secondPage());
324 if (page === 3) return Promise.reject(new Error("connection reset"));
325 open++;
326 return new Promise((_resolve, reject) => {
327 init.signal?.addEventListener("abort", () => {
328 open--;
329 reject(init.signal?.reason);
330 });
331 });
332 },
333 async () => accessToken,
334 );
335 assert.strictEqual(result.error, error);
336 assert.deepStrictEqual(result.records, []);
337 assert.strictEqual(open, 0);
338 }
339 });
340});
packages/provider-cursor/src/server/accountUsage.ts created+300
...@@ -0,0 +1,300 @@
1// Node fs reads CLI credentials, and crypto hashes account IDs for deduplication.
2// @effect-diagnostics nodeBuiltinImport:off
3/**
4 * Cursor account usage from its dashboard API, read with the saved CLI or
5 * Keychain login. The dashboard covers CLI, desktop and headless usage from
6 * every machine on the account.
7 *
8 * @module provider-cursor/server/accountUsage
9 */
10import * as NodeFSP from "node:fs/promises";
11import * as NodeCrypto from "node:crypto";
12import * as NodeTimersPromises from "node:timers/promises";
13
14import type { UsageRecord } from "@t3tools/provider-core/server/usage";
15
16import { isCursorKeychainTimeoutError } from "./CursorKeychain.ts";
17
18function object(value: unknown): Record<string, unknown> {
19 return typeof value === "object" && value !== null && !Array.isArray(value)
20 ? (value as Record<string, unknown>)
21 : {};
22}
23
24function tokens(value: unknown): number {
25 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
26}
27
28/**
29 * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`,
30 * `claude-fable-5-1-thinking-high`) to the base model's rate-table key.
31 * Grok resolves through xAI's first-party entry, which has no bare alias.
32 */
33export function cursorRateModel(model: string): string {
34 const base = model
35 .replace(/^cursor-/, "")
36 .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, "");
37 return base.startsWith("grok-") ? `xai/${base}` : base;
38}
39
40export interface CursorAccountUsageReadResult {
41 readonly accountKey: string | null;
42 readonly records: readonly UsageRecord[];
43 readonly missing: boolean;
44 readonly error: string | null;
45}
46
47const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex");
48
49function canonicalJson(value: unknown): string {
50 if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
51 if (value !== null && typeof value === "object") {
52 return `{${Object.entries(value)
53 .sort(([a], [b]) => a.localeCompare(b))
54 .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
55 .join(",")}}`;
56 }
57 return JSON.stringify(value) ?? "null";
58}
59
60/** Find the longest exact suffix/prefix overlap in linear time. */
61function boundaryOverlap(previous: readonly string[], current: readonly string[]): number {
62 const sequence = [...current, "", ...previous];
63 const lengths = Array.from({ length: sequence.length }, () => 0);
64 for (let index = 1; index < sequence.length; index++) {
65 let length = lengths[index - 1]!;
66 while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!;
67 if (sequence[index] === sequence[length]) length++;
68 lengths[index] = length;
69 }
70 return lengths.at(-1) ?? 0;
71}
72
73/**
74 * Dashboard usage includes headless agents and reports fresh input separately
75 * from cache reads. `keychainToken` is only called for a Keychain login.
76 */
77export async function readCursorAccountUsage(
78 credentialSource: string | { readonly kind: "keychain" },
79 sinceMs: number,
80 endDate: number,
81 request: (url: string, init: RequestInit) => Promise<Response> = globalThis.fetch,
82 keychainToken: () => Promise<string | null> = () =>
83 Promise.reject(new Error("No Keychain reader")),
84): Promise<CursorAccountUsageReadResult> {
85 let accessToken: unknown;
86 try {
87 accessToken =
88 typeof credentialSource === "string"
89 ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken
90 : await keychainToken();
91 } catch (cause) {
92 const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT";
93 return {
94 accountKey: null,
95 records: [],
96 missing,
97 error: missing
98 ? null
99 : typeof credentialSource === "string"
100 ? "Cursor credentials could not be read."
101 : isCursorKeychainTimeoutError(cause)
102 ? "Allow Keychain access on the Mac running T3 Code, then refresh."
103 : "Cursor Keychain credentials could not be read.",
104 };
105 }
106 if (typeof accessToken !== "string" || !accessToken) {
107 return {
108 accountKey: null,
109 records: [],
110 missing: true,
111 error:
112 typeof credentialSource === "string"
113 ? null
114 : "Cursor account history needs a macOS Keychain CLI login on this server.",
115 };
116 }
117 let accountKey: string | null = null;
118 const cancel = new AbortController();
119 try {
120 const payload = accessToken.split(".")[1];
121 const subject = object(
122 JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")),
123 ).sub;
124 if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication");
125 const userId = subject.split("|").at(-1);
126 if (!userId) throw new Error("Invalid authentication");
127 accountKey = accountHash(subject);
128 if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate)
129 throw new Error("Invalid date window");
130 const deadline = AbortSignal.any([cancel.signal, AbortSignal.timeout(60_000)]);
131 const records: UsageRecord[] = [];
132 const occurrences = new Map<string, number>();
133 const pages: unknown[][] = [];
134 let completed = false;
135 const pageSize = 1000;
136 let total: number | undefined;
137 const readPage = async (page: number) => {
138 // A count can include overlapping page boundaries. Allow room to
139 // reconcile them without imposing a fixed account-size limit.
140 if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) {
141 throw new Error("Account usage page limit exceeded");
142 }
143 const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", {
144 method: "POST",
145 redirect: "error",
146 signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]),
147 headers: {
148 "Content-Type": "application/json",
149 Origin: "https://cursor.com",
150 Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`,
151 },
152 body: JSON.stringify({
153 page,
154 pageSize,
155 startDate: String(sinceMs),
156 endDate: String(endDate),
157 }),
158 });
159 if (response.status === 401 || response.status === 403) {
160 return {
161 accountKey,
162 records: [],
163 missing: false,
164 error: "Sign in to Cursor again to read account usage.",
165 };
166 }
167 if (!response.ok) throw new Error("Account usage request failed");
168 const parsed: unknown = await response.json();
169 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
170 throw new Error("Invalid account usage page");
171 }
172 const body = object(parsed);
173 const keys = Object.keys(body);
174 if ("error" in body || "message" in body || "code" in body)
175 throw new Error("Account usage error response");
176 const count = keys.length === 0 ? 0 : body.totalUsageEventsCount;
177 const events =
178 keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount")
179 ? []
180 : body.usageEventsDisplay;
181 if (
182 (count !== undefined &&
183 (typeof count !== "number" ||
184 !Number.isSafeInteger(count) ||
185 count < 0 ||
186 (total !== undefined && count !== total))) ||
187 !Array.isArray(events) ||
188 events.length > pageSize ||
189 (count === undefined && !Array.isArray(body.usageEventsDisplay))
190 ) {
191 throw new Error("Inconsistent account usage page");
192 }
193 if (typeof count === "number") total = count;
194 return events;
195 };
196 // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages
197 // the first one's count implies are requested up to six ahead and consumed in page order.
198 const ahead: ReturnType<typeof readPage>[] = [];
199 for (let page = 1; ; page++) {
200 while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) {
201 const pending = readPage(page + ahead.length);
202 // Handled when its turn comes, or cancelled if the read ends first.
203 pending.catch(() => undefined);
204 ahead.push(pending);
205 }
206 const events = await (ahead.shift() ?? readPage(page));
207 // A rejected login comes back as the finished result instead of a page.
208 if (!Array.isArray(events)) return events;
209 pages.push(events);
210 if (events.length < pageSize) {
211 completed = true;
212 break;
213 }
214 }
215 if (!completed) throw new Error("Account usage page limit exceeded");
216 const rawCount = pages.reduce((sum, page) => sum + page.length, 0);
217 if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages");
218 let removalsRemaining = total === undefined ? 0 : rawCount - total;
219 let previousKeys: string[] = [];
220 for (const events of pages) {
221 const eventKeys =
222 removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : [];
223 const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys));
224 removalsRemaining -= removalCount;
225 previousKeys = eventKeys;
226 for (const raw of events.slice(removalCount)) {
227 const event = object(raw);
228 const usage = object(event.tokenUsage);
229 if (event.tokenUsage === undefined || event.tokenUsage === null) continue;
230 for (const key of [
231 "inputTokens",
232 "outputTokens",
233 "cacheReadTokens",
234 "cacheWriteTokens",
235 "totalCents",
236 ]) {
237 const value = usage[key];
238 if (
239 value !== undefined &&
240 (typeof value !== "number" || !Number.isFinite(value) || value < 0)
241 ) {
242 throw new Error("Invalid account usage totals");
243 }
244 }
245 const timestampMs =
246 typeof event.timestamp === "string" && event.timestamp.trim() !== ""
247 ? Number(event.timestamp)
248 : event.timestamp;
249 if (
250 typeof timestampMs !== "number" ||
251 !Number.isFinite(timestampMs) ||
252 typeof event.model !== "string" ||
253 !event.model
254 )
255 throw new Error("Invalid account usage event");
256 if (timestampMs < sinceMs || timestampMs > endDate) continue;
257 const totals = {
258 uncachedInputTokens: tokens(usage.inputTokens),
259 cachedInputTokens: tokens(usage.cacheReadTokens),
260 cacheCreationTokens: tokens(usage.cacheWriteTokens),
261 outputTokens: tokens(usage.outputTokens),
262 reasoningTokens: 0,
263 };
264 const reportedCostUsd =
265 typeof usage.totalCents === "number" ? usage.totalCents / 100 : null;
266 const sessionId = typeof event.conversationId === "string" ? event.conversationId : "";
267 // No event ID is provided. Preserve identical billed rows with an occurrence index.
268 const key = accountHash(
269 JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]),
270 );
271 const occurrence = occurrences.get(key) ?? 0;
272 occurrences.set(key, occurrence + 1);
273 records.push({
274 provider: "cursor",
275 timestampMs,
276 model: event.model,
277 rateModel: cursorRateModel(event.model),
278 sessionId,
279 totals,
280 reportedCostUsd,
281 speed: "standard",
282 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
283 });
284 }
285 await NodeTimersPromises.setImmediate();
286 }
287 if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries");
288 return { accountKey, records, missing: false, error: null };
289 } catch {
290 return {
291 accountKey,
292 records: [],
293 missing: false,
294 error: "Cursor account usage could not be read.",
295 };
296 } finally {
297 // An early exit leaves the pages requested ahead of it in flight.
298 cancel.abort();
299 }
300}
packages/provider-cursor/src/server/driver.test.ts+6
...@@ -15,6 +15,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";...@@ -15,6 +15,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
15import { CursorDriver } from "./driver.ts";15import { CursorDriver } from "./driver.ts";
16import * as CursorAgentSdk from "./CursorAgentSdk.ts";16import * as CursorAgentSdk from "./CursorAgentSdk.ts";
17import * as CursorSdk from "./CursorSdk.ts";17import * as CursorSdk from "./CursorSdk.ts";
18import * as CursorKeychain from "./CursorKeychain.ts";
18import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";19import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
19import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";20import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
20import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";21import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
...@@ -30,6 +31,11 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe(...@@ -30,6 +31,11 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe(
30 open: () => Effect.die("Maintenance resolution must not open a Cursor session"),31 open: () => Effect.die("Maintenance resolution must not open a Cursor session"),
31 }),32 }),
32 ),33 ),
34 Layer.provideMerge(
35 Layer.succeed(CursorKeychain.CursorKeychain, {
36 accessToken: Effect.die("The driver test must not read the Keychain"),
37 }),
38 ),
33 Layer.provideMerge(39 Layer.provideMerge(
34 Layer.succeed(40 Layer.succeed(
35 ProviderEventLoggers.ProviderEventLoggers,41 ProviderEventLoggers.ProviderEventLoggers,
packages/provider-cursor/src/server/driver.ts+28-2
...@@ -41,6 +41,9 @@ import { makeCursorAuth } from "./auth.ts";...@@ -41,6 +41,9 @@ import { makeCursorAuth } from "./auth.ts";
41import * as CursorCredentialStore from "./credentialStore.ts";41import * as CursorCredentialStore from "./credentialStore.ts";
42import * as CursorAgentSdk from "./CursorAgentSdk.ts";42import * as CursorAgentSdk from "./CursorAgentSdk.ts";
43import * as CursorSdk from "./CursorSdk.ts";43import * as CursorSdk from "./CursorSdk.ts";
44import * as CursorKeychain from "./CursorKeychain.ts";
45import * as CursorUsageAccounts from "./CursorUsageAccounts.ts";
46
44const decodeCursorSettings = Schema.decodeSync(CursorSettings);47const decodeCursorSettings = Schema.decodeSync(CursorSettings);
45const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError);48const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError);
4649
...@@ -57,9 +60,14 @@ export type CursorDriverEnv =...@@ -57,9 +60,14 @@ export type CursorDriverEnv =
57 | Path.Path60 | Path.Path
58 | HttpClient.HttpClient61 | HttpClient.HttpClient
59 | CursorSdk.CursorSdk62 | CursorSdk.CursorSdk
60 | ProviderHost.ProviderHost;63 | ProviderHost.ProviderHost
64 | CursorKeychain.CursorKeychain;
6165
62export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {66export const CursorDriver: ProviderDriver<
67 CursorSettings,
68 CursorDriverEnv,
69 CursorUsageAccounts.CursorUsageAccounts
70> = {
63 driverKind: DRIVER_KIND,71 driverKind: DRIVER_KIND,
64 metadata: {72 metadata: {
65 displayName: "Cursor",73 displayName: "Cursor",
...@@ -67,12 +75,29 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -67,12 +75,29 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
67 },75 },
68 configSchema: CursorSettings,76 configSchema: CursorSettings,
69 defaultConfig: (): CursorSettings => decodeCursorSettings({}),77 defaultConfig: (): CursorSettings => decodeCursorSettings({}),
78 // One account source per environment: the host's Cursor CLI login.
79 usage: {
80 kind: "scan",
81 provider: "cursor",
82 scan: ({ settings, windowStartMs, retentionCutoffMs, awaitRefresh }) =>
83 CursorUsageAccounts.CursorUsageAccounts.pipe(
84 Effect.flatMap((accounts) =>
85 accounts.scan({
86 keychainUsageEnabled: settings.cursorKeychainUsageEnabled,
87 windowStartMs,
88 retentionCutoffMs,
89 awaitRefresh,
90 }),
91 ),
92 ),
93 },
70 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>94 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
71 Effect.gen(function* () {95 Effect.gen(function* () {
72 const host = yield* ProviderHost.ProviderHost;96 const host = yield* ProviderHost.ProviderHost;
73 const fileSystem = yield* FileSystem.FileSystem;97 const fileSystem = yield* FileSystem.FileSystem;
74 const path = yield* Path.Path;98 const path = yield* Path.Path;
75 const httpClient = yield* HttpClient.HttpClient;99 const httpClient = yield* HttpClient.HttpClient;
100 const keychain = yield* CursorKeychain.CursorKeychain;
76 const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner;101 const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner;
77 const processEnv = mergeProviderInstanceEnvironment(environment);102 const processEnv = mergeProviderInstanceEnvironment(environment);
78 const continuationIdentity = defaultProviderContinuationIdentity({103 const continuationIdentity = defaultProviderContinuationIdentity({
...@@ -221,6 +246,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -221,6 +246,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
221 Effect.provideService(HttpClient.HttpClient, httpClient),246 Effect.provideService(HttpClient.HttpClient, httpClient),
222 Effect.provideService(FileSystem.FileSystem, fileSystem),247 Effect.provideService(FileSystem.FileSystem, fileSystem),
223 Effect.provideService(Path.Path, path),248 Effect.provideService(Path.Path, path),
249 Effect.provideService(CursorKeychain.CursorKeychain, keychain),
224 Effect.map(stampSnapshot),250 Effect.map(stampSnapshot),
225 Effect.provide(CursorSdkCatalog.layer),251 Effect.provide(CursorSdkCatalog.layer),
226 );252 );
packages/provider-cursor/src/server/keychainToken.test.ts deleted-43
...@@ -1,43 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { CursorKeychainTimeoutError, makeCachedCursorAccessTokenReader } from "./keychainToken.ts";
4
5describe("Cursor Keychain reader", () => {
6 it("shares concurrent reads and rechecks after the cache expires", async () => {
7 let reads = 0;
8 let time = 0;
9 const read = makeCachedCursorAccessTokenReader(
10 async () => {
11 reads++;
12 return `token-${reads}`;
13 },
14 () => time,
15 );
16 assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]);
17 assert.strictEqual(await read(), "token-1");
18 assert.strictEqual(reads, 1);
19 time = 5 * 60_000;
20 assert.strictEqual(await read(), "token-2");
21 });
22
23 it("gives up on an unanswered prompt and reuses it on the next read", async () => {
24 let reads = 0;
25 let allow: (token: string) => void = () => {};
26 const read = makeCachedCursorAccessTokenReader(
27 () => {
28 reads++;
29 return new Promise((resolve) => {
30 allow = resolve;
31 });
32 },
33 () => 0,
34 1,
35 );
36 const error = await read().catch((cause: unknown) => cause);
37 assert.instanceOf(error, CursorKeychainTimeoutError);
38 const retry = read();
39 allow("token");
40 assert.strictEqual(await retry, "token");
41 assert.strictEqual(reads, 1);
42 });
43});
packages/provider-cursor/src/server/keychainToken.ts deleted-55
...@@ -1,55 +0,0 @@
1import * as NodeModule from "node:module";
2
3const CACHE_MS = 5 * 60_000;
4
5const requireForKeyring = NodeModule.createRequire(import.meta.url);
6
7/** Rejected when nobody answers the macOS Keychain prompt in time. */
8export class CursorKeychainTimeoutError extends Error {
9 constructor() {
10 super("Timed out waiting for Keychain access.");
11 }
12}
13
14/**
15 * Share one Keychain request across usage history and limits in this server process.
16 *
17 * macOS shows the access prompt on the server's own screen, which a remote
18 * client cannot answer, so callers give up after `timeoutMs`. The read stays in
19 * flight: the next call reuses it instead of stacking a second prompt, and picks
20 * up the token once someone allows access.
21 */
22export function makeCachedCursorAccessTokenReader(
23 read: () => Promise<string | null>,
24 now: () => number = Date.now,
25 timeoutMs = 30_000,
26): () => Promise<string | null> {
27 let cached: { token: string; until: number } | null = null;
28 let pending: Promise<string | null> | null = null;
29 return () => {
30 if (cached && cached.until > now()) return Promise.resolve(cached.token);
31 pending ??= read()
32 .then((token) => {
33 cached = token ? { token, until: now() + CACHE_MS } : null;
34 return token;
35 })
36 .finally(() => {
37 pending = null;
38 });
39 const deadline = AbortSignal.timeout(timeoutMs);
40 return Promise.race([
41 pending,
42 new Promise<never>((_, reject) => {
43 deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), {
44 once: true,
45 });
46 }),
47 ]);
48 };
49}
50
51/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */
52export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => {
53 const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring");
54 return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null;
55});
packages/provider-cursor/src/server/usageLimits.test.ts+20-12
...@@ -6,10 +6,19 @@ import * as FileSystem from "effect/FileSystem";...@@ -6,10 +6,19 @@ import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";6import * as Path from "effect/Path";
7import * as HttpClient from "effect/http/HttpClient";7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientResponse from "effect/http/HttpClientResponse";8import * as HttpClientResponse from "effect/http/HttpClientResponse";
9import * as CursorKeychain from "./CursorKeychain.ts";
9import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts";10import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts";
1011
11const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>12const withNodeServices = <A, E>(
12 effect.pipe(Effect.provide(NodeServices.layer));13 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | CursorKeychain.CursorKeychain>,
14 accessToken: CursorKeychain.CursorKeychain["Service"]["accessToken"] = Effect.die(
15 "must not read Keychain",
16 ),
17) =>
18 effect.pipe(
19 Effect.provideService(CursorKeychain.CursorKeychain, { accessToken }),
20 Effect.provide(NodeServices.layer),
21 );
1322
14describe("Cursor usage limits", () => {23describe("Cursor usage limits", () => {
15 const checkedAt = "2026-09-16T00:00:00.000Z";24 const checkedAt = "2026-09-16T00:00:00.000Z";
...@@ -121,9 +130,6 @@ describe("Cursor usage limits", () => {...@@ -121,9 +130,6 @@ describe("Cursor usage limits", () => {
121 ...(token ? { CURSOR_AUTH_TOKEN: token } : {}),130 ...(token ? { CURSOR_AUTH_TOKEN: token } : {}),
122 },131 },
123 false,132 false,
124 async () => {
125 throw new Error("must not read Keychain before opt-in");
126 },
127 ).pipe(133 ).pipe(
128 Effect.provideService(HostProcessPlatform, platform),134 Effect.provideService(HostProcessPlatform, platform),
129 Effect.provideService(135 Effect.provideService(
...@@ -195,7 +201,7 @@ describe("Cursor usage limits", () => {...@@ -195,7 +201,7 @@ describe("Cursor usage limits", () => {
195 it.effect("reads the default macOS Cursor login from Keychain for limits", () =>201 it.effect("reads the default macOS Cursor login from Keychain for limits", () =>
196 Effect.gen(function* () {202 Effect.gen(function* () {
197 const limits = yield* withNodeServices(203 const limits = yield* withNodeServices(
198 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe(204 readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe(
199 Effect.provideService(HostProcessPlatform, "darwin"),205 Effect.provideService(HostProcessPlatform, "darwin"),
200 Effect.provideService(206 Effect.provideService(
201 FileSystem.FileSystem,207 FileSystem.FileSystem,
...@@ -216,6 +222,7 @@ describe("Cursor usage limits", () => {...@@ -216,6 +222,7 @@ describe("Cursor usage limits", () => {
216 }),222 }),
217 ),223 ),
218 ),224 ),
225 Effect.succeed("keychain-token"),
219 );226 );
220 expect(limits.windows[0]?.usedPercent).toBe(42);227 expect(limits.windows[0]?.usedPercent).toBe(42);
221 }),228 }),
...@@ -224,15 +231,18 @@ describe("Cursor usage limits", () => {...@@ -224,15 +231,18 @@ describe("Cursor usage limits", () => {
224 it.effect("reports a Keychain initialization failure without failing the provider refresh", () =>231 it.effect("reports a Keychain initialization failure without failing the provider refresh", () =>
225 Effect.gen(function* () {232 Effect.gen(function* () {
226 const limits = yield* withNodeServices(233 const limits = yield* withNodeServices(
227 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => {234 readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe(
228 throw new Error("Keychain initialization failed");
229 }).pipe(
230 Effect.provideService(HostProcessPlatform, "darwin"),235 Effect.provideService(HostProcessPlatform, "darwin"),
231 Effect.provideService(236 Effect.provideService(
232 HttpClient.HttpClient,237 HttpClient.HttpClient,
233 HttpClient.make(() => Effect.die("must not request limits without a login")),238 HttpClient.make(() => Effect.die("must not request limits without a login")),
234 ),239 ),
235 ),240 ),
241 Effect.fail(
242 new CursorKeychain.CursorKeychainReadError({
243 cause: new Error("Keychain initialization failed"),
244 }),
245 ),
236 );246 );
237 expect(limits.unavailable?.reason).toBe("probeFailed");247 expect(limits.unavailable?.reason).toBe("probeFailed");
238 }),248 }),
...@@ -247,9 +257,7 @@ describe("Cursor usage limits", () => {...@@ -247,9 +257,7 @@ describe("Cursor usage limits", () => {
247 ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }],257 ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }],
248 ] as const) {258 ] as const) {
249 const limits = yield* withNodeServices(259 const limits = yield* withNodeServices(
250 readCursorUsageLimits({ apiEndpoint }, environment, true, async () => {260 readCursorUsageLimits({ apiEndpoint }, environment, true).pipe(
251 throw new Error("must not read Keychain for a custom endpoint");
252 }).pipe(
253 Effect.provideService(HostProcessPlatform, "darwin"),261 Effect.provideService(HostProcessPlatform, "darwin"),
254 Effect.provideService(262 Effect.provideService(
255 HttpClient.HttpClient,263 HttpClient.HttpClient,
packages/provider-cursor/src/server/usageLimits.ts+3-3
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17 makeUnavailableUsageLimits,17 makeUnavailableUsageLimits,
18 makeUsageLimits,18 makeUsageLimits,
19} from "@t3tools/provider-core/server/usageLimits";19} from "@t3tools/provider-core/server/usageLimits";
20import { readMacCursorAccessToken } from "./keychainToken.ts";20import * as CursorKeychain from "./CursorKeychain.ts";
2121
22const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });22const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });
23const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh";23const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh";
...@@ -66,7 +66,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function...@@ -66,7 +66,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function
66 settings: Pick<CursorSettings, "apiEndpoint">,66 settings: Pick<CursorSettings, "apiEndpoint">,
67 environment: NodeJS.ProcessEnv = process.env,67 environment: NodeJS.ProcessEnv = process.env,
68 allowKeychain = false,68 allowKeychain = false,
69 keychainToken: () => Promise<string | null> = readMacCursorAccessToken,
70) {69) {
71 const checkedAt = DateTime.formatIso(yield* DateTime.now);70 const checkedAt = DateTime.formatIso(yield* DateTime.now);
72 return yield* Effect.gen(function* () {71 return yield* Effect.gen(function* () {
...@@ -106,7 +105,8 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function...@@ -106,7 +105,8 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function
106 message: "Cursor account usage requires the default Cursor endpoint when using Keychain.",105 message: "Cursor account usage requires the default Cursor endpoint when using Keychain.",
107 });106 });
108 }107 }
109 token = (yield* Effect.tryPromise(keychainToken))?.trim();108 const keychain = yield* CursorKeychain.CursorKeychain;
109 token = (yield* keychain.accessToken)?.trim();
110 } else if (!token) {110 } else if (!token) {
111 const home =111 const home =
112 (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir();112 (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir();