authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-25 08:26:36-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 06:26:36-05:00
loge5a46d6c5d00b89afba5274a94d42428c8d79763
tree3933ae2cafdf21ea29db7d36092ed9f0ebf29dd8
parent1c1270663880903b16568594c4bf5f322407903a
signature Signed by PGP key B5690EEEBB952194

feat(usage): read cursor, opencode, and antigravity history (#10409)

Co-authored-by: Yash Singh <saiansh2525@gmail.com>

43 files changed, 3049 insertions(+), 129 deletions(-)

apps/mobile/src/features/usage/UsageLimitsPooled.tsx+51-22
......@@ -5,13 +5,15 @@ import {
55 collectLimitAccounts,
66 collectLimitNotices,
77 collectLimitPools,
8 cursorUsageWindowDetails,
9 displayLimitWindows,
810 formatDuration,
911 formatResetsIn,
1012 remainingPercent,
1113 type LimitAccount,
1214 type LimitPoolWindow,
1315} from "@t3tools/shared/usageLimits";
14import { useId, useState } from "react";
16import { Fragment, type ReactNode, useId, useState } from "react";
1517import { Pressable, ScrollView, View } from "react-native";
1618import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
1719import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -70,11 +72,15 @@ function PoolWindowCard({
7072 color,
7173 now,
7274 environmentIds,
75 label,
76 description,
7377}: {
7478 readonly pool: LimitPoolWindow;
7579 readonly color: string;
7680 readonly now: number;
7781 readonly environmentIds: readonly string[] | null;
82 readonly label?: string;
83 readonly description?: string;
7884}) {
7985 const navigation = useNavigation();
8086 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
......@@ -96,7 +102,7 @@ function PoolWindowCard({
96102 <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">
97103 <View className="flex-row items-start justify-between gap-3">
98104 <View className="gap-1">
99 <Text className="text-sm font-t3-medium text-foreground">{pool.label}</Text>
105 <Text className="text-sm font-t3-medium text-foreground">{label ?? pool.label}</Text>
100106 <View className="flex-row items-baseline gap-1.5">
101107 <Text className="text-3xl font-t3-bold tabular-nums text-foreground">
102108 {pool.remainingPercent}%
......@@ -108,6 +114,7 @@ function PoolWindowCard({
108114 <Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>
109115 ) : null}
110116 </View>
117 {description ? <Text className="text-xs text-foreground-muted">{description}</Text> : null}
111118 {nextRefill ? (
112119 <Text className="text-xs tabular-nums text-foreground-muted">
113120 ↻ +{nextRefill.restoresPercent}%{" "}
......@@ -196,10 +203,12 @@ export function UsageLimitsSection({
196203 now,
197204 failedLabels,
198205 selectedEnvironmentIds,
206 cursorPrompt,
199207}: {
200208 readonly now: number;
201209 readonly failedLabels: readonly string[];
202210 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
211 readonly cursorPrompt?: ReactNode;
203212}) {
204213 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
205214 const selected =
......@@ -209,34 +218,54 @@ export function UsageLimitsSection({
209218 const pools = collectLimitPools(collectLimitAccounts(selected), now);
210219 const notices = collectLimitNotices(selected);
211220 const colors = useProviderColors();
221 const cursorPromptAt =
222 Math.max(
223 pools.findIndex((pool) => pool.driver === "codex"),
224 pools.findIndex((pool) => pool.driver === "claudeAgent"),
225 ) + 1;
212226 return (
213227 <View className="gap-6">
214 {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 ? (
228 {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 && !cursorPrompt ? (
215229 <Text className="py-12 text-center text-base text-foreground-muted">
216230 {selected.size === 0
217231 ? "Select an environment to see limits."
218232 : "No provider on the selected environments reports subscription limits."}
219233 </Text>
220234 ) : null}
221 {pools.map((pool) => (
222 <View key={pool.driver} className="gap-3">
223 <View className="flex-row items-center gap-2 px-1">
224 <ProviderIcon provider={pool.driver} size={18} />
225 <Text className="text-base font-t3-medium text-foreground">
226 {DRIVER_LABEL[pool.driver] ?? pool.driver}
227 </Text>
228 </View>
229 {pool.windows.map((window) => (
230 <PoolWindowCard
231 key={`${window.kind}:${window.id}`}
232 pool={window}
233 color={pool.driver === "claudeAgent" ? colors.claude : colors.codex}
234 now={now}
235 environmentIds={selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]}
236 />
237 ))}
238 </View>
239 ))}
235 {pools.map((pool, index) => {
236 const windows = displayLimitWindows(pool);
237 return (
238 <Fragment key={pool.driver}>
239 {index === cursorPromptAt ? cursorPrompt : null}
240 <View className="gap-3">
241 <View className="flex-row items-center gap-2 px-1">
242 <ProviderIcon provider={pool.driver} size={18} />
243 <Text className="text-base font-t3-medium text-foreground">
244 {DRIVER_LABEL[pool.driver] ?? pool.driver}
245 </Text>
246 </View>
247 {windows.map((window) => {
248 const details =
249 pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined;
250 return (
251 <PoolWindowCard
252 key={`${window.kind}:${window.id}`}
253 pool={window}
254 color={pool.driver === "claudeAgent" ? colors.claude : colors.codex}
255 now={now}
256 environmentIds={
257 selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]
258 }
259 label={details?.label}
260 description={details?.description}
261 />
262 );
263 })}
264 </View>
265 </Fragment>
266 );
267 })}
268 {cursorPromptAt === pools.length ? cursorPrompt : null}
240269 {notices.length > 0 || failedLabels.length > 0 ? (
241270 <View
242271 accessible
apps/mobile/src/features/usage/UsageLimitsSection.tsx+9-2
......@@ -294,7 +294,7 @@ export function useRefreshLimits(
294294 const [failedEnvironments, setFailedEnvironments] = useState<
295295 readonly { environmentId: EnvironmentId; label: string }[]
296296 >([]);
297 const refresh = async (automatic = false) => {
297 const refresh = async (automatic = false, afterPending = false) => {
298298 const connected = [...presentations].filter(
299299 ([environmentId, presentation]) =>
300300 presentation.connection.phase === "connected" &&
......@@ -307,6 +307,7 @@ export function useRefreshLimits(
307307 environmentId,
308308 () => refreshProviders({ environmentId, input: {} }),
309309 automatic,
310 afterPending,
310311 );
311312 if (result === undefined) return;
312313 setFailedEnvironments((previous) => [
......@@ -354,5 +355,11 @@ export function useRefreshLimits(
354355 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),
355356 )
356357 .map(({ label }) => label);
357 return { now, refreshing, failedLabels, refresh: refreshManually };
358 return {
359 now,
360 refreshing,
361 failedLabels,
362 refresh: refreshManually,
363 refreshAfterEnable: () => refresh(false, true),
364 };
358365}
apps/mobile/src/features/usage/UsageRouteScreen.tsx+186-4
......@@ -25,9 +25,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
2525
2626import { SegmentedControl } from "../../components/SegmentedControl";
2727import { AppText as Text } from "../../components/AppText";
28import { ProviderIcon } from "../../components/ProviderIcon";
2829import { cn } from "../../lib/cn";
2930import { SettingsScreen } from "../settings/components/SettingsScreen";
3031import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
32import { serverEnvironment } from "../../state/server";
33import { useAtomCommand } from "../../state/use-atom-command";
3134import { SettingsSection } from "../settings/components/SettingsSection";
3235import { UsageDailyChart } from "./UsageDailyChart";
3336import { toggleUsageEnvironment } from "./usageEnvironmentSelection";
......@@ -59,6 +62,7 @@ const METRIC_OPTIONS = [
5962] as const satisfies readonly { value: UsageChartMetric; label: string }[];
6063
6164const CHART_HEIGHT = 180;
65const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";
6266
6367/**
6468 * Two tabs over one screen. Usage is the transcript-derived spend for a
......@@ -97,6 +101,29 @@ export function UsageRouteScreen() {
97101 );
98102 const isFocused = useIsFocused();
99103 const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");
104 const cursorAccessEnvironments = selectedEnvironments.filter((environment) =>
105 environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"),
106 );
107 const refreshAfterCursorEnable = () => {
108 void refresh();
109 void limits.refreshAfterEnable();
110 };
111 const sourceMessages = [
112 ...new Set(
113 selectedEnvironments.flatMap(
114 (environment) =>
115 environment.summary?.sources.flatMap((source) =>
116 source.message &&
117 !source.action &&
118 (source.status === "partial" ||
119 source.status === "failed" ||
120 source.fingerprint.provider === "cursor")
121 ? [source.message]
122 : [],
123 ) ?? [],
124 ),
125 ),
126 ];
100127
101128 const days = useMemo(
102129 () => enumerateDays(window.sinceDay, window.untilDay),
......@@ -251,7 +278,6 @@ export function UsageRouteScreen() {
251278 }
252279 >
253280 <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />
254
255281 <Animated.View
256282 key={tab}
257283 entering={FadeIn.duration(160).reduceMotion(ReduceMotion.System)}
......@@ -262,6 +288,14 @@ export function UsageRouteScreen() {
262288 now={limits.now}
263289 failedLabels={limits.failedLabels}
264290 selectedEnvironmentIds={selectedEnvironmentIds}
291 cursorPrompt={
292 cursorAccessEnvironments.length > 0 ? (
293 <CursorEnableLimits
294 environments={cursorAccessEnvironments}
295 onEnabled={refreshAfterCursorEnable}
296 />
297 ) : null
298 }
265299 />
266300 ) : (
267301 <>
......@@ -301,6 +335,11 @@ export function UsageRouteScreen() {
301335 </Text>
302336 ) : (
303337 <>
338 {sourceMessages.map((message) => (
339 <Text key={message} className="text-sm text-foreground-muted">
340 {message}
341 </Text>
342 ))}
304343 <ChartCard
305344 merged={merged}
306345 days={chartDays}
......@@ -311,7 +350,13 @@ export function UsageRouteScreen() {
311350 isPast24Hours={isPast24Hours}
312351 timeZone={window.timeZone}
313352 />
314 <ProviderSection merged={merged} metric={metric} />
353 <ProviderSection
354 merged={merged}
355 metric={metric}
356 cursorAccessEnvironments={cursorAccessEnvironments}
357 showCursorEnvironment={selectedEnvironments.length > 1}
358 onCursorEnabled={refreshAfterCursorEnable}
359 />
315360 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
316361 <ModelsSection merged={merged} />
317362 </>
......@@ -324,6 +369,110 @@ export function UsageRouteScreen() {
324369 );
325370}
326371
372function CursorEnableAction({
373 environmentId,
374 label,
375 onEnabled,
376 buttonText = "Enable",
377}: {
378 readonly environmentId: EnvironmentId;
379 readonly label: string;
380 readonly onEnabled: () => void;
381 readonly buttonText?: string;
382}) {
383 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
384 label: "enable Cursor account usage",
385 });
386 const [pending, setPending] = useState(false);
387 const enable = async () => {
388 setPending(true);
389 try {
390 const result = await updateSettings({
391 environmentId,
392 input: { patch: { cursorKeychainUsageEnabled: true } },
393 });
394 if (result._tag === "Success") onEnabled();
395 } finally {
396 setPending(false);
397 }
398 };
399 return (
400 <Pressable
401 accessibilityRole="button"
402 accessibilityLabel={`Enable Cursor usage from ${label}`}
403 accessibilityHint={CURSOR_KEYCHAIN_COPY}
404 disabled={pending}
405 onPress={() => void enable()}
406 className="rounded-full bg-primary px-4 py-2"
407 >
408 <Text className="text-sm font-medium text-primary-foreground">{buttonText}</Text>
409 </Pressable>
410 );
411}
412
413function CursorEnableRow({
414 environmentId,
415 label,
416 showEnvironment,
417 bordered,
418 onEnabled,
419}: {
420 readonly environmentId: EnvironmentId;
421 readonly label: string;
422 readonly showEnvironment: boolean;
423 readonly bordered: boolean;
424 readonly onEnabled: () => void;
425}) {
426 const colors = useProviderColors();
427 return (
428 <View
429 className={cn(
430 "flex-row items-center justify-between gap-3 p-4",
431 bordered && "border-t border-border-subtle",
432 )}
433 >
434 <View className="min-w-0 flex-1 flex-row items-center gap-2">
435 <View className="size-2.5 rounded-full" style={{ backgroundColor: colors.cursor }} />
436 <Text className="shrink text-lg text-foreground">
437 Cursor{showEnvironment ? ` · ${label}` : ""}
438 </Text>
439 </View>
440 <CursorEnableAction environmentId={environmentId} label={label} onEnabled={onEnabled} />
441 </View>
442 );
443}
444
445function CursorEnableLimits({
446 environments,
447 onEnabled,
448}: {
449 readonly environments: readonly EnvironmentUsageStatus[];
450 readonly onEnabled: () => void;
451}) {
452 return (
453 <View className="gap-3">
454 <View className="flex-row items-center gap-2 px-1">
455 <ProviderIcon provider="cursor" size={18} />
456 <Text className="text-base font-t3-medium text-foreground">Cursor</Text>
457 </View>
458 <View className="items-start gap-3 rounded-[24px] border-continuous bg-card p-4">
459 <Text className="text-xs text-foreground-muted">{CURSOR_KEYCHAIN_COPY}</Text>
460 <View className="flex-row flex-wrap gap-2">
461 {environments.map((environment) => (
462 <CursorEnableAction
463 key={environment.environmentId}
464 environmentId={environment.environmentId}
465 label={environment.label}
466 buttonText={environments.length > 1 ? `Enable on ${environment.label}` : "Enable"}
467 onEnabled={onEnabled}
468 />
469 ))}
470 </View>
471 </View>
472 </View>
473 );
474}
475
327476/** Headline figure, the animated daily chart, and its legend, in one card. */
328477function ChartCard(props: {
329478 readonly merged: MergedUsage;
......@@ -400,20 +549,53 @@ function ChartCard(props: {
400549function ProviderSection(props: {
401550 readonly merged: MergedUsage;
402551 readonly metric: UsageChartMetric;
552 readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[];
553 readonly showCursorEnvironment: boolean;
554 readonly onCursorEnabled: () => void;
403555}) {
404556 const { merged, metric } = props;
405557 const colors = useProviderColors();
406 if (merged.providers.length === 0) return null;
558 if (merged.providers.length === 0 && props.cursorAccessEnvironments.length === 0) return null;
407559
408560 // Ranked by whatever the toggle is showing, so the rows always descend.
409561 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method.
410562 const ordered = [...merged.providers].sort((a, b) =>
411563 metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
412564 );
565 const rows: Array<
566 | { readonly kind: "usage"; readonly provider: (typeof ordered)[number] }
567 | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus }
568 > = ordered.map((provider) => ({ kind: "usage", provider }));
569 const cursorInsertAt =
570 Math.max(
571 ordered.findIndex((provider) => provider.provider === "codex"),
572 ordered.findIndex((provider) => provider.provider === "claude"),
573 ) + 1;
574 rows.splice(
575 cursorInsertAt,
576 0,
577 ...props.cursorAccessEnvironments.map((environment) => ({
578 kind: "enable" as const,
579 environment,
580 })),
581 );
413582
414583 return (
415584 <SettingsSection title="Providers">
416 {ordered.map((provider, index) => {
585 {rows.map((row, index) => {
586 if (row.kind === "enable") {
587 return (
588 <CursorEnableRow
589 key={`enable:${row.environment.environmentId}`}
590 environmentId={row.environment.environmentId}
591 label={row.environment.label}
592 showEnvironment={props.showCursorEnvironment}
593 bordered={index > 0}
594 onEnabled={props.onCursorEnabled}
595 />
596 );
597 }
598 const provider = row.provider;
417599 const share = metric === "cost" ? provider.costShare : provider.tokenShare;
418600 return (
419601 <View
apps/mobile/src/features/usage/usageProviders.ts+14-1
......@@ -5,12 +5,22 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
55 * Series and table order. The chart stacks providers from the bottom in this
66 * order, so it also fixes which band sits on top of the bars.
77 */
8export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"];
8export const PROVIDER_ORDER: readonly UsageProviderKind[] = [
9 "codex",
10 "claude",
11 "grok",
12 "cursor",
13 "opencode",
14 "antigravity",
15];
916
1017export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
1118 claude: "Claude Code",
1219 codex: "Codex",
1320 grok: "Grok Build",
21 cursor: "Cursor",
22 opencode: "OpenCode",
23 antigravity: "Antigravity",
1424};
1525
1626/**
......@@ -23,5 +33,8 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
2333 claude: "#d97757",
2434 codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
2535 grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
36 cursor: "#8b8b8b",
37 opencode: "#5b9bbd",
38 antigravity: "#8c7bd1",
2639 };
2740}
apps/server/package.json+1
......@@ -27,6 +27,7 @@
2727 "@effect/platform-node": "catalog:",
2828 "@effect/platform-node-shared": "catalog:",
2929 "@ff-labs/fff-node": "0.9.4",
30 "@napi-rs/keyring": "^1.3.0",
3031 "@opencode-ai/sdk": "^1.3.15",
3132 "diff": "8.0.3",
3233 "effect": "catalog:",
apps/server/src/provider/Drivers/CursorDriver.ts+9-3
......@@ -148,9 +148,15 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
148148 snapshot.auth.status === "authenticated"
149149 ),
150150 (snapshot) =>
151 readCursorUsageLimits(effectiveConfig, processEnv).pipe(
152 Effect.map((usageLimits) => ({ ...snapshot, usageLimits })),
153 ),
151 Effect.gen(function* () {
152 const settings = yield* serverSettings.getSettings;
153 const usageLimits = yield* readCursorUsageLimits(
154 effectiveConfig,
155 processEnv,
156 settings.cursorKeychainUsageEnabled,
157 );
158 return { ...snapshot, usageLimits };
159 }),
154160 ),
155161 Effect.map(stampIdentity),
156162 Effect.provideService(HttpClient.HttpClient, httpClient),
apps/server/src/provider/Layers/CursorProvider.test.ts+74-5
......@@ -991,21 +991,21 @@ describe("Cursor usage limits", () => {
991991 {
992992 id: "totalPercentUsed",
993993 kind: "monthly",
994 label: "Monthly",
994 label: "Overall",
995995 usedPercent: 72.4,
996996 resetsAt: "2026-09-20T03:53:06.000Z",
997997 },
998998 {
999999 id: "autoPercentUsed",
10001000 kind: "monthly",
1001 label: "Monthly · Auto",
1001 label: "Cursor Models",
10021002 usedPercent: 69.5,
10031003 resetsAt: "2026-09-20T03:53:06.000Z",
10041004 },
10051005 {
10061006 id: "apiPercentUsed",
10071007 kind: "monthly",
1008 label: "Monthly · API",
1008 label: "Other Models",
10091009 usedPercent: 100,
10101010 resetsAt: "2026-09-20T03:53:06.000Z",
10111011 },
......@@ -1019,10 +1019,10 @@ describe("Cursor usage limits", () => {
10191019 );
10201020 expect(
10211021 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows,
1022 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 0 }]);
1022 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]);
10231023 expect(
10241024 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows,
1025 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 100 }]);
1025 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]);
10261026 });
10271027
10281028 it("reads the instance's credentials and endpoint even when usage enabled is false", async () => {
......@@ -1079,6 +1079,10 @@ describe("Cursor usage limits", () => {
10791079 AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default",
10801080 ...(token ? { CURSOR_AUTH_TOKEN: token } : {}),
10811081 },
1082 false,
1083 async () => {
1084 throw new Error("must not read Keychain before opt-in");
1085 },
10821086 ).pipe(
10831087 Effect.provideService(HostProcessPlatform, platform),
10841088 Effect.provideService(
......@@ -1108,6 +1112,71 @@ describe("Cursor usage limits", () => {
11081112 }
11091113 });
11101114
1115 it("reads the default macOS Cursor login from Keychain for limits", async () => {
1116 const limits = await runNode(
1117 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe(
1118 Effect.provideService(HostProcessPlatform, "darwin"),
1119 Effect.provideService(
1120 FileSystem.FileSystem,
1121 FileSystem.makeNoop({
1122 readFileString: () => Effect.die("must not read a stale credential file"),
1123 }),
1124 ),
1125 Effect.provideService(
1126 HttpClient.HttpClient,
1127 HttpClient.make((request) => {
1128 expect(request.headers.authorization).toBe("Bearer keychain-token");
1129 return Effect.succeed(
1130 HttpClientResponse.fromWeb(
1131 request,
1132 Response.json({ planUsage: { totalPercentUsed: 42 } }),
1133 ),
1134 );
1135 }),
1136 ),
1137 ),
1138 );
1139 expect(limits.windows[0]?.usedPercent).toBe(42);
1140 });
1141
1142 it("reports a Keychain initialization failure without failing the provider refresh", async () => {
1143 const limits = await runNode(
1144 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => {
1145 throw new Error("Keychain initialization failed");
1146 }).pipe(
1147 Effect.provideService(HostProcessPlatform, "darwin"),
1148 Effect.provideService(
1149 HttpClient.HttpClient,
1150 HttpClient.make(() => Effect.die("must not request limits without a login")),
1151 ),
1152 ),
1153 );
1154 expect(limits.unavailable?.reason).toBe("probeFailed");
1155 });
1156
1157 it("does not read Keychain or send its token to a custom endpoint", async () => {
1158 for (const [apiEndpoint, environment] of [
1159 ["http://localhost:3000", {}],
1160 ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }],
1161 ["https://cursor-proxy.example", {}],
1162 ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }],
1163 ] as const) {
1164 const limits = await runNode(
1165 readCursorUsageLimits({ apiEndpoint }, environment, true, async () => {
1166 throw new Error("must not read Keychain for a custom endpoint");
1167 }).pipe(
1168 Effect.provideService(HostProcessPlatform, "darwin"),
1169 Effect.provideService(
1170 HttpClient.HttpClient,
1171 HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")),
1172 ),
1173 ),
1174 );
1175 expect(limits.unavailable?.reason).toBe("unsupported");
1176 expect(limits.unavailable?.message).toContain("default Cursor endpoint");
1177 }
1178 });
1179
11111180 it("reports failed requests without exposing credentials or response bodies", async () => {
11121181 const limits = await runNode(
11131182 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe(
apps/server/src/provider/Layers/cursorUsageLimits.ts+32-19
......@@ -1,6 +1,7 @@
11import * as NodeOS from "node:os";
22import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts";
33import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits";
45import * as DateTime from "effect/DateTime";
56import * as Effect from "effect/Effect";
67import * as FileSystem from "effect/FileSystem";
......@@ -13,8 +14,10 @@ import {
1314 makeUnavailableUsageLimits,
1415 makeUsageLimits,
1516} from "../providerUsageLimits.ts";
17import { readMacCursorAccessToken } from "../cursorCredentialStore.ts";
1618
1719const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });
20const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh";
1821const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials));
1922const CursorUsageResponse = Schema.Struct({
2023 billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
......@@ -39,15 +42,11 @@ export function cursorUsageResponseToLimits(
3942 : undefined;
4043 const windows: ServerProviderUsageWindow[] = [];
4144 if (response.planUsage) {
42 for (const [key, label] of [
43 ["totalPercentUsed", "Monthly"],
44 ["autoPercentUsed", "Monthly · Auto"],
45 ["apiPercentUsed", "Monthly · API"],
46 ] as const) {
47 const usedPercent = response.planUsage[key];
45 for (const { id, label } of CURSOR_USAGE_WINDOWS) {
46 const usedPercent = response.planUsage[id];
4847 if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue;
4948 windows.push({
50 id: key,
49 id,
5150 kind: "monthly",
5251 label,
5352 usedPercent: clampPercent(usedPercent),
......@@ -63,30 +62,49 @@ export function cursorUsageResponseToLimits(
6362export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* (
6463 settings: Pick<CursorSettings, "apiEndpoint">,
6564 environment: NodeJS.ProcessEnv = process.env,
65 allowKeychain = false,
66 keychainToken: () => Promise<string | null> = readMacCursorAccessToken,
6667) {
6768 const checkedAt = DateTime.formatIso(yield* DateTime.now);
6869 return yield* Effect.gen(function* () {
6970 const fs = yield* FileSystem.FileSystem;
7071 const path = yield* Path.Path;
7172 const platform = yield* HostProcessPlatform;
73 const endpoint = (
74 settings.apiEndpoint.trim() ||
75 environment.CURSOR_API_ENDPOINT?.trim() ||
76 DEFAULT_CURSOR_API_ENDPOINT
77 ).replace(/\/$/, "");
7278 let token = environment.CURSOR_AUTH_TOKEN?.trim();
7379 // An explicit API key can name a different account from the stored login.
7480 if (!token && environment.CURSOR_API_KEY?.trim()) {
7581 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
7682 }
7783 const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE;
78 if (
79 !token &&
80 (credentialStore === "memory" || (platform === "darwin" && credentialStore !== "file"))
81 ) {
82 // Cursor's default macOS login lives in the keychain; a leftover file may be another account.
84 if (!token && credentialStore === "memory") {
8385 return makeUnavailableUsageLimits({
8486 checkedAt,
8587 reason: "unsupported",
86 message: "Cursor usage requires a file-based login or CURSOR_AUTH_TOKEN.",
88 message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.",
8789 });
8890 }
89 if (!token) {
91 if (!token && platform === "darwin" && credentialStore !== "file") {
92 if (!allowKeychain) {
93 return makeUnavailableUsageLimits({
94 checkedAt,
95 reason: "unsupported",
96 message: "Enable Cursor account usage in T3 Code to read its Keychain login.",
97 });
98 }
99 if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) {
100 return makeUnavailableUsageLimits({
101 checkedAt,
102 reason: "unsupported",
103 message: "Cursor account usage requires the default Cursor endpoint when using Keychain.",
104 });
105 }
106 token = (yield* Effect.tryPromise(keychainToken))?.trim();
107 } else if (!token) {
90108 const home =
91109 (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir();
92110 const directory =
......@@ -106,11 +124,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function
106124 }
107125 if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
108126 const client = yield* HttpClient.HttpClient;
109 const endpoint = (
110 settings.apiEndpoint.trim() ||
111 environment.CURSOR_API_ENDPOINT?.trim() ||
112 "https://api2.cursor.sh"
113 ).replace(/\/$/, "");
114127 const response = yield* client.execute(
115128 HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe(
116129 HttpClientRequest.bearerToken(token),
apps/server/src/provider/cursorCredentialStore.test.ts created+22
......@@ -0,0 +1,22 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.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});
apps/server/src/provider/cursorCredentialStore.ts created+29
......@@ -0,0 +1,29 @@
1const CACHE_MS = 5 * 60_000;
2
3/** Share one Keychain request across usage history and limits in this server process. */
4export function makeCachedCursorAccessTokenReader(
5 read: () => Promise<string | null>,
6 now: () => number = Date.now,
7): () => Promise<string | null> {
8 let cached: { token: string; until: number } | null = null;
9 let pending: Promise<string | null> | null = null;
10 return () => {
11 if (cached && cached.until > now()) return Promise.resolve(cached.token);
12 if (pending) return pending;
13 pending = read()
14 .then((token) => {
15 cached = token ? { token, until: now() + CACHE_MS } : null;
16 return token;
17 })
18 .finally(() => {
19 pending = null;
20 });
21 return pending;
22 };
23}
24
25/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */
26export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => {
27 const { AsyncEntry } = await import("@napi-rs/keyring");
28 return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null;
29});
apps/server/src/usage/UsageService.test.ts+209-4
......@@ -3,10 +3,11 @@
33import * as NodeFSP from "node:fs/promises";
44import * as NodeOS from "node:os";
55import * as NodePath from "node:path";
6import * as NodeSqlite from "node:sqlite";
67
78import { assert, describe, it } from "@effect/vitest";
89import * as NodeServices from "@effect/platform-node/NodeServices";
9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
10import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1011import { mergeUsage } from "@t3tools/shared/usageMerge";
1112import {
1213 EnvironmentId,
......@@ -31,6 +32,7 @@ import * as ServerConfig from "../config.ts";
3132import * as ServerSettings from "../serverSettings.ts";
3233import * as UsageService from "./UsageService.ts";
3334
35const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
3436const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
3537
3638function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {
......@@ -82,9 +84,11 @@ const serviceLayers = (input: {
8284 /** Defaults to an unparsable document so every scan retries the fetch. */
8385 readonly ratesDocument?: unknown;
8486 readonly environment?: NodeJS.ProcessEnv;
87 readonly platform?: NodeJS.Platform;
8588}) =>
8689 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(
8790 Layer.provideMerge(NodeServices.layer),
91 Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")),
8892 Layer.provideMerge(ServerSettings.layerTest(input.settings)),
8993 Layer.provideMerge(
9094 Layer.succeed(
......@@ -101,7 +105,12 @@ const serviceLayers = (input: {
101105 ),
102106 Layer.provideMerge(
103107 Layer.succeed(HostProcessEnvironment, {
108 HOME: input.home,
104109 GROK_HOME: NodePath.join(input.home, "grok"),
110 OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"),
111 ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"),
112 XDG_CONFIG_HOME: NodePath.join(input.home, "config"),
113 APPDATA: NodePath.join(input.home, "config"),
105114 ...input.environment,
106115 }),
107116 ),
......@@ -112,6 +121,193 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
112121}
113122
114123describe("UsageService", () => {
124 it.live("does not read the macOS Cursor Keychain before account usage is enabled", () =>
125 Effect.gen(function* () {
126 const { settings, home } = yield* setup;
127 const service = yield* UsageService.make.pipe(
128 Effect.provide(
129 serviceLayers({
130 prefix: "usage-service-cursor-keychain-disabled",
131 home,
132 settings,
133 platform: "darwin",
134 environment: {},
135 }),
136 ),
137 );
138 const summary = yield* service.readSummary(WINDOW);
139 const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor");
140 assert.strictEqual(cursor?.status, "missing");
141 assert.strictEqual(cursor?.action, "enableCursorKeychain");
142 }).pipe(Effect.scoped),
143 );
144
145 it.live("ignores stale Cursor file logins when the active credential store differs", () =>
146 Effect.gen(function* () {
147 const { settings, home } = yield* setup;
148 for (const [index, testCase] of [
149 {
150 platform: "darwin" as const,
151 environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" },
152 authPath: [".cursor", "auth.json"],
153 },
154 {
155 platform: "linux" as const,
156 environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" },
157 authPath: ["config", "cursor", "auth.json"],
158 },
159 {
160 platform: "linux" as const,
161 environment: { CURSOR_API_KEY: "different-account" },
162 authPath: ["config", "cursor", "auth.json"],
163 },
164 ].entries()) {
165 const authPath = NodePath.join(home, ...testCase.authPath);
166 yield* Effect.promise(async () => {
167 await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true });
168 await NodeFSP.writeFile(
169 authPath,
170 encodeUnknownJsonString({ accessToken: "stale-token" }),
171 );
172 });
173 const service = yield* UsageService.make.pipe(
174 Effect.provide(
175 serviceLayers({
176 prefix: `usage-service-cursor-store-${index}`,
177 home,
178 settings,
179 platform: testCase.platform,
180 environment: testCase.environment,
181 }),
182 ),
183 );
184 const summary = yield* service.readSummary(WINDOW);
185 const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor");
186 assert.strictEqual(cursor?.status, "missing");
187 assert.include(cursor?.message ?? "", "Cursor CLI login");
188 assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor"));
189 }
190 }).pipe(Effect.scoped),
191 );
192
193 it.live(
194 "includes OpenCode history but does not substitute desktop usage for an unavailable Cursor account",
195 () =>
196 Effect.gen(function* () {
197 const { settings, home } = yield* setup;
198 const root = NodePath.join(home, "opencode");
199 const message = yield* encodeUnknownJson({
200 id: "msg_1",
201 sessionID: "session-1",
202 role: "assistant",
203 modelID: "example-model",
204 time: { created: Date.parse("2026-08-01T10:00:00Z") },
205 tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 3 } },
206 });
207 const bubble = yield* encodeUnknownJson({
208 type: 2,
209 createdAt: "2026-08-01T10:00:00Z",
210 modelInfo: { modelName: "example-model" },
211 tokenCount: { inputTokens: 100, outputTokens: 20 },
212 });
213 yield* Effect.promise(async () => {
214 const directory = NodePath.join(root, "storage", "message", "session-1");
215 await NodeFSP.mkdir(directory, { recursive: true });
216 await NodeFSP.writeFile(NodePath.join(directory, "msg_1.json"), message);
217 const desktop = NodePath.join(home, "config", "Cursor", "User", "globalStorage");
218 await NodeFSP.mkdir(desktop, { recursive: true });
219 const db = new NodeSqlite.DatabaseSync(NodePath.join(desktop, "state.vscdb"));
220 try {
221 db.exec("CREATE TABLE cursorDiskKV (key TEXT, value TEXT)");
222 db.prepare("INSERT INTO cursorDiskKV VALUES (?, ?)").run(
223 "bubbleId:session:assistant",
224 bubble,
225 );
226 } finally {
227 db.close();
228 }
229 });
230 const service = yield* UsageService.make.pipe(
231 Effect.provide(serviceLayers({ prefix: "usage-service-opencode", home, settings })),
232 );
233 const summary = yield* service.readSummary(WINDOW);
234 assert.strictEqual(summary.buckets[0]?.provider, "opencode");
235 assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor"));
236 assert.strictEqual(
237 summary.sources.find((source) => source.fingerprint.provider === "cursor")?.status,
238 "missing",
239 );
240 assert.strictEqual(
241 summary.buckets[0]?.sourcePath,
242 yield* Effect.promise(() => NodeFSP.realpath(root)),
243 );
244 assert.strictEqual(summary.buckets[0]?.totals.outputTokens, 7);
245 assert.strictEqual(
246 summary.sources.find((source) => source.fingerprint.provider === "opencode")
247 ?.distinctSessions,
248 1,
249 );
250 assert.include(
251 summary.sources.find((source) => source.fingerprint.provider === "cursor")?.message ?? "",
252 "Cursor account history needs a Cursor CLI login",
253 );
254 }).pipe(Effect.scoped),
255 );
256
257 it.live("counts aliased OpenCode and Antigravity directories once", () =>
258 Effect.gen(function* () {
259 const { settings, home } = yield* setup;
260 const opencode = NodePath.join(home, "opencode-store");
261 const opencodeAlias = NodePath.join(home, "opencode-alias");
262 const conversations = NodePath.join(home, "antigravity-conversations");
263 const antigravityA = NodePath.join(home, "antigravity-a");
264 const antigravityB = NodePath.join(home, "antigravity-b");
265 yield* Effect.promise(async () => {
266 await NodeFSP.mkdir(opencode);
267 await NodeFSP.symlink(opencode, opencodeAlias, "junction");
268 await NodeFSP.mkdir(conversations);
269 await NodeFSP.mkdir(antigravityA);
270 await NodeFSP.mkdir(antigravityB);
271 await NodeFSP.symlink(
272 conversations,
273 NodePath.join(antigravityA, "conversations"),
274 "junction",
275 );
276 await NodeFSP.symlink(
277 conversations,
278 NodePath.join(antigravityB, "conversations"),
279 "junction",
280 );
281 });
282 const service = yield* UsageService.make.pipe(
283 Effect.provide(
284 serviceLayers({
285 prefix: "usage-service-aliased-roots-test",
286 home,
287 settings,
288 environment: {
289 OPENCODE_DATA_DIR: `${opencode},${opencodeAlias}`,
290 ANTIGRAVITY_DATA_DIR: `${antigravityA},${antigravityB}`,
291 },
292 }),
293 ),
294 );
295 const summary = yield* service.readSummary(WINDOW);
296 const sourcesFor = (provider: "opencode" | "antigravity") =>
297 summary.sources.filter((source) => source.fingerprint.provider === provider);
298 assert.strictEqual(sourcesFor("opencode").length, 1);
299 assert.strictEqual(sourcesFor("antigravity").length, 1);
300 assert.strictEqual(
301 sourcesFor("opencode")[0]?.fingerprint.resolvedHomePath,
302 yield* Effect.promise(() => NodeFSP.realpath(opencode)),
303 );
304 assert.strictEqual(
305 sourcesFor("antigravity")[0]?.fingerprint.resolvedHomePath,
306 yield* Effect.promise(() => NodeFSP.realpath(conversations)),
307 );
308 }).pipe(Effect.scoped),
309 );
310
115311 it.live("reads configured and disabled accounts once across shared and aliased homes", () =>
116312 Effect.gen(function* () {
117313 const { transcript, settings, home } = yield* setup;
......@@ -258,9 +454,12 @@ describe("UsageService", () => {
258454 const service = yield* UsageService.make;
259455 const first = yield* service.readSummary(WINDOW);
260456 assert.strictEqual(totalOutputTokens(first), 7);
457 const configuredProjects = yield* Effect.promise(() =>
458 NodeFSP.realpath(NodePath.join(configured, "projects")),
459 );
261460 assert.include(
262461 first.sources.map((source) => source.fingerprint.resolvedHomePath),
263 NodePath.join(configured, "projects"),
462 configuredProjects,
264463 );
265464 yield* settingsService.updateSettings({
266465 providerInstances: {
......@@ -275,9 +474,12 @@ describe("UsageService", () => {
275474 });
276475 const second = yield* service.readSummary(WINDOW);
277476 assert.strictEqual(totalOutputTokens(second), 8);
477 const environmentProjects = yield* Effect.promise(() =>
478 NodeFSP.realpath(NodePath.join(environmentHome, "projects")),
479 );
278480 assert.include(
279481 second.sources.map((source) => source.fingerprint.resolvedHomePath),
280 NodePath.join(environmentHome, "projects"),
482 environmentProjects,
281483 );
282484 }).pipe(
283485 Effect.provide(
......@@ -505,6 +707,9 @@ describe("UsageService", () => {
505707 Effect.gen(function* () {
506708 const { transcript, settings, home } = yield* setup;
507709 yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model")));
710 const transcriptDir = yield* Effect.promise(() =>
711 NodeFSP.realpath(NodePath.join(home, "claude", "projects")),
712 );
508713
509714 yield* Effect.gen(function* () {
510715 const settingsService = yield* ServerSettings.ServerSettingsService;
......@@ -519,7 +724,7 @@ describe("UsageService", () => {
519724 exists: (path) =>
520725 fileSystem.exists(path).pipe(
521726 Effect.tap(() => {
522 if (path !== NodePath.join(home, "claude", "projects")) return Effect.void;
727 if (path !== transcriptDir) return Effect.void;
523728 homeProbes += 1;
524729 return Deferred.succeed(
525730 homeProbes === 1 ? firstScanStarted : secondScanStarted,
apps/server/src/usage/UsageService.ts+201-11
......@@ -1,14 +1,14 @@
11/**
22 * UsageService - scans provider transcripts and returns priced usage buckets.
33 *
4 * The scan reads the provider CLIs' own session files (Claude Code, Codex, and
5 * Grok Build) rather than T3 Code's orchestration projections, so usage covers
6 * turns driven outside T3 Code too. This is the approach `ccusage` takes.
4 * The scan reads native session files and databases, including work driven
5 * outside T3 Code. Cursor's local records provide only partial coverage.
76 *
8 * Transcripts are append-only, so parsed records are memoised per file by
7 * JSONL transcripts are append-only, so parsed records are memoised per file by
98 * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm
109 * scans only reparse files that changed, and a file that merely grew resumes
1110 * from its cached parse position so only the appended bytes are read.
11 * SQLite readers query live databases each scan so WAL writes remain visible.
1212 *
1313 * @module UsageService
1414 */
......@@ -19,6 +19,7 @@ import {
1919 CodexSettings,
2020 type ProviderInstanceConfig,
2121 USAGE_CONTRACT_VERSION,
22 ProviderInstanceId,
2223 type ServerSettings as ServerSettingsValue,
2324 type UsageProviderKind,
2425 type UsageSource,
......@@ -27,10 +28,11 @@ import {
2728 type UsageSummaryInput,
2829 UsageReadError,
2930} from "@t3tools/contracts";
30import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
31import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
3132import * as Cause from "effect/Cause";
3233import * as Clock from "effect/Clock";
3334import * as Context from "effect/Context";
35import * as Crypto from "effect/Crypto";
3436import * as DateTime from "effect/DateTime";
3537import * as Deferred from "effect/Deferred";
3638import * as Effect from "effect/Effect";
......@@ -46,7 +48,11 @@ import { ServerConfig } from "../config.ts";
4648import { expandHomePath } from "../pathExpansion.ts";
4749import * as ServerSettings from "../serverSettings.ts";
4850import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
51import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts";
4952import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
53import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
54import { readAntigravityUsage } from "./antigravityUsageReader.ts";
55import { readCursorAccountUsage } from "./cursorUsageReader.ts";
5056import { UsageAggregator } from "./usageAggregation.ts";
5157import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
5258import {
......@@ -144,12 +150,14 @@ export const layerTest = Layer.succeed(
144150);
145151
146152export const make = Effect.gen(function* () {
153 const crypto = yield* Crypto.Crypto;
147154 const fileSystem = yield* FileSystem.FileSystem;
148155 const path = yield* Path.Path;
149156 const config = yield* ServerConfig;
150157 const settingsService = yield* ServerSettings.ServerSettingsService;
151158 const httpClient = yield* HttpClient.HttpClient;
152159 const hostEnvironment = yield* HostProcessEnvironment;
160 const platform = yield* HostProcessPlatform;
153161
154162 const fileCache: ScanCache = new Map();
155163 const sourceCache = new Map<string, typeof CachedSource.Type>();
......@@ -446,6 +454,10 @@ export const make = Effect.gen(function* () {
446454 readonly provider: UsageProviderKind;
447455 readonly dir: string;
448456 readonly volumeId: string;
457 readonly hostId?: string;
458 readonly status?: UsageSource["status"];
459 readonly message?: string;
460 readonly action?: UsageSource["action"];
449461 /** Parsed records per file, or `null` when the directory does not exist. */
450462 readonly files:
451463 | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]
......@@ -481,6 +493,171 @@ export const make = Effect.gen(function* () {
481493 }
482494 scanned.push({ provider, dir, volumeId, files: parsedFiles });
483495 }
496
497 const home = NodeOS.homedir();
498 const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) {
499 const roots = hostEnvironment[key]
500 ?.split(",")
501 .map((value) => value.trim())
502 .filter(Boolean);
503 const canonical = new Set<string>();
504 for (const root of roots?.length ? roots : defaults) {
505 const resolved = path.resolve(expandHomePath(root));
506 canonical.add(
507 yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)),
508 );
509 }
510 return [...canonical];
511 });
512 const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim();
513 for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [
514 path.join(
515 dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"),
516 "opencode",
517 ),
518 ])) {
519 const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs));
520 scanned.push({
521 provider: "opencode",
522 dir,
523 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),
524 files: result.missing && !result.error ? null : result.files,
525 status: result.error ? "partial" : "ok",
526 ...(result.error ? { message: "Some OpenCode history could not be read." } : {}),
527 });
528 }
529 const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [
530 ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) =>
531 path.join(home, ".gemini", name),
532 ),
533 path.join(home, ".config", "antigravity"),
534 ]);
535 for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {
536 if (instance.driver === "antigravity") {
537 const directories = yield* resolveAntigravityInstanceDirectories(
538 config.stateDir,
539 ProviderInstanceId.make(instanceId),
540 ).pipe(
541 Effect.provideService(Crypto.Crypto, crypto),
542 Effect.provideService(Path.Path, path),
543 Effect.mapError(
544 (cause) =>
545 new UsageReadError({
546 reason: "scanFailed",
547 detail: "Antigravity profile directory could not be resolved.",
548 cause,
549 }),
550 ),
551 );
552 antigravityRoots.push(path.join(directories.profile, "antigravity-acp"));
553 }
554 }
555 const antigravityDirs = new Set<string>();
556 for (const root of antigravityRoots) {
557 const resolvedRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root));
558 const nested = path.join(resolvedRoot, "conversations");
559 const dir = (yield* fileSystem
560 .exists(nested)
561 .pipe(Effect.catchCause(() => Effect.succeed(false))))
562 ? nested
563 : resolvedRoot;
564 antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir)));
565 }
566 const antigravity = yield* Effect.promise(() =>
567 readAntigravityUsage([...antigravityDirs], windowStartMs),
568 );
569 for (const dir of antigravityDirs) {
570 const exists = yield* fileSystem
571 .exists(dir)
572 .pipe(Effect.catchCause(() => Effect.succeed(false)));
573 const failed = antigravity.errors.some(
574 (error) => error === dir || error.startsWith(`${dir}${path.sep}`),
575 );
576 scanned.push({
577 provider: "antigravity",
578 dir,
579 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),
580 files: !exists && !failed ? null : antigravity.files.filter((file) => file.root === dir),
581 status: failed ? "partial" : "ok",
582 ...(failed ? { message: "Some Antigravity history could not be read." } : {}),
583 });
584 }
585 const cursorUserHome =
586 (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home;
587 const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim();
588 const cursorHome =
589 platform === "darwin"
590 ? path.join(cursorUserHome, "Library", "Application Support")
591 : platform === "win32"
592 ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming")
593 : configHome && path.isAbsolute(configHome)
594 ? configHome
595 : path.join(cursorUserHome, ".config");
596 const cursorAuthPath =
597 platform === "darwin"
598 ? path.join(cursorUserHome, ".cursor", "auth.json")
599 : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json");
600 const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"];
601 const loginUnavailable =
602 Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) ||
603 Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) ||
604 credentialStore === "memory";
605 if (
606 platform === "darwin" &&
607 credentialStore !== "file" &&
608 !loginUnavailable &&
609 !settings.cursorKeychainUsageEnabled
610 ) {
611 scanned.push({
612 provider: "cursor",
613 dir: cursorAuthPath,
614 volumeId: "",
615 files: null,
616 message: "Cursor account usage is off on this environment.",
617 action: "enableCursorKeychain",
618 });
619 return scanned;
620 }
621 const cursorUntilMs = yield* Clock.currentTimeMillis;
622 const account = loginUnavailable
623 ? {
624 accountKey: null,
625 records: [],
626 missing: true,
627 error: "Cursor account history needs a Cursor CLI login on this server.",
628 }
629 : yield* Effect.promise(() =>
630 readCursorAccountUsage(
631 platform === "darwin" && credentialStore !== "file"
632 ? { kind: "keychain" }
633 : cursorAuthPath,
634 windowStartMs,
635 cursorUntilMs,
636 ),
637 );
638 if (account.accountKey !== null && account.error === null && !account.missing) {
639 // The same account includes CLI and desktop history from every machine.
640 // A stable remote fingerprint prevents connected environments counting it twice.
641 const source = `cursor-account:${account.accountKey}`;
642 scanned.push({
643 provider: "cursor",
644 dir: source,
645 hostId: "cursor.com",
646 volumeId: account.accountKey,
647 files: [{ path: source, records: account.records }],
648 status: "ok",
649 });
650 return scanned;
651 }
652 scanned.push({
653 provider: "cursor",
654 dir: cursorAuthPath,
655 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)),
656 // Never combine a local fallback with another server's account-wide history.
657 files: null,
658 message:
659 account.error ?? "Cursor account history needs a Cursor CLI login saved on this server.",
660 });
484661 return scanned;
485662 });
486663
......@@ -555,7 +732,16 @@ export const make = Effect.gen(function* () {
555732
556733 const sources: UsageSource[] = [];
557734
558 for (const { provider, dir, volumeId, files } of scannedDirs) {
735 for (const {
736 provider,
737 dir,
738 volumeId,
739 files,
740 status,
741 message,
742 action,
743 hostId: sourceHostId,
744 } of scannedDirs) {
559745 const retainedFiles = [...(files ?? [])];
560746 const livePaths = new Set(retainedFiles.map((file) => file.path));
561747 // Cleanup may remove transcripts, but the usage we already saved still
......@@ -601,21 +787,23 @@ export const make = Effect.gen(function* () {
601787 }
602788 // Only sessions contributing in-window count; the mtime slack can
603789 // admit boundary files whose records fall outside the range.
604 if (aggregator.add(usageRecord) && record.sessionId.length > 0) {
790 if (aggregator.add(usageRecord, dir) && record.sessionId.length > 0) {
605791 sessionIds.add(record.sessionId);
606792 }
607793 }
608794 }
609795
610796 sources.push({
611 fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
797 fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId },
612798 // Clients exclude missing sources, so saved records remain an available source.
613 status: files === null && scannedFiles === 0 ? "missing" : "ok",
799 status: files === null && scannedFiles === 0 ? "missing" : (status ?? "ok"),
614800 scannedFiles,
615801 skippedFiles,
616802 malformedRecords: 0,
617803 distinctSessions: sessionIds.size,
618 message: files === null ? "No transcript directory on this environment." : null,
804 message:
805 message ?? (files === null ? "No transcript directory on this environment." : null),
806 ...(action ? { action } : {}),
619807 });
620808 }
621809
......@@ -650,6 +838,7 @@ export const make = Effect.gen(function* () {
650838 const scanKey = (
651839 input: UsageSummaryInput,
652840 priceOverrides: ServerSettingsValue["usagePriceOverrides"],
841 cursorKeychainUsageEnabled: boolean,
653842 ): string =>
654843 JSON.stringify([
655844 input.timeZone,
......@@ -659,11 +848,12 @@ export const make = Effect.gen(function* () {
659848 input.sinceTime ?? null,
660849 input.untilTime ?? null,
661850 priceOverrides,
851 cursorKeychainUsageEnabled,
662852 ]);
663853
664854 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
665855 const settings = yield* readSettings;
666 const key = scanKey(input, settings.usagePriceOverrides);
856 const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled);
667857 const deferred = yield* Effect.uninterruptible(
668858 Effect.gen(function* () {
669859 const existing = inflightScans.get(key);
apps/server/src/usage/antigravityUsageReader.ts created+374
......@@ -0,0 +1,374 @@
1// node:sqlite reads live conversation databases while Node fs discovers them.
2// @effect-diagnostics nodeBuiltinImport:off
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as NodeSqlite from "node:sqlite";
6import * as NodeTimersPromises from "node:timers/promises";
7
8import type { UsageRecord } from "./usageTranscripts.ts";
9
10type FieldValue = number | bigint | Uint8Array;
11type Fields = Map<number, FieldValue[]>;
12
13/** Antigravity stores usage metadata as protobuf, independently of conversation text. */
14function fields(bytes: Uint8Array): Fields {
15 let offset = 0;
16 const result: Fields = new Map();
17 const varint = () => {
18 let value = 0n;
19 for (let shift = 0n; shift < 70n; shift += 7n) {
20 const byte = bytes[offset++];
21 if (byte === undefined || (shift === 63n && byte > 1)) {
22 throw new Error("Invalid Antigravity protobuf varint");
23 }
24 value |= BigInt(byte & 127) << shift;
25 if (byte < 128) {
26 return value > BigInt(Number.MAX_SAFE_INTEGER) ? value : Number(value);
27 }
28 }
29 throw new Error("Invalid Antigravity protobuf varint");
30 };
31 while (offset < bytes.length) {
32 const tag = varint();
33 if (typeof tag !== "number") throw new Error("Invalid protobuf field");
34 const number = Math.floor(tag / 8);
35 const wire = tag % 8;
36 if (number === 0) throw new Error("Invalid protobuf field");
37 let value: FieldValue;
38 if (wire === 0) {
39 value = varint();
40 } else if (wire === 1 || wire === 5 || wire === 2) {
41 const length = wire === 2 ? varint() : wire === 1 ? 8 : 4;
42 if (typeof length !== "number") throw new Error("Invalid protobuf field length");
43 if (length > bytes.length - offset) throw new Error("Truncated protobuf field");
44 value = bytes.subarray(offset, offset + length);
45 offset += length;
46 if (wire !== 2) continue;
47 } else {
48 throw new Error("Unsupported protobuf wire type");
49 }
50 const entries = result.get(number) ?? [];
51 entries.push(value);
52 result.set(number, entries);
53 }
54 return result;
55}
56
57const numberAt = (value: Fields, key: number) => {
58 const entry = value.get(key)?.[0];
59 return typeof entry === "number" ? entry : 0;
60};
61const bytesAt = (value: Fields, key: number) => {
62 const entry = value.get(key)?.[0];
63 return entry instanceof Uint8Array ? entry : undefined;
64};
65const nested = (value: Fields, key: number) => {
66 const bytes = bytesAt(value, key);
67 return bytes === undefined ? new Map<number, FieldValue[]>() : fields(bytes);
68};
69const textAt = (value: Fields, key: number) => {
70 const bytes = bytesAt(value, key);
71 return bytes === undefined ? "" : new TextDecoder("utf-8", { fatal: true }).decode(bytes).trim();
72};
73const timestamp = (value: Fields) => {
74 const seconds = numberAt(value, 1);
75 return seconds > 0 ? seconds * 1000 + Math.floor(numberAt(value, 2) / 1_000_000) : null;
76};
77
78const MODEL_IDS: Record<number, string> = {
79 246: "gemini-2.5-pro",
80 312: "gemini-2.5-flash",
81 313: "gemini-2.5-flash-thinking",
82 329: "gemini-2.5-flash-thinking",
83 330: "gemini-2.5-flash-lite",
84 281: "claude-sonnet-4",
85 282: "claude-sonnet-4",
86 290: "claude-opus-4",
87 291: "claude-opus-4",
88 333: "claude-sonnet-4-5",
89 334: "claude-sonnet-4-5",
90 340: "claude-haiku-4-5",
91 341: "claude-haiku-4-5",
92 1026: "claude-opus-4-6",
93 1035: "claude-sonnet-4-6",
94 1016: "gemini-3.1-pro",
95 1036: "gemini-3.1-pro",
96 1037: "gemini-3.1-pro",
97 1018: "gemini-3-flash-preview",
98 1084: "gemini-3-flash-preview",
99 1047: "gemini-3-flash-preview",
100};
101
102function modelName(name: string, id: number): string {
103 if (name) {
104 const normalized = name
105 .toLowerCase()
106 .replace(/\s*\([^)]*\)\s*$/, "")
107 .replaceAll(" ", "-");
108 if (normalized.startsWith("claude-")) {
109 return normalized
110 .replace(/^claude-(4(?:\.\d+)?)-(sonnet|opus|haiku)/, "claude-$2-$1")
111 .replaceAll(".", "-");
112 }
113 return normalized;
114 }
115 return MODEL_IDS[id] ?? (id > 0 ? `antigravity-model-${id}` : "");
116}
117
118interface Metadata {
119 model: string;
120 timestampMs: number | null;
121 usages: Fields[];
122}
123
124function metadata(bytes: Uint8Array, step: boolean): Metadata {
125 const root = fields(bytes);
126 if (!step && bytesAt(root, 1) === undefined) {
127 throw new Error("Missing Antigravity generation metadata");
128 }
129 const data = step ? root : nested(root, 1);
130 const model = step ? nested(data, 24) : data;
131 const usage = bytesAt(data, step ? 9 : 4);
132 const usages = usage === undefined ? [] : [fields(usage)];
133 for (const retry of data.get(step ? 28 : 17) ?? []) {
134 if (!(retry instanceof Uint8Array)) throw new Error("Invalid retry metadata");
135 const retryUsage = bytesAt(fields(retry), 2);
136 if (retryUsage !== undefined) usages.push(fields(retryUsage));
137 }
138 return {
139 model: modelName(
140 textAt(model, step ? 12 : 19) || textAt(model, step ? 8 : 21),
141 numberAt(model, step ? 1 : 3),
142 ),
143 timestampMs: step
144 ? (timestamp(nested(data, 8)) ?? timestamp(nested(data, 1)))
145 : timestamp(nested(nested(data, 9), 4)),
146 usages,
147 };
148}
149
150function blob(value: unknown): Uint8Array {
151 if (!(value instanceof Uint8Array)) throw new Error("Invalid Antigravity metadata blob");
152 return value;
153}
154
155interface UsageCandidate {
156 record: UsageRecord;
157 keys: readonly string[];
158 timestampQuality: number;
159}
160
161async function readDatabase(path: string, fallbackTimestamp: number): Promise<UsageCandidate[]> {
162 const db = new NodeSqlite.DatabaseSync(path, { readOnly: true });
163 try {
164 db.exec("PRAGMA busy_timeout = 100; BEGIN");
165 const tables = new Set(
166 db
167 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
168 .all()
169 .map((row) => row.name),
170 );
171 if (!tables.has("gen_metadata") && !tables.has("steps")) {
172 throw new Error("Missing Antigravity usage tables");
173 }
174 const readMetadata = async (query: string, column: string, step: boolean) => {
175 const entries: Array<{ idx: number; entry: Metadata }> = [];
176 for (const row of db.prepare(query).iterate()) {
177 if (typeof row.idx !== "number") throw new Error("Invalid Antigravity metadata index");
178 entries.push({ idx: row.idx, entry: metadata(blob(row[column]), step) });
179 if (entries.length % 256 === 0) await NodeTimersPromises.setImmediate();
180 }
181 return entries;
182 };
183 const generations = tables.has("gen_metadata")
184 ? await readMetadata("SELECT idx, data FROM gen_metadata ORDER BY idx", "data", false)
185 : [];
186 let trajectoryTimestamp: number | null = null;
187 if (tables.has("trajectory_metadata_blob")) {
188 for (const row of db.prepare("SELECT data FROM trajectory_metadata_blob").iterate()) {
189 trajectoryTimestamp ??= timestamp(nested(fields(blob(row.data)), 2));
190 }
191 }
192 const steps = tables.has("steps")
193 ? await readMetadata(
194 "SELECT idx, metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx",
195 "metadata",
196 true,
197 )
198 : [];
199 const sessionId = NodePath.basename(path, ".db");
200 const records: UsageCandidate[] = [];
201 const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry.model]));
202 for (const [source, entries] of [
203 ["step", steps],
204 ["generation", generations],
205 ] as const) {
206 for (const [index, { idx, entry }] of entries.entries()) {
207 for (const [usageIndex, usage] of entry.usages.entries()) {
208 const outputTokens = Math.max(
209 numberAt(usage, 3),
210 numberAt(usage, 9) + numberAt(usage, 10),
211 );
212 const totals = {
213 uncachedInputTokens: numberAt(usage, 2),
214 cachedInputTokens: numberAt(usage, 5),
215 cacheCreationTokens: numberAt(usage, 4),
216 outputTokens,
217 reasoningTokens: Math.min(outputTokens, numberAt(usage, 9)),
218 };
219 if (
220 totals.uncachedInputTokens +
221 totals.cachedInputTokens +
222 totals.cacheCreationTokens +
223 outputTokens ===
224 0
225 )
226 continue;
227 const keys = ([11, 12, 7] as const).flatMap((key) => {
228 const id = textAt(usage, key);
229 return id ? [`antigravity:${key}:${id}`] : [];
230 });
231 const record: UsageRecord = {
232 provider: "antigravity",
233 sessionId,
234 timestampMs: entry.timestampMs ?? trajectoryTimestamp ?? fallbackTimestamp,
235 model:
236 MODEL_IDS[numberAt(usage, 1)] ||
237 entry.model ||
238 (source === "step" ? generationModels.get(idx) : "") ||
239 modelName("", numberAt(usage, 1)) ||
240 "antigravity-unknown",
241 totals,
242 reportedCostUsd: null,
243 fast: false,
244 dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`,
245 };
246 records.push({
247 record,
248 keys,
249 timestampQuality: entry.timestampMs !== null ? 2 : trajectoryTimestamp !== null ? 1 : 0,
250 });
251 }
252 }
253 }
254 return records;
255 } finally {
256 db.close();
257 }
258}
259
260/** Reads and merges aliases across every configured Antigravity store before date filtering. */
261export async function readAntigravityUsage(
262 conversationsDirectories: string | readonly string[],
263 sinceMs: number,
264) {
265 const roots =
266 typeof conversationsDirectories === "string"
267 ? [conversationsDirectories]
268 : conversationsDirectories;
269 const files: Array<{ root: string; path: string; records: UsageRecord[] }> = [];
270 const errors: string[] = [];
271 const identities = new Map<string, number>();
272 const groups: Array<
273 UsageCandidate & { parent: number; size: number; owner: number; fileIndex: number }
274 > = [];
275 const find = (index: number): number => {
276 let root = index;
277 while (groups[root]!.parent !== root) root = groups[root]!.parent;
278 while (index !== root) {
279 const parent = groups[index]!.parent;
280 groups[index]!.parent = root;
281 index = parent;
282 }
283 return root;
284 };
285 const merge = (left: number, right: number): number => {
286 let a = find(left);
287 let b = find(right);
288 if (a === b) return a;
289 if (groups[a]!.size < groups[b]!.size) [a, b] = [b, a];
290 const target = groups[a]!;
291 const source = groups[b]!;
292 const first = target.owner < source.owner ? target : source;
293 const bestTime =
294 source.timestampQuality > target.timestampQuality ||
295 (source.timestampQuality === target.timestampQuality &&
296 source.record.timestampMs < target.record.timestampMs)
297 ? source
298 : target;
299 const x = target.record.totals;
300 const y = source.record.totals;
301 target.record = {
302 ...first.record,
303 model:
304 first.record.model === "antigravity-unknown"
305 ? first === target
306 ? source.record.model
307 : target.record.model
308 : first.record.model,
309 timestampMs: bestTime.record.timestampMs,
310 totals: {
311 uncachedInputTokens: Math.max(x.uncachedInputTokens, y.uncachedInputTokens),
312 cachedInputTokens: Math.max(x.cachedInputTokens, y.cachedInputTokens),
313 cacheCreationTokens: Math.max(x.cacheCreationTokens, y.cacheCreationTokens),
314 outputTokens: Math.max(x.outputTokens, y.outputTokens),
315 reasoningTokens: Math.max(x.reasoningTokens, y.reasoningTokens),
316 },
317 };
318 target.timestampQuality = bestTime.timestampQuality;
319 target.owner = first.owner;
320 target.fileIndex = first.fileIndex;
321 target.size += source.size;
322 source.parent = a;
323 return a;
324 };
325 const append = (candidate: UsageCandidate, fileIndex: number) => {
326 const index = groups.length;
327 groups.push({ ...candidate, parent: index, size: 1, owner: index, fileIndex });
328 for (const key of candidate.keys) {
329 const existing = identities.get(key);
330 if (existing !== undefined) merge(index, existing);
331 identities.set(key, index);
332 }
333 };
334 const visited = new Set<string>();
335 const walk = async (directory: string, root: string): Promise<void> => {
336 let entries;
337 try {
338 entries = await NodeFSP.readdir(directory, { withFileTypes: true });
339 } catch (error) {
340 if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push(directory);
341 return;
342 }
343 entries.sort((a, b) => a.name.localeCompare(b.name));
344 for (const entry of entries) {
345 const path = NodePath.join(directory, entry.name);
346 if (entry.isDirectory()) {
347 await walk(path, root);
348 } else if (entry.isFile() && entry.name.endsWith(".db")) {
349 try {
350 const canonical = await NodeFSP.realpath(path);
351 if (visited.has(canonical)) continue;
352 visited.add(canonical);
353 const stat = await NodeFSP.stat(path);
354 const candidates = await readDatabase(path, stat.mtimeMs);
355 const fileIndex = files.length;
356 files.push({ root, path, records: [] });
357 for (const [index, candidate] of candidates.entries()) {
358 append(candidate, fileIndex);
359 if (index % 256 === 255) await NodeTimersPromises.setImmediate();
360 }
361 } catch {
362 errors.push(path);
363 }
364 }
365 }
366 };
367 for (const root of roots) await walk(root, root);
368 for (const [index, group] of groups.entries()) {
369 if (group.parent === index && group.record.timestampMs >= sinceMs) {
370 files[group.fileIndex]!.records.push(group.record);
371 }
372 }
373 return { files, errors };
374}
apps/server/src/usage/cursorUsageReader.ts created+254
......@@ -0,0 +1,254 @@
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 type { UsageRecord } from "./usageTranscripts.ts";
8import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts";
9
10function object(value: unknown): Record<string, unknown> {
11 return typeof value === "object" && value !== null && !Array.isArray(value)
12 ? (value as Record<string, unknown>)
13 : {};
14}
15
16function tokens(value: unknown): number {
17 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
18}
19
20export interface CursorAccountUsageReadResult {
21 readonly accountKey: string | null;
22 readonly records: readonly UsageRecord[];
23 readonly missing: boolean;
24 readonly error: string | null;
25}
26
27const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex");
28
29function canonicalJson(value: unknown): string {
30 if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
31 if (value !== null && typeof value === "object") {
32 return `{${Object.entries(value)
33 .sort(([a], [b]) => a.localeCompare(b))
34 .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
35 .join(",")}}`;
36 }
37 return JSON.stringify(value) ?? "null";
38}
39
40/** Find the longest exact suffix/prefix overlap in linear time. */
41function boundaryOverlap(previous: readonly string[], current: readonly string[]): number {
42 const sequence = [...current, "", ...previous];
43 const lengths = Array.from({ length: sequence.length }, () => 0);
44 for (let index = 1; index < sequence.length; index++) {
45 let length = lengths[index - 1]!;
46 while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!;
47 if (sequence[index] === sequence[length]) length++;
48 lengths[index] = length;
49 }
50 return lengths.at(-1) ?? 0;
51}
52
53/** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */
54export async function readCursorAccountUsage(
55 credentialSource: string | { readonly kind: "keychain" },
56 sinceMs: number,
57 endDate: number,
58 request: (url: string, init: RequestInit) => Promise<Response> = globalThis.fetch,
59 keychainToken: () => Promise<string | null> = readMacCursorAccessToken,
60): Promise<CursorAccountUsageReadResult> {
61 let accessToken: unknown;
62 try {
63 accessToken =
64 typeof credentialSource === "string"
65 ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken
66 : await keychainToken();
67 } catch (cause) {
68 const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT";
69 return {
70 accountKey: null,
71 records: [],
72 missing,
73 error: missing
74 ? null
75 : typeof credentialSource === "string"
76 ? "Cursor credentials could not be read."
77 : "Cursor Keychain credentials could not be read.",
78 };
79 }
80 if (typeof accessToken !== "string" || !accessToken) {
81 return {
82 accountKey: null,
83 records: [],
84 missing: true,
85 error:
86 typeof credentialSource === "string"
87 ? null
88 : "Cursor account history needs a macOS Keychain CLI login on this server.",
89 };
90 }
91 let accountKey: string | null = null;
92 try {
93 const payload = accessToken.split(".")[1];
94 const subject = object(
95 JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")),
96 ).sub;
97 if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication");
98 const userId = subject.split("|").at(-1);
99 if (!userId) throw new Error("Invalid authentication");
100 accountKey = accountHash(subject);
101 if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate)
102 throw new Error("Invalid date window");
103 const deadline = AbortSignal.timeout(60_000);
104 const records: UsageRecord[] = [];
105 const occurrences = new Map<string, number>();
106 const pages: unknown[][] = [];
107 let completed = false;
108 const pageSize = 1000;
109 let total: number | undefined;
110 for (let page = 1; ; page++) {
111 // A count can include overlapping page boundaries. Allow room to
112 // reconcile them without imposing a fixed account-size limit.
113 if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) {
114 throw new Error("Account usage page limit exceeded");
115 }
116 const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", {
117 method: "POST",
118 redirect: "error",
119 signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]),
120 headers: {
121 "Content-Type": "application/json",
122 Origin: "https://cursor.com",
123 Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`,
124 },
125 body: JSON.stringify({
126 page,
127 pageSize,
128 startDate: String(sinceMs),
129 endDate: String(endDate),
130 }),
131 });
132 if (response.status === 401 || response.status === 403) {
133 return {
134 accountKey,
135 records: [],
136 missing: false,
137 error: "Sign in to Cursor again to read account usage.",
138 };
139 }
140 if (!response.ok) throw new Error("Account usage request failed");
141 const parsed: unknown = await response.json();
142 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
143 throw new Error("Invalid account usage page");
144 }
145 const body = object(parsed);
146 const keys = Object.keys(body);
147 if ("error" in body || "message" in body || "code" in body)
148 throw new Error("Account usage error response");
149 const count = keys.length === 0 ? 0 : body.totalUsageEventsCount;
150 const events =
151 keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount")
152 ? []
153 : body.usageEventsDisplay;
154 if (
155 (count !== undefined &&
156 (typeof count !== "number" ||
157 !Number.isSafeInteger(count) ||
158 count < 0 ||
159 (total !== undefined && count !== total))) ||
160 !Array.isArray(events) ||
161 events.length > pageSize ||
162 (count === undefined && !Array.isArray(body.usageEventsDisplay))
163 ) {
164 throw new Error("Inconsistent account usage page");
165 }
166 if (typeof count === "number") total = count;
167 pages.push(events);
168 if (events.length < pageSize) {
169 completed = true;
170 break;
171 }
172 }
173 if (!completed) throw new Error("Account usage page limit exceeded");
174 const rawCount = pages.reduce((sum, page) => sum + page.length, 0);
175 if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages");
176 let removalsRemaining = total === undefined ? 0 : rawCount - total;
177 let previousKeys: string[] = [];
178 for (const events of pages) {
179 const eventKeys =
180 removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : [];
181 const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys));
182 removalsRemaining -= removalCount;
183 previousKeys = eventKeys;
184 for (const raw of events.slice(removalCount)) {
185 const event = object(raw);
186 const usage = object(event.tokenUsage);
187 if (event.tokenUsage === undefined || event.tokenUsage === null) continue;
188 for (const key of [
189 "inputTokens",
190 "outputTokens",
191 "cacheReadTokens",
192 "cacheWriteTokens",
193 "totalCents",
194 ]) {
195 const value = usage[key];
196 if (
197 value !== undefined &&
198 (typeof value !== "number" || !Number.isFinite(value) || value < 0)
199 ) {
200 throw new Error("Invalid account usage totals");
201 }
202 }
203 const timestampMs =
204 typeof event.timestamp === "string" && event.timestamp.trim() !== ""
205 ? Number(event.timestamp)
206 : event.timestamp;
207 if (
208 typeof timestampMs !== "number" ||
209 !Number.isFinite(timestampMs) ||
210 typeof event.model !== "string" ||
211 !event.model
212 )
213 throw new Error("Invalid account usage event");
214 if (timestampMs < sinceMs || timestampMs > endDate) continue;
215 const totals = {
216 uncachedInputTokens: tokens(usage.inputTokens),
217 cachedInputTokens: tokens(usage.cacheReadTokens),
218 cacheCreationTokens: tokens(usage.cacheWriteTokens),
219 outputTokens: tokens(usage.outputTokens),
220 reasoningTokens: 0,
221 };
222 const reportedCostUsd =
223 typeof usage.totalCents === "number" ? usage.totalCents / 100 : null;
224 const sessionId = typeof event.conversationId === "string" ? event.conversationId : "";
225 // No event ID is provided. Preserve identical billed rows with an occurrence index.
226 const key = accountHash(
227 JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]),
228 );
229 const occurrence = occurrences.get(key) ?? 0;
230 occurrences.set(key, occurrence + 1);
231 records.push({
232 provider: "cursor",
233 timestampMs,
234 model: event.model,
235 sessionId,
236 totals,
237 reportedCostUsd,
238 fast: false,
239 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
240 });
241 }
242 await NodeTimersPromises.setImmediate();
243 }
244 if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries");
245 return { accountKey, records, missing: false, error: null };
246 } catch {
247 return {
248 accountKey,
249 records: [],
250 missing: false,
251 error: "Cursor account usage could not be read.",
252 };
253 }
254}
apps/server/src/usage/opencodeUsageReader.ts created+188
......@@ -0,0 +1,188 @@
1// node:sqlite reads live OpenCode databases; Node fs walks legacy JSON history.
2// @effect-diagnostics nodeBuiltinImport:off
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as NodeSqlite from "node:sqlite";
6import * as NodeTimersPromises from "node:timers/promises";
7
8import { totalTokens, type UsageRecord } from "./usageTranscripts.ts";
9
10function object(value: unknown): Record<string, unknown> {
11 return typeof value === "object" && value !== null && !Array.isArray(value)
12 ? (value as Record<string, unknown>)
13 : {};
14}
15
16function tokens(value: unknown): number {
17 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
18}
19
20function text(value: unknown): string {
21 return typeof value === "string" ? value : "";
22}
23
24/** OpenCode stores uncached input and reasoning separately from input/output. */
25function parseOpenCodeMessage(
26 source: string,
27 fallback: {
28 readonly id?: string;
29 readonly sessionId?: string;
30 readonly timestampMs?: number;
31 } = {},
32): UsageRecord | null {
33 let parsed: unknown;
34 try {
35 parsed = JSON.parse(source);
36 } catch {
37 return null;
38 }
39 const message = object(parsed);
40 if (message.role !== undefined && message.role !== "assistant") return null;
41 const usage = object(message.tokens);
42 const cache = object(usage.cache);
43 const modelReference = object(message.model);
44 const model = text(modelReference.id) || text(modelReference.modelID) || text(message.modelID);
45 const timestampMs = object(message.time).created ?? fallback.timestampMs;
46 if (!model || typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return null;
47 const reasoningTokens = tokens(usage.reasoning);
48 const totals = {
49 uncachedInputTokens: tokens(usage.input),
50 cachedInputTokens: tokens(cache.read),
51 cacheCreationTokens: tokens(cache.write),
52 outputTokens: tokens(usage.output) + reasoningTokens,
53 reasoningTokens,
54 };
55 if (totalTokens(totals) === 0) return null;
56 const id = fallback.id || text(message.id);
57 const cost = message.cost;
58 return {
59 provider: "opencode",
60 timestampMs,
61 model,
62 sessionId: fallback.sessionId || text(message.sessionID),
63 totals,
64 // OpenCode writes zero for models without a known rate, including paid
65 // subscription models. Let the shared price table estimate those records.
66 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null,
67 fast: false,
68 dedupeKey: id ? `opencode:${id}` : null,
69 };
70}
71
72export interface OpenCodeUsageReadResult {
73 readonly files: readonly { readonly path: string; readonly records: readonly UsageRecord[] }[];
74 readonly missing: boolean;
75 readonly error: boolean;
76}
77
78/** Reads current SQLite and pre-migration JSON stores without modifying either. */
79export async function readOpenCodeUsage(
80 root: string,
81 sinceMs: number,
82): Promise<OpenCodeUsageReadResult> {
83 const files: { path: string; records: UsageRecord[] }[] = [];
84 const seen = new Set<string>();
85 let found = false;
86 let error = false;
87 const append = (records: UsageRecord[], record: UsageRecord | null) => {
88 if (record === null || record.timestampMs < sinceMs) return;
89 if (record.dedupeKey !== null) {
90 if (seen.has(record.dedupeKey)) return;
91 seen.add(record.dedupeKey);
92 }
93 records.push(record);
94 };
95
96 let databases: string[] = [];
97 try {
98 databases = (await NodeFSP.readdir(root, { withFileTypes: true }))
99 .filter((entry) => entry.isFile() && /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(entry.name))
100 .map((entry) => entry.name)
101 .sort((a, b) => (a === "opencode.db" ? -1 : b === "opencode.db" ? 1 : a.localeCompare(b)));
102 } catch (cause) {
103 if (object(cause).code !== "ENOENT") error = true;
104 }
105 for (const name of databases) {
106 found = true;
107 const file = { path: NodePath.join(root, name), records: [] as UsageRecord[] };
108 files.push(file);
109 let database: NodeSqlite.DatabaseSync | undefined;
110 try {
111 database = new NodeSqlite.DatabaseSync(NodePath.join(root, name), { readOnly: true });
112 // A busy live provider should fail this source promptly rather than
113 // stalling the server while SQLite waits for its writer.
114 database.exec("PRAGMA busy_timeout = 100");
115 const tables = new Set(
116 database
117 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
118 .all()
119 .map((row) => row.name),
120 );
121 if (!tables.has("message") && !tables.has("session_message")) error = true;
122 for (const table of ["message", "session_message"] as const) {
123 if (!tables.has(table)) continue;
124 const columns = new Set(
125 database
126 .prepare(`PRAGMA table_info(${table})`)
127 .all()
128 .map((row) => row.name),
129 );
130 const timestamp = columns.has("time_created") ? "time_created" : "NULL";
131 const predicates = table === "session_message" ? ["type = 'assistant'"] : [];
132 if (timestamp !== "NULL") predicates.push("time_created >= ?");
133 const where = predicates.length > 0 ? ` WHERE ${predicates.join(" AND ")}` : "";
134 const statement = database.prepare(
135 `SELECT id, session_id, data, ${timestamp} AS created FROM ${table}${where}`,
136 );
137 let count = 0;
138 for (const row of statement.iterate(...(timestamp === "NULL" ? [] : [sinceMs]))) {
139 append(
140 file.records,
141 parseOpenCodeMessage(text(row.data), {
142 id: text(row.id),
143 sessionId: text(row.session_id),
144 ...(typeof row.created === "number" ? { timestampMs: row.created } : {}),
145 }),
146 );
147 if (++count % 256 === 0) await NodeTimersPromises.setImmediate();
148 }
149 }
150 } catch {
151 error = true;
152 } finally {
153 database?.close();
154 }
155 }
156
157 // Do not follow symlinks, including cycles. Database records win over their
158 // old JSON copies when OpenCode has migrated a store in place.
159 const directories = [NodePath.join(root, "storage", "message")];
160 while (directories.length > 0) {
161 const directory = directories.pop()!;
162 try {
163 for (const entry of await NodeFSP.readdir(directory, { withFileTypes: true })) {
164 const path = NodePath.join(directory, entry.name);
165 if (entry.isDirectory()) {
166 directories.push(path);
167 } else if (entry.isFile() && entry.name.endsWith(".json")) {
168 found = true;
169 const id = entry.name.slice(0, -5);
170 if (seen.has(`opencode:${id}`)) continue;
171 const file = { path, records: [] as UsageRecord[] };
172 files.push(file);
173 try {
174 append(
175 file.records,
176 parseOpenCodeMessage(await NodeFSP.readFile(path, "utf8"), { id }),
177 );
178 } catch (cause) {
179 if (object(cause).code !== "ENOENT") error = true;
180 }
181 }
182 }
183 } catch (cause) {
184 if (object(cause).code !== "ENOENT") error = true;
185 }
186 }
187 return { files, missing: !found && !error, error };
188}
apps/server/src/usage/usageAggregation.ts+5-3
......@@ -112,7 +112,7 @@ export class UsageAggregator {
112112 * can derive per-window facts (distinct sessions, for one) from the records
113113 * that landed rather than everything the mtime prefilter happened to admit.
114114 */
115 add(record: UsageRecord): boolean {
115 add(record: UsageRecord, sourcePath?: string): boolean {
116116 if (record.dedupeKey !== null) {
117117 if (this.#seen.has(record.dedupeKey)) {
118118 this.#duplicatesDropped += 1;
......@@ -146,7 +146,7 @@ export class UsageAggregator {
146146 this.#hourlyWindow.sinceTimeMs +
147147 Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS,
148148 ).toISOString();
149 const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`;
149 const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourcePath ?? ""}`;
150150 let bucket = this.#buckets.get(key);
151151 if (bucket === undefined) {
152152 bucket = {
......@@ -180,12 +180,14 @@ export class UsageAggregator {
180180 finish(): AggregateResult {
181181 const buckets: UsageBucket[] = [];
182182 for (const [key, bucket] of this.#buckets) {
183 const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000");
183 const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] =
184 key.split("\u0000");
184185 buckets.push({
185186 day: day as UsageDay,
186187 ...(hourStart === "" ? {} : { hourStart }),
187188 provider: provider as UsageBucket["provider"],
188189 model,
190 ...(sourcePath === "" ? {} : { sourcePath }),
189191 totals: bucket.totals,
190192 costUsd: bucket.costUsd,
191193 cacheSavingsUsd: bucket.cacheSavingsUsd,
apps/server/src/usage/usageTranscriptReader.test.ts+554
......@@ -4,13 +4,40 @@
44import * as NodeFSP from "node:fs/promises";
55import * as NodeOS from "node:os";
66import * as NodePath from "node:path";
7import * as NodeSqlite from "node:sqlite";
78
89import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";
910
1011import { readTranscriptRecords } from "./usageTranscriptReader.ts";
12import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
13import { readCursorAccountUsage } from "./cursorUsageReader.ts";
14import { readAntigravityUsage } from "./antigravityUsageReader.ts";
1115
1216let dir: string;
1317
18function protoNumber(field: number, value: number): number[] {
19 const varint = (number: number) => {
20 const bytes: number[] = [];
21 do {
22 const byte = number % 128;
23 number = Math.floor(number / 128);
24 bytes.push(byte + (number > 0 ? 128 : 0));
25 } while (number > 0);
26 return bytes;
27 };
28 return [...varint(field * 8), ...varint(value)];
29}
30
31function protoBytes(field: number, bytes: readonly number[]): number[] {
32 const encoded = protoNumber(field, bytes.length);
33 encoded[0] = encoded[0]! + 2;
34 return [...encoded, ...bytes];
35}
36
37function protoText(field: number, value: string): number[] {
38 return protoBytes(field, [...Buffer.from(value)]);
39}
40
1441beforeEach(async () => {
1542 dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-"));
1643});
......@@ -208,3 +235,530 @@ describe("readTranscriptRecords resume", () => {
208235 assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude"));
209236 });
210237});
238
239describe("SQLite usage readers", () => {
240 it("reads Cursor account history with the default macOS Keychain login", async () => {
241 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
242 let keychainReads = 0;
243 const result = await readCursorAccountUsage(
244 { kind: "keychain" },
245 0,
246 1781000000000,
247 async (_url, init) => {
248 assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A");
249 return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] });
250 },
251 async () => {
252 keychainReads++;
253 return accessToken;
254 },
255 );
256 assert.strictEqual(keychainReads, 1);
257 assert.isNull(result.error);
258 assert.isFalse(result.missing);
259 assert.isNotNull(result.accountKey);
260 });
261
262 it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => {
263 const authPath = NodePath.join(dir, "auth.json");
264 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
265 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
266 const pages: number[] = [];
267 const signals: AbortSignal[] = [];
268 const request = async (url: string, init: RequestInit) => {
269 assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events");
270 assert.strictEqual(init?.redirect, "error");
271 const headers = new Headers(init?.headers);
272 assert.strictEqual(headers.get("origin"), "https://cursor.com");
273 assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A");
274 const body = JSON.parse(String(init?.body));
275 pages.push(body.page);
276 if (init.signal) signals.push(init.signal);
277 return Response.json({
278 totalUsageEventsCount: 1001,
279 usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({
280 timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000),
281 model: "claude-sonnet-4-5",
282 conversationId: `conversation-${body.page}`,
283 isHeadless: body.page === 2,
284 chargedCents: 0,
285 tokenUsage: {
286 inputTokens: 10,
287 outputTokens: 5,
288 cacheReadTokens: 30,
289 cacheWriteTokens: 2,
290 totalCents: 25,
291 },
292 })),
293 });
294 };
295 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request);
296 assert.isNull(result.error);
297 assert.deepStrictEqual(pages, [1, 2]);
298 assert.lengthOf(signals, 2);
299 assert.notStrictEqual(signals[0], signals[1]);
300 assert.strictEqual(result.records.length, 1001);
301 assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2");
302 assert.deepStrictEqual(result.records[0]?.totals, {
303 uncachedInputTokens: 10,
304 cachedInputTokens: 30,
305 cacheCreationTokens: 2,
306 outputTokens: 5,
307 reasoningTokens: 0,
308 });
309 assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25);
310 assert.isFalse(result.accountKey?.includes("demo") ?? true);
311 });
312
313 it("reads Cursor account history beyond 100 pages", async () => {
314 const authPath = NodePath.join(dir, "auth.json");
315 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
316 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
317 const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null }));
318 let requests = 0;
319 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
320 requests += 1;
321 return Response.json({
322 totalUsageEventsCount: 100_001,
323 usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }],
324 });
325 });
326 assert.isNull(result.error);
327 assert.strictEqual(requests, 101);
328 assert.deepStrictEqual(result.records, []);
329 });
330
331 it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => {
332 const authPath = NodePath.join(dir, "auth.json");
333 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
334 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
335 for (const body of [
336 {},
337 { totalUsageEventsCount: 0 },
338 { totalUsageEventsCount: 0, usageEventsDisplay: [] },
339 ]) {
340 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
341 Response.json(body),
342 );
343 assert.isNull(result.error);
344 assert.deepStrictEqual(result.records, []);
345 assert.isFalse(result.missing);
346 }
347 for (const body of [
348 { error: "upstream error" },
349 { detail: "unknown error envelope" },
350 { totalUsageEventsCount: 0, error: "upstream error" },
351 null,
352 [],
353 "invalid",
354 0,
355 ]) {
356 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
357 Response.json(body),
358 );
359 assert.isNotNull(result.error);
360 assert.deepStrictEqual(result.records, []);
361 }
362 });
363
364 it("requires a terminal Cursor page after a full page reaches the reported count", async () => {
365 const authPath = NodePath.join(dir, "auth.json");
366 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
367 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
368 let requests = 0;
369 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
370 requests++;
371 return Response.json(
372 requests === 1
373 ? {
374 totalUsageEventsCount: 1000,
375 usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({
376 timestamp: String(1780000000000 + index),
377 model: "gpt-5",
378 tokenUsage: { inputTokens: 10, outputTokens: 5 },
379 })),
380 }
381 : { totalUsageEventsCount: 1000 },
382 );
383 });
384 assert.isNull(result.error);
385 assert.strictEqual(result.records.length, 1000);
386 assert.strictEqual(requests, 2);
387 });
388
389 it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => {
390 const authPath = NodePath.join(dir, "auth.json");
391 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
392 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
393 const event = (index: number) => ({
394 timestamp: String(1780000000000 + index),
395 model: "gpt-5",
396 tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 },
397 });
398 for (const total of [2000, 2001]) {
399 let requests = 0;
400 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
401 requests++;
402 return Response.json({
403 totalUsageEventsCount: total,
404 usageEventsDisplay:
405 requests === 1
406 ? Array.from({ length: 1000 }, (_, index) => event(index))
407 : requests === 2
408 ? Array.from({ length: 1000 }, (_, index) => event(999 + index))
409 : [event(1999)],
410 });
411 });
412 assert.isNull(result.error);
413 assert.strictEqual(result.records.length, total);
414 assert.strictEqual(requests, 3);
415 assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999);
416 assert.strictEqual(
417 result.records.filter((record) => record.timestampMs === 1780000000999).length,
418 total === 2000 ? 1 : 2,
419 );
420 assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total);
421 }
422 let requests = 0;
423 const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
424 requests++;
425 return Response.json({
426 totalUsageEventsCount: 1001,
427 usageEventsDisplay:
428 requests === 1
429 ? Array.from({ length: 1000 }, (_, index) => event(index))
430 : [event(500), event(1000)],
431 });
432 });
433 assert.isNotNull(inconsistent.error);
434 assert.deepStrictEqual(inconsistent.records, []);
435 });
436
437 it("does not present truncated Cursor account pages or authentication failures as complete history", async () => {
438 const authPath = NodePath.join(dir, "auth.json");
439 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
440 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
441 const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
442 Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }),
443 );
444 assert.isNotNull(truncated.error);
445 assert.deepStrictEqual(truncated.records, []);
446 const denied = await readCursorAccountUsage(
447 authPath,
448 0,
449 1781000000000,
450 async () => new Response(accessToken, { status: 401 }),
451 );
452 assert.isNotNull(denied.error);
453 assert.isFalse(denied.error?.includes(accessToken) ?? true);
454 assert.deepStrictEqual(denied.records, []);
455 let requested = false;
456 const missing = await readCursorAccountUsage(
457 NodePath.join(dir, "missing.json"),
458 0,
459 1781000000000,
460 async () => {
461 requested = true;
462 return Response.json({});
463 },
464 );
465 assert.isTrue(missing.missing);
466 assert.isFalse(requested);
467 });
468
469 it("counts migrated OpenCode messages once and sees subsequent WAL writes", async () => {
470 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "opencode.db"));
471 try {
472 db.exec(
473 "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; CREATE TABLE message (id TEXT, session_id TEXT, data TEXT)",
474 );
475 const message = {
476 id: "msg-1",
477 sessionID: "session-1",
478 role: "assistant",
479 modelID: "claude-sonnet-4-5",
480 time: { created: 1780000000000 },
481 cost: 0.25,
482 tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 30, write: 10 } },
483 };
484 const insert = db.prepare("INSERT INTO message VALUES (?, ?, ?)");
485 insert.run(message.id, message.sessionID, JSON.stringify(message));
486 const legacy = NodePath.join(dir, "storage", "message", message.sessionID);
487 await NodeFSP.mkdir(legacy, { recursive: true });
488 await NodeFSP.writeFile(NodePath.join(legacy, "msg-1.json"), JSON.stringify(message));
489 const first = await readOpenCodeUsage(dir, 0);
490 assert.isFalse(first.error);
491 const records = first.files.flatMap((file) => file.records);
492 assert.strictEqual(records.length, 1);
493 assert.deepStrictEqual(records[0]?.totals, {
494 uncachedInputTokens: 100,
495 cachedInputTokens: 30,
496 cacheCreationTokens: 10,
497 outputTokens: 25,
498 reasoningTokens: 5,
499 });
500 assert.strictEqual(records[0]?.reportedCostUsd, 0.25);
501 insert.run(
502 "msg-2",
503 message.sessionID,
504 JSON.stringify({ ...message, id: "msg-2", time: { created: 1780000001000 } }),
505 );
506 const next = await readOpenCodeUsage(dir, 1780000001000);
507 assert.isFalse(next.error);
508 assert.deepStrictEqual(
509 next.files.flatMap((file) => file.records).map((record) => record.dedupeKey),
510 ["opencode:msg-2"],
511 );
512 assert.isAbove((await NodeFSP.stat(NodePath.join(dir, "opencode.db-wal"))).size, 0);
513 } finally {
514 db.close();
515 }
516 });
517
518 it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => {
519 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db"));
520 const stamp = protoNumber(1, 1780000000);
521 const usage = [
522 ...protoNumber(2, 100),
523 ...protoNumber(3, 40),
524 ...protoNumber(4, 5),
525 ...protoNumber(5, 20),
526 ...protoNumber(9, 10),
527 ...protoText(11, "response-1"),
528 ];
529 const retry = [
530 ...protoNumber(1, 1026),
531 ...protoNumber(2, 12),
532 ...protoNumber(3, 3),
533 ...protoText(11, "retry-1"),
534 ];
535 const generation = protoBytes(1, [
536 ...protoBytes(4, usage),
537 ...protoText(19, "Gemini 3 Pro"),
538 ...protoBytes(9, protoBytes(4, stamp)),
539 ]);
540 const step = [
541 ...protoBytes(9, usage),
542 ...protoBytes(8, stamp),
543 ...protoBytes(28, protoBytes(2, retry)),
544 ];
545 try {
546 db.exec(
547 "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)",
548 );
549 db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, new Uint8Array(generation));
550 db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(step));
551 } finally {
552 db.close();
553 }
554 const result = await readAntigravityUsage(dir, 0);
555 assert.deepStrictEqual(result.errors, []);
556 const records = result.files.flatMap((file) => file.records);
557 assert.strictEqual(records.length, 2);
558 const main = records.find((record) => record.model === "gemini-3-pro");
559 assert.isDefined(main);
560 assert.strictEqual(main?.timestampMs, 1780000000000);
561 assert.strictEqual(main?.sessionId, "session-1");
562 assert.deepStrictEqual(main?.totals, {
563 uncachedInputTokens: 100,
564 cachedInputTokens: 20,
565 cacheCreationTokens: 5,
566 outputTokens: 40,
567 reasoningTokens: 10,
568 });
569 assert.strictEqual(
570 records.find((record) => record.model === "claude-opus-4-6")?.totals.uncachedInputTokens,
571 12,
572 );
573 assert.deepStrictEqual(
574 (await readAntigravityUsage(dir, 1780000000001)).files.flatMap((file) => file.records),
575 [],
576 );
577 });
578
579 it("uses the matching Antigravity generation model for each model-less step", async () => {
580 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "model-switch.db"));
581 try {
582 db.exec(
583 "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)",
584 );
585 const generation = db.prepare("INSERT INTO gen_metadata VALUES (?, ?)");
586 const step = db.prepare("INSERT INTO steps VALUES (?, ?)");
587 for (const [idx, name] of ["Gemini 3 Pro", "Claude Opus 4.6"].entries()) {
588 generation.run(idx, new Uint8Array(protoBytes(1, protoText(19, name))));
589 step.run(idx, new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx))));
590 }
591 } finally {
592 db.close();
593 }
594 const result = await readAntigravityUsage(dir, 0);
595 assert.deepStrictEqual(result.errors, []);
596 assert.deepStrictEqual(
597 result.files.flatMap((file) => file.records).map((record) => record.model),
598 ["gemini-3-pro", "claude-opus-4-6"],
599 );
600 });
601
602 it("merges Antigravity aliases that bridge previously separate step records", async () => {
603 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "bridge.db"));
604 try {
605 db.exec(
606 "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)",
607 );
608 const step = db.prepare("INSERT INTO steps VALUES (?, ?)");
609 step.run(
610 0,
611 new Uint8Array(protoBytes(9, [...protoNumber(2, 100), ...protoText(11, "response")])),
612 );
613 step.run(
614 1,
615 new Uint8Array(protoBytes(9, [...protoNumber(3, 40), ...protoText(12, "provider")])),
616 );
617 db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(
618 0,
619 new Uint8Array(
620 protoBytes(1, [
621 ...protoText(19, "Gemini 3 Pro"),
622 ...protoBytes(4, [
623 ...protoNumber(2, 50),
624 ...protoNumber(5, 20),
625 ...protoText(11, "response"),
626 ...protoText(12, "provider"),
627 ]),
628 ]),
629 ),
630 );
631 } finally {
632 db.close();
633 }
634 const result = await readAntigravityUsage(dir, 0);
635 assert.deepStrictEqual(result.errors, []);
636 const records = result.files.flatMap((file) => file.records);
637 assert.strictEqual(records.length, 1);
638 assert.deepStrictEqual(records[0]?.totals, {
639 uncachedInputTokens: 100,
640 cachedInputTokens: 20,
641 cacheCreationTokens: 0,
642 outputTokens: 40,
643 reasoningTokens: 0,
644 });
645 });
646
647 it("merges Antigravity provider and message aliases across configured roots while keeping original ownership", async () => {
648 const roots = [NodePath.join(dir, "first"), NodePath.join(dir, "second")];
649 for (const [index, root] of roots.entries()) {
650 await NodeFSP.mkdir(root);
651 const db = new NodeSqlite.DatabaseSync(NodePath.join(root, `session-${index}.db`));
652 try {
653 db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)");
654 for (const identity of [7, 12]) {
655 const usage = [
656 ...protoNumber(1, 246),
657 ...protoNumber(2, index === 0 ? 100 : 150),
658 ...protoText(11, `response-${index}-${identity}`),
659 ...protoText(identity, `shared-${identity}`),
660 ];
661 db.prepare("INSERT INTO steps VALUES (?, ?)").run(
662 identity,
663 new Uint8Array(protoBytes(9, usage)),
664 );
665 }
666 } finally {
667 db.close();
668 }
669 }
670 const result = await readAntigravityUsage(roots, 0);
671 assert.deepStrictEqual(result.errors, []);
672 assert.strictEqual(result.files.length, 2);
673 assert.strictEqual(result.files[0]?.root, roots[0]);
674 assert.strictEqual(result.files[0]?.records.length, 2);
675 assert.strictEqual(result.files[1]?.records.length, 0);
676 assert.deepStrictEqual(
677 result.files[0]?.records.map((record) => record.totals.uncachedInputTokens),
678 [150, 150],
679 );
680 assert.isTrue(result.files[0]?.records.every((record) => record.sessionId === "session-0"));
681 });
682
683 it("upgrades Antigravity fallback timestamps before applying the date window", async () => {
684 for (const fallback of ["mtime", "trajectory"]) {
685 const path = NodePath.join(dir, `${fallback}.db`);
686 const db = new NodeSqlite.DatabaseSync(path);
687 try {
688 db.exec(
689 "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)",
690 );
691 if (fallback === "trajectory") {
692 db.exec("CREATE TABLE trajectory_metadata_blob (data BLOB)");
693 db.prepare("INSERT INTO trajectory_metadata_blob VALUES (?)").run(
694 new Uint8Array(protoBytes(2, protoNumber(1, 1780000200))),
695 );
696 }
697 for (const [index, seconds] of [1780000000, 1780000200].entries()) {
698 const usage = [...protoNumber(2, 10), ...protoText(11, `${fallback}-${index}`)];
699 db.prepare("INSERT INTO steps VALUES (?, ?)").run(
700 index,
701 new Uint8Array(protoBytes(9, usage)),
702 );
703 db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(
704 index,
705 new Uint8Array(
706 protoBytes(1, [
707 ...protoBytes(4, usage),
708 ...protoBytes(9, protoBytes(4, protoNumber(1, seconds))),
709 ]),
710 ),
711 );
712 }
713 } finally {
714 db.close();
715 }
716 await NodeFSP.utimes(path, 1780000000, 1780000000);
717 }
718 const result = await readAntigravityUsage(dir, 1780000100000);
719 assert.deepStrictEqual(result.errors, []);
720 const records = result.files.flatMap((file) => file.records);
721 assert.strictEqual(records.length, 2);
722 assert.deepStrictEqual(
723 records.map((record) => record.timestampMs),
724 [1780000200000, 1780000200000],
725 );
726 });
727
728 it("reads Antigravity step-only stores and reports malformed databases", async () => {
729 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "steps.db"));
730 try {
731 db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)");
732 const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...protoNumber(3, 5)];
733 db.prepare("INSERT INTO steps VALUES (?, ?)").run(
734 0,
735 new Uint8Array([...protoBytes(9, usage), ...protoBytes(8, protoNumber(1, 1780000000))]),
736 );
737 } finally {
738 db.close();
739 }
740 await NodeFSP.writeFile(NodePath.join(dir, "broken.db"), "not a sqlite database");
741 const result = await readAntigravityUsage(dir, 0);
742 assert.strictEqual(result.errors.length, 1);
743 assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.model, "gemini-2.5-pro");
744 assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.totals.outputTokens, 5);
745 });
746
747 it("ignores large values in unused Antigravity protobuf fields", async () => {
748 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "large-varint.db"));
749 try {
750 db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)");
751 const unusedField = [...protoNumber(99, 0).slice(0, -1), ...Array(9).fill(0xff), 0x01];
752 const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...unusedField];
753 db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(protoBytes(9, usage)));
754 } finally {
755 db.close();
756 }
757 const result = await readAntigravityUsage(dir, 0);
758 assert.deepStrictEqual(result.errors, []);
759 assert.strictEqual(
760 result.files.flatMap((file) => file.records)[0]?.totals.uncachedInputTokens,
761 10,
762 );
763 });
764});
apps/web/src/components/settings/ProviderSettingsPanel.tsx+21
......@@ -314,7 +314,25 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
314314 hasServerConfig: environment.serverConfig !== null,
315315 }),
316316 )?.environmentId;
317 const searchableCursorEnvironmentId = options.find(
318 (environment) =>
319 environment.serverConfig?.environment.platform.os === "darwin" &&
320 isProviderSettingsEnvironmentAvailable({
321 connectionPhase: environment.connection.phase,
322 hasServerConfig: true,
323 }),
324 )?.environmentId;
317325 useEffect(() => {
326 if (
327 !target.scoped &&
328 searchTargetId === searchableSetting("cursor-keychain-usage").id &&
329 (!selectedEnvironmentCanRenderSettings ||
330 selectedEnvironment?.serverConfig?.environment.platform.os !== "darwin") &&
331 searchableCursorEnvironmentId !== undefined
332 ) {
333 setSelectedEnvironmentId(searchableCursorEnvironmentId);
334 return;
335 }
318336 if (
319337 !target.scoped &&
320338 (searchTargetId === searchableSetting("provider-health-check-interval").id ||
......@@ -326,7 +344,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
326344 }
327345 }, [
328346 searchTargetId,
347 searchableCursorEnvironmentId,
329348 searchableEnvironmentId,
349 selectedEnvironment,
330350 selectedEnvironmentCanRenderSettings,
331351 target.scoped,
332352 ]);
......@@ -1102,6 +1122,7 @@ export function EnvironmentProviderSettings({
11021122 environmentId={environmentId}
11031123 environmentLabel={environmentLabel}
11041124 sources={settings.usageLimitSources}
1125 cursorKeychainUsageEnabled={settings.cursorKeychainUsageEnabled}
11051126 readOnly={readOnly}
11061127 />
11071128
apps/web/src/components/settings/SettingsSidebarNav.tsx+1-1
......@@ -116,7 +116,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
116116 const searchInputRef = useRef<HTMLInputElement>(null);
117117 const [query, setQuery] = useState("");
118118 const [activeResultIndex, setActiveResultIndex] = useState(0);
119 const searchableItems = useAvailableSettingsSearchItems();
119 const searchableItems = useAvailableSettingsSearchItems(scopeSearch);
120120 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);
121121 const isSearching = query.trim().length > 0;
122122 const hasResults = results.length > 0;
apps/web/src/components/settings/UsageProviderSettings.tsx+46-1
......@@ -1,8 +1,11 @@
11import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts";
2import { useAtomValue } from "@effect/atom-react";
23import { PlusIcon } from "lucide-react";
34import { useState } from "react";
45
56import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
7import { serverEnvironment } from "../../state/server";
8import { useAtomCommand } from "../../state/use-atom-command";
69import {
710 AlertDialog,
811 AlertDialogClose,
......@@ -13,6 +16,7 @@ import {
1316 AlertDialogTitle,
1417} from "../ui/alert-dialog";
1518import { Button } from "../ui/button";
19import { Switch } from "../ui/switch";
1620import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";
1721import { searchableSetting } from "./settingsSearch";
1822import { SettingsRow, SettingsSection } from "./settingsLayout";
......@@ -22,17 +26,43 @@ export function UsageProviderSettings({
2226 environmentId,
2327 environmentLabel,
2428 sources,
29 cursorKeychainUsageEnabled,
2530 readOnly,
2631}: {
2732 readonly environmentId: EnvironmentId;
2833 readonly environmentLabel: string;
2934 readonly sources: UnifiedSettings["usageLimitSources"];
35 readonly cursorKeychainUsageEnabled: boolean;
3036 readonly readOnly: boolean;
3137}) {
3238 const updateSettings = useUpdateEnvironmentSettings(environmentId);
39 const updateCursorSettings = useAtomCommand(serverEnvironment.updateSettings, {
40 label: "update Cursor account usage",
41 });
42 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
43 reportFailure: false,
44 });
45 const platform = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.environment
46 .platform;
3347 const [adding, setAdding] = useState(false);
48 const [updatingCursor, setUpdatingCursor] = useState(false);
3449 const entries = Object.entries(sources);
3550
51 const setCursorUsageEnabled = async (enabled: boolean) => {
52 setUpdatingCursor(true);
53 try {
54 const result = await updateCursorSettings({
55 environmentId,
56 input: { patch: { cursorKeychainUsageEnabled: enabled } },
57 });
58 if (result._tag === "Success") {
59 await refreshProviders({ environmentId, input: {} });
60 }
61 } finally {
62 setUpdatingCursor(false);
63 }
64 };
65
3666 return (
3767 <>
3868 <SettingsSection
......@@ -46,8 +76,23 @@ export function UsageProviderSettings({
4676 ) : null
4777 }
4878 >
79 {platform?.os === "darwin" ? (
80 <SettingsRow
81 id="cursor-keychain-usage"
82 title="Cursor account usage"
83 description="Read your existing Cursor CLI login from macOS Keychain to show account history and monthly limits. macOS may ask you to allow access."
84 control={
85 <Switch
86 aria-label="Cursor account usage"
87 checked={cursorKeychainUsageEnabled}
88 disabled={readOnly || updatingCursor}
89 onCheckedChange={(enabled) => void setCursorUsageEnabled(enabled)}
90 />
91 }
92 />
93 ) : null}
4994 {entries.length === 0 ? (
50 <SettingsRow title="No usage providers configured." />
95 <SettingsRow title="No hubs configured." />
5196 ) : (
5297 entries.map(([id, source]) => {
5398 const label = source.label?.trim() || source.url;
apps/web/src/components/settings/settingsSearch.test.ts+25
......@@ -154,6 +154,7 @@ describe("searchSettings", () => {
154154 hasCloudPublicConfig: false,
155155 hasEnvironment: false,
156156 hasProviderSettingsEnvironment: false,
157 hasMacProviderSettingsEnvironment: false,
157158 canManageLocalBackend: false,
158159 isWslSettingsRowVisible: false,
159160 hasThreadAutoSettlement: false,
......@@ -165,6 +166,7 @@ describe("searchSettings", () => {
165166 "network-access",
166167 "publish-agent-activity",
167168 "provider-health-check-interval",
169 "cursor-keychain-usage",
168170 "source-control-writer-model",
169171 "source-control-writing-style",
170172 "t3-connect",
......@@ -177,11 +179,31 @@ describe("searchSettings", () => {
177179 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
178180 });
179181
182 it("offers Cursor Keychain settings only when a macOS provider environment is available", () => {
183 const availability = {
184 hasCloudPublicConfig: false,
185 hasEnvironment: true,
186 hasProviderSettingsEnvironment: true,
187 hasMacProviderSettingsEnvironment: false,
188 canManageLocalBackend: false,
189 isWslSettingsRowVisible: false,
190 hasThreadAutoSettlement: false,
191 };
192 const itemIds = (macAvailable: boolean) =>
193 filterAvailableSettingsSearchItems({
194 ...availability,
195 hasMacProviderSettingsEnvironment: macAvailable,
196 }).map((item) => item.id);
197 expect(itemIds(false)).not.toContain("cursor-keychain-usage");
198 expect(itemIds(true)).toContain("cursor-keychain-usage");
199 });
200
180201 it("keeps the local toggle searchable without offering hidden host publishing controls", () => {
181202 const availability = {
182203 hasCloudPublicConfig: true,
183204 hasEnvironment: true,
184205 hasProviderSettingsEnvironment: true,
206 hasMacProviderSettingsEnvironment: false,
185207 canManageLocalBackend: false,
186208 isWslSettingsRowVisible: false,
187209 hasThreadAutoSettlement: false,
......@@ -204,6 +226,7 @@ describe("searchSettings", () => {
204226 hasCloudPublicConfig: false,
205227 hasEnvironment: false,
206228 hasProviderSettingsEnvironment: false,
229 hasMacProviderSettingsEnvironment: false,
207230 canManageLocalBackend: false,
208231 isWslSettingsRowVisible: false,
209232 hasThreadAutoSettlement: true,
......@@ -330,6 +353,7 @@ describe("searchSettings", () => {
330353 hasCloudPublicConfig: false,
331354 hasEnvironment: true,
332355 hasProviderSettingsEnvironment: true,
356 hasMacProviderSettingsEnvironment: false,
333357 canManageLocalBackend: false,
334358 isWslSettingsRowVisible: false,
335359 hasThreadAutoSettlement: true,
......@@ -424,6 +448,7 @@ describe("auto-settlement search availability", () => {
424448 hasCloudPublicConfig: false,
425449 hasEnvironment: true,
426450 hasProviderSettingsEnvironment: true,
451 hasMacProviderSettingsEnvironment: false,
427452 canManageLocalBackend: false,
428453 isWslSettingsRowVisible: false,
429454 hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0,
apps/web/src/components/settings/settingsSearch.ts+11
......@@ -55,6 +55,7 @@ export interface SettingsSearchItem {
5555 readonly cloudOnly?: boolean;
5656 readonly environmentOnly?: boolean;
5757 readonly providerSettingsOnly?: boolean;
58 readonly macProviderSettingsOnly?: boolean;
5859 readonly localBackendManagementOnly?: boolean;
5960 readonly localEnvironmentOnly?: boolean;
6061 readonly wslAvailableOnly?: boolean;
......@@ -71,6 +72,7 @@ export interface SettingsSearchAvailability {
7172 readonly hasCloudPublicConfig: boolean;
7273 readonly hasEnvironment: boolean;
7374 readonly hasProviderSettingsEnvironment: boolean;
75 readonly hasMacProviderSettingsEnvironment: boolean;
7476 readonly canManageLocalBackend: boolean;
7577 readonly isWslSettingsRowVisible: boolean;
7678 readonly hasThreadAutoSettlement: boolean;
......@@ -546,6 +548,14 @@ export const SETTINGS_SEARCH_ITEMS = [
546548 ],
547549 providerSettingsOnly: true,
548550 },
551 {
552 id: "cursor-keychain-usage",
553 title: "Cursor account usage",
554 to: "/settings/providers",
555 searchTerms: ["cursor macOS keychain usage tokens cost limits permission"],
556 providerSettingsOnly: true,
557 macProviderSettingsOnly: true,
558 },
549559 {
550560 id: "provider-health-check-interval",
551561 title: "Health check interval",
......@@ -943,6 +953,7 @@ export function filterAvailableSettingsSearchItems(
943953 (!item.cloudOnly || availability.hasCloudPublicConfig) &&
944954 (!item.environmentOnly || availability.hasEnvironment) &&
945955 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
956 (!item.macProviderSettingsOnly || availability.hasMacProviderSettingsEnvironment) &&
946957 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
947958 (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) &&
948959 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+13-1
......@@ -10,12 +10,13 @@ import { useEnvironmentQuery } from "~/state/query";
1010import { usePrimarySessionState } from "~/environments/primary";
1111import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
1212import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
13import type { SettingsScopeSearch } from "./settingsScope";
1314import {
1415 filterAvailableSettingsSearchItems,
1516 getThreadAutoSettlementSearchAvailability,
1617} from "./settingsSearch";
1718
18export function useAvailableSettingsSearchItems() {
19export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) {
1920 const { environments } = useEnvironments();
2021 const primarySessionState = usePrimarySessionState();
2122 const localEnvironmentDisabled = isLocalEnvironmentDisabled();
......@@ -41,6 +42,16 @@ export function useAvailableSettingsSearchItems() {
4142 hasServerConfig: environment.serverConfig !== null,
4243 }),
4344 ),
45 hasMacProviderSettingsEnvironment: environments.some(
46 (environment) =>
47 (scopeSearch.machine === undefined ||
48 environment.environmentId === scopeSearch.machine) &&
49 environment.serverConfig?.environment.platform.os === "darwin" &&
50 isProviderSettingsEnvironmentAvailable({
51 connectionPhase: environment.connection.phase,
52 hasServerConfig: true,
53 }),
54 ),
4455 canManageLocalBackend,
4556 isWslSettingsRowVisible: isWslSettingsRowVisible({
4657 state: desktopWsl.data,
......@@ -55,6 +66,7 @@ export function useAvailableSettingsSearchItems() {
5566 desktopWsl.error,
5667 environments,
5768 localEnvironmentDisabled,
69 scopeSearch.machine,
5870 ],
5971 );
6072}
apps/web/src/components/usage/UsageLimits.tsx+4-2
......@@ -17,7 +17,7 @@ import {
1717 remainingPercent,
1818} from "@t3tools/shared/usageLimits";
1919import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
20import { Fragment, useState } from "react";
20import { Fragment, type ReactNode, useState } from "react";
2121
2222import { usePrimarySettings } from "../../hooks/useSettings";
2323import { environmentPresentations } from "../../state/presentation";
......@@ -322,14 +322,16 @@ export function ResetCredits({
322322export function UsageLimitsSection({
323323 selectedEnvironmentIds,
324324 now,
325 cursorPrompt,
325326}: {
326327 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
327328 readonly now: number;
329 readonly cursorPrompt?: ReactNode;
328330}) {
329331 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
330332 const selected =
331333 selectedEnvironmentIds === null
332334 ? presentations
333335 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
334 return <UsageLimitsPooled presentations={selected} now={now} />;
336 return <UsageLimitsPooled presentations={selected} now={now} cursorPrompt={cursorPrompt} />;
335337}
apps/web/src/components/usage/UsageLimitsPooled.tsx+51-14
......@@ -2,7 +2,8 @@ import {
22 collectLimitAccounts,
33 collectLimitNotices,
44 collectLimitPools,
5 formatDuration,
5 cursorUsageWindowDetails,
6 displayLimitWindows,
67 formatResetsIn,
78 type LimitAccount,
89 type LimitPool,
......@@ -11,7 +12,7 @@ import {
1112 remainingPercent,
1213} from "@t3tools/shared/usageLimits";
1314import { AlertTriangleIcon, TicketIcon } from "lucide-react";
14import { type ReactNode, useState } from "react";
15import { Fragment, type ReactNode, useState } from "react";
1516
1617import { usePrimarySettings } from "../../hooks/useSettings";
1718import { cn } from "../../lib/utils";
......@@ -224,6 +225,7 @@ function PoolSegment({
224225 color,
225226 now,
226227 index,
228 showAccountName,
227229}: {
228230 readonly account: LimitAccount;
229231 readonly window: LimitPoolMember["window"];
......@@ -232,6 +234,7 @@ function PoolSegment({
232234 readonly now: number;
233235 /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */
234236 readonly index: number;
237 readonly showAccountName: boolean;
235238}) {
236239 const [open, setOpen] = useState(false);
237240 const remaining = remainingPercent(window);
......@@ -274,7 +277,12 @@ function PoolSegment({
274277 {index}
275278 </span>
276279 <div className="relative hidden h-full min-w-0 items-center gap-1.5 px-2 text-xs @2xl/pool:flex">
277 <AccountName account={account} className="min-w-0 truncate font-medium text-foreground" />
280 {showAccountName ? (
281 <AccountName
282 account={account}
283 className="min-w-0 truncate font-medium text-foreground"
284 />
285 ) : null}
278286 <span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span>
279287 {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */}
280288 <span className="ms-auto flex shrink-0 items-center gap-1.5 rounded-sm bg-background/85 px-1.5 py-0.5 text-2xs text-foreground tabular-nums">
......@@ -463,6 +471,7 @@ function PoolBar({
463471 color={color}
464472 now={now}
465473 index={position + 1}
474 showAccountName={pool.columns.length > 1}
466475 />
467476 ) : null,
468477 )}
......@@ -479,17 +488,21 @@ function PoolWindowCard({
479488 pool,
480489 color,
481490 now,
491 label,
492 description,
482493}: {
483494 readonly pool: LimitPoolWindow;
484495 readonly color: string;
485496 readonly now: number;
497 readonly label?: string | undefined;
498 readonly description?: string | undefined;
486499}) {
487500 // The soonest reset that hands anything back; an untouched account resets to no effect.
488501 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
489502 return (
490503 <div className="grid items-center gap-x-6 gap-y-3 rounded-lg border border-border/60 p-4 md:grid-cols-[11rem_minmax(0,1fr)]">
491504 <div className="flex flex-col gap-1">
492 <span className="text-sm font-medium text-foreground">{pool.label}</span>
505 <span className="text-sm font-medium text-foreground">{label ?? pool.label}</span>
493506 <span className="flex items-baseline gap-2">
494507 <span className="text-3xl font-semibold text-foreground tabular-nums">
495508 {pool.remainingPercent}%
......@@ -497,14 +510,16 @@ function PoolWindowCard({
497510 <span className="text-sm text-muted-foreground">left</span>
498511 {pool.pace ? <PaceIcon pace={pool.pace} /> : null}
499512 </span>
500 {nextRefill ? (
501 <span className="text-xs text-muted-foreground tabular-nums">
502 <span className="font-medium text-foreground">↻ +{nextRefill.restoresPercent}%</span>{" "}
503 {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`}
513 {nextRefill && pool.columns.length > 1 ? (
514 <span className="text-xs font-medium text-foreground tabular-nums">
515 ↻ +{nextRefill.restoresPercent}%
504516 </span>
505517 ) : null}
506518 </div>
507519 <PoolBar pool={pool} color={color} now={now} />
520 {description ? (
521 <p className="text-xs text-muted-foreground md:col-span-2">{description}</p>
522 ) : null}
508523 </div>
509524 );
510525}
......@@ -512,6 +527,7 @@ function PoolWindowCard({
512527function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) {
513528 const color = barColor(pool.driver);
514529 const label = getDriverOption(pool.driver)?.label ?? String(pool.driver);
530 const windows = displayLimitWindows(pool);
515531 return (
516532 <section className="flex flex-col gap-3">
517533 <h2 className="flex items-center gap-2 text-sm font-medium text-foreground">
......@@ -524,9 +540,19 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
524540 />
525541 {label}
526542 </h2>
527 {pool.windows.map((window) => (
528 <PoolWindowCard key={`${window.kind}:${window.id}`} pool={window} color={color} now={now} />
529 ))}
543 {windows.map((window) => {
544 const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined;
545 return (
546 <PoolWindowCard
547 key={`${window.kind}:${window.id}`}
548 pool={window}
549 color={color}
550 now={now}
551 label={details?.label}
552 description={details?.description}
553 />
554 );
555 })}
530556 </section>
531557 );
532558}
......@@ -539,22 +565,33 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
539565export function UsageLimitsPooled({
540566 presentations,
541567 now,
568 cursorPrompt,
542569}: {
543570 readonly presentations: Parameters<typeof collectLimitAccounts>[0];
544571 readonly now: number;
572 readonly cursorPrompt?: ReactNode;
545573}) {
546574 const pools = collectLimitPools(collectLimitAccounts(presentations), now);
547575 const notices = collectLimitNotices(presentations);
576 const cursorPromptAt =
577 Math.max(
578 pools.findIndex((pool) => pool.driver === "codex"),
579 pools.findIndex((pool) => pool.driver === "claudeAgent"),
580 ) + 1;
548581 return (
549582 <div className="flex flex-col gap-8">
550 {pools.length === 0 && notices.length === 0 ? (
583 {pools.length === 0 && notices.length === 0 && !cursorPrompt ? (
551584 <p className="text-sm text-muted-foreground">
552585 No provider on the selected environments reports subscription limits.
553586 </p>
554587 ) : null}
555 {pools.map((pool) => (
556 <PoolSection key={pool.driver} pool={pool} now={now} />
588 {pools.map((pool, index) => (
589 <Fragment key={pool.driver}>
590 {index === cursorPromptAt ? cursorPrompt : null}
591 <PoolSection pool={pool} now={now} />
592 </Fragment>
557593 ))}
594 {cursorPromptAt === pools.length ? cursorPrompt : null}
558595 <LimitNotices notices={notices} />
559596 </div>
560597 );
apps/web/src/components/usage/UsagePage.tsx+202-3
......@@ -1,6 +1,7 @@
11import { RefreshIcon } from "~/components/ui/refresh-icon";
22import { useAtomValue } from "@effect/atom-react";
33import {
4 ProviderDriverKind,
45 USAGE_CONTRACT_VERSION,
56 type EnvironmentId,
67 type UsageProviderKind,
......@@ -40,6 +41,7 @@ import {
4041 makeWindow,
4142} from "@t3tools/shared/usageFormat";
4243import { Button, InlineButton } from "../ui/button";
44import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
4345import {
4446 Menu,
4547 MenuCheckboxItem,
......@@ -53,6 +55,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../
5355import { SidebarInset } from "../ui/sidebar";
5456import { Skeleton } from "../ui/skeleton";
5557import { Toggle, ToggleGroup } from "../ui/toggle-group";
58import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5659import {
5760 WorkspaceBreadcrumb,
5861 WorkspaceBreadcrumbItem,
......@@ -118,6 +121,25 @@ export function UsagePage() {
118121 selectedEnvironmentIds,
119122 );
120123 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
124 const cursorAccessEnvironments = selectedEnvironments.filter((environment) =>
125 environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"),
126 );
127 const sourceMessages = [
128 ...new Set(
129 selectedEnvironments.flatMap(
130 (environment) =>
131 environment.summary?.sources.flatMap((source) =>
132 source.message &&
133 !source.action &&
134 (source.status === "partial" ||
135 source.status === "failed" ||
136 source.fingerprint.provider === "cursor")
137 ? [source.message]
138 : [],
139 ) ?? [],
140 ),
141 ),
142 ];
121143 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
122144 reportFailure: false,
123145 });
......@@ -147,6 +169,17 @@ export function UsagePage() {
147169 [breakdown, merged.models, metric],
148170 );
149171 const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);
172 const summaryRows: Array<
173 | { readonly kind: "usage"; readonly provider: UsageProviderKind }
174 | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus }
175 > = activeProviders.map((provider) => ({ kind: "usage", provider }));
176 const cursorInsertAt =
177 Math.max(activeProviders.indexOf("codex"), activeProviders.indexOf("claude")) + 1;
178 summaryRows.splice(
179 cursorInsertAt,
180 0,
181 ...cursorAccessEnvironments.map((environment) => ({ kind: "enable" as const, environment })),
182 );
150183 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;
151184
152185 const selectWindow = (days: number) => {
......@@ -165,7 +198,7 @@ export function UsagePage() {
165198 setPreferences(nextPreferences);
166199 saveUsagePagePreferences(nextPreferences);
167200 };
168 const refreshLimits = async (automatic = false) => {
201 const refreshLimits = async (automatic = false, afterPending = false) => {
169202 try {
170203 await Promise.all(
171204 Array.from(presentations, ([environmentId, presentation]) => {
......@@ -175,6 +208,7 @@ export function UsagePage() {
175208 environmentId,
176209 () => refreshProviders({ environmentId, input: {} }),
177210 automatic,
211 afterPending,
178212 );
179213 }
180214 }),
......@@ -380,11 +414,30 @@ export function UsagePage() {
380414 : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
381415 </p>
382416 ) : showingLimits ? (
383 <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} now={limitsNow} />
417 <UsageLimitsSection
418 selectedEnvironmentIds={selectedEnvironmentIds}
419 now={limitsNow}
420 cursorPrompt={
421 cursorAccessEnvironments.length > 0 ? (
422 <CursorEnableLimits
423 environments={cursorAccessEnvironments}
424 onEnabled={() => {
425 void refresh();
426 void refreshLimits(false, true);
427 }}
428 />
429 ) : null
430 }
431 />
384432 ) : isPending ? (
385433 <UsageSkeleton />
386434 ) : (
387435 <>
436 {sourceMessages.map((message) => (
437 <p key={message} className="mb-4 text-sm text-muted-foreground">
438 {message}
439 </p>
440 ))}
388441 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
389442 <div className="flex min-w-0 flex-col gap-5">
390443 <div className="flex flex-col gap-1">
......@@ -404,7 +457,22 @@ export function UsagePage() {
404457 </span>
405458 </div>
406459
407 {activeProviders.map((provider) => {
460 {summaryRows.map((row) => {
461 if (row.kind === "enable") {
462 return (
463 <CursorEnableRow
464 key={`enable:${row.environment.environmentId}`}
465 environmentId={row.environment.environmentId}
466 label={row.environment.label}
467 showEnvironment={selectedEnvironments.length > 1}
468 onEnabled={() => {
469 void refresh();
470 void refreshLimits(false, true);
471 }}
472 />
473 );
474 }
475 const provider = row.provider;
408476 const totals = merged.providers.find((entry) => entry.provider === provider);
409477 const share =
410478 metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0);
......@@ -636,6 +704,137 @@ export function UsagePage() {
636704 );
637705}
638706
707const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";
708
709function CursorEnableButton({
710 environmentId,
711 label,
712 onEnabled,
713 tooltip,
714 buttonText = "Enable",
715}: {
716 readonly environmentId: EnvironmentId;
717 readonly label: string;
718 readonly onEnabled: () => void;
719 readonly tooltip: boolean;
720 readonly buttonText?: string;
721}) {
722 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
723 label: "enable Cursor account usage",
724 });
725 const [pending, setPending] = useState(false);
726 const enable = async () => {
727 setPending(true);
728 try {
729 const result = await updateSettings({
730 environmentId,
731 input: { patch: { cursorKeychainUsageEnabled: true } },
732 });
733 if (result._tag === "Success") onEnabled();
734 } finally {
735 setPending(false);
736 }
737 };
738 const button = tooltip ? (
739 <InlineButton
740 disabled={pending}
741 aria-busy={pending}
742 aria-label={`Enable Cursor usage from ${label}`}
743 onClick={() => void enable()}
744 >
745 {buttonText}
746 </InlineButton>
747 ) : (
748 <Button
749 size="sm"
750 variant="outline"
751 disabled={pending}
752 aria-busy={pending}
753 aria-label={`Enable Cursor usage from ${label}`}
754 onClick={() => void enable()}
755 >
756 {buttonText}
757 </Button>
758 );
759 if (!tooltip) return button;
760 return (
761 <Tooltip>
762 <TooltipTrigger render={button} />
763 <TooltipPopup>{CURSOR_KEYCHAIN_COPY}</TooltipPopup>
764 </Tooltip>
765 );
766}
767
768function CursorEnableRow({
769 environmentId,
770 label,
771 showEnvironment,
772 onEnabled,
773}: {
774 readonly environmentId: EnvironmentId;
775 readonly label: string;
776 readonly showEnvironment: boolean;
777 readonly onEnabled: () => void;
778}) {
779 return (
780 <div className="flex min-w-0 items-baseline justify-between gap-4 text-sm">
781 <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
782 <span
783 aria-hidden
784 className="size-2 shrink-0 rounded-full"
785 style={{ backgroundColor: PROVIDER_PRESENTATION.cursor.color }}
786 />
787 <ProviderMark provider="cursor" className="size-4" />
788 <span className="truncate">Cursor{showEnvironment ? ` · ${label}` : ""}</span>
789 </span>
790 <CursorEnableButton
791 environmentId={environmentId}
792 label={label}
793 onEnabled={onEnabled}
794 tooltip
795 />
796 </div>
797 );
798}
799
800function CursorEnableLimits({
801 environments,
802 onEnabled,
803}: {
804 readonly environments: readonly EnvironmentUsageStatus[];
805 readonly onEnabled: () => void;
806}) {
807 return (
808 <section className="flex flex-col gap-3">
809 <h2 className="flex items-center gap-2 text-sm font-medium text-foreground">
810 <ProviderInstanceIcon
811 driverKind={ProviderDriverKind.make("cursor")}
812 displayName="Cursor"
813 indicatorBackground="var(--background)"
814 className="size-5"
815 iconClassName="size-4 text-foreground/80"
816 />
817 Cursor
818 </h2>
819 <div className="flex flex-col items-start gap-3 rounded-lg border border-border/60 p-4">
820 <p className="text-xs text-muted-foreground">{CURSOR_KEYCHAIN_COPY}</p>
821 <div className="flex flex-wrap gap-2">
822 {environments.map((environment) => (
823 <CursorEnableButton
824 key={environment.environmentId}
825 environmentId={environment.environmentId}
826 label={environment.label}
827 buttonText={environments.length > 1 ? `Enable on ${environment.label}` : "Enable"}
828 onEnabled={onEnabled}
829 tooltip={false}
830 />
831 ))}
832 </div>
833 </div>
834 </section>
835 );
836}
837
639838/** Brand mark for the harness a row belongs to. */
640839function ProviderMark({
641840 provider,
apps/web/src/components/usage/UsageProviderChart.test.ts+3
......@@ -89,6 +89,9 @@ describe("buildPeriodColumns", () => {
8989 { provider: "codex", value: 10 },
9090 { provider: "claude", value: 20 },
9191 { provider: "grok", value: 0 },
92 { provider: "cursor", value: 0 },
93 { provider: "opencode", value: 0 },
94 { provider: "antigravity", value: 0 },
9295 ]);
9396 });
9497
apps/web/src/components/usage/usageProviders.ts+12-1
......@@ -1,6 +1,14 @@
11import type { UsageProviderKind } from "@t3tools/contracts";
22
3import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons";
3import {
4 AntigravityIcon,
5 ClaudeAI,
6 CursorIcon,
7 GrokIcon,
8 type Icon,
9 OpenAI,
10 OpenCodeIcon,
11} from "../Icons";
412
513type UsageProviderPresentation = {
614 readonly label: string;
......@@ -30,6 +38,9 @@ export const PROVIDER_PRESENTATION = {
3038 color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))",
3139 mark: GrokIcon,
3240 },
41 cursor: { label: "Cursor", color: "#8b8b8b", mark: CursorIcon },
42 opencode: { label: "OpenCode", color: "#5b9bbd", mark: OpenCodeIcon },
43 antigravity: { label: "Antigravity", color: "#8c7bd1", mark: AntigravityIcon },
3344} satisfies Record<UsageProviderKind, UsageProviderPresentation>;
3445
3546/** Stable provider reading order across charts, summaries, tables, and hover rows. */
docs/user/usage.md+17-5
......@@ -6,13 +6,25 @@ desktop when the terminal is not focused. Customize `usage.open` in
66
77## Understand your usage
88
9**Usage** combines Codex, Claude Code, and Grok Build session history from your connected
9**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected
1010environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent
1111cost. These estimates are not your subscription bill.
1212
1313Totals depend on the history available on each server. Grok turns without a saved completed-turn
1414record are missing from the totals.
1515
16OpenCode reads its SQLite database and older JSON history. Antigravity reads local conversation
17databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the
18server to read a different data directory; comma-separated paths read multiple directories.
19
20Cursor reads account usage from Cursor's dashboard API using the CLI login saved on the server.
21This includes headless T3 sessions and desktop usage across machines; the same account counts
22once across connected environments. Without an accessible CLI login, T3 shows a
23notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text.
24On macOS, choose **Enable Cursor usage** on Usage to allow T3 to read your existing CLI login
25from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask
26you to allow access on the server Mac.
27
1628Usage includes each configured account's history, including disabled accounts. Custom homes follow
1729the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment
1830variable. Use absolute paths or `~/` paths in the account's environment settings; relative
......@@ -78,10 +90,10 @@ anything. The command is offered only for providers that appear under **Usage
7890OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in
7991the environment. T3 cannot report limits for external OpenCode servers because their credentials
8092belong to the remote server. Cursor reports
81its monthly allowance, including separate Auto and API usage, using a file-based CLI login or
82`CURSOR_AUTH_TOKEN`. Cursor's default macOS keychain login does not currently report limits.
83On macOS, use `AGENT_CLI_CREDENTIAL_STORE=file` when signing in and in the provider's environment
84to use a file-based login.
93its monthly allowance, including separate Auto and API usage, using the CLI login or
94`CURSOR_AUTH_TOKEN`. On macOS, this includes the default Keychain login after you enable Cursor
95usage. Keychain login is used for limits only with Cursor's default API endpoint. If you configure
96a custom Cursor endpoint, use an explicit token or file-based CLI login for limits.
8597
8698Grok reports the remaining subscription allowance and reset time for its current billing period
8799after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication
packages/client-runtime/src/state/server.ts+9-1
......@@ -933,6 +933,14 @@ export function createServerEnvironmentAtoms<R, E>(
933933 );
934934 }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)),
935935 );
936 const usageScanSettingsAtom = Atom.family((environmentId: EnvironmentId) =>
937 Atom.make((get) =>
938 JSON.stringify([
939 get(usagePricesAtom(environmentId)),
940 get(settingsValueAtom(environmentId))?.cursorKeychainUsageEnabled ?? false,
941 ]),
942 ).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)),
943 );
936944 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>
937945 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(
938946 Atom.withLabel(`environment-data:server:providers:${environmentId}`),
......@@ -1050,7 +1058,7 @@ export function createServerEnvironmentAtoms<R, E>(
10501058 label: "environment-data:server:usage-summary",
10511059 tag: WS_METHODS.serverGetUsageSummary,
10521060 staleTimeMs: 60_000,
1053 refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId),
1061 refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId),
10541062 }),
10551063 configProjection,
10561064 welcome,
packages/client-runtime/src/state/usage.test.ts+13
......@@ -184,6 +184,19 @@ describe("manual usage refresh", () => {
184184});
185185
186186describe("limits refresh cooldown", () => {
187 it("runs a fresh check after an in-flight check when settings change", async () => {
188 const id = EnvironmentId.make("limits-after-enable");
189 const oldCheck = Promise.withResolvers<string>();
190 const first = refreshUsageLimits(id, () => oldCheck.promise, true);
191 const newCheck = vi.fn(async () => "new limits");
192 const afterEnable = refreshUsageLimits(id, newCheck, false, true);
193 expect(newCheck).not.toHaveBeenCalled();
194 oldCheck.resolve("old limits");
195 expect(await first).toBe("old limits");
196 expect(await afterEnable).toBe("new limits");
197 expect(newCheck).toHaveBeenCalledTimes(1);
198 });
199
187200 it("joins manual calls and gates automatic refreshes after success or failure", async () => {
188201 const clock = vi.spyOn(Date, "now").mockReturnValue(1_000);
189202 try {
packages/client-runtime/src/state/usage.ts+9
......@@ -16,9 +16,18 @@ export async function refreshUsageLimits<A>(
1616 environmentId: EnvironmentId,
1717 refresh: () => Promise<A>,
1818 automatic = false,
19 afterPending = false,
1920): Promise<A | undefined> {
2021 const pending = limitsRefreshes.get(environmentId);
2122 if (pending !== undefined) {
23 if (afterPending) {
24 try {
25 await pending;
26 } catch {
27 // The new check still needs to run if the earlier one failed.
28 }
29 return refreshUsageLimits(environmentId, refresh, false, true);
30 }
2231 // Manual refresh waits for the current check; automatic refresh does not repeat it.
2332 return automatic ? undefined : ((await pending) as A);
2433 }
packages/contracts/src/settings.ts+5
......@@ -1277,6 +1277,10 @@ export const ServerSettings = Schema.Struct({
12771277 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
12781278 Schema.withDecodingDefault(Effect.succeed({})),
12791279 ),
1280 /** Allows this server to read the Cursor CLI's macOS Keychain login for account usage. */
1281 cursorKeychainUsageEnabled: Schema.Boolean.pipe(
1282 Schema.withDecodingDefault(Effect.succeed(false)),
1283 ),
12801284 /** Exact model IDs, applied to past and future usage on this environment. */
12811285 usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe(
12821286 Schema.withDecodingDefault(Effect.succeed({})),
......@@ -1550,6 +1554,7 @@ export const ServerSettingsPatch = Schema.Struct({
15501554 usageLimitSources: Schema.optionalKey(
15511555 Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)),
15521556 ),
1557 cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean),
15531558 /** Each entry replaces one model's rates; `null` restores automatic pricing. */
15541559 usagePriceOverrides: Schema.optionalKey(
15551560 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)),
packages/contracts/src/usage.ts+18-11
......@@ -1,13 +1,10 @@
11/**
22 * Usage reporting contract.
33 *
4 * Each environment scans the provider CLIs' own on-disk session transcripts
5 * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`,
6 * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own
7 * orchestration projections, so usage stays complete even for turns that were
8 * never driven through T3 Code. This mirrors the approach `ccusage` takes.
4 * Each environment scans native session files and databases, including work
5 * driven outside T3 Code. Source status describes gaps in local coverage.
96 *
10 * Environments return pre-aggregated `(day, hourStart?, provider, model)`
7 * Environments return pre-aggregated `(day, hourStart?, provider, model, sourcePath?)`
118 * buckets. Raw transcript records never cross the wire.
129 *
1310 * @module usage
......@@ -21,18 +18,24 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
2118 * client renders partial coverage when an environment reports an older version
2219 * rather than failing the whole page.
2320 */
24export const USAGE_CONTRACT_VERSION = 5 as const;
21export const USAGE_CONTRACT_VERSION = 6 as const;
2522
2623/**
2724 * Oldest {@link UsageSummary} version a current client will still merge.
2825 *
29 * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets
30 * remain valid, so mixed-version environments keep those totals instead of
31 * treating every older server as stale.
26 * v5/v6 add providers and optional source attribution; v4 Claude/Codex
27 * buckets remain valid in mixed-version environments.
3228 */
3329export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const;
3430
35export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]);
31export const UsageProviderKind = Schema.Literals([
32 "claude",
33 "codex",
34 "grok",
35 "cursor",
36 "opencode",
37 "antigravity",
38]);
3639export type UsageProviderKind = typeof UsageProviderKind.Type;
3740
3841/**
......@@ -93,6 +96,8 @@ export const UsageBucket = Schema.Struct({
9396 hourStart: Schema.optional(TrimmedNonEmptyString),
9497 provider: UsageProviderKind,
9598 model: TrimmedNonEmptyString,
99 /** Source directory, so overlapping multi-home environments merge once per source. */
100 sourcePath: Schema.optional(TrimmedNonEmptyString),
96101 totals: UsageTokenTotals,
97102 costUsd: Schema.Number,
98103 /**
......@@ -151,6 +156,8 @@ export const UsageSource = Schema.Struct({
151156 */
152157 distinctSessions: NonNegativeInt,
153158 message: Schema.NullOr(TrimmedNonEmptyString),
159 /** An action the client can offer to make this source available. */
160 action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")),
154161});
155162export type UsageSource = typeof UsageSource.Type;
156163
packages/shared/src/usageFormat.test.ts+12
......@@ -5,10 +5,22 @@ import {
55 enumerateHourStarts,
66 formatDateTimeShort,
77 formatHourShort,
8 formatPercent,
89 formatRelativeHourShort,
910 makeWindow,
1011} from "./usageFormat.ts";
1112
13describe("formatPercent", () => {
14 it("distinguishes a small positive share from zero", () => {
15 expect(formatPercent(0)).toBe("0.0%");
16 expect(formatPercent(0.0004)).toBe("<0.1%");
17 expect(formatPercent(0.0009)).toBe("<0.1%");
18 expect(formatPercent(0.001)).toBe("0.1%");
19 expect(formatPercent(0.023)).toBe("2.3%");
20 expect(formatPercent(0.00004, 2)).toBe("<0.01%");
21 });
22});
23
1224describe("hourly usage formatting", () => {
1325 it("keeps requested zones separate when formatting repeated calls", () => {
1426 const instant = "2026-08-11T12:37:00.000Z";
packages/shared/src/usageFormat.ts+4-1
......@@ -43,7 +43,10 @@ function trim(value: number): string {
4343}
4444
4545export function formatPercent(share: number, digits = 1): string {
46 return `${(share * 100).toFixed(digits)}%`;
46 const percent = share * 100;
47 const smallest = 10 ** -digits;
48 if (percent > 0 && percent < smallest) return `<${smallest.toFixed(digits)}%`;
49 return `${percent.toFixed(digits)}%`;
4750}
4851
4952/** `2026-08-07` to `Aug 7`. */
packages/shared/src/usageLimits.test.ts+48
......@@ -16,6 +16,7 @@ import {
1616 collectLimitAccounts,
1717 collectLimitNotices,
1818 collectLimitPools,
19 displayLimitWindows,
1920 elapsedShare,
2021 formatResetsIn,
2122 limitsNotice,
......@@ -687,6 +688,53 @@ describe("pooled account columns", () => {
687688 });
688689});
689690
691describe("Cursor limit presentation", () => {
692 const cursorAccount: LimitAccount = {
693 key: "cursor",
694 driver: ProviderDriverKind.make("cursor"),
695 displayName: "Cursor",
696 email: undefined,
697 plan: undefined,
698 accentColor: undefined,
699 environments: [],
700 sourceLabel: "Cursor",
701 redeem: null,
702 limits: {
703 checkedAt: "2026-09-03T11:00:00.000Z",
704 windows: [
705 { id: "apiPercentUsed", kind: "monthly", label: "Other Models", usedPercent: 49 },
706 { id: "autoPercentUsed", kind: "monthly", label: "Cursor Models", usedPercent: 9 },
707 { id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 15 },
708 ],
709 },
710 };
711
712 it("hides the combined percentage and orders the two pools", () => {
713 const [pool] = collectLimitPools([cursorAccount], now);
714 const display = displayLimitWindows(pool!);
715 expect(display.map((window) => window.id)).toEqual(["autoPercentUsed", "apiPercentUsed"]);
716 });
717
718 it("keeps the combined percentage as a card if either allowance is missing", () => {
719 const [pool] = collectLimitPools(
720 [
721 {
722 ...cursorAccount,
723 limits: {
724 ...cursorAccount.limits,
725 windows: cursorAccount.limits.windows.filter(
726 (window) => window.id !== "apiPercentUsed",
727 ),
728 },
729 },
730 ],
731 now,
732 );
733 const display = displayLimitWindows(pool!);
734 expect(display.map((window) => window.id)).toEqual(["totalPercentUsed", "autoPercentUsed"]);
735 });
736});
737
690738describe("collectLimitNotices", () => {
691739 const checkedAt = "2026-09-03T11:00:00.000Z";
692740 const claude = ProviderDriverKind.make("claudeAgent");
packages/shared/src/usageLimits.ts+38
......@@ -24,6 +24,33 @@ const MINUTE = 60_000;
2424const HOUR = 60 * MINUTE;
2525const DAY = 24 * HOUR;
2626
27export const CURSOR_USAGE_WINDOWS = [
28 {
29 id: "totalPercentUsed",
30 label: "Overall",
31 description: "Combined usage across both allowances, not a third quota.",
32 },
33 {
34 id: "autoPercentUsed",
35 label: "Cursor Models",
36 description: "Grok and Composer use this first. Auto can use either pool.",
37 },
38 {
39 id: "apiPercentUsed",
40 label: "Other Models",
41 description: "Claude, GPT, and Gemini use this pool. Grok and Composer fall back here.",
42 },
43] as const;
44
45export function cursorUsageWindowDetails(id: string) {
46 return CURSOR_USAGE_WINDOWS.find((window) => window.id === id);
47}
48
49function cursorUsageWindowRank(id: string): number {
50 const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id);
51 return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank;
52}
53
2754/**
2855 * Providers that belong on the Limits view: enabled, installed, and one whose
2956 * driver reports subscription usage at all. A driver with no notion of usage
......@@ -282,6 +309,17 @@ export interface LimitPool {
282309 readonly windows: readonly LimitPoolWindow[];
283310}
284311
312/** Show Cursor's two usable pools instead of a combined percentage when both are available. */
313export function displayLimitWindows(pool: LimitPool) {
314 if (pool.driver !== "cursor") return pool.windows;
315 const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed");
316 const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed");
317 const hasBothPools = hasAuto && hasApi;
318 return pool.windows
319 .filter((window) => !hasBothPools || window.id !== "totalPercentUsed")
320 .sort((left, right) => cursorUsageWindowRank(left.id) - cursorUsageWindowRank(right.id));
321}
322
285323const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {
286324 session: 0,
287325 weekly: 1,
packages/shared/src/usageMerge.test.ts+160-1
......@@ -1,5 +1,6 @@
11import {
22 USAGE_CONTRACT_VERSION,
3 USAGE_MERGE_COMPATIBLE_SINCE,
34 type EnvironmentId,
45 type UsageBucket,
56 type UsageDay,
......@@ -74,6 +75,41 @@ function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {
7475}
7576
7677describe("mergeUsage", () => {
78 it("counts a Cursor account once across servers while retaining each server's other providers", () => {
79 const account = {
80 provider: "cursor" as const,
81 hostId: "cursor.com",
82 homePath: "cursor-account:account-hash",
83 volumeId: "account-hash",
84 };
85 const merged = mergeUsage(
86 [
87 environment(
88 "mac",
89 summary([bucket({ provider: "cursor", sourcePath: account.homePath })], [account]),
90 ),
91 environment(
92 "linux",
93 summary(
94 [
95 bucket({ provider: "cursor", sourcePath: account.homePath }),
96 bucket({ provider: "opencode", sourcePath: "/opencode" }),
97 ],
98 [account, { provider: "opencode", hostId: "linux", homePath: "/opencode" }],
99 ),
100 ),
101 ],
102 USAGE_CONTRACT_VERSION,
103 );
104 expect(
105 merged.providers.map((provider) => [provider.provider, provider.costUsd]).sort(),
106 ).toEqual([
107 ["cursor", 10],
108 ["opencode", 10],
109 ]);
110 expect(merged.duplicateSources).toHaveLength(1);
111 });
112
77113 it("sums environments that read different transcript directories", () => {
78114 const merged = mergeUsage(
79115 [
......@@ -146,6 +182,31 @@ describe("mergeUsage", () => {
146182 ).toEqual({ claude: 1, codex: 1 });
147183 });
148184
185 it("counts overlapping provider roots once while keeping each environment's unique root", () => {
186 const source = (homePath: string) => ({
187 provider: "opencode" as const,
188 hostId: "host",
189 homePath,
190 });
191 const usage = (sourcePath: string, costUsd: number) =>
192 bucket({ provider: "opencode", sourcePath, costUsd });
193 const merged = mergeUsage(
194 [
195 environment(
196 "env-a",
197 summary([usage("/shared", 10), usage("/a", 2)], [source("/shared"), source("/a")]),
198 ),
199 environment(
200 "env-b",
201 summary([usage("/shared", 10), usage("/b", 3)], [source("/shared"), source("/b")]),
202 ),
203 ],
204 USAGE_CONTRACT_VERSION,
205 );
206 expect(merged.costUsd).toBe(15);
207 expect(merged.sessions).toBe(3);
208 });
209
149210 it("uses the newest scan when environments share the same transcript directory", () => {
150211 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
151212 const environments = [
......@@ -166,6 +227,104 @@ describe("mergeUsage", () => {
166227 }
167228 });
168229
230 it("prefers a complete scan over a newer partial scan of the same directory", () => {
231 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
232 const incomplete = summary([bucket({ costUsd: 4, records: 2 })], [source]);
233 const partial = environment("new", {
234 ...incomplete,
235 readAt: "2026-08-07T01:00:00.000Z",
236 sources: incomplete.sources.map((entry) => ({ ...entry, status: "partial" as const })),
237 });
238 const complete = environment("old", summary([bucket()], [source]));
239
240 for (const ordered of [
241 [partial, complete],
242 [complete, partial],
243 ]) {
244 const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION);
245 expect(merged.costUsd).toBe(10);
246 expect(merged.contributingEnvironments).toEqual(["old"]);
247 expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]);
248 }
249 expect(mergeUsage([partial], USAGE_CONTRACT_VERSION).costUsd).toBe(4);
250 });
251
252 it("keeps new cells from a later partial scan without recounting older cells", () => {
253 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
254 const complete = environment(
255 "old",
256 summary([bucket()], [source], USAGE_MERGE_COMPATIBLE_SINCE),
257 );
258 const partialSummary = summary(
259 [
260 bucket({ sourcePath: source.homePath, costUsd: 4, records: 2 }),
261 bucket({
262 day: "2026-08-08" as UsageDay,
263 sourcePath: source.homePath,
264 costUsd: 3,
265 records: 1,
266 }),
267 ],
268 [{ ...source, distinctSessions: 2 }],
269 );
270 const partial = environment("new", {
271 ...partialSummary,
272 readAt: "2026-08-08T01:00:00.000Z",
273 sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })),
274 });
275
276 for (const ordered of [
277 [complete, partial],
278 [partial, complete],
279 ]) {
280 const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION);
281 expect(merged.costUsd).toBe(13);
282 expect(merged.records).toBe(6);
283 expect(merged.sessions).toBe(2);
284 expect(merged.daily.map(({ day, costUsd }) => [day, costUsd])).toEqual([
285 ["2026-08-07", 10],
286 ["2026-08-08", 3],
287 ]);
288 expect(merged.contributingEnvironments).toEqual(
289 ordered.map(({ environmentId }) => environmentId),
290 );
291 expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]);
292 }
293 });
294
295 it("retains a complete cell when a larger partial cell may have skipped old records", () => {
296 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
297 const complete = environment("old", summary([bucket()], [source]));
298 const partialSummary = summary(
299 [
300 bucket({
301 costUsd: 4,
302 records: 6,
303 totals: {
304 uncachedInputTokens: 80,
305 cachedInputTokens: 500,
306 cacheCreationTokens: 10,
307 outputTokens: 30,
308 reasoningTokens: 0,
309 },
310 }),
311 ],
312 [{ ...source, distinctSessions: 2 }],
313 );
314 const partial = environment("new", {
315 ...partialSummary,
316 readAt: "2026-08-07T01:00:00.000Z",
317 sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })),
318 });
319
320 const merged = mergeUsage([complete, partial], USAGE_CONTRACT_VERSION);
321 expect(merged.costUsd).toBe(10);
322 expect(merged.totalTokens).toBe(1160);
323 expect(merged.records).toBe(5);
324 expect(merged.sessions).toBe(1);
325 expect(merged.contributingEnvironments).toEqual(["old"]);
326 });
327
169328 it("excludes an environment reporting an older contract version", () => {
170329 const merged = mergeUsage(
171330 [
......@@ -178,7 +337,7 @@ describe("mergeUsage", () => {
178337 summary(
179338 [bucket()],
180339 [{ provider: "claude", hostId: "linux", homePath: "/b" }],
181 USAGE_CONTRACT_VERSION - 2,
340 USAGE_MERGE_COMPATIBLE_SINCE - 1,
182341 ),
183342 ),
184343 ],
packages/shared/src/usageMerge.ts+109-12
......@@ -11,6 +11,7 @@ import {
1111 type EnvironmentId,
1212 type UsageBucket,
1313 type UsageProviderKind,
14 type UsageSource,
1415 type UsageSourceFingerprint,
1516 type UsageSummary,
1617} from "@t3tools/contracts";
......@@ -113,20 +114,46 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string {
113114 ].join(" ");
114115}
115116
117function bucketsForSource(summary: UsageSummary, source: UsageSource): readonly UsageBucket[] {
118 const providerSources = summary.sources.filter(
119 (entry) => entry.fingerprint.provider === source.fingerprint.provider,
120 );
121 return summary.buckets.filter(
122 (bucket) =>
123 bucket.provider === source.fingerprint.provider &&
124 (bucket.sourcePath === source.fingerprint.resolvedHomePath ||
125 (bucket.sourcePath === undefined && providerSources.length === 1)),
126 );
127}
128
129function bucketKey(bucket: UsageBucket): string {
130 return JSON.stringify([bucket.day, bucket.hourStart ?? null, bucket.provider, bucket.model]);
131}
132
116133/**
117134 * Decides which environment owns each physical transcript directory.
118135 *
119136 * Several environments on one machine (worktree servers, for instance) resolve
120137 * the same provider home and would otherwise double count every token. The
121 * most recently read summary claims a fingerprint; the rest have that provider's
122 * buckets dropped. Environment ids break ties so the winner is stable when
123 * summaries have the same read time.
138 * Complete scans claim a fingerprint ahead of partial scans, then the most
139 * recently read scan wins within each status. A newer partial scan can still
140 * contribute cells absent from an older complete scan. Environment ids break
141 * ties so the result is stable when summaries have the same read time.
124142 */
125143function claimSources(environments: readonly EnvironmentUsage[]): {
126144 readonly ownerByFingerprint: ReadonlyMap<string, EnvironmentId>;
145 readonly supplementalBucketsByEnvironment: ReadonlyMap<EnvironmentId, ReadonlySet<UsageBucket>>;
146 readonly sessionsByFingerprint: ReadonlyMap<string, number>;
127147 readonly duplicates: readonly string[];
128148} {
129149 const ownerByFingerprint = new Map<string, EnvironmentId>();
150 const ownerScanByFingerprint = new Map<
151 string,
152 { environment: EnvironmentUsage; source: UsageSource }
153 >();
154 const seenBucketKeysByFingerprint = new Map<string, Set<string>>();
155 const supplementalBucketsByEnvironment = new Map<EnvironmentId, Set<UsageBucket>>();
156 const sessionsByFingerprint = new Map<string, number>();
130157 const duplicates: string[] = [];
131158
132159 const ordered = [...environments].sort(
......@@ -135,30 +162,82 @@ function claimSources(environments: readonly EnvironmentUsage[]): {
135162 a.environmentId.localeCompare(b.environmentId),
136163 );
137164
165 // A complete scan takes precedence over a newer partial scan of the same
166 // directory. Partial history still contributes when no complete copy exists.
167 for (const status of ["ok", "partial", "failed"] as const) {
168 for (const environment of ordered) {
169 for (const source of environment.summary.sources) {
170 if (source.status !== status) continue;
171 const key = fingerprintKey(source.fingerprint);
172 if (ownerByFingerprint.has(key)) {
173 duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);
174 continue;
175 }
176 ownerByFingerprint.set(key, environment.environmentId);
177 ownerScanByFingerprint.set(key, { environment, source });
178 sessionsByFingerprint.set(key, source.distinctSessions);
179 }
180 }
181 }
182
183 // A newer partial scan may contain usage recorded after an older complete
184 // scan. Keep cells absent from the complete scan. Aggregated cells do not
185 // reveal enough to reconcile overlapping records without double counting.
138186 for (const environment of ordered) {
139187 for (const source of environment.summary.sources) {
140 if (source.status === "missing") continue;
188 if (source.status !== "partial") continue;
141189 const key = fingerprintKey(source.fingerprint);
142 if (ownerByFingerprint.has(key)) {
143 duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);
190 const owner = ownerScanByFingerprint.get(key);
191 if (
192 owner?.source.status !== "ok" ||
193 Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt)
194 ) {
144195 continue;
145196 }
146 ownerByFingerprint.set(key, environment.environmentId);
197 let seen = seenBucketKeysByFingerprint.get(key);
198 if (seen === undefined) {
199 seen = new Set(bucketsForSource(owner.environment.summary, owner.source).map(bucketKey));
200 seenBucketKeysByFingerprint.set(key, seen);
201 }
202 const supplemental =
203 supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set<UsageBucket>();
204 let added = false;
205 for (const bucket of bucketsForSource(environment.summary, source)) {
206 const cell = bucketKey(bucket);
207 if (seen.has(cell)) continue;
208 seen.add(cell);
209 supplemental.add(bucket);
210 added = true;
211 }
212 if (!added) continue;
213 supplementalBucketsByEnvironment.set(environment.environmentId, supplemental);
214 sessionsByFingerprint.set(
215 key,
216 Math.max(sessionsByFingerprint.get(key) ?? 0, source.distinctSessions),
217 );
147218 }
148219 }
149220
150 return { ownerByFingerprint, duplicates };
221 return {
222 ownerByFingerprint,
223 supplementalBucketsByEnvironment,
224 sessionsByFingerprint,
225 duplicates,
226 };
151227}
152228
153229/** Sources this environment owns after fingerprint claims, plus their buckets. */
154230function ownedContribution(
155231 environment: EnvironmentUsage,
156232 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
233 supplementalBuckets: ReadonlySet<UsageBucket>,
234 sessionsByFingerprint: ReadonlyMap<string, number>,
157235): {
158236 readonly buckets: readonly UsageBucket[];
159237 readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>;
160238} {
161239 const ownedProviders = new Set<UsageProviderKind>();
240 const ownedSources = new Set<string>();
162241 const sessionsByProvider = new Map<UsageProviderKind, number>();
163242 for (const source of environment.summary.sources) {
164243 if (source.status === "missing") continue;
......@@ -166,16 +245,24 @@ function ownedContribution(
166245 if (ownerByFingerprint.get(key) === environment.environmentId) {
167246 const provider = source.fingerprint.provider;
168247 ownedProviders.add(provider);
248 ownedSources.add(`${provider}\u0000${source.fingerprint.resolvedHomePath}`);
169249 // Distinct within a directory. Summing per-bucket session counts instead
170250 // would count a session once per day and model it spans.
171251 sessionsByProvider.set(
172252 provider,
173 (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions,
253 (sessionsByProvider.get(provider) ?? 0) +
254 (sessionsByFingerprint.get(key) ?? source.distinctSessions),
174255 );
175256 }
176257 }
177258 return {
178 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
259 buckets: environment.summary.buckets.filter(
260 (bucket) =>
261 supplementalBuckets.has(bucket) ||
262 (bucket.sourcePath === undefined
263 ? ownedProviders.has(bucket.provider)
264 : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`)),
265 ),
179266 sessionsByProvider,
180267 };
181268}
......@@ -246,7 +333,12 @@ export function mergeUsage(
246333 }
247334 }
248335
249 const { ownerByFingerprint, duplicates } = claimSources(current);
336 const {
337 ownerByFingerprint,
338 supplementalBucketsByEnvironment,
339 sessionsByFingerprint,
340 duplicates,
341 } = claimSources(current);
250342
251343 let costUsd = 0;
252344 let uncachedInputTokens = 0;
......@@ -295,7 +387,12 @@ export function mergeUsage(
295387 const contributingEnvironments: EnvironmentId[] = [];
296388
297389 for (const environment of current) {
298 const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint);
390 const { buckets, sessionsByProvider } = ownedContribution(
391 environment,
392 ownerByFingerprint,
393 supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(),
394 sessionsByFingerprint,
395 );
299396 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
300397
301398 for (const [providerKind, providerSessions] of sessionsByProvider) {
pnpm-lock.yaml+3
......@@ -516,6 +516,9 @@ importers:
516516 '@ff-labs/fff-node':
517517 specifier: 0.9.4
518518 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8)
519 '@napi-rs/keyring':
520 specifier: ^1.3.0
521 version: 1.3.0
519522 '@opencode-ai/sdk':
520523 specifier: ^1.3.15
521524 version: 1.15.13
scripts/lib/cli-external-packages.test.ts+2-1
......@@ -49,6 +49,7 @@ describe("shouldBundleCliDependency", () => {
4949 "ffi-rs",
5050 "@yuuang/ffi-rs-win32-x64-msvc",
5151 "@ff-labs/fff-node",
52 "@napi-rs/keyring",
5253 "@clerk/electron-passkeys",
5354 "node-addon-api",
5455 ]) {
......@@ -82,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => {
8283 it("selects every external root declared by the server", () => {
8384 assert.deepStrictEqual(
8485 Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(),
85 ["@ff-labs/fff-node", "node-pty"],
86 ["@ff-labs/fff-node", "@napi-rs/keyring", "node-pty"],
8687 );
8788 });
8889});
scripts/lib/cli-external-packages.ts+1
......@@ -30,6 +30,7 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [
3030 "ffi-rs",
3131 "@yuuang/",
3232 "@ff-labs/",
33 "@napi-rs/keyring",
3334 "@clerk/electron-passkeys",
3435 "node-gyp-build",
3536 "node-addon-api",