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 {...@@ -5,13 +5,15 @@ import {
5 collectLimitAccounts,5 collectLimitAccounts,
6 collectLimitNotices,6 collectLimitNotices,
7 collectLimitPools,7 collectLimitPools,
8 cursorUsageWindowDetails,
9 displayLimitWindows,
8 formatDuration,10 formatDuration,
9 formatResetsIn,11 formatResetsIn,
10 remainingPercent,12 remainingPercent,
11 type LimitAccount,13 type LimitAccount,
12 type LimitPoolWindow,14 type LimitPoolWindow,
13} from "@t3tools/shared/usageLimits";15} from "@t3tools/shared/usageLimits";
14import { useId, useState } from "react";16import { Fragment, type ReactNode, useId, useState } from "react";
15import { Pressable, ScrollView, View } from "react-native";17import { Pressable, ScrollView, View } from "react-native";
16import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";18import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
17import { useSafeAreaInsets } from "react-native-safe-area-context";19import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -70,11 +72,15 @@ function PoolWindowCard({...@@ -70,11 +72,15 @@ function PoolWindowCard({
70 color,72 color,
71 now,73 now,
72 environmentIds,74 environmentIds,
75 label,
76 description,
73}: {77}: {
74 readonly pool: LimitPoolWindow;78 readonly pool: LimitPoolWindow;
75 readonly color: string;79 readonly color: string;
76 readonly now: number;80 readonly now: number;
77 readonly environmentIds: readonly string[] | null;81 readonly environmentIds: readonly string[] | null;
82 readonly label?: string;
83 readonly description?: string;
78}) {84}) {
79 const navigation = useNavigation();85 const navigation = useNavigation();
80 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);86 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
...@@ -96,7 +102,7 @@ function PoolWindowCard({...@@ -96,7 +102,7 @@ function PoolWindowCard({
96 <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">102 <View className="gap-3 rounded-[24px] border-continuous bg-card p-4">
97 <View className="flex-row items-start justify-between gap-3">103 <View className="flex-row items-start justify-between gap-3">
98 <View className="gap-1">104 <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>
100 <View className="flex-row items-baseline gap-1.5">106 <View className="flex-row items-baseline gap-1.5">
101 <Text className="text-3xl font-t3-bold tabular-nums text-foreground">107 <Text className="text-3xl font-t3-bold tabular-nums text-foreground">
102 {pool.remainingPercent}%108 {pool.remainingPercent}%
...@@ -108,6 +114,7 @@ function PoolWindowCard({...@@ -108,6 +114,7 @@ function PoolWindowCard({
108 <Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>114 <Text className="text-xs text-foreground-tertiary">{PACE_LABEL[pool.pace]}</Text>
109 ) : null}115 ) : null}
110 </View>116 </View>
117 {description ? <Text className="text-xs text-foreground-muted">{description}</Text> : null}
111 {nextRefill ? (118 {nextRefill ? (
112 <Text className="text-xs tabular-nums text-foreground-muted">119 <Text className="text-xs tabular-nums text-foreground-muted">
113 ↻ +{nextRefill.restoresPercent}%{" "}120 ↻ +{nextRefill.restoresPercent}%{" "}
...@@ -196,10 +203,12 @@ export function UsageLimitsSection({...@@ -196,10 +203,12 @@ export function UsageLimitsSection({
196 now,203 now,
197 failedLabels,204 failedLabels,
198 selectedEnvironmentIds,205 selectedEnvironmentIds,
206 cursorPrompt,
199}: {207}: {
200 readonly now: number;208 readonly now: number;
201 readonly failedLabels: readonly string[];209 readonly failedLabels: readonly string[];
202 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;210 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
211 readonly cursorPrompt?: ReactNode;
203}) {212}) {
204 const presentations = useAtomValue(environmentPresentations.presentationsAtom);213 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
205 const selected =214 const selected =
...@@ -209,34 +218,54 @@ export function UsageLimitsSection({...@@ -209,34 +218,54 @@ export function UsageLimitsSection({
209 const pools = collectLimitPools(collectLimitAccounts(selected), now);218 const pools = collectLimitPools(collectLimitAccounts(selected), now);
210 const notices = collectLimitNotices(selected);219 const notices = collectLimitNotices(selected);
211 const colors = useProviderColors();220 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;
212 return (226 return (
213 <View className="gap-6">227 <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 ? (
215 <Text className="py-12 text-center text-base text-foreground-muted">229 <Text className="py-12 text-center text-base text-foreground-muted">
216 {selected.size === 0230 {selected.size === 0
217 ? "Select an environment to see limits."231 ? "Select an environment to see limits."
218 : "No provider on the selected environments reports subscription limits."}232 : "No provider on the selected environments reports subscription limits."}
219 </Text>233 </Text>
220 ) : null}234 ) : null}
221 {pools.map((pool) => (235 {pools.map((pool, index) => {
222 <View key={pool.driver} className="gap-3">236 const windows = displayLimitWindows(pool);
223 <View className="flex-row items-center gap-2 px-1">237 return (
224 <ProviderIcon provider={pool.driver} size={18} />238 <Fragment key={pool.driver}>
225 <Text className="text-base font-t3-medium text-foreground">239 {index === cursorPromptAt ? cursorPrompt : null}
226 {DRIVER_LABEL[pool.driver] ?? pool.driver}240 <View className="gap-3">
227 </Text>241 <View className="flex-row items-center gap-2 px-1">
228 </View>242 <ProviderIcon provider={pool.driver} size={18} />
229 {pool.windows.map((window) => (243 <Text className="text-base font-t3-medium text-foreground">
230 <PoolWindowCard244 {DRIVER_LABEL[pool.driver] ?? pool.driver}
231 key={`${window.kind}:${window.id}`}245 </Text>
232 pool={window}246 </View>
233 color={pool.driver === "claudeAgent" ? colors.claude : colors.codex}247 {windows.map((window) => {
234 now={now}248 const details =
235 environmentIds={selectedEnvironmentIds === null ? null : [...selectedEnvironmentIds]}249 pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined;
236 />250 return (
237 ))}251 <PoolWindowCard
238 </View>252 key={`${window.kind}:${window.id}`}
239 ))}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}
240 {notices.length > 0 || failedLabels.length > 0 ? (269 {notices.length > 0 || failedLabels.length > 0 ? (
241 <View270 <View
242 accessible271 accessible
apps/mobile/src/features/usage/UsageLimitsSection.tsx+9-2
...@@ -294,7 +294,7 @@ export function useRefreshLimits(...@@ -294,7 +294,7 @@ export function useRefreshLimits(
294 const [failedEnvironments, setFailedEnvironments] = useState<294 const [failedEnvironments, setFailedEnvironments] = useState<
295 readonly { environmentId: EnvironmentId; label: string }[]295 readonly { environmentId: EnvironmentId; label: string }[]
296 >([]);296 >([]);
297 const refresh = async (automatic = false) => {297 const refresh = async (automatic = false, afterPending = false) => {
298 const connected = [...presentations].filter(298 const connected = [...presentations].filter(
299 ([environmentId, presentation]) =>299 ([environmentId, presentation]) =>
300 presentation.connection.phase === "connected" &&300 presentation.connection.phase === "connected" &&
...@@ -307,6 +307,7 @@ export function useRefreshLimits(...@@ -307,6 +307,7 @@ export function useRefreshLimits(
307 environmentId,307 environmentId,
308 () => refreshProviders({ environmentId, input: {} }),308 () => refreshProviders({ environmentId, input: {} }),
309 automatic,309 automatic,
310 afterPending,
310 );311 );
311 if (result === undefined) return;312 if (result === undefined) return;
312 setFailedEnvironments((previous) => [313 setFailedEnvironments((previous) => [
...@@ -354,5 +355,11 @@ export function useRefreshLimits(...@@ -354,5 +355,11 @@ export function useRefreshLimits(
354 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),355 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),
355 )356 )
356 .map(({ label }) => label);357 .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 };
358}365}
apps/mobile/src/features/usage/UsageRouteScreen.tsx+186-4
...@@ -25,9 +25,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -25,9 +25,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
2525
26import { SegmentedControl } from "../../components/SegmentedControl";26import { SegmentedControl } from "../../components/SegmentedControl";
27import { AppText as Text } from "../../components/AppText";27import { AppText as Text } from "../../components/AppText";
28import { ProviderIcon } from "../../components/ProviderIcon";
28import { cn } from "../../lib/cn";29import { cn } from "../../lib/cn";
29import { SettingsScreen } from "../settings/components/SettingsScreen";30import { SettingsScreen } from "../settings/components/SettingsScreen";
30import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";31import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
32import { serverEnvironment } from "../../state/server";
33import { useAtomCommand } from "../../state/use-atom-command";
31import { SettingsSection } from "../settings/components/SettingsSection";34import { SettingsSection } from "../settings/components/SettingsSection";
32import { UsageDailyChart } from "./UsageDailyChart";35import { UsageDailyChart } from "./UsageDailyChart";
33import { toggleUsageEnvironment } from "./usageEnvironmentSelection";36import { toggleUsageEnvironment } from "./usageEnvironmentSelection";
...@@ -59,6 +62,7 @@ const METRIC_OPTIONS = [...@@ -59,6 +62,7 @@ const METRIC_OPTIONS = [
59] as const satisfies readonly { value: UsageChartMetric; label: string }[];62] as const satisfies readonly { value: UsageChartMetric; label: string }[];
6063
61const CHART_HEIGHT = 180;64const CHART_HEIGHT = 180;
65const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";
6266
63/**67/**
64 * Two tabs over one screen. Usage is the transcript-derived spend for a68 * Two tabs over one screen. Usage is the transcript-derived spend for a
...@@ -97,6 +101,29 @@ export function UsageRouteScreen() {...@@ -97,6 +101,29 @@ export function UsageRouteScreen() {
97 );101 );
98 const isFocused = useIsFocused();102 const isFocused = useIsFocused();
99 const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");103 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
101 const days = useMemo(128 const days = useMemo(
102 () => enumerateDays(window.sinceDay, window.untilDay),129 () => enumerateDays(window.sinceDay, window.untilDay),
...@@ -251,7 +278,6 @@ export function UsageRouteScreen() {...@@ -251,7 +278,6 @@ export function UsageRouteScreen() {
251 }278 }
252 >279 >
253 <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />280 <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />
254
255 <Animated.View281 <Animated.View
256 key={tab}282 key={tab}
257 entering={FadeIn.duration(160).reduceMotion(ReduceMotion.System)}283 entering={FadeIn.duration(160).reduceMotion(ReduceMotion.System)}
...@@ -262,6 +288,14 @@ export function UsageRouteScreen() {...@@ -262,6 +288,14 @@ export function UsageRouteScreen() {
262 now={limits.now}288 now={limits.now}
263 failedLabels={limits.failedLabels}289 failedLabels={limits.failedLabels}
264 selectedEnvironmentIds={selectedEnvironmentIds}290 selectedEnvironmentIds={selectedEnvironmentIds}
291 cursorPrompt={
292 cursorAccessEnvironments.length > 0 ? (
293 <CursorEnableLimits
294 environments={cursorAccessEnvironments}
295 onEnabled={refreshAfterCursorEnable}
296 />
297 ) : null
298 }
265 />299 />
266 ) : (300 ) : (
267 <>301 <>
...@@ -301,6 +335,11 @@ export function UsageRouteScreen() {...@@ -301,6 +335,11 @@ export function UsageRouteScreen() {
301 </Text>335 </Text>
302 ) : (336 ) : (
303 <>337 <>
338 {sourceMessages.map((message) => (
339 <Text key={message} className="text-sm text-foreground-muted">
340 {message}
341 </Text>
342 ))}
304 <ChartCard343 <ChartCard
305 merged={merged}344 merged={merged}
306 days={chartDays}345 days={chartDays}
...@@ -311,7 +350,13 @@ export function UsageRouteScreen() {...@@ -311,7 +350,13 @@ export function UsageRouteScreen() {
311 isPast24Hours={isPast24Hours}350 isPast24Hours={isPast24Hours}
312 timeZone={window.timeZone}351 timeZone={window.timeZone}
313 />352 />
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 />
315 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />360 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
316 <ModelsSection merged={merged} />361 <ModelsSection merged={merged} />
317 </>362 </>
...@@ -324,6 +369,110 @@ export function UsageRouteScreen() {...@@ -324,6 +369,110 @@ export function UsageRouteScreen() {
324 );369 );
325}370}
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
327/** Headline figure, the animated daily chart, and its legend, in one card. */476/** Headline figure, the animated daily chart, and its legend, in one card. */
328function ChartCard(props: {477function ChartCard(props: {
329 readonly merged: MergedUsage;478 readonly merged: MergedUsage;
...@@ -400,20 +549,53 @@ function ChartCard(props: {...@@ -400,20 +549,53 @@ function ChartCard(props: {
400function ProviderSection(props: {549function ProviderSection(props: {
401 readonly merged: MergedUsage;550 readonly merged: MergedUsage;
402 readonly metric: UsageChartMetric;551 readonly metric: UsageChartMetric;
552 readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[];
553 readonly showCursorEnvironment: boolean;
554 readonly onCursorEnabled: () => void;
403}) {555}) {
404 const { merged, metric } = props;556 const { merged, metric } = props;
405 const colors = useProviderColors();557 const colors = useProviderColors();
406 if (merged.providers.length === 0) return null;558 if (merged.providers.length === 0 && props.cursorAccessEnvironments.length === 0) return null;
407559
408 // Ranked by whatever the toggle is showing, so the rows always descend.560 // Ranked by whatever the toggle is showing, so the rows always descend.
409 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method.561 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method.
410 const ordered = [...merged.providers].sort((a, b) =>562 const ordered = [...merged.providers].sort((a, b) =>
411 metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,563 metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
412 );564 );
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
414 return (583 return (
415 <SettingsSection title="Providers">584 <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;
417 const share = metric === "cost" ? provider.costShare : provider.tokenShare;599 const share = metric === "cost" ? provider.costShare : provider.tokenShare;
418 return (600 return (
419 <View601 <View
apps/mobile/src/features/usage/usageProviders.ts+14-1
...@@ -5,12 +5,22 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe...@@ -5,12 +5,22 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
5 * Series and table order. The chart stacks providers from the bottom in this5 * Series and table order. The chart stacks providers from the bottom in this
6 * order, so it also fixes which band sits on top of the bars.6 * order, so it also fixes which band sits on top of the bars.
7 */7 */
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
10export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {17export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
11 claude: "Claude Code",18 claude: "Claude Code",
12 codex: "Codex",19 codex: "Codex",
13 grok: "Grok Build",20 grok: "Grok Build",
21 cursor: "Cursor",
22 opencode: "OpenCode",
23 antigravity: "Antigravity",
14};24};
1525
16/**26/**
...@@ -23,5 +33,8 @@ export function useProviderColors(): Record<UsageProviderKind, string> {...@@ -23,5 +33,8 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
23 claude: "#d97757",33 claude: "#d97757",
24 codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",34 codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
25 grok: scheme === "dark" ? "#a1a1aa" : "#52525b",35 grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
36 cursor: "#8b8b8b",
37 opencode: "#5b9bbd",
38 antigravity: "#8c7bd1",
26 };39 };
27}40}
apps/server/package.json+1
...@@ -27,6 +27,7 @@...@@ -27,6 +27,7 @@
27 "@effect/platform-node": "catalog:",27 "@effect/platform-node": "catalog:",
28 "@effect/platform-node-shared": "catalog:",28 "@effect/platform-node-shared": "catalog:",
29 "@ff-labs/fff-node": "0.9.4",29 "@ff-labs/fff-node": "0.9.4",
30 "@napi-rs/keyring": "^1.3.0",
30 "@opencode-ai/sdk": "^1.3.15",31 "@opencode-ai/sdk": "^1.3.15",
31 "diff": "8.0.3",32 "diff": "8.0.3",
32 "effect": "catalog:",33 "effect": "catalog:",
apps/server/src/provider/Drivers/CursorDriver.ts+9-3
...@@ -148,9 +148,15 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -148,9 +148,15 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
148 snapshot.auth.status === "authenticated"148 snapshot.auth.status === "authenticated"
149 ),149 ),
150 (snapshot) =>150 (snapshot) =>
151 readCursorUsageLimits(effectiveConfig, processEnv).pipe(151 Effect.gen(function* () {
152 Effect.map((usageLimits) => ({ ...snapshot, usageLimits })),152 const settings = yield* serverSettings.getSettings;
153 ),153 const usageLimits = yield* readCursorUsageLimits(
154 effectiveConfig,
155 processEnv,
156 settings.cursorKeychainUsageEnabled,
157 );
158 return { ...snapshot, usageLimits };
159 }),
154 ),160 ),
155 Effect.map(stampIdentity),161 Effect.map(stampIdentity),
156 Effect.provideService(HttpClient.HttpClient, httpClient),162 Effect.provideService(HttpClient.HttpClient, httpClient),
apps/server/src/provider/Layers/CursorProvider.test.ts+74-5
...@@ -991,21 +991,21 @@ describe("Cursor usage limits", () => {...@@ -991,21 +991,21 @@ describe("Cursor usage limits", () => {
991 {991 {
992 id: "totalPercentUsed",992 id: "totalPercentUsed",
993 kind: "monthly",993 kind: "monthly",
994 label: "Monthly",994 label: "Overall",
995 usedPercent: 72.4,995 usedPercent: 72.4,
996 resetsAt: "2026-09-20T03:53:06.000Z",996 resetsAt: "2026-09-20T03:53:06.000Z",
997 },997 },
998 {998 {
999 id: "autoPercentUsed",999 id: "autoPercentUsed",
1000 kind: "monthly",1000 kind: "monthly",
1001 label: "Monthly · Auto",1001 label: "Cursor Models",
1002 usedPercent: 69.5,1002 usedPercent: 69.5,
1003 resetsAt: "2026-09-20T03:53:06.000Z",1003 resetsAt: "2026-09-20T03:53:06.000Z",
1004 },1004 },
1005 {1005 {
1006 id: "apiPercentUsed",1006 id: "apiPercentUsed",
1007 kind: "monthly",1007 kind: "monthly",
1008 label: "Monthly · API",1008 label: "Other Models",
1009 usedPercent: 100,1009 usedPercent: 100,
1010 resetsAt: "2026-09-20T03:53:06.000Z",1010 resetsAt: "2026-09-20T03:53:06.000Z",
1011 },1011 },
...@@ -1019,10 +1019,10 @@ describe("Cursor usage limits", () => {...@@ -1019,10 +1019,10 @@ describe("Cursor usage limits", () => {
1019 );1019 );
1020 expect(1020 expect(
1021 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows,1021 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 }]);
1023 expect(1023 expect(
1024 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows,1024 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 }]);
1026 });1026 });
10271027
1028 it("reads the instance's credentials and endpoint even when usage enabled is false", async () => {1028 it("reads the instance's credentials and endpoint even when usage enabled is false", async () => {
...@@ -1079,6 +1079,10 @@ describe("Cursor usage limits", () => {...@@ -1079,6 +1079,10 @@ describe("Cursor usage limits", () => {
1079 AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default",1079 AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default",
1080 ...(token ? { CURSOR_AUTH_TOKEN: token } : {}),1080 ...(token ? { CURSOR_AUTH_TOKEN: token } : {}),
1081 },1081 },
1082 false,
1083 async () => {
1084 throw new Error("must not read Keychain before opt-in");
1085 },
1082 ).pipe(1086 ).pipe(
1083 Effect.provideService(HostProcessPlatform, platform),1087 Effect.provideService(HostProcessPlatform, platform),
1084 Effect.provideService(1088 Effect.provideService(
...@@ -1108,6 +1112,71 @@ describe("Cursor usage limits", () => {...@@ -1108,6 +1112,71 @@ describe("Cursor usage limits", () => {
1108 }1112 }
1109 });1113 });
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
1111 it("reports failed requests without exposing credentials or response bodies", async () => {1180 it("reports failed requests without exposing credentials or response bodies", async () => {
1112 const limits = await runNode(1181 const limits = await runNode(
1113 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe(1182 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe(
apps/server/src/provider/Layers/cursorUsageLimits.ts+32-19
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as NodeOS from "node:os";1import * as NodeOS from "node:os";
2import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts";2import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits";
4import * as DateTime from "effect/DateTime";5import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
...@@ -13,8 +14,10 @@ import {...@@ -13,8 +14,10 @@ import {
13 makeUnavailableUsageLimits,14 makeUnavailableUsageLimits,
14 makeUsageLimits,15 makeUsageLimits,
15} from "../providerUsageLimits.ts";16} from "../providerUsageLimits.ts";
17import { readMacCursorAccessToken } from "../cursorCredentialStore.ts";
1618
17const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });19const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });
20const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh";
18const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials));21const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials));
19const CursorUsageResponse = Schema.Struct({22const CursorUsageResponse = Schema.Struct({
20 billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])),23 billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
...@@ -39,15 +42,11 @@ export function cursorUsageResponseToLimits(...@@ -39,15 +42,11 @@ export function cursorUsageResponseToLimits(
39 : undefined;42 : undefined;
40 const windows: ServerProviderUsageWindow[] = [];43 const windows: ServerProviderUsageWindow[] = [];
41 if (response.planUsage) {44 if (response.planUsage) {
42 for (const [key, label] of [45 for (const { id, label } of CURSOR_USAGE_WINDOWS) {
43 ["totalPercentUsed", "Monthly"],46 const usedPercent = response.planUsage[id];
44 ["autoPercentUsed", "Monthly · Auto"],
45 ["apiPercentUsed", "Monthly · API"],
46 ] as const) {
47 const usedPercent = response.planUsage[key];
48 if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue;47 if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue;
49 windows.push({48 windows.push({
50 id: key,49 id,
51 kind: "monthly",50 kind: "monthly",
52 label,51 label,
53 usedPercent: clampPercent(usedPercent),52 usedPercent: clampPercent(usedPercent),
...@@ -63,30 +62,49 @@ export function cursorUsageResponseToLimits(...@@ -63,30 +62,49 @@ export function cursorUsageResponseToLimits(
63export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* (62export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* (
64 settings: Pick<CursorSettings, "apiEndpoint">,63 settings: Pick<CursorSettings, "apiEndpoint">,
65 environment: NodeJS.ProcessEnv = process.env,64 environment: NodeJS.ProcessEnv = process.env,
65 allowKeychain = false,
66 keychainToken: () => Promise<string | null> = readMacCursorAccessToken,
66) {67) {
67 const checkedAt = DateTime.formatIso(yield* DateTime.now);68 const checkedAt = DateTime.formatIso(yield* DateTime.now);
68 return yield* Effect.gen(function* () {69 return yield* Effect.gen(function* () {
69 const fs = yield* FileSystem.FileSystem;70 const fs = yield* FileSystem.FileSystem;
70 const path = yield* Path.Path;71 const path = yield* Path.Path;
71 const platform = yield* HostProcessPlatform;72 const platform = yield* HostProcessPlatform;
73 const endpoint = (
74 settings.apiEndpoint.trim() ||
75 environment.CURSOR_API_ENDPOINT?.trim() ||
76 DEFAULT_CURSOR_API_ENDPOINT
77 ).replace(/\/$/, "");
72 let token = environment.CURSOR_AUTH_TOKEN?.trim();78 let token = environment.CURSOR_AUTH_TOKEN?.trim();
73 // An explicit API key can name a different account from the stored login.79 // An explicit API key can name a different account from the stored login.
74 if (!token && environment.CURSOR_API_KEY?.trim()) {80 if (!token && environment.CURSOR_API_KEY?.trim()) {
75 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });81 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
76 }82 }
77 const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE;83 const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE;
78 if (84 if (!token && credentialStore === "memory") {
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.
83 return makeUnavailableUsageLimits({85 return makeUnavailableUsageLimits({
84 checkedAt,86 checkedAt,
85 reason: "unsupported",87 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.",
87 });89 });
88 }90 }
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) {
90 const home =108 const home =
91 (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir();109 (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir();
92 const directory =110 const directory =
...@@ -106,11 +124,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function...@@ -106,11 +124,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function
106 }124 }
107 if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });125 if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
108 const client = yield* HttpClient.HttpClient;126 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(/\/$/, "");
114 const response = yield* client.execute(127 const response = yield* client.execute(
115 HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe(128 HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe(
116 HttpClientRequest.bearerToken(token),129 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 @@...@@ -3,10 +3,11 @@
3import * as NodeFSP from "node:fs/promises";3import * as NodeFSP from "node:fs/promises";
4import * as NodeOS from "node:os";4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";5import * as NodePath from "node:path";
6import * as NodeSqlite from "node:sqlite";
67
7import { assert, describe, it } from "@effect/vitest";8import { assert, describe, it } from "@effect/vitest";
8import * as NodeServices from "@effect/platform-node/NodeServices";9import * as NodeServices from "@effect/platform-node/NodeServices";
9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";10import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
10import { mergeUsage } from "@t3tools/shared/usageMerge";11import { mergeUsage } from "@t3tools/shared/usageMerge";
11import {12import {
12 EnvironmentId,13 EnvironmentId,
...@@ -31,6 +32,7 @@ import * as ServerConfig from "../config.ts";...@@ -31,6 +32,7 @@ import * as ServerConfig from "../config.ts";
31import * as ServerSettings from "../serverSettings.ts";32import * as ServerSettings from "../serverSettings.ts";
32import * as UsageService from "./UsageService.ts";33import * as UsageService from "./UsageService.ts";
3334
35const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
34const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));36const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
3537
36function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {38function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {
...@@ -82,9 +84,11 @@ const serviceLayers = (input: {...@@ -82,9 +84,11 @@ const serviceLayers = (input: {
82 /** Defaults to an unparsable document so every scan retries the fetch. */84 /** Defaults to an unparsable document so every scan retries the fetch. */
83 readonly ratesDocument?: unknown;85 readonly ratesDocument?: unknown;
84 readonly environment?: NodeJS.ProcessEnv;86 readonly environment?: NodeJS.ProcessEnv;
87 readonly platform?: NodeJS.Platform;
85}) =>88}) =>
86 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(89 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(
87 Layer.provideMerge(NodeServices.layer),90 Layer.provideMerge(NodeServices.layer),
91 Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")),
88 Layer.provideMerge(ServerSettings.layerTest(input.settings)),92 Layer.provideMerge(ServerSettings.layerTest(input.settings)),
89 Layer.provideMerge(93 Layer.provideMerge(
90 Layer.succeed(94 Layer.succeed(
...@@ -101,7 +105,12 @@ const serviceLayers = (input: {...@@ -101,7 +105,12 @@ const serviceLayers = (input: {
101 ),105 ),
102 Layer.provideMerge(106 Layer.provideMerge(
103 Layer.succeed(HostProcessEnvironment, {107 Layer.succeed(HostProcessEnvironment, {
108 HOME: input.home,
104 GROK_HOME: NodePath.join(input.home, "grok"),109 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"),
105 ...input.environment,114 ...input.environment,
106 }),115 }),
107 ),116 ),
...@@ -112,6 +121,193 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens...@@ -112,6 +121,193 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
112}121}
113122
114describe("UsageService", () => {123describe("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
115 it.live("reads configured and disabled accounts once across shared and aliased homes", () =>311 it.live("reads configured and disabled accounts once across shared and aliased homes", () =>
116 Effect.gen(function* () {312 Effect.gen(function* () {
117 const { transcript, settings, home } = yield* setup;313 const { transcript, settings, home } = yield* setup;
...@@ -258,9 +454,12 @@ describe("UsageService", () => {...@@ -258,9 +454,12 @@ describe("UsageService", () => {
258 const service = yield* UsageService.make;454 const service = yield* UsageService.make;
259 const first = yield* service.readSummary(WINDOW);455 const first = yield* service.readSummary(WINDOW);
260 assert.strictEqual(totalOutputTokens(first), 7);456 assert.strictEqual(totalOutputTokens(first), 7);
457 const configuredProjects = yield* Effect.promise(() =>
458 NodeFSP.realpath(NodePath.join(configured, "projects")),
459 );
261 assert.include(460 assert.include(
262 first.sources.map((source) => source.fingerprint.resolvedHomePath),461 first.sources.map((source) => source.fingerprint.resolvedHomePath),
263 NodePath.join(configured, "projects"),462 configuredProjects,
264 );463 );
265 yield* settingsService.updateSettings({464 yield* settingsService.updateSettings({
266 providerInstances: {465 providerInstances: {
...@@ -275,9 +474,12 @@ describe("UsageService", () => {...@@ -275,9 +474,12 @@ describe("UsageService", () => {
275 });474 });
276 const second = yield* service.readSummary(WINDOW);475 const second = yield* service.readSummary(WINDOW);
277 assert.strictEqual(totalOutputTokens(second), 8);476 assert.strictEqual(totalOutputTokens(second), 8);
477 const environmentProjects = yield* Effect.promise(() =>
478 NodeFSP.realpath(NodePath.join(environmentHome, "projects")),
479 );
278 assert.include(480 assert.include(
279 second.sources.map((source) => source.fingerprint.resolvedHomePath),481 second.sources.map((source) => source.fingerprint.resolvedHomePath),
280 NodePath.join(environmentHome, "projects"),482 environmentProjects,
281 );483 );
282 }).pipe(484 }).pipe(
283 Effect.provide(485 Effect.provide(
...@@ -505,6 +707,9 @@ describe("UsageService", () => {...@@ -505,6 +707,9 @@ describe("UsageService", () => {
505 Effect.gen(function* () {707 Effect.gen(function* () {
506 const { transcript, settings, home } = yield* setup;708 const { transcript, settings, home } = yield* setup;
507 yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model")));709 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
509 yield* Effect.gen(function* () {714 yield* Effect.gen(function* () {
510 const settingsService = yield* ServerSettings.ServerSettingsService;715 const settingsService = yield* ServerSettings.ServerSettingsService;
...@@ -519,7 +724,7 @@ describe("UsageService", () => {...@@ -519,7 +724,7 @@ describe("UsageService", () => {
519 exists: (path) =>724 exists: (path) =>
520 fileSystem.exists(path).pipe(725 fileSystem.exists(path).pipe(
521 Effect.tap(() => {726 Effect.tap(() => {
522 if (path !== NodePath.join(home, "claude", "projects")) return Effect.void;727 if (path !== transcriptDir) return Effect.void;
523 homeProbes += 1;728 homeProbes += 1;
524 return Deferred.succeed(729 return Deferred.succeed(
525 homeProbes === 1 ? firstScanStarted : secondScanStarted,730 homeProbes === 1 ? firstScanStarted : secondScanStarted,
apps/server/src/usage/UsageService.ts+201-11
...@@ -1,14 +1,14 @@...@@ -1,14 +1,14 @@
1/**1/**
2 * UsageService - scans provider transcripts and returns priced usage buckets.2 * UsageService - scans provider transcripts and returns priced usage buckets.
3 *3 *
4 * The scan reads the provider CLIs' own session files (Claude Code, Codex, and4 * The scan reads native session files and databases, including work driven
5 * Grok Build) rather than T3 Code's orchestration projections, so usage covers5 * outside T3 Code. Cursor's local records provide only partial coverage.
6 * turns driven outside T3 Code too. This is the approach `ccusage` takes.
7 *6 *
8 * Transcripts are append-only, so parsed records are memoised per file by7 * JSONL transcripts are append-only, so parsed records are memoised per file by
9 * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm8 * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm
10 * scans only reparse files that changed, and a file that merely grew resumes9 * scans only reparse files that changed, and a file that merely grew resumes
11 * from its cached parse position so only the appended bytes are read.10 * 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.
12 *12 *
13 * @module UsageService13 * @module UsageService
14 */14 */
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 CodexSettings,19 CodexSettings,
20 type ProviderInstanceConfig,20 type ProviderInstanceConfig,
21 USAGE_CONTRACT_VERSION,21 USAGE_CONTRACT_VERSION,
22 ProviderInstanceId,
22 type ServerSettings as ServerSettingsValue,23 type ServerSettings as ServerSettingsValue,
23 type UsageProviderKind,24 type UsageProviderKind,
24 type UsageSource,25 type UsageSource,
...@@ -27,10 +28,11 @@ import {...@@ -27,10 +28,11 @@ import {
27 type UsageSummaryInput,28 type UsageSummaryInput,
28 UsageReadError,29 UsageReadError,
29} from "@t3tools/contracts";30} from "@t3tools/contracts";
30import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";31import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
31import * as Cause from "effect/Cause";32import * as Cause from "effect/Cause";
32import * as Clock from "effect/Clock";33import * as Clock from "effect/Clock";
33import * as Context from "effect/Context";34import * as Context from "effect/Context";
35import * as Crypto from "effect/Crypto";
34import * as DateTime from "effect/DateTime";36import * as DateTime from "effect/DateTime";
35import * as Deferred from "effect/Deferred";37import * as Deferred from "effect/Deferred";
36import * as Effect from "effect/Effect";38import * as Effect from "effect/Effect";
...@@ -46,7 +48,11 @@ import { ServerConfig } from "../config.ts";...@@ -46,7 +48,11 @@ import { ServerConfig } from "../config.ts";
46import { expandHomePath } from "../pathExpansion.ts";48import { expandHomePath } from "../pathExpansion.ts";
47import * as ServerSettings from "../serverSettings.ts";49import * as ServerSettings from "../serverSettings.ts";
48import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";50import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
51import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts";
49import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";52import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
53import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
54import { readAntigravityUsage } from "./antigravityUsageReader.ts";
55import { readCursorAccountUsage } from "./cursorUsageReader.ts";
50import { UsageAggregator } from "./usageAggregation.ts";56import { UsageAggregator } from "./usageAggregation.ts";
51import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";57import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
52import {58import {
...@@ -144,12 +150,14 @@ export const layerTest = Layer.succeed(...@@ -144,12 +150,14 @@ export const layerTest = Layer.succeed(
144);150);
145151
146export const make = Effect.gen(function* () {152export const make = Effect.gen(function* () {
153 const crypto = yield* Crypto.Crypto;
147 const fileSystem = yield* FileSystem.FileSystem;154 const fileSystem = yield* FileSystem.FileSystem;
148 const path = yield* Path.Path;155 const path = yield* Path.Path;
149 const config = yield* ServerConfig;156 const config = yield* ServerConfig;
150 const settingsService = yield* ServerSettings.ServerSettingsService;157 const settingsService = yield* ServerSettings.ServerSettingsService;
151 const httpClient = yield* HttpClient.HttpClient;158 const httpClient = yield* HttpClient.HttpClient;
152 const hostEnvironment = yield* HostProcessEnvironment;159 const hostEnvironment = yield* HostProcessEnvironment;
160 const platform = yield* HostProcessPlatform;
153161
154 const fileCache: ScanCache = new Map();162 const fileCache: ScanCache = new Map();
155 const sourceCache = new Map<string, typeof CachedSource.Type>();163 const sourceCache = new Map<string, typeof CachedSource.Type>();
...@@ -446,6 +454,10 @@ export const make = Effect.gen(function* () {...@@ -446,6 +454,10 @@ export const make = Effect.gen(function* () {
446 readonly provider: UsageProviderKind;454 readonly provider: UsageProviderKind;
447 readonly dir: string;455 readonly dir: string;
448 readonly volumeId: string;456 readonly volumeId: string;
457 readonly hostId?: string;
458 readonly status?: UsageSource["status"];
459 readonly message?: string;
460 readonly action?: UsageSource["action"];
449 /** Parsed records per file, or `null` when the directory does not exist. */461 /** Parsed records per file, or `null` when the directory does not exist. */
450 readonly files:462 readonly files:
451 | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]463 | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]
...@@ -481,6 +493,171 @@ export const make = Effect.gen(function* () {...@@ -481,6 +493,171 @@ export const make = Effect.gen(function* () {
481 }493 }
482 scanned.push({ provider, dir, volumeId, files: parsedFiles });494 scanned.push({ provider, dir, volumeId, files: parsedFiles });
483 }495 }
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 });
484 return scanned;661 return scanned;
485 });662 });
486663
...@@ -555,7 +732,16 @@ export const make = Effect.gen(function* () {...@@ -555,7 +732,16 @@ export const make = Effect.gen(function* () {
555732
556 const sources: UsageSource[] = [];733 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) {
559 const retainedFiles = [...(files ?? [])];745 const retainedFiles = [...(files ?? [])];
560 const livePaths = new Set(retainedFiles.map((file) => file.path));746 const livePaths = new Set(retainedFiles.map((file) => file.path));
561 // Cleanup may remove transcripts, but the usage we already saved still747 // Cleanup may remove transcripts, but the usage we already saved still
...@@ -601,21 +787,23 @@ export const make = Effect.gen(function* () {...@@ -601,21 +787,23 @@ export const make = Effect.gen(function* () {
601 }787 }
602 // Only sessions contributing in-window count; the mtime slack can788 // Only sessions contributing in-window count; the mtime slack can
603 // admit boundary files whose records fall outside the range.789 // 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) {
605 sessionIds.add(record.sessionId);791 sessionIds.add(record.sessionId);
606 }792 }
607 }793 }
608 }794 }
609795
610 sources.push({796 sources.push({
611 fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },797 fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId },
612 // Clients exclude missing sources, so saved records remain an available source.798 // 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"),
614 scannedFiles,800 scannedFiles,
615 skippedFiles,801 skippedFiles,
616 malformedRecords: 0,802 malformedRecords: 0,
617 distinctSessions: sessionIds.size,803 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 } : {}),
619 });807 });
620 }808 }
621809
...@@ -650,6 +838,7 @@ export const make = Effect.gen(function* () {...@@ -650,6 +838,7 @@ export const make = Effect.gen(function* () {
650 const scanKey = (838 const scanKey = (
651 input: UsageSummaryInput,839 input: UsageSummaryInput,
652 priceOverrides: ServerSettingsValue["usagePriceOverrides"],840 priceOverrides: ServerSettingsValue["usagePriceOverrides"],
841 cursorKeychainUsageEnabled: boolean,
653 ): string =>842 ): string =>
654 JSON.stringify([843 JSON.stringify([
655 input.timeZone,844 input.timeZone,
...@@ -659,11 +848,12 @@ export const make = Effect.gen(function* () {...@@ -659,11 +848,12 @@ export const make = Effect.gen(function* () {
659 input.sinceTime ?? null,848 input.sinceTime ?? null,
660 input.untilTime ?? null,849 input.untilTime ?? null,
661 priceOverrides,850 priceOverrides,
851 cursorKeychainUsageEnabled,
662 ]);852 ]);
663853
664 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {854 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
665 const settings = yield* readSettings;855 const settings = yield* readSettings;
666 const key = scanKey(input, settings.usagePriceOverrides);856 const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled);
667 const deferred = yield* Effect.uninterruptible(857 const deferred = yield* Effect.uninterruptible(
668 Effect.gen(function* () {858 Effect.gen(function* () {
669 const existing = inflightScans.get(key);859 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 {...@@ -112,7 +112,7 @@ export class UsageAggregator {
112 * can derive per-window facts (distinct sessions, for one) from the records112 * can derive per-window facts (distinct sessions, for one) from the records
113 * that landed rather than everything the mtime prefilter happened to admit.113 * that landed rather than everything the mtime prefilter happened to admit.
114 */114 */
115 add(record: UsageRecord): boolean {115 add(record: UsageRecord, sourcePath?: string): boolean {
116 if (record.dedupeKey !== null) {116 if (record.dedupeKey !== null) {
117 if (this.#seen.has(record.dedupeKey)) {117 if (this.#seen.has(record.dedupeKey)) {
118 this.#duplicatesDropped += 1;118 this.#duplicatesDropped += 1;
...@@ -146,7 +146,7 @@ export class UsageAggregator {...@@ -146,7 +146,7 @@ export class UsageAggregator {
146 this.#hourlyWindow.sinceTimeMs +146 this.#hourlyWindow.sinceTimeMs +
147 Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS,147 Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS,
148 ).toISOString();148 ).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 ?? ""}`;
150 let bucket = this.#buckets.get(key);150 let bucket = this.#buckets.get(key);
151 if (bucket === undefined) {151 if (bucket === undefined) {
152 bucket = {152 bucket = {
...@@ -180,12 +180,14 @@ export class UsageAggregator {...@@ -180,12 +180,14 @@ export class UsageAggregator {
180 finish(): AggregateResult {180 finish(): AggregateResult {
181 const buckets: UsageBucket[] = [];181 const buckets: UsageBucket[] = [];
182 for (const [key, bucket] of this.#buckets) {182 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");
184 buckets.push({185 buckets.push({
185 day: day as UsageDay,186 day: day as UsageDay,
186 ...(hourStart === "" ? {} : { hourStart }),187 ...(hourStart === "" ? {} : { hourStart }),
187 provider: provider as UsageBucket["provider"],188 provider: provider as UsageBucket["provider"],
188 model,189 model,
190 ...(sourcePath === "" ? {} : { sourcePath }),
189 totals: bucket.totals,191 totals: bucket.totals,
190 costUsd: bucket.costUsd,192 costUsd: bucket.costUsd,
191 cacheSavingsUsd: bucket.cacheSavingsUsd,193 cacheSavingsUsd: bucket.cacheSavingsUsd,
apps/server/src/usage/usageTranscriptReader.test.ts+554
...@@ -4,13 +4,40 @@...@@ -4,13 +4,40 @@
4import * as NodeFSP from "node:fs/promises";4import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";6import * as NodePath from "node:path";
7import * as NodeSqlite from "node:sqlite";
78
8import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";9import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";
910
10import { readTranscriptRecords } from "./usageTranscriptReader.ts";11import { readTranscriptRecords } from "./usageTranscriptReader.ts";
12import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
13import { readCursorAccountUsage } from "./cursorUsageReader.ts";
14import { readAntigravityUsage } from "./antigravityUsageReader.ts";
1115
12let dir: string;16let 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
14beforeEach(async () => {41beforeEach(async () => {
15 dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-"));42 dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-"));
16});43});
...@@ -208,3 +235,530 @@ describe("readTranscriptRecords resume", () => {...@@ -208,3 +235,530 @@ describe("readTranscriptRecords resume", () => {
208 assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude"));235 assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude"));
209 });236 });
210});237});
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) {...@@ -314,7 +314,25 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
314 hasServerConfig: environment.serverConfig !== null,314 hasServerConfig: environment.serverConfig !== null,
315 }),315 }),
316 )?.environmentId;316 )?.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;
317 useEffect(() => {325 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 }
318 if (336 if (
319 !target.scoped &&337 !target.scoped &&
320 (searchTargetId === searchableSetting("provider-health-check-interval").id ||338 (searchTargetId === searchableSetting("provider-health-check-interval").id ||
...@@ -326,7 +344,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {...@@ -326,7 +344,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
326 }344 }
327 }, [345 }, [
328 searchTargetId,346 searchTargetId,
347 searchableCursorEnvironmentId,
329 searchableEnvironmentId,348 searchableEnvironmentId,
349 selectedEnvironment,
330 selectedEnvironmentCanRenderSettings,350 selectedEnvironmentCanRenderSettings,
331 target.scoped,351 target.scoped,
332 ]);352 ]);
...@@ -1102,6 +1122,7 @@ export function EnvironmentProviderSettings({...@@ -1102,6 +1122,7 @@ export function EnvironmentProviderSettings({
1102 environmentId={environmentId}1122 environmentId={environmentId}
1103 environmentLabel={environmentLabel}1123 environmentLabel={environmentLabel}
1104 sources={settings.usageLimitSources}1124 sources={settings.usageLimitSources}
1125 cursorKeychainUsageEnabled={settings.cursorKeychainUsageEnabled}
1105 readOnly={readOnly}1126 readOnly={readOnly}
1106 />1127 />
11071128
apps/web/src/components/settings/SettingsSidebarNav.tsx+1-1
...@@ -116,7 +116,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -116,7 +116,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
116 const searchInputRef = useRef<HTMLInputElement>(null);116 const searchInputRef = useRef<HTMLInputElement>(null);
117 const [query, setQuery] = useState("");117 const [query, setQuery] = useState("");
118 const [activeResultIndex, setActiveResultIndex] = useState(0);118 const [activeResultIndex, setActiveResultIndex] = useState(0);
119 const searchableItems = useAvailableSettingsSearchItems();119 const searchableItems = useAvailableSettingsSearchItems(scopeSearch);
120 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);120 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);
121 const isSearching = query.trim().length > 0;121 const isSearching = query.trim().length > 0;
122 const hasResults = results.length > 0;122 const hasResults = results.length > 0;
apps/web/src/components/settings/UsageProviderSettings.tsx+46-1
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts";1import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts";
2import { useAtomValue } from "@effect/atom-react";
2import { PlusIcon } from "lucide-react";3import { PlusIcon } from "lucide-react";
3import { useState } from "react";4import { useState } from "react";
45
5import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";6import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
7import { serverEnvironment } from "../../state/server";
8import { useAtomCommand } from "../../state/use-atom-command";
6import {9import {
7 AlertDialog,10 AlertDialog,
8 AlertDialogClose,11 AlertDialogClose,
...@@ -13,6 +16,7 @@ import {...@@ -13,6 +16,7 @@ import {
13 AlertDialogTitle,16 AlertDialogTitle,
14} from "../ui/alert-dialog";17} from "../ui/alert-dialog";
15import { Button } from "../ui/button";18import { Button } from "../ui/button";
19import { Switch } from "../ui/switch";
16import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";20import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";
17import { searchableSetting } from "./settingsSearch";21import { searchableSetting } from "./settingsSearch";
18import { SettingsRow, SettingsSection } from "./settingsLayout";22import { SettingsRow, SettingsSection } from "./settingsLayout";
...@@ -22,17 +26,43 @@ export function UsageProviderSettings({...@@ -22,17 +26,43 @@ export function UsageProviderSettings({
22 environmentId,26 environmentId,
23 environmentLabel,27 environmentLabel,
24 sources,28 sources,
29 cursorKeychainUsageEnabled,
25 readOnly,30 readOnly,
26}: {31}: {
27 readonly environmentId: EnvironmentId;32 readonly environmentId: EnvironmentId;
28 readonly environmentLabel: string;33 readonly environmentLabel: string;
29 readonly sources: UnifiedSettings["usageLimitSources"];34 readonly sources: UnifiedSettings["usageLimitSources"];
35 readonly cursorKeychainUsageEnabled: boolean;
30 readonly readOnly: boolean;36 readonly readOnly: boolean;
31}) {37}) {
32 const updateSettings = useUpdateEnvironmentSettings(environmentId);38 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;
33 const [adding, setAdding] = useState(false);47 const [adding, setAdding] = useState(false);
48 const [updatingCursor, setUpdatingCursor] = useState(false);
34 const entries = Object.entries(sources);49 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
36 return (66 return (
37 <>67 <>
38 <SettingsSection68 <SettingsSection
...@@ -46,8 +76,23 @@ export function UsageProviderSettings({...@@ -46,8 +76,23 @@ export function UsageProviderSettings({
46 ) : null76 ) : null
47 }77 }
48 >78 >
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}
49 {entries.length === 0 ? (94 {entries.length === 0 ? (
50 <SettingsRow title="No usage providers configured." />95 <SettingsRow title="No hubs configured." />
51 ) : (96 ) : (
52 entries.map(([id, source]) => {97 entries.map(([id, source]) => {
53 const label = source.label?.trim() || source.url;98 const label = source.label?.trim() || source.url;
apps/web/src/components/settings/settingsSearch.test.ts+25
...@@ -154,6 +154,7 @@ describe("searchSettings", () => {...@@ -154,6 +154,7 @@ describe("searchSettings", () => {
154 hasCloudPublicConfig: false,154 hasCloudPublicConfig: false,
155 hasEnvironment: false,155 hasEnvironment: false,
156 hasProviderSettingsEnvironment: false,156 hasProviderSettingsEnvironment: false,
157 hasMacProviderSettingsEnvironment: false,
157 canManageLocalBackend: false,158 canManageLocalBackend: false,
158 isWslSettingsRowVisible: false,159 isWslSettingsRowVisible: false,
159 hasThreadAutoSettlement: false,160 hasThreadAutoSettlement: false,
...@@ -165,6 +166,7 @@ describe("searchSettings", () => {...@@ -165,6 +166,7 @@ describe("searchSettings", () => {
165 "network-access",166 "network-access",
166 "publish-agent-activity",167 "publish-agent-activity",
167 "provider-health-check-interval",168 "provider-health-check-interval",
169 "cursor-keychain-usage",
168 "source-control-writer-model",170 "source-control-writer-model",
169 "source-control-writing-style",171 "source-control-writing-style",
170 "t3-connect",172 "t3-connect",
...@@ -177,11 +179,31 @@ describe("searchSettings", () => {...@@ -177,11 +179,31 @@ describe("searchSettings", () => {
177 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);179 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
178 });180 });
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
180 it("keeps the local toggle searchable without offering hidden host publishing controls", () => {201 it("keeps the local toggle searchable without offering hidden host publishing controls", () => {
181 const availability = {202 const availability = {
182 hasCloudPublicConfig: true,203 hasCloudPublicConfig: true,
183 hasEnvironment: true,204 hasEnvironment: true,
184 hasProviderSettingsEnvironment: true,205 hasProviderSettingsEnvironment: true,
206 hasMacProviderSettingsEnvironment: false,
185 canManageLocalBackend: false,207 canManageLocalBackend: false,
186 isWslSettingsRowVisible: false,208 isWslSettingsRowVisible: false,
187 hasThreadAutoSettlement: false,209 hasThreadAutoSettlement: false,
...@@ -204,6 +226,7 @@ describe("searchSettings", () => {...@@ -204,6 +226,7 @@ describe("searchSettings", () => {
204 hasCloudPublicConfig: false,226 hasCloudPublicConfig: false,
205 hasEnvironment: false,227 hasEnvironment: false,
206 hasProviderSettingsEnvironment: false,228 hasProviderSettingsEnvironment: false,
229 hasMacProviderSettingsEnvironment: false,
207 canManageLocalBackend: false,230 canManageLocalBackend: false,
208 isWslSettingsRowVisible: false,231 isWslSettingsRowVisible: false,
209 hasThreadAutoSettlement: true,232 hasThreadAutoSettlement: true,
...@@ -330,6 +353,7 @@ describe("searchSettings", () => {...@@ -330,6 +353,7 @@ describe("searchSettings", () => {
330 hasCloudPublicConfig: false,353 hasCloudPublicConfig: false,
331 hasEnvironment: true,354 hasEnvironment: true,
332 hasProviderSettingsEnvironment: true,355 hasProviderSettingsEnvironment: true,
356 hasMacProviderSettingsEnvironment: false,
333 canManageLocalBackend: false,357 canManageLocalBackend: false,
334 isWslSettingsRowVisible: false,358 isWslSettingsRowVisible: false,
335 hasThreadAutoSettlement: true,359 hasThreadAutoSettlement: true,
...@@ -424,6 +448,7 @@ describe("auto-settlement search availability", () => {...@@ -424,6 +448,7 @@ describe("auto-settlement search availability", () => {
424 hasCloudPublicConfig: false,448 hasCloudPublicConfig: false,
425 hasEnvironment: true,449 hasEnvironment: true,
426 hasProviderSettingsEnvironment: true,450 hasProviderSettingsEnvironment: true,
451 hasMacProviderSettingsEnvironment: false,
427 canManageLocalBackend: false,452 canManageLocalBackend: false,
428 isWslSettingsRowVisible: false,453 isWslSettingsRowVisible: false,
429 hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0,454 hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0,
apps/web/src/components/settings/settingsSearch.ts+11
...@@ -55,6 +55,7 @@ export interface SettingsSearchItem {...@@ -55,6 +55,7 @@ export interface SettingsSearchItem {
55 readonly cloudOnly?: boolean;55 readonly cloudOnly?: boolean;
56 readonly environmentOnly?: boolean;56 readonly environmentOnly?: boolean;
57 readonly providerSettingsOnly?: boolean;57 readonly providerSettingsOnly?: boolean;
58 readonly macProviderSettingsOnly?: boolean;
58 readonly localBackendManagementOnly?: boolean;59 readonly localBackendManagementOnly?: boolean;
59 readonly localEnvironmentOnly?: boolean;60 readonly localEnvironmentOnly?: boolean;
60 readonly wslAvailableOnly?: boolean;61 readonly wslAvailableOnly?: boolean;
...@@ -71,6 +72,7 @@ export interface SettingsSearchAvailability {...@@ -71,6 +72,7 @@ export interface SettingsSearchAvailability {
71 readonly hasCloudPublicConfig: boolean;72 readonly hasCloudPublicConfig: boolean;
72 readonly hasEnvironment: boolean;73 readonly hasEnvironment: boolean;
73 readonly hasProviderSettingsEnvironment: boolean;74 readonly hasProviderSettingsEnvironment: boolean;
75 readonly hasMacProviderSettingsEnvironment: boolean;
74 readonly canManageLocalBackend: boolean;76 readonly canManageLocalBackend: boolean;
75 readonly isWslSettingsRowVisible: boolean;77 readonly isWslSettingsRowVisible: boolean;
76 readonly hasThreadAutoSettlement: boolean;78 readonly hasThreadAutoSettlement: boolean;
...@@ -546,6 +548,14 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -546,6 +548,14 @@ export const SETTINGS_SEARCH_ITEMS = [
546 ],548 ],
547 providerSettingsOnly: true,549 providerSettingsOnly: true,
548 },550 },
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 },
549 {559 {
550 id: "provider-health-check-interval",560 id: "provider-health-check-interval",
551 title: "Health check interval",561 title: "Health check interval",
...@@ -943,6 +953,7 @@ export function filterAvailableSettingsSearchItems(...@@ -943,6 +953,7 @@ export function filterAvailableSettingsSearchItems(
943 (!item.cloudOnly || availability.hasCloudPublicConfig) &&953 (!item.cloudOnly || availability.hasCloudPublicConfig) &&
944 (!item.environmentOnly || availability.hasEnvironment) &&954 (!item.environmentOnly || availability.hasEnvironment) &&
945 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&955 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
956 (!item.macProviderSettingsOnly || availability.hasMacProviderSettingsEnvironment) &&
946 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&957 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
947 (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) &&958 (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) &&
948 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&959 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+13-1
...@@ -10,12 +10,13 @@ import { useEnvironmentQuery } from "~/state/query";...@@ -10,12 +10,13 @@ import { useEnvironmentQuery } from "~/state/query";
10import { usePrimarySessionState } from "~/environments/primary";10import { usePrimarySessionState } from "~/environments/primary";
11import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";11import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
12import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";12import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
13import type { SettingsScopeSearch } from "./settingsScope";
13import {14import {
14 filterAvailableSettingsSearchItems,15 filterAvailableSettingsSearchItems,
15 getThreadAutoSettlementSearchAvailability,16 getThreadAutoSettlementSearchAvailability,
16} from "./settingsSearch";17} from "./settingsSearch";
1718
18export function useAvailableSettingsSearchItems() {19export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) {
19 const { environments } = useEnvironments();20 const { environments } = useEnvironments();
20 const primarySessionState = usePrimarySessionState();21 const primarySessionState = usePrimarySessionState();
21 const localEnvironmentDisabled = isLocalEnvironmentDisabled();22 const localEnvironmentDisabled = isLocalEnvironmentDisabled();
...@@ -41,6 +42,16 @@ export function useAvailableSettingsSearchItems() {...@@ -41,6 +42,16 @@ export function useAvailableSettingsSearchItems() {
41 hasServerConfig: environment.serverConfig !== null,42 hasServerConfig: environment.serverConfig !== null,
42 }),43 }),
43 ),44 ),
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 ),
44 canManageLocalBackend,55 canManageLocalBackend,
45 isWslSettingsRowVisible: isWslSettingsRowVisible({56 isWslSettingsRowVisible: isWslSettingsRowVisible({
46 state: desktopWsl.data,57 state: desktopWsl.data,
...@@ -55,6 +66,7 @@ export function useAvailableSettingsSearchItems() {...@@ -55,6 +66,7 @@ export function useAvailableSettingsSearchItems() {
55 desktopWsl.error,66 desktopWsl.error,
56 environments,67 environments,
57 localEnvironmentDisabled,68 localEnvironmentDisabled,
69 scopeSearch.machine,
58 ],70 ],
59 );71 );
60}72}
apps/web/src/components/usage/UsageLimits.tsx+4-2
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17 remainingPercent,17 remainingPercent,
18} from "@t3tools/shared/usageLimits";18} from "@t3tools/shared/usageLimits";
19import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";19import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
20import { Fragment, useState } from "react";20import { Fragment, type ReactNode, useState } from "react";
2121
22import { usePrimarySettings } from "../../hooks/useSettings";22import { usePrimarySettings } from "../../hooks/useSettings";
23import { environmentPresentations } from "../../state/presentation";23import { environmentPresentations } from "../../state/presentation";
...@@ -322,14 +322,16 @@ export function ResetCredits({...@@ -322,14 +322,16 @@ export function ResetCredits({
322export function UsageLimitsSection({322export function UsageLimitsSection({
323 selectedEnvironmentIds,323 selectedEnvironmentIds,
324 now,324 now,
325 cursorPrompt,
325}: {326}: {
326 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;327 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
327 readonly now: number;328 readonly now: number;
329 readonly cursorPrompt?: ReactNode;
328}) {330}) {
329 const presentations = useAtomValue(environmentPresentations.presentationsAtom);331 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
330 const selected =332 const selected =
331 selectedEnvironmentIds === null333 selectedEnvironmentIds === null
332 ? presentations334 ? presentations
333 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));335 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
334 return <UsageLimitsPooled presentations={selected} now={now} />;336 return <UsageLimitsPooled presentations={selected} now={now} cursorPrompt={cursorPrompt} />;
335}337}
apps/web/src/components/usage/UsageLimitsPooled.tsx+51-14
...@@ -2,7 +2,8 @@ import {...@@ -2,7 +2,8 @@ import {
2 collectLimitAccounts,2 collectLimitAccounts,
3 collectLimitNotices,3 collectLimitNotices,
4 collectLimitPools,4 collectLimitPools,
5 formatDuration,5 cursorUsageWindowDetails,
6 displayLimitWindows,
6 formatResetsIn,7 formatResetsIn,
7 type LimitAccount,8 type LimitAccount,
8 type LimitPool,9 type LimitPool,
...@@ -11,7 +12,7 @@ import {...@@ -11,7 +12,7 @@ import {
11 remainingPercent,12 remainingPercent,
12} from "@t3tools/shared/usageLimits";13} from "@t3tools/shared/usageLimits";
13import { AlertTriangleIcon, TicketIcon } from "lucide-react";14import { AlertTriangleIcon, TicketIcon } from "lucide-react";
14import { type ReactNode, useState } from "react";15import { Fragment, type ReactNode, useState } from "react";
1516
16import { usePrimarySettings } from "../../hooks/useSettings";17import { usePrimarySettings } from "../../hooks/useSettings";
17import { cn } from "../../lib/utils";18import { cn } from "../../lib/utils";
...@@ -224,6 +225,7 @@ function PoolSegment({...@@ -224,6 +225,7 @@ function PoolSegment({
224 color,225 color,
225 now,226 now,
226 index,227 index,
228 showAccountName,
227}: {229}: {
228 readonly account: LimitAccount;230 readonly account: LimitAccount;
229 readonly window: LimitPoolMember["window"];231 readonly window: LimitPoolMember["window"];
...@@ -232,6 +234,7 @@ function PoolSegment({...@@ -232,6 +234,7 @@ function PoolSegment({
232 readonly now: number;234 readonly now: number;
233 /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */235 /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */
234 readonly index: number;236 readonly index: number;
237 readonly showAccountName: boolean;
235}) {238}) {
236 const [open, setOpen] = useState(false);239 const [open, setOpen] = useState(false);
237 const remaining = remainingPercent(window);240 const remaining = remainingPercent(window);
...@@ -274,7 +277,12 @@ function PoolSegment({...@@ -274,7 +277,12 @@ function PoolSegment({
274 {index}277 {index}
275 </span>278 </span>
276 <div className="relative hidden h-full min-w-0 items-center gap-1.5 px-2 text-xs @2xl/pool:flex">279 <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}
278 <span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span>286 <span className="shrink-0 font-semibold text-foreground tabular-nums">{remaining}%</span>
279 {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */}287 {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */}
280 <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">288 <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({...@@ -463,6 +471,7 @@ function PoolBar({
463 color={color}471 color={color}
464 now={now}472 now={now}
465 index={position + 1}473 index={position + 1}
474 showAccountName={pool.columns.length > 1}
466 />475 />
467 ) : null,476 ) : null,
468 )}477 )}
...@@ -479,17 +488,21 @@ function PoolWindowCard({...@@ -479,17 +488,21 @@ function PoolWindowCard({
479 pool,488 pool,
480 color,489 color,
481 now,490 now,
491 label,
492 description,
482}: {493}: {
483 readonly pool: LimitPoolWindow;494 readonly pool: LimitPoolWindow;
484 readonly color: string;495 readonly color: string;
485 readonly now: number;496 readonly now: number;
497 readonly label?: string | undefined;
498 readonly description?: string | undefined;
486}) {499}) {
487 // The soonest reset that hands anything back; an untouched account resets to no effect.500 // The soonest reset that hands anything back; an untouched account resets to no effect.
488 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);501 const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
489 return (502 return (
490 <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)]">503 <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)]">
491 <div className="flex flex-col gap-1">504 <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>
493 <span className="flex items-baseline gap-2">506 <span className="flex items-baseline gap-2">
494 <span className="text-3xl font-semibold text-foreground tabular-nums">507 <span className="text-3xl font-semibold text-foreground tabular-nums">
495 {pool.remainingPercent}%508 {pool.remainingPercent}%
...@@ -497,14 +510,16 @@ function PoolWindowCard({...@@ -497,14 +510,16 @@ function PoolWindowCard({
497 <span className="text-sm text-muted-foreground">left</span>510 <span className="text-sm text-muted-foreground">left</span>
498 {pool.pace ? <PaceIcon pace={pool.pace} /> : null}511 {pool.pace ? <PaceIcon pace={pool.pace} /> : null}
499 </span>512 </span>
500 {nextRefill ? (513 {nextRefill && pool.columns.length > 1 ? (
501 <span className="text-xs text-muted-foreground tabular-nums">514 <span className="text-xs font-medium text-foreground tabular-nums">
502 <span className="font-medium text-foreground">↻ +{nextRefill.restoresPercent}%</span>{" "}515 ↻ +{nextRefill.restoresPercent}%
503 {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`}
504 </span>516 </span>
505 ) : null}517 ) : null}
506 </div>518 </div>
507 <PoolBar pool={pool} color={color} now={now} />519 <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}
508 </div>523 </div>
509 );524 );
510}525}
...@@ -512,6 +527,7 @@ function PoolWindowCard({...@@ -512,6 +527,7 @@ function PoolWindowCard({
512function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) {527function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) {
513 const color = barColor(pool.driver);528 const color = barColor(pool.driver);
514 const label = getDriverOption(pool.driver)?.label ?? String(pool.driver);529 const label = getDriverOption(pool.driver)?.label ?? String(pool.driver);
530 const windows = displayLimitWindows(pool);
515 return (531 return (
516 <section className="flex flex-col gap-3">532 <section className="flex flex-col gap-3">
517 <h2 className="flex items-center gap-2 text-sm font-medium text-foreground">533 <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...@@ -524,9 +540,19 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
524 />540 />
525 {label}541 {label}
526 </h2>542 </h2>
527 {pool.windows.map((window) => (543 {windows.map((window) => {
528 <PoolWindowCard key={`${window.kind}:${window.id}`} pool={window} color={color} now={now} />544 const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined;
529 ))}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 })}
530 </section>556 </section>
531 );557 );
532}558}
...@@ -539,22 +565,33 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu...@@ -539,22 +565,33 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu
539export function UsageLimitsPooled({565export function UsageLimitsPooled({
540 presentations,566 presentations,
541 now,567 now,
568 cursorPrompt,
542}: {569}: {
543 readonly presentations: Parameters<typeof collectLimitAccounts>[0];570 readonly presentations: Parameters<typeof collectLimitAccounts>[0];
544 readonly now: number;571 readonly now: number;
572 readonly cursorPrompt?: ReactNode;
545}) {573}) {
546 const pools = collectLimitPools(collectLimitAccounts(presentations), now);574 const pools = collectLimitPools(collectLimitAccounts(presentations), now);
547 const notices = collectLimitNotices(presentations);575 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;
548 return (581 return (
549 <div className="flex flex-col gap-8">582 <div className="flex flex-col gap-8">
550 {pools.length === 0 && notices.length === 0 ? (583 {pools.length === 0 && notices.length === 0 && !cursorPrompt ? (
551 <p className="text-sm text-muted-foreground">584 <p className="text-sm text-muted-foreground">
552 No provider on the selected environments reports subscription limits.585 No provider on the selected environments reports subscription limits.
553 </p>586 </p>
554 ) : null}587 ) : null}
555 {pools.map((pool) => (588 {pools.map((pool, index) => (
556 <PoolSection key={pool.driver} pool={pool} now={now} />589 <Fragment key={pool.driver}>
590 {index === cursorPromptAt ? cursorPrompt : null}
591 <PoolSection pool={pool} now={now} />
592 </Fragment>
557 ))}593 ))}
594 {cursorPromptAt === pools.length ? cursorPrompt : null}
558 <LimitNotices notices={notices} />595 <LimitNotices notices={notices} />
559 </div>596 </div>
560 );597 );
apps/web/src/components/usage/UsagePage.tsx+202-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import {3import {
4 ProviderDriverKind,
4 USAGE_CONTRACT_VERSION,5 USAGE_CONTRACT_VERSION,
5 type EnvironmentId,6 type EnvironmentId,
6 type UsageProviderKind,7 type UsageProviderKind,
...@@ -40,6 +41,7 @@ import {...@@ -40,6 +41,7 @@ import {
40 makeWindow,41 makeWindow,
41} from "@t3tools/shared/usageFormat";42} from "@t3tools/shared/usageFormat";
42import { Button, InlineButton } from "../ui/button";43import { Button, InlineButton } from "../ui/button";
44import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
43import {45import {
44 Menu,46 Menu,
45 MenuCheckboxItem,47 MenuCheckboxItem,
...@@ -53,6 +55,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../...@@ -53,6 +55,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../
53import { SidebarInset } from "../ui/sidebar";55import { SidebarInset } from "../ui/sidebar";
54import { Skeleton } from "../ui/skeleton";56import { Skeleton } from "../ui/skeleton";
55import { Toggle, ToggleGroup } from "../ui/toggle-group";57import { Toggle, ToggleGroup } from "../ui/toggle-group";
58import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
56import {59import {
57 WorkspaceBreadcrumb,60 WorkspaceBreadcrumb,
58 WorkspaceBreadcrumbItem,61 WorkspaceBreadcrumbItem,
...@@ -118,6 +121,25 @@ export function UsagePage() {...@@ -118,6 +121,25 @@ export function UsagePage() {
118 selectedEnvironmentIds,121 selectedEnvironmentIds,
119 );122 );
120 const presentations = useAtomValue(environmentPresentations.presentationsAtom);123 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 ];
121 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {143 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
122 reportFailure: false,144 reportFailure: false,
123 });145 });
...@@ -147,6 +169,17 @@ export function UsagePage() {...@@ -147,6 +169,17 @@ export function UsagePage() {
147 [breakdown, merged.models, metric],169 [breakdown, merged.models, metric],
148 );170 );
149 const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);171 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 );
150 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;183 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;
151184
152 const selectWindow = (days: number) => {185 const selectWindow = (days: number) => {
...@@ -165,7 +198,7 @@ export function UsagePage() {...@@ -165,7 +198,7 @@ export function UsagePage() {
165 setPreferences(nextPreferences);198 setPreferences(nextPreferences);
166 saveUsagePagePreferences(nextPreferences);199 saveUsagePagePreferences(nextPreferences);
167 };200 };
168 const refreshLimits = async (automatic = false) => {201 const refreshLimits = async (automatic = false, afterPending = false) => {
169 try {202 try {
170 await Promise.all(203 await Promise.all(
171 Array.from(presentations, ([environmentId, presentation]) => {204 Array.from(presentations, ([environmentId, presentation]) => {
...@@ -175,6 +208,7 @@ export function UsagePage() {...@@ -175,6 +208,7 @@ export function UsagePage() {
175 environmentId,208 environmentId,
176 () => refreshProviders({ environmentId, input: {} }),209 () => refreshProviders({ environmentId, input: {} }),
177 automatic,210 automatic,
211 afterPending,
178 );212 );
179 }213 }
180 }),214 }),
...@@ -380,11 +414,30 @@ export function UsagePage() {...@@ -380,11 +414,30 @@ export function UsagePage() {
380 : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}414 : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
381 </p>415 </p>
382 ) : showingLimits ? (416 ) : 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 />
384 ) : isPending ? (432 ) : isPending ? (
385 <UsageSkeleton />433 <UsageSkeleton />
386 ) : (434 ) : (
387 <>435 <>
436 {sourceMessages.map((message) => (
437 <p key={message} className="mb-4 text-sm text-muted-foreground">
438 {message}
439 </p>
440 ))}
388 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">441 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
389 <div className="flex min-w-0 flex-col gap-5">442 <div className="flex min-w-0 flex-col gap-5">
390 <div className="flex flex-col gap-1">443 <div className="flex flex-col gap-1">
...@@ -404,7 +457,22 @@ export function UsagePage() {...@@ -404,7 +457,22 @@ export function UsagePage() {
404 </span>457 </span>
405 </div>458 </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;
408 const totals = merged.providers.find((entry) => entry.provider === provider);476 const totals = merged.providers.find((entry) => entry.provider === provider);
409 const share =477 const share =
410 metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0);478 metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0);
...@@ -636,6 +704,137 @@ export function UsagePage() {...@@ -636,6 +704,137 @@ export function UsagePage() {
636 );704 );
637}705}
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
639/** Brand mark for the harness a row belongs to. */838/** Brand mark for the harness a row belongs to. */
640function ProviderMark({839function ProviderMark({
641 provider,840 provider,
apps/web/src/components/usage/UsageProviderChart.test.ts+3
...@@ -89,6 +89,9 @@ describe("buildPeriodColumns", () => {...@@ -89,6 +89,9 @@ describe("buildPeriodColumns", () => {
89 { provider: "codex", value: 10 },89 { provider: "codex", value: 10 },
90 { provider: "claude", value: 20 },90 { provider: "claude", value: 20 },
91 { provider: "grok", value: 0 },91 { provider: "grok", value: 0 },
92 { provider: "cursor", value: 0 },
93 { provider: "opencode", value: 0 },
94 { provider: "antigravity", value: 0 },
92 ]);95 ]);
93 });96 });
9497
apps/web/src/components/usage/usageProviders.ts+12-1
...@@ -1,6 +1,14 @@...@@ -1,6 +1,14 @@
1import type { UsageProviderKind } from "@t3tools/contracts";1import 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
5type UsageProviderPresentation = {13type UsageProviderPresentation = {
6 readonly label: string;14 readonly label: string;
...@@ -30,6 +38,9 @@ export const PROVIDER_PRESENTATION = {...@@ -30,6 +38,9 @@ export const PROVIDER_PRESENTATION = {
30 color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))",38 color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))",
31 mark: GrokIcon,39 mark: GrokIcon,
32 },40 },
41 cursor: { label: "Cursor", color: "#8b8b8b", mark: CursorIcon },
42 opencode: { label: "OpenCode", color: "#5b9bbd", mark: OpenCodeIcon },
43 antigravity: { label: "Antigravity", color: "#8c7bd1", mark: AntigravityIcon },
33} satisfies Record<UsageProviderKind, UsageProviderPresentation>;44} satisfies Record<UsageProviderKind, UsageProviderPresentation>;
3445
35/** Stable provider reading order across charts, summaries, tables, and hover rows. */46/** 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...@@ -6,13 +6,25 @@ desktop when the terminal is not focused. Customize `usage.open` in
66
7## Understand your usage7## Understand your usage
88
9**Usage** combines Codex, Claude Code, and Grok Build session history from your connected9**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected
10environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent10environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent
11cost. These estimates are not your subscription bill.11cost. These estimates are not your subscription bill.
1212
13Totals depend on the history available on each server. Grok turns without a saved completed-turn13Totals depend on the history available on each server. Grok turns without a saved completed-turn
14record are missing from the totals.14record 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
16Usage includes each configured account's history, including disabled accounts. Custom homes follow28Usage includes each configured account's history, including disabled accounts. Custom homes follow
17the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment29the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment
18variable. Use absolute paths or `~/` paths in the account's environment settings; relative30variable. 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 ...@@ -78,10 +90,10 @@ anything. The command is offered only for providers that appear under **Usage
78OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in90OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in
79the environment. T3 cannot report limits for external OpenCode servers because their credentials91the environment. T3 cannot report limits for external OpenCode servers because their credentials
80belong to the remote server. Cursor reports92belong to the remote server. Cursor reports
81its monthly allowance, including separate Auto and API usage, using a file-based CLI login or93its monthly allowance, including separate Auto and API usage, using the CLI login or
82`CURSOR_AUTH_TOKEN`. Cursor's default macOS keychain login does not currently report limits.94`CURSOR_AUTH_TOKEN`. On macOS, this includes the default Keychain login after you enable Cursor
83On macOS, use `AGENT_CLI_CREDENTIAL_STORE=file` when signing in and in the provider's environment95usage. Keychain login is used for limits only with Cursor's default API endpoint. If you configure
84to use a file-based login.96a custom Cursor endpoint, use an explicit token or file-based CLI login for limits.
8597
86Grok reports the remaining subscription allowance and reset time for its current billing period98Grok reports the remaining subscription allowance and reset time for its current billing period
87after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication99after 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>(...@@ -933,6 +933,14 @@ export function createServerEnvironmentAtoms<R, E>(
933 );933 );
934 }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)),934 }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)),
935 );935 );
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 );
936 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>944 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>
937 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(945 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(
938 Atom.withLabel(`environment-data:server:providers:${environmentId}`),946 Atom.withLabel(`environment-data:server:providers:${environmentId}`),
...@@ -1050,7 +1058,7 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -1050,7 +1058,7 @@ export function createServerEnvironmentAtoms<R, E>(
1050 label: "environment-data:server:usage-summary",1058 label: "environment-data:server:usage-summary",
1051 tag: WS_METHODS.serverGetUsageSummary,1059 tag: WS_METHODS.serverGetUsageSummary,
1052 staleTimeMs: 60_000,1060 staleTimeMs: 60_000,
1053 refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId),1061 refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId),
1054 }),1062 }),
1055 configProjection,1063 configProjection,
1056 welcome,1064 welcome,
packages/client-runtime/src/state/usage.test.ts+13
...@@ -184,6 +184,19 @@ describe("manual usage refresh", () => {...@@ -184,6 +184,19 @@ describe("manual usage refresh", () => {
184});184});
185185
186describe("limits refresh cooldown", () => {186describe("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
187 it("joins manual calls and gates automatic refreshes after success or failure", async () => {200 it("joins manual calls and gates automatic refreshes after success or failure", async () => {
188 const clock = vi.spyOn(Date, "now").mockReturnValue(1_000);201 const clock = vi.spyOn(Date, "now").mockReturnValue(1_000);
189 try {202 try {
packages/client-runtime/src/state/usage.ts+9
...@@ -16,9 +16,18 @@ export async function refreshUsageLimits<A>(...@@ -16,9 +16,18 @@ export async function refreshUsageLimits<A>(
16 environmentId: EnvironmentId,16 environmentId: EnvironmentId,
17 refresh: () => Promise<A>,17 refresh: () => Promise<A>,
18 automatic = false,18 automatic = false,
19 afterPending = false,
19): Promise<A | undefined> {20): Promise<A | undefined> {
20 const pending = limitsRefreshes.get(environmentId);21 const pending = limitsRefreshes.get(environmentId);
21 if (pending !== undefined) {22 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 }
22 // Manual refresh waits for the current check; automatic refresh does not repeat it.31 // Manual refresh waits for the current check; automatic refresh does not repeat it.
23 return automatic ? undefined : ((await pending) as A);32 return automatic ? undefined : ((await pending) as A);
24 }33 }
packages/contracts/src/settings.ts+5
...@@ -1277,6 +1277,10 @@ export const ServerSettings = Schema.Struct({...@@ -1277,6 +1277,10 @@ export const ServerSettings = Schema.Struct({
1277 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(1277 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
1278 Schema.withDecodingDefault(Effect.succeed({})),1278 Schema.withDecodingDefault(Effect.succeed({})),
1279 ),1279 ),
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 ),
1280 /** Exact model IDs, applied to past and future usage on this environment. */1284 /** Exact model IDs, applied to past and future usage on this environment. */
1281 usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe(1285 usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe(
1282 Schema.withDecodingDefault(Effect.succeed({})),1286 Schema.withDecodingDefault(Effect.succeed({})),
...@@ -1550,6 +1554,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1550,6 +1554,7 @@ export const ServerSettingsPatch = Schema.Struct({
1550 usageLimitSources: Schema.optionalKey(1554 usageLimitSources: Schema.optionalKey(
1551 Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)),1555 Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)),
1552 ),1556 ),
1557 cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean),
1553 /** Each entry replaces one model's rates; `null` restores automatic pricing. */1558 /** Each entry replaces one model's rates; `null` restores automatic pricing. */
1554 usagePriceOverrides: Schema.optionalKey(1559 usagePriceOverrides: Schema.optionalKey(
1555 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)),1560 Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)),
packages/contracts/src/usage.ts+18-11
...@@ -1,13 +1,10 @@...@@ -1,13 +1,10 @@
1/**1/**
2 * Usage reporting contract.2 * Usage reporting contract.
3 *3 *
4 * Each environment scans the provider CLIs' own on-disk session transcripts4 * Each environment scans native session files and databases, including work
5 * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`,5 * driven outside T3 Code. Source status describes gaps in local coverage.
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.
9 *6 *
10 * Environments return pre-aggregated `(day, hourStart?, provider, model)`7 * Environments return pre-aggregated `(day, hourStart?, provider, model, sourcePath?)`
11 * buckets. Raw transcript records never cross the wire.8 * buckets. Raw transcript records never cross the wire.
12 *9 *
13 * @module usage10 * @module usage
...@@ -21,18 +18,24 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";...@@ -21,18 +18,24 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
21 * client renders partial coverage when an environment reports an older version18 * client renders partial coverage when an environment reports an older version
22 * rather than failing the whole page.19 * rather than failing the whole page.
23 */20 */
24export const USAGE_CONTRACT_VERSION = 5 as const;21export const USAGE_CONTRACT_VERSION = 6 as const;
2522
26/**23/**
27 * Oldest {@link UsageSummary} version a current client will still merge.24 * Oldest {@link UsageSummary} version a current client will still merge.
28 *25 *
29 * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets26 * v5/v6 add providers and optional source attribution; v4 Claude/Codex
30 * remain valid, so mixed-version environments keep those totals instead of27 * buckets remain valid in mixed-version environments.
31 * treating every older server as stale.
32 */28 */
33export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const;29export 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]);
36export type UsageProviderKind = typeof UsageProviderKind.Type;39export type UsageProviderKind = typeof UsageProviderKind.Type;
3740
38/**41/**
...@@ -93,6 +96,8 @@ export const UsageBucket = Schema.Struct({...@@ -93,6 +96,8 @@ export const UsageBucket = Schema.Struct({
93 hourStart: Schema.optional(TrimmedNonEmptyString),96 hourStart: Schema.optional(TrimmedNonEmptyString),
94 provider: UsageProviderKind,97 provider: UsageProviderKind,
95 model: TrimmedNonEmptyString,98 model: TrimmedNonEmptyString,
99 /** Source directory, so overlapping multi-home environments merge once per source. */
100 sourcePath: Schema.optional(TrimmedNonEmptyString),
96 totals: UsageTokenTotals,101 totals: UsageTokenTotals,
97 costUsd: Schema.Number,102 costUsd: Schema.Number,
98 /**103 /**
...@@ -151,6 +156,8 @@ export const UsageSource = Schema.Struct({...@@ -151,6 +156,8 @@ export const UsageSource = Schema.Struct({
151 */156 */
152 distinctSessions: NonNegativeInt,157 distinctSessions: NonNegativeInt,
153 message: Schema.NullOr(TrimmedNonEmptyString),158 message: Schema.NullOr(TrimmedNonEmptyString),
159 /** An action the client can offer to make this source available. */
160 action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")),
154});161});
155export type UsageSource = typeof UsageSource.Type;162export type UsageSource = typeof UsageSource.Type;
156163
packages/shared/src/usageFormat.test.ts+12
...@@ -5,10 +5,22 @@ import {...@@ -5,10 +5,22 @@ import {
5 enumerateHourStarts,5 enumerateHourStarts,
6 formatDateTimeShort,6 formatDateTimeShort,
7 formatHourShort,7 formatHourShort,
8 formatPercent,
8 formatRelativeHourShort,9 formatRelativeHourShort,
9 makeWindow,10 makeWindow,
10} from "./usageFormat.ts";11} 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
12describe("hourly usage formatting", () => {24describe("hourly usage formatting", () => {
13 it("keeps requested zones separate when formatting repeated calls", () => {25 it("keeps requested zones separate when formatting repeated calls", () => {
14 const instant = "2026-08-11T12:37:00.000Z";26 const instant = "2026-08-11T12:37:00.000Z";
packages/shared/src/usageFormat.ts+4-1
...@@ -43,7 +43,10 @@ function trim(value: number): string {...@@ -43,7 +43,10 @@ function trim(value: number): string {
43}43}
4444
45export function formatPercent(share: number, digits = 1): string {45export 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)}%`;
47}50}
4851
49/** `2026-08-07` to `Aug 7`. */52/** `2026-08-07` to `Aug 7`. */
packages/shared/src/usageLimits.test.ts+48
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 collectLimitAccounts,16 collectLimitAccounts,
17 collectLimitNotices,17 collectLimitNotices,
18 collectLimitPools,18 collectLimitPools,
19 displayLimitWindows,
19 elapsedShare,20 elapsedShare,
20 formatResetsIn,21 formatResetsIn,
21 limitsNotice,22 limitsNotice,
...@@ -687,6 +688,53 @@ describe("pooled account columns", () => {...@@ -687,6 +688,53 @@ describe("pooled account columns", () => {
687 });688 });
688});689});
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
690describe("collectLimitNotices", () => {738describe("collectLimitNotices", () => {
691 const checkedAt = "2026-09-03T11:00:00.000Z";739 const checkedAt = "2026-09-03T11:00:00.000Z";
692 const claude = ProviderDriverKind.make("claudeAgent");740 const claude = ProviderDriverKind.make("claudeAgent");
packages/shared/src/usageLimits.ts+38
...@@ -24,6 +24,33 @@ const MINUTE = 60_000;...@@ -24,6 +24,33 @@ const MINUTE = 60_000;
24const HOUR = 60 * MINUTE;24const HOUR = 60 * MINUTE;
25const DAY = 24 * HOUR;25const 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
27/**54/**
28 * Providers that belong on the Limits view: enabled, installed, and one whose55 * Providers that belong on the Limits view: enabled, installed, and one whose
29 * driver reports subscription usage at all. A driver with no notion of usage56 * driver reports subscription usage at all. A driver with no notion of usage
...@@ -282,6 +309,17 @@ export interface LimitPool {...@@ -282,6 +309,17 @@ export interface LimitPool {
282 readonly windows: readonly LimitPoolWindow[];309 readonly windows: readonly LimitPoolWindow[];
283}310}
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
285const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {323const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = {
286 session: 0,324 session: 0,
287 weekly: 1,325 weekly: 1,
packages/shared/src/usageMerge.test.ts+160-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 USAGE_CONTRACT_VERSION,2 USAGE_CONTRACT_VERSION,
3 USAGE_MERGE_COMPATIBLE_SINCE,
3 type EnvironmentId,4 type EnvironmentId,
4 type UsageBucket,5 type UsageBucket,
5 type UsageDay,6 type UsageDay,
...@@ -74,6 +75,41 @@ function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {...@@ -74,6 +75,41 @@ function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {
74}75}
7576
76describe("mergeUsage", () => {77describe("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
77 it("sums environments that read different transcript directories", () => {113 it("sums environments that read different transcript directories", () => {
78 const merged = mergeUsage(114 const merged = mergeUsage(
79 [115 [
...@@ -146,6 +182,31 @@ describe("mergeUsage", () => {...@@ -146,6 +182,31 @@ describe("mergeUsage", () => {
146 ).toEqual({ claude: 1, codex: 1 });182 ).toEqual({ claude: 1, codex: 1 });
147 });183 });
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
149 it("uses the newest scan when environments share the same transcript directory", () => {210 it("uses the newest scan when environments share the same transcript directory", () => {
150 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };211 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
151 const environments = [212 const environments = [
...@@ -166,6 +227,104 @@ describe("mergeUsage", () => {...@@ -166,6 +227,104 @@ describe("mergeUsage", () => {
166 }227 }
167 });228 });
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
169 it("excludes an environment reporting an older contract version", () => {328 it("excludes an environment reporting an older contract version", () => {
170 const merged = mergeUsage(329 const merged = mergeUsage(
171 [330 [
...@@ -178,7 +337,7 @@ describe("mergeUsage", () => {...@@ -178,7 +337,7 @@ describe("mergeUsage", () => {
178 summary(337 summary(
179 [bucket()],338 [bucket()],
180 [{ provider: "claude", hostId: "linux", homePath: "/b" }],339 [{ provider: "claude", hostId: "linux", homePath: "/b" }],
181 USAGE_CONTRACT_VERSION - 2,340 USAGE_MERGE_COMPATIBLE_SINCE - 1,
182 ),341 ),
183 ),342 ),
184 ],343 ],
packages/shared/src/usageMerge.ts+109-12
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 type EnvironmentId,11 type EnvironmentId,
12 type UsageBucket,12 type UsageBucket,
13 type UsageProviderKind,13 type UsageProviderKind,
14 type UsageSource,
14 type UsageSourceFingerprint,15 type UsageSourceFingerprint,
15 type UsageSummary,16 type UsageSummary,
16} from "@t3tools/contracts";17} from "@t3tools/contracts";
...@@ -113,20 +114,46 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string {...@@ -113,20 +114,46 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string {
113 ].join(" ");114 ].join(" ");
114}115}
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
116/**133/**
117 * Decides which environment owns each physical transcript directory.134 * Decides which environment owns each physical transcript directory.
118 *135 *
119 * Several environments on one machine (worktree servers, for instance) resolve136 * Several environments on one machine (worktree servers, for instance) resolve
120 * the same provider home and would otherwise double count every token. The137 * 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's138 * Complete scans claim a fingerprint ahead of partial scans, then the most
122 * buckets dropped. Environment ids break ties so the winner is stable when139 * recently read scan wins within each status. A newer partial scan can still
123 * summaries have the same read time.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.
124 */142 */
125function claimSources(environments: readonly EnvironmentUsage[]): {143function claimSources(environments: readonly EnvironmentUsage[]): {
126 readonly ownerByFingerprint: ReadonlyMap<string, EnvironmentId>;144 readonly ownerByFingerprint: ReadonlyMap<string, EnvironmentId>;
145 readonly supplementalBucketsByEnvironment: ReadonlyMap<EnvironmentId, ReadonlySet<UsageBucket>>;
146 readonly sessionsByFingerprint: ReadonlyMap<string, number>;
127 readonly duplicates: readonly string[];147 readonly duplicates: readonly string[];
128} {148} {
129 const ownerByFingerprint = new Map<string, EnvironmentId>();149 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>();
130 const duplicates: string[] = [];157 const duplicates: string[] = [];
131158
132 const ordered = [...environments].sort(159 const ordered = [...environments].sort(
...@@ -135,30 +162,82 @@ function claimSources(environments: readonly EnvironmentUsage[]): {...@@ -135,30 +162,82 @@ function claimSources(environments: readonly EnvironmentUsage[]): {
135 a.environmentId.localeCompare(b.environmentId),162 a.environmentId.localeCompare(b.environmentId),
136 );163 );
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.
138 for (const environment of ordered) {186 for (const environment of ordered) {
139 for (const source of environment.summary.sources) {187 for (const source of environment.summary.sources) {
140 if (source.status === "missing") continue;188 if (source.status !== "partial") continue;
141 const key = fingerprintKey(source.fingerprint);189 const key = fingerprintKey(source.fingerprint);
142 if (ownerByFingerprint.has(key)) {190 const owner = ownerScanByFingerprint.get(key);
143 duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);191 if (
192 owner?.source.status !== "ok" ||
193 Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt)
194 ) {
144 continue;195 continue;
145 }196 }
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 );
147 }218 }
148 }219 }
149220
150 return { ownerByFingerprint, duplicates };221 return {
222 ownerByFingerprint,
223 supplementalBucketsByEnvironment,
224 sessionsByFingerprint,
225 duplicates,
226 };
151}227}
152228
153/** Sources this environment owns after fingerprint claims, plus their buckets. */229/** Sources this environment owns after fingerprint claims, plus their buckets. */
154function ownedContribution(230function ownedContribution(
155 environment: EnvironmentUsage,231 environment: EnvironmentUsage,
156 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,232 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
233 supplementalBuckets: ReadonlySet<UsageBucket>,
234 sessionsByFingerprint: ReadonlyMap<string, number>,
157): {235): {
158 readonly buckets: readonly UsageBucket[];236 readonly buckets: readonly UsageBucket[];
159 readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>;237 readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>;
160} {238} {
161 const ownedProviders = new Set<UsageProviderKind>();239 const ownedProviders = new Set<UsageProviderKind>();
240 const ownedSources = new Set<string>();
162 const sessionsByProvider = new Map<UsageProviderKind, number>();241 const sessionsByProvider = new Map<UsageProviderKind, number>();
163 for (const source of environment.summary.sources) {242 for (const source of environment.summary.sources) {
164 if (source.status === "missing") continue;243 if (source.status === "missing") continue;
...@@ -166,16 +245,24 @@ function ownedContribution(...@@ -166,16 +245,24 @@ function ownedContribution(
166 if (ownerByFingerprint.get(key) === environment.environmentId) {245 if (ownerByFingerprint.get(key) === environment.environmentId) {
167 const provider = source.fingerprint.provider;246 const provider = source.fingerprint.provider;
168 ownedProviders.add(provider);247 ownedProviders.add(provider);
248 ownedSources.add(`${provider}\u0000${source.fingerprint.resolvedHomePath}`);
169 // Distinct within a directory. Summing per-bucket session counts instead249 // Distinct within a directory. Summing per-bucket session counts instead
170 // would count a session once per day and model it spans.250 // would count a session once per day and model it spans.
171 sessionsByProvider.set(251 sessionsByProvider.set(
172 provider,252 provider,
173 (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions,253 (sessionsByProvider.get(provider) ?? 0) +
254 (sessionsByFingerprint.get(key) ?? source.distinctSessions),
174 );255 );
175 }256 }
176 }257 }
177 return {258 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 ),
179 sessionsByProvider,266 sessionsByProvider,
180 };267 };
181}268}
...@@ -246,7 +333,12 @@ export function mergeUsage(...@@ -246,7 +333,12 @@ export function mergeUsage(
246 }333 }
247 }334 }
248335
249 const { ownerByFingerprint, duplicates } = claimSources(current);336 const {
337 ownerByFingerprint,
338 supplementalBucketsByEnvironment,
339 sessionsByFingerprint,
340 duplicates,
341 } = claimSources(current);
250342
251 let costUsd = 0;343 let costUsd = 0;
252 let uncachedInputTokens = 0;344 let uncachedInputTokens = 0;
...@@ -295,7 +387,12 @@ export function mergeUsage(...@@ -295,7 +387,12 @@ export function mergeUsage(
295 const contributingEnvironments: EnvironmentId[] = [];387 const contributingEnvironments: EnvironmentId[] = [];
296388
297 for (const environment of current) {389 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 );
299 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);396 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
300397
301 for (const [providerKind, providerSessions] of sessionsByProvider) {398 for (const [providerKind, providerSessions] of sessionsByProvider) {
pnpm-lock.yaml+3
...@@ -516,6 +516,9 @@ importers:...@@ -516,6 +516,9 @@ importers:
516 '@ff-labs/fff-node':516 '@ff-labs/fff-node':
517 specifier: 0.9.4517 specifier: 0.9.4
518 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8)518 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8)
519 '@napi-rs/keyring':
520 specifier: ^1.3.0
521 version: 1.3.0
519 '@opencode-ai/sdk':522 '@opencode-ai/sdk':
520 specifier: ^1.3.15523 specifier: ^1.3.15
521 version: 1.15.13524 version: 1.15.13
scripts/lib/cli-external-packages.test.ts+2-1
...@@ -49,6 +49,7 @@ describe("shouldBundleCliDependency", () => {...@@ -49,6 +49,7 @@ describe("shouldBundleCliDependency", () => {
49 "ffi-rs",49 "ffi-rs",
50 "@yuuang/ffi-rs-win32-x64-msvc",50 "@yuuang/ffi-rs-win32-x64-msvc",
51 "@ff-labs/fff-node",51 "@ff-labs/fff-node",
52 "@napi-rs/keyring",
52 "@clerk/electron-passkeys",53 "@clerk/electron-passkeys",
53 "node-addon-api",54 "node-addon-api",
54 ]) {55 ]) {
...@@ -82,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => {...@@ -82,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => {
82 it("selects every external root declared by the server", () => {83 it("selects every external root declared by the server", () => {
83 assert.deepStrictEqual(84 assert.deepStrictEqual(
84 Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(),85 Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(),
85 ["@ff-labs/fff-node", "node-pty"],86 ["@ff-labs/fff-node", "@napi-rs/keyring", "node-pty"],
86 );87 );
87 });88 });
88});89});
scripts/lib/cli-external-packages.ts+1
...@@ -30,6 +30,7 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [...@@ -30,6 +30,7 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [
30 "ffi-rs",30 "ffi-rs",
31 "@yuuang/",31 "@yuuang/",
32 "@ff-labs/",32 "@ff-labs/",
33 "@napi-rs/keyring",
33 "@clerk/electron-passkeys",34 "@clerk/electron-passkeys",
34 "node-gyp-build",35 "node-gyp-build",
35 "node-addon-api",36 "node-addon-api",