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