| author | |
| committer | |
| log | e5a46d6c5d00b89afba5274a94d42428c8d79763 |
| tree | 3933ae2cafdf21ea29db7d36092ed9f0ebf29dd8 |
| parent | 1c1270663880903b16568594c4bf5f322407903a |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 14 | import { useId, useState } from "react"; | 16 | import { Fragment, type ReactNode, useId, useState } from "react"; |
| 15 | import { Pressable, ScrollView, View } from "react-native"; | 17 | import { Pressable, ScrollView, View } from "react-native"; |
| 16 | import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; | 18 | import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; |
| 17 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 19 | import { 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 === 0 | 230 | {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 | <PoolWindowCard | 244 | {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 | <View | 270 | <View |
| 242 | accessible | 271 | 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"; |
| 25 | 25 | ||
| 26 | import { SegmentedControl } from "../../components/SegmentedControl"; | 26 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| 27 | import { AppText as Text } from "../../components/AppText"; | 27 | import { AppText as Text } from "../../components/AppText"; |
| 28 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 28 | import { cn } from "../../lib/cn"; | 29 | import { cn } from "../../lib/cn"; |
| 29 | import { SettingsScreen } from "../settings/components/SettingsScreen"; | 30 | import { SettingsScreen } from "../settings/components/SettingsScreen"; |
| 30 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; | 31 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 32 | import { serverEnvironment } from "../../state/server"; | ||
| 33 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 31 | import { SettingsSection } from "../settings/components/SettingsSection"; | 34 | import { SettingsSection } from "../settings/components/SettingsSection"; |
| 32 | import { UsageDailyChart } from "./UsageDailyChart"; | 35 | import { UsageDailyChart } from "./UsageDailyChart"; |
| 33 | import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; | 36 | import { 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 }[]; |
| 60 | 63 | ||
| 61 | const CHART_HEIGHT = 180; | 64 | const CHART_HEIGHT = 180; |
| 65 | const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; | ||
| 62 | 66 | ||
| 63 | /** | 67 | /** |
| 64 | * Two tabs over one screen. Usage is the transcript-derived spend for a | 68 | * 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 | ]; | ||
| 100 | 127 | ||
| 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.View | 281 | <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 | <ChartCard | 343 | <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 | } |
| 326 | 371 | ||
| 372 | function 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 | |||
| 413 | function 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 | |||
| 445 | function 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. */ |
| 328 | function ChartCard(props: { | 477 | function ChartCard(props: { |
| 329 | readonly merged: MergedUsage; | 478 | readonly merged: MergedUsage; |
| ... | @@ -400,20 +549,53 @@ function ChartCard(props: { | ... | @@ -400,20 +549,53 @@ function ChartCard(props: { |
| 400 | function ProviderSection(props: { | 549 | function 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; |
| 407 | 559 | ||
| 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 | ); | ||
| 413 | 582 | ||
| 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 | <View | 601 | <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 this | 5 | * 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 | */ |
| 8 | export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"]; | 8 | export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ |
| 9 | "codex", | ||
| 10 | "claude", | ||
| 11 | "grok", | ||
| 12 | "cursor", | ||
| 13 | "opencode", | ||
| 14 | "antigravity", | ||
| 15 | ]; | ||
| 9 | 16 | ||
| 10 | export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { | 17 | export 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 | }; |
| 15 | 25 | ||
| 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 | }); |
| 1027 | 1027 | ||
| 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 | }); |
| 1110 | 1114 | ||
| 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 @@ |
| 1 | import * as NodeOS from "node:os"; | 1 | import * as NodeOS from "node:os"; |
| 2 | import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; | 2 | import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; |
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 4 | import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; | ||
| 4 | import * as DateTime from "effect/DateTime"; | 5 | import * as DateTime from "effect/DateTime"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 7 | import * 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"; |
| 17 | import { readMacCursorAccessToken } from "../cursorCredentialStore.ts"; | ||
| 16 | 18 | ||
| 17 | const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); | 19 | const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); |
| 20 | const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; | ||
| 18 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); | 21 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); |
| 19 | const CursorUsageResponse = Schema.Struct({ | 22 | const 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( |
| 63 | export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( | 62 | export 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 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.ts"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | const CACHE_MS = 5 * 60_000; | ||
| 2 | |||
| 3 | /** Share one Keychain request across usage history and limits in this server process. */ | ||
| 4 | export 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. */ | ||
| 26 | export 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 @@ |
| 3 | import * as NodeFSP from "node:fs/promises"; | 3 | import * as NodeFSP from "node:fs/promises"; |
| 4 | import * as NodeOS from "node:os"; | 4 | import * as NodeOS from "node:os"; |
| 5 | import * as NodePath from "node:path"; | 5 | import * as NodePath from "node:path"; |
| 6 | import * as NodeSqlite from "node:sqlite"; | ||
| 6 | 7 | ||
| 7 | import { assert, describe, it } from "@effect/vitest"; | 8 | import { assert, describe, it } from "@effect/vitest"; |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | 10 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 10 | import { mergeUsage } from "@t3tools/shared/usageMerge"; | 11 | import { mergeUsage } from "@t3tools/shared/usageMerge"; |
| 11 | import { | 12 | import { |
| 12 | EnvironmentId, | 13 | EnvironmentId, |
| ... | @@ -31,6 +32,7 @@ import * as ServerConfig from "../config.ts"; | ... | @@ -31,6 +32,7 @@ import * as ServerConfig from "../config.ts"; |
| 31 | import * as ServerSettings from "../serverSettings.ts"; | 32 | import * as ServerSettings from "../serverSettings.ts"; |
| 32 | import * as UsageService from "./UsageService.ts"; | 33 | import * as UsageService from "./UsageService.ts"; |
| 33 | 34 | ||
| 35 | const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 34 | const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 36 | const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 35 | 37 | ||
| 36 | function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { | 38 | function 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 | } |
| 113 | 122 | ||
| 114 | describe("UsageService", () => { | 123 | describe("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 | ); | ||
| 508 | 713 | ||
| 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, and | 4 | * The scan reads native session files and databases, including work driven |
| 5 | * Grok Build) rather than T3 Code's orchestration projections, so usage covers | 5 | * 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 by | 7 | * 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; warm | 8 | * `(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 resumes | 9 | * 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 UsageService | 13 | * @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"; |
| 30 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | 31 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 31 | import * as Cause from "effect/Cause"; | 32 | import * as Cause from "effect/Cause"; |
| 32 | import * as Clock from "effect/Clock"; | 33 | import * as Clock from "effect/Clock"; |
| 33 | import * as Context from "effect/Context"; | 34 | import * as Context from "effect/Context"; |
| 35 | import * as Crypto from "effect/Crypto"; | ||
| 34 | import * as DateTime from "effect/DateTime"; | 36 | import * as DateTime from "effect/DateTime"; |
| 35 | import * as Deferred from "effect/Deferred"; | 37 | import * as Deferred from "effect/Deferred"; |
| 36 | import * as Effect from "effect/Effect"; | 38 | import * as Effect from "effect/Effect"; |
| ... | @@ -46,7 +48,11 @@ import { ServerConfig } from "../config.ts"; | ... | @@ -46,7 +48,11 @@ import { ServerConfig } from "../config.ts"; |
| 46 | import { expandHomePath } from "../pathExpansion.ts"; | 48 | import { expandHomePath } from "../pathExpansion.ts"; |
| 47 | import * as ServerSettings from "../serverSettings.ts"; | 49 | import * as ServerSettings from "../serverSettings.ts"; |
| 48 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; | 50 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; |
| 51 | import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts"; | ||
| 49 | import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; | 52 | import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; |
| 53 | import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; | ||
| 54 | import { readAntigravityUsage } from "./antigravityUsageReader.ts"; | ||
| 55 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; | ||
| 50 | import { UsageAggregator } from "./usageAggregation.ts"; | 56 | import { UsageAggregator } from "./usageAggregation.ts"; |
| 51 | import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; | 57 | import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; |
| 52 | import { | 58 | import { |
| ... | @@ -144,12 +150,14 @@ export const layerTest = Layer.succeed( | ... | @@ -144,12 +150,14 @@ export const layerTest = Layer.succeed( |
| 144 | ); | 150 | ); |
| 145 | 151 | ||
| 146 | export const make = Effect.gen(function* () { | 152 | export 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; | ||
| 153 | 161 | ||
| 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 | }); |
| 486 | 663 | ||
| ... | @@ -555,7 +732,16 @@ export const make = Effect.gen(function* () { | ... | @@ -555,7 +732,16 @@ export const make = Effect.gen(function* () { |
| 555 | 732 | ||
| 556 | const sources: UsageSource[] = []; | 733 | const sources: UsageSource[] = []; |
| 557 | 734 | ||
| 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 still | 747 | // 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 can | 788 | // 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 | } |
| 609 | 795 | ||
| 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 | } |
| 621 | 809 | ||
| ... | @@ -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 | ]); |
| 663 | 853 | ||
| 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 | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import * as NodeSqlite from "node:sqlite"; | ||
| 6 | import * as NodeTimersPromises from "node:timers/promises"; | ||
| 7 | |||
| 8 | import type { UsageRecord } from "./usageTranscripts.ts"; | ||
| 9 | |||
| 10 | type FieldValue = number | bigint | Uint8Array; | ||
| 11 | type Fields = Map<number, FieldValue[]>; | ||
| 12 | |||
| 13 | /** Antigravity stores usage metadata as protobuf, independently of conversation text. */ | ||
| 14 | function 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 | |||
| 57 | const numberAt = (value: Fields, key: number) => { | ||
| 58 | const entry = value.get(key)?.[0]; | ||
| 59 | return typeof entry === "number" ? entry : 0; | ||
| 60 | }; | ||
| 61 | const bytesAt = (value: Fields, key: number) => { | ||
| 62 | const entry = value.get(key)?.[0]; | ||
| 63 | return entry instanceof Uint8Array ? entry : undefined; | ||
| 64 | }; | ||
| 65 | const nested = (value: Fields, key: number) => { | ||
| 66 | const bytes = bytesAt(value, key); | ||
| 67 | return bytes === undefined ? new Map<number, FieldValue[]>() : fields(bytes); | ||
| 68 | }; | ||
| 69 | const 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 | }; | ||
| 73 | const 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 | |||
| 78 | const 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 | |||
| 102 | function 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 | |||
| 118 | interface Metadata { | ||
| 119 | model: string; | ||
| 120 | timestampMs: number | null; | ||
| 121 | usages: Fields[]; | ||
| 122 | } | ||
| 123 | |||
| 124 | function 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 | |||
| 150 | function blob(value: unknown): Uint8Array { | ||
| 151 | if (!(value instanceof Uint8Array)) throw new Error("Invalid Antigravity metadata blob"); | ||
| 152 | return value; | ||
| 153 | } | ||
| 154 | |||
| 155 | interface UsageCandidate { | ||
| 156 | record: UsageRecord; | ||
| 157 | keys: readonly string[]; | ||
| 158 | timestampQuality: number; | ||
| 159 | } | ||
| 160 | |||
| 161 | async 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. */ | ||
| 261 | export 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 | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | ||
| 4 | import * as NodeCrypto from "node:crypto"; | ||
| 5 | import * as NodeTimersPromises from "node:timers/promises"; | ||
| 6 | |||
| 7 | import type { UsageRecord } from "./usageTranscripts.ts"; | ||
| 8 | import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts"; | ||
| 9 | |||
| 10 | function 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 | |||
| 16 | function tokens(value: unknown): number { | ||
| 17 | return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface CursorAccountUsageReadResult { | ||
| 21 | readonly accountKey: string | null; | ||
| 22 | readonly records: readonly UsageRecord[]; | ||
| 23 | readonly missing: boolean; | ||
| 24 | readonly error: string | null; | ||
| 25 | } | ||
| 26 | |||
| 27 | const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); | ||
| 28 | |||
| 29 | function 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. */ | ||
| 41 | function 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. */ | ||
| 54 | export 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 | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import * as NodeSqlite from "node:sqlite"; | ||
| 6 | import * as NodeTimersPromises from "node:timers/promises"; | ||
| 7 | |||
| 8 | import { totalTokens, type UsageRecord } from "./usageTranscripts.ts"; | ||
| 9 | |||
| 10 | function 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 | |||
| 16 | function tokens(value: unknown): number { | ||
| 17 | return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; | ||
| 18 | } | ||
| 19 | |||
| 20 | function text(value: unknown): string { | ||
| 21 | return typeof value === "string" ? value : ""; | ||
| 22 | } | ||
| 23 | |||
| 24 | /** OpenCode stores uncached input and reasoning separately from input/output. */ | ||
| 25 | function 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 | |||
| 72 | export 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. */ | ||
| 79 | export 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 records | 112 | * 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 @@ |
| 4 | import * as NodeFSP from "node:fs/promises"; | 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodeOS from "node:os"; | 5 | import * as NodeOS from "node:os"; |
| 6 | import * as NodePath from "node:path"; | 6 | import * as NodePath from "node:path"; |
| 7 | import * as NodeSqlite from "node:sqlite"; | ||
| 7 | 8 | ||
| 8 | import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; | 9 | import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; |
| 9 | 10 | ||
| 10 | import { readTranscriptRecords } from "./usageTranscriptReader.ts"; | 11 | import { readTranscriptRecords } from "./usageTranscriptReader.ts"; |
| 12 | import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; | ||
| 13 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; | ||
| 14 | import { readAntigravityUsage } from "./antigravityUsageReader.ts"; | ||
| 11 | 15 | ||
| 12 | let dir: string; | 16 | let dir: string; |
| 13 | 17 | ||
| 18 | function 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 | |||
| 31 | function 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 | |||
| 37 | function protoText(field: number, value: string): number[] { | ||
| 38 | return protoBytes(field, [...Buffer.from(value)]); | ||
| 39 | } | ||
| 40 | |||
| 14 | beforeEach(async () => { | 41 | beforeEach(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 | |||
| 239 | describe("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 | /> |
| 1107 | 1128 |
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 @@ |
| 1 | import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { PlusIcon } from "lucide-react"; | 3 | import { PlusIcon } from "lucide-react"; |
| 3 | import { useState } from "react"; | 4 | import { useState } from "react"; |
| 4 | 5 | ||
| 5 | import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | 6 | import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; |
| 7 | import { serverEnvironment } from "../../state/server"; | ||
| 8 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 6 | import { | 9 | import { |
| 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"; |
| 15 | import { Button } from "../ui/button"; | 18 | import { Button } from "../ui/button"; |
| 19 | import { Switch } from "../ui/switch"; | ||
| 16 | import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; | 20 | import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; |
| 17 | import { searchableSetting } from "./settingsSearch"; | 21 | import { searchableSetting } from "./settingsSearch"; |
| 18 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | 22 | import { 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); |
| 35 | 50 | ||
| 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 | <SettingsSection | 68 | <SettingsSection |
| ... | @@ -46,8 +76,23 @@ export function UsageProviderSettings({ | ... | @@ -46,8 +76,23 @@ export function UsageProviderSettings({ |
| 46 | ) : null | 76 | ) : 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 | }); |
| 179 | 181 | ||
| 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"; |
| 10 | import { usePrimarySessionState } from "~/environments/primary"; | 10 | import { usePrimarySessionState } from "~/environments/primary"; |
| 11 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; | 11 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; |
| 12 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; | 12 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; |
| 13 | import type { SettingsScopeSearch } from "./settingsScope"; | ||
| 13 | import { | 14 | import { |
| 14 | filterAvailableSettingsSearchItems, | 15 | filterAvailableSettingsSearchItems, |
| 15 | getThreadAutoSettlementSearchAvailability, | 16 | getThreadAutoSettlementSearchAvailability, |
| 16 | } from "./settingsSearch"; | 17 | } from "./settingsSearch"; |
| 17 | 18 | ||
| 18 | export function useAvailableSettingsSearchItems() { | 19 | export 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"; |
| 19 | import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; | 19 | import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; |
| 20 | import { Fragment, useState } from "react"; | 20 | import { Fragment, type ReactNode, useState } from "react"; |
| 21 | 21 | ||
| 22 | import { usePrimarySettings } from "../../hooks/useSettings"; | 22 | import { usePrimarySettings } from "../../hooks/useSettings"; |
| 23 | import { environmentPresentations } from "../../state/presentation"; | 23 | import { environmentPresentations } from "../../state/presentation"; |
| ... | @@ -322,14 +322,16 @@ export function ResetCredits({ | ... | @@ -322,14 +322,16 @@ export function ResetCredits({ |
| 322 | export function UsageLimitsSection({ | 322 | export 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 === null | 333 | selectedEnvironmentIds === null |
| 332 | ? presentations | 334 | ? 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"; |
| 13 | import { AlertTriangleIcon, TicketIcon } from "lucide-react"; | 14 | import { AlertTriangleIcon, TicketIcon } from "lucide-react"; |
| 14 | import { type ReactNode, useState } from "react"; | 15 | import { Fragment, type ReactNode, useState } from "react"; |
| 15 | 16 | ||
| 16 | import { usePrimarySettings } from "../../hooks/useSettings"; | 17 | import { usePrimarySettings } from "../../hooks/useSettings"; |
| 17 | import { cn } from "../../lib/utils"; | 18 | import { 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({ |
| 512 | function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { | 527 | function 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 |
| 539 | export function UsageLimitsPooled({ | 565 | export 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 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { | 3 | import { |
| 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"; |
| 42 | import { Button, InlineButton } from "../ui/button"; | 43 | import { Button, InlineButton } from "../ui/button"; |
| 44 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | ||
| 43 | import { | 45 | import { |
| 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 "../ |
| 53 | import { SidebarInset } from "../ui/sidebar"; | 55 | import { SidebarInset } from "../ui/sidebar"; |
| 54 | import { Skeleton } from "../ui/skeleton"; | 56 | import { Skeleton } from "../ui/skeleton"; |
| 55 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | 57 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 58 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 56 | import { | 59 | import { |
| 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)}%`; |
| 151 | 184 | ||
| 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> |
| 406 | 459 | ||
| 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 | } |
| 638 | 706 | ||
| 707 | const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; | ||
| 708 | |||
| 709 | function 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 | |||
| 768 | function 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 | |||
| 800 | function 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. */ |
| 640 | function ProviderMark({ | 839 | function 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 | }); |
| 94 | 97 |
apps/web/src/components/usage/usageProviders.ts+12-1| ... | @@ -1,6 +1,14 @@ | ... | @@ -1,6 +1,14 @@ |
| 1 | import type { UsageProviderKind } from "@t3tools/contracts"; | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; | 3 | import { |
| 4 | AntigravityIcon, | ||
| 5 | ClaudeAI, | ||
| 6 | CursorIcon, | ||
| 7 | GrokIcon, | ||
| 8 | type Icon, | ||
| 9 | OpenAI, | ||
| 10 | OpenCodeIcon, | ||
| 11 | } from "../Icons"; | ||
| 4 | 12 | ||
| 5 | type UsageProviderPresentation = { | 13 | type 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>; |
| 34 | 45 | ||
| 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 |
| 6 | 6 | ||
| 7 | ## Understand your usage | 7 | ## Understand your usage |
| 8 | 8 | ||
| 9 | **Usage** combines Codex, Claude Code, and Grok Build session history from your connected | 9 | **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected |
| 10 | environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent | 10 | environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent |
| 11 | cost. These estimates are not your subscription bill. | 11 | cost. These estimates are not your subscription bill. |
| 12 | 12 | ||
| 13 | Totals depend on the history available on each server. Grok turns without a saved completed-turn | 13 | Totals depend on the history available on each server. Grok turns without a saved completed-turn |
| 14 | record are missing from the totals. | 14 | record are missing from the totals. |
| 15 | 15 | ||
| 16 | OpenCode reads its SQLite database and older JSON history. Antigravity reads local conversation | ||
| 17 | databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the | ||
| 18 | server to read a different data directory; comma-separated paths read multiple directories. | ||
| 19 | |||
| 20 | Cursor reads account usage from Cursor's dashboard API using the CLI login saved on the server. | ||
| 21 | This includes headless T3 sessions and desktop usage across machines; the same account counts | ||
| 22 | once across connected environments. Without an accessible CLI login, T3 shows a | ||
| 23 | notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. | ||
| 24 | On macOS, choose **Enable Cursor usage** on Usage to allow T3 to read your existing CLI login | ||
| 25 | from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask | ||
| 26 | you to allow access on the server Mac. | ||
| 27 | |||
| 16 | Usage includes each configured account's history, including disabled accounts. Custom homes follow | 28 | Usage includes each configured account's history, including disabled accounts. Custom homes follow |
| 17 | the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment | 29 | the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment |
| 18 | variable. Use absolute paths or `~/` paths in the account's environment settings; relative | 30 | variable. 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 |
| 78 | OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in | 90 | OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in |
| 79 | the environment. T3 cannot report limits for external OpenCode servers because their credentials | 91 | the environment. T3 cannot report limits for external OpenCode servers because their credentials |
| 80 | belong to the remote server. Cursor reports | 92 | belong to the remote server. Cursor reports |
| 81 | its monthly allowance, including separate Auto and API usage, using a file-based CLI login or | 93 | its 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 |
| 83 | On macOS, use `AGENT_CLI_CREDENTIAL_STORE=file` when signing in and in the provider's environment | 95 | usage. Keychain login is used for limits only with Cursor's default API endpoint. If you configure |
| 84 | to use a file-based login. | 96 | a custom Cursor endpoint, use an explicit token or file-based CLI login for limits. |
| 85 | 97 | ||
| 86 | Grok reports the remaining subscription allowance and reset time for its current billing period | 98 | Grok reports the remaining subscription allowance and reset time for its current billing period |
| 87 | after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication | 99 | after 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 | }); |
| 185 | 185 | ||
| 186 | describe("limits refresh cooldown", () => { | 186 | describe("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 transcripts | 4 | * 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 usage | 10 | * @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 version | 18 | * 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 | */ |
| 24 | export const USAGE_CONTRACT_VERSION = 5 as const; | 21 | export const USAGE_CONTRACT_VERSION = 6 as const; |
| 25 | 22 | ||
| 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 buckets | 26 | * v5/v6 add providers and optional source attribution; v4 Claude/Codex |
| 30 | * remain valid, so mixed-version environments keep those totals instead of | 27 | * buckets remain valid in mixed-version environments. |
| 31 | * treating every older server as stale. | ||
| 32 | */ | 28 | */ |
| 33 | export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; | 29 | export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; |
| 34 | 30 | ||
| 35 | export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); | 31 | export const UsageProviderKind = Schema.Literals([ |
| 32 | "claude", | ||
| 33 | "codex", | ||
| 34 | "grok", | ||
| 35 | "cursor", | ||
| 36 | "opencode", | ||
| 37 | "antigravity", | ||
| 38 | ]); | ||
| 36 | export type UsageProviderKind = typeof UsageProviderKind.Type; | 39 | export type UsageProviderKind = typeof UsageProviderKind.Type; |
| 37 | 40 | ||
| 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 | }); |
| 155 | export type UsageSource = typeof UsageSource.Type; | 162 | export type UsageSource = typeof UsageSource.Type; |
| 156 | 163 |
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"; |
| 11 | 12 | ||
| 13 | describe("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 | |||
| 12 | describe("hourly usage formatting", () => { | 24 | describe("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 | } |
| 44 | 44 | ||
| 45 | export function formatPercent(share: number, digits = 1): string { | 45 | export 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 | } |
| 48 | 51 | ||
| 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 | }); |
| 689 | 690 | ||
| 691 | describe("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 | |||
| 690 | describe("collectLimitNotices", () => { | 738 | describe("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; |
| 24 | const HOUR = 60 * MINUTE; | 24 | const HOUR = 60 * MINUTE; |
| 25 | const DAY = 24 * HOUR; | 25 | const DAY = 24 * HOUR; |
| 26 | 26 | ||
| 27 | export 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 | |||
| 45 | export function cursorUsageWindowDetails(id: string) { | ||
| 46 | return CURSOR_USAGE_WINDOWS.find((window) => window.id === id); | ||
| 47 | } | ||
| 48 | |||
| 49 | function 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 whose | 55 | * 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 usage | 56 | * 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 | } |
| 284 | 311 | ||
| 312 | /** Show Cursor's two usable pools instead of a combined percentage when both are available. */ | ||
| 313 | export 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 | |||
| 285 | const WINDOW_KIND_ORDER: Record<ServerProviderUsageWindow["kind"], number> = { | 323 | const 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 @@ |
| 1 | import { | 1 | import { |
| 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 | } |
| 75 | 76 | ||
| 76 | describe("mergeUsage", () => { | 77 | describe("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 | }); |
| 148 | 184 | ||
| 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 | }); |
| 168 | 229 | ||
| 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 | } |
| 115 | 116 | ||
| 117 | function 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 | |||
| 129 | function 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) resolve | 136 | * Several environments on one machine (worktree servers, for instance) resolve |
| 120 | * the same provider home and would otherwise double count every token. The | 137 | * the same provider home and would otherwise double count every token. The |
| 121 | * most recently read summary claims a fingerprint; the rest have that provider's | 138 | * Complete scans claim a fingerprint ahead of partial scans, then the most |
| 122 | * buckets dropped. Environment ids break ties so the winner is stable when | 139 | * 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 | */ |
| 125 | function claimSources(environments: readonly EnvironmentUsage[]): { | 143 | function 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[] = []; |
| 131 | 158 | ||
| 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 | ); |
| 137 | 164 | ||
| 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 | } |
| 149 | 220 | ||
| 150 | return { ownerByFingerprint, duplicates }; | 221 | return { |
| 222 | ownerByFingerprint, | ||
| 223 | supplementalBucketsByEnvironment, | ||
| 224 | sessionsByFingerprint, | ||
| 225 | duplicates, | ||
| 226 | }; | ||
| 151 | } | 227 | } |
| 152 | 228 | ||
| 153 | /** Sources this environment owns after fingerprint claims, plus their buckets. */ | 229 | /** Sources this environment owns after fingerprint claims, plus their buckets. */ |
| 154 | function ownedContribution( | 230 | function 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 instead | 249 | // 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 | } |
| 248 | 335 | ||
| 249 | const { ownerByFingerprint, duplicates } = claimSources(current); | 336 | const { |
| 337 | ownerByFingerprint, | ||
| 338 | supplementalBucketsByEnvironment, | ||
| 339 | sessionsByFingerprint, | ||
| 340 | duplicates, | ||
| 341 | } = claimSources(current); | ||
| 250 | 342 | ||
| 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[] = []; |
| 296 | 388 | ||
| 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); |
| 300 | 397 | ||
| 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.4 | 517 | 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.15 | 523 | specifier: ^1.3.15 |
| 521 | version: 1.15.13 | 524 | 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", |