From e55e7315e4a4c3870711acd6825ea5347c2d4812 Mon Sep 17 00:00:00 2001 From: aaditagrawal <103925638+aaditagrawal@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:04:16 +0530 Subject: [PATCH 01/73] fix: update OpenAI logo to current brand asset (#13611) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- apps/marketing/public/harnesses/openai_dark.svg | 4 +++- apps/mobile/src/components/ProviderIcon.tsx | 6 ++++-- apps/web/src/components/Icons.tsx | 8 ++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/marketing/public/harnesses/openai_dark.svg b/apps/marketing/public/harnesses/openai_dark.svg index b78a51db7bc6..956f87c99f60 100644 --- a/apps/marketing/public/harnesses/openai_dark.svg +++ b/apps/marketing/public/harnesses/openai_dark.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..49de96a8d74c 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -75,10 +75,12 @@ export function ProviderIcon(props: ProviderIconProps) { // codex (and unknown drivers) return ( - + ); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 2a4463c11a06..df7ea7b1c95e 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -547,10 +547,14 @@ export const OpenAI: Icon = ({ className, ...props }) => ( - + ); From 1c1270663880903b16568594c4bf5f322407903a Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:01:48 -0400 Subject: [PATCH 02/73] fix(mobile): render assigned project icons in chat list (#12810) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/mobile/src/components/ProjectFavicon.tsx | 69 +++++++++++++++- .../archive/ArchivedThreadsScreen.tsx | 1 + .../SettingsProjectOverviewRouteScreen.tsx | 3 +- .../features/threads/NewTaskRouteScreen.tsx | 2 + .../features/threads/thread-list-v2-items.tsx | 9 ++- apps/mobile/src/lib/projectIcon.ts | 81 +++++++++++++++++++ 6 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/lib/projectIcon.ts diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index e1d883a01026..83505730d808 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,8 +1,9 @@ import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; import { Image } from "expo-image"; import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, getProjectFaviconResourceKey, @@ -11,6 +12,12 @@ import { import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/unstable/reactivity"; import { projectFaviconUrlAtom } from "../state/assets"; +import { + countGlyphs, + projectIconColorClassNames, + resolveProjectIconGlyph, + type ProjectIconGlyph, +} from "../lib/projectIcon"; import { beginProjectFaviconRequest, @@ -30,10 +37,12 @@ export function ProjectFavicon(props: { readonly projectTitle: string; readonly workspaceRoot?: string | null; readonly faviconPath?: string | null; + readonly projectIcon?: ProjectIconOverride | null; }) { const size = props.size ?? 42; + const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle); const faviconUrl = useAtomValue( - props.workspaceRoot == null + props.workspaceRoot == null || glyph !== null ? EMPTY_FAVICON_URL : projectFaviconUrlAtom({ environmentId: props.environmentId, @@ -51,6 +60,10 @@ export function ProjectFavicon(props: { : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; + if (glyph !== null) { + return ; + } + return ( + + {glyph.emoji} + + + ); + } + + const colors = projectIconColorClassNames(glyph.color); + return ( + + + {glyph.text} + + + ); +} + function ProjectFaviconImage(props: { readonly cacheKey: string | null; readonly faviconUrl: string | null; @@ -105,7 +168,7 @@ function ProjectFaviconImage(props: { {!showImage ? ( diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index ea150180393c..b786f9f4066c 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -151,6 +151,7 @@ function ProjectGroupLabel(props: { diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index f74542076be2..27a9b3217c7e 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps ) : null} @@ -890,8 +891,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} @@ -1124,8 +1126,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { diff --git a/apps/mobile/src/lib/projectIcon.ts b/apps/mobile/src/lib/projectIcon.ts new file mode 100644 index 000000000000..885c583aa543 --- /dev/null +++ b/apps/mobile/src/lib/projectIcon.ts @@ -0,0 +1,81 @@ +import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; + +export type ProjectIconGlyph = + | { readonly kind: "emoji"; readonly emoji: string } + | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor }; + +/** + * Visible glyph count for sizing monogram text. Hermes has no Intl.Segmenter, so combining + * marks are folded into their base character instead of full grapheme segmentation. + */ +export function countGlyphs(text: string): number { + return Array.from(text.replace(/\p{M}/gu, "")).length; +} + +/** Mirrors the automatic monogram web derives from a project name when it has no favicon. */ +export function projectMonogram(projectName: string): string { + const words = + projectName + .normalize("NFKC") + .trim() + .match(/[\p{L}\p{N}]+/gu) ?? []; + const firstWord = words[0]; + if (!firstWord) return "PR"; + + const glyphs = Array.from(firstWord); + const first = glyphs[0] ?? "P"; + const second = + glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ?? + (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ?? + first; + return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); +} + +/** + * Picks what mobile draws for an assigned project icon. Mobile does not bundle + * the Lucide set, so a Lucide override keeps its color and falls back to the + * project's monogram instead of the folder glyph. + */ +export function resolveProjectIconGlyph( + projectIcon: ProjectIconOverride | null | undefined, + projectTitle: string, +): ProjectIconGlyph | null { + switch (projectIcon?.kind) { + case "emoji": + return { kind: "emoji", emoji: projectIcon.emoji }; + case "monogram": + return { kind: "monogram", text: projectIcon.text, color: projectIcon.color }; + case "lucide": + return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color }; + case undefined: + return null; + } +} + +const PROJECT_ICON_COLOR_CLASSES: Record< + ProjectIconColor, + { readonly text: string; readonly background: string } +> = { + gray: { text: "text-gray-500", background: "bg-gray-500/15" }, + red: { text: "text-red-500", background: "bg-red-500/15" }, + orange: { text: "text-orange-500", background: "bg-orange-500/15" }, + amber: { text: "text-amber-500", background: "bg-amber-500/15" }, + yellow: { text: "text-yellow-500", background: "bg-yellow-500/15" }, + lime: { text: "text-lime-500", background: "bg-lime-500/15" }, + green: { text: "text-green-500", background: "bg-green-500/15" }, + emerald: { text: "text-emerald-500", background: "bg-emerald-500/15" }, + teal: { text: "text-teal-500", background: "bg-teal-500/15" }, + cyan: { text: "text-cyan-500", background: "bg-cyan-500/15" }, + sky: { text: "text-sky-500", background: "bg-sky-500/15" }, + blue: { text: "text-blue-500", background: "bg-blue-500/15" }, + indigo: { text: "text-indigo-500", background: "bg-indigo-500/15" }, + violet: { text: "text-violet-500", background: "bg-violet-500/15" }, + purple: { text: "text-purple-500", background: "bg-purple-500/15" }, + fuchsia: { text: "text-fuchsia-500", background: "bg-fuchsia-500/15" }, + pink: { text: "text-pink-500", background: "bg-pink-500/15" }, + rose: { text: "text-rose-500", background: "bg-rose-500/15" }, +}; + +export function projectIconColorClassNames(color: ProjectIconColor) { + return PROJECT_ICON_COLOR_CLASSES[color]; +} From e5a46d6c5d00b89afba5274a94d42428c8d79763 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 25 Sep 2026 08:26:36 -0300 Subject: [PATCH 03/73] feat(usage): read cursor, opencode, and antigravity history (#10409) Co-authored-by: Yash Singh --- .../src/features/usage/UsageLimitsPooled.tsx | 73 ++- .../src/features/usage/UsageLimitsSection.tsx | 11 +- .../src/features/usage/UsageRouteScreen.tsx | 190 +++++- .../src/features/usage/usageProviders.ts | 15 +- apps/server/package.json | 1 + .../src/provider/Drivers/CursorDriver.ts | 12 +- .../provider/Layers/CursorProvider.test.ts | 79 ++- .../src/provider/Layers/cursorUsageLimits.ts | 51 +- .../provider/cursorCredentialStore.test.ts | 22 + .../src/provider/cursorCredentialStore.ts | 29 + apps/server/src/usage/UsageService.test.ts | 213 ++++++- apps/server/src/usage/UsageService.ts | 212 ++++++- .../src/usage/antigravityUsageReader.ts | 374 ++++++++++++ apps/server/src/usage/cursorUsageReader.ts | 254 ++++++++ apps/server/src/usage/opencodeUsageReader.ts | 188 ++++++ apps/server/src/usage/usageAggregation.ts | 8 +- .../src/usage/usageTranscriptReader.test.ts | 554 ++++++++++++++++++ .../settings/ProviderSettingsPanel.tsx | 21 + .../settings/SettingsSidebarNav.tsx | 2 +- .../settings/UsageProviderSettings.tsx | 47 +- .../settings/settingsSearch.test.ts | 25 + .../src/components/settings/settingsSearch.ts | 11 + .../useAvailableSettingsSearchItems.ts | 14 +- apps/web/src/components/usage/UsageLimits.tsx | 6 +- .../components/usage/UsageLimitsPooled.tsx | 65 +- apps/web/src/components/usage/UsagePage.tsx | 205 ++++++- .../usage/UsageProviderChart.test.ts | 3 + .../src/components/usage/usageProviders.ts | 13 +- docs/user/usage.md | 22 +- packages/client-runtime/src/state/server.ts | 10 +- .../client-runtime/src/state/usage.test.ts | 13 + packages/client-runtime/src/state/usage.ts | 9 + packages/contracts/src/settings.ts | 5 + packages/contracts/src/usage.ts | 29 +- packages/shared/src/usageFormat.test.ts | 12 + packages/shared/src/usageFormat.ts | 5 +- packages/shared/src/usageLimits.test.ts | 48 ++ packages/shared/src/usageLimits.ts | 38 ++ packages/shared/src/usageMerge.test.ts | 161 ++++- packages/shared/src/usageMerge.ts | 121 +++- pnpm-lock.yaml | 3 + scripts/lib/cli-external-packages.test.ts | 3 +- scripts/lib/cli-external-packages.ts | 1 + 43 files changed, 3049 insertions(+), 129 deletions(-) create mode 100644 apps/server/src/provider/cursorCredentialStore.test.ts create mode 100644 apps/server/src/provider/cursorCredentialStore.ts create mode 100644 apps/server/src/usage/antigravityUsageReader.ts create mode 100644 apps/server/src/usage/cursorUsageReader.ts create mode 100644 apps/server/src/usage/opencodeUsageReader.ts diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 4e3e7339765c..0bcc0cd74958 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -5,13 +5,15 @@ import { collectLimitAccounts, collectLimitNotices, collectLimitPools, + cursorUsageWindowDetails, + displayLimitWindows, formatDuration, formatResetsIn, remainingPercent, type LimitAccount, type LimitPoolWindow, } from "@t3tools/shared/usageLimits"; -import { useId, useState } from "react"; +import { Fragment, type ReactNode, useId, useState } from "react"; import { Pressable, ScrollView, View } from "react-native"; import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -70,11 +72,15 @@ function PoolWindowCard({ color, now, environmentIds, + label, + description, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; readonly environmentIds: readonly string[] | null; + readonly label?: string; + readonly description?: string; }) { const navigation = useNavigation(); const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); @@ -96,7 +102,7 @@ function PoolWindowCard({ - {pool.label} + {label ?? pool.label} {pool.remainingPercent}% @@ -108,6 +114,7 @@ function PoolWindowCard({ {PACE_LABEL[pool.pace]} ) : null} + {description ? {description} : null} {nextRefill ? ( ↻ +{nextRefill.restoresPercent}%{" "} @@ -196,10 +203,12 @@ export function UsageLimitsSection({ now, failedLabels, selectedEnvironmentIds, + cursorPrompt, }: { readonly now: number; readonly failedLabels: readonly string[]; readonly selectedEnvironmentIds: ReadonlySet | null; + readonly cursorPrompt?: ReactNode; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const selected = @@ -209,34 +218,54 @@ export function UsageLimitsSection({ const pools = collectLimitPools(collectLimitAccounts(selected), now); const notices = collectLimitNotices(selected); const colors = useProviderColors(); + const cursorPromptAt = + Math.max( + pools.findIndex((pool) => pool.driver === "codex"), + pools.findIndex((pool) => pool.driver === "claudeAgent"), + ) + 1; return ( - {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 && !cursorPrompt ? ( {selected.size === 0 ? "Select an environment to see limits." : "No provider on the selected environments reports subscription limits."} ) : null} - {pools.map((pool) => ( - - - - - {DRIVER_LABEL[pool.driver] ?? pool.driver} - - - {pool.windows.map((window) => ( - - ))} - - ))} + {pools.map((pool, index) => { + const windows = displayLimitWindows(pool); + return ( + + {index === cursorPromptAt ? cursorPrompt : null} + + + + + {DRIVER_LABEL[pool.driver] ?? pool.driver} + + + {windows.map((window) => { + const details = + pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; + return ( + + ); + })} + + + ); + })} + {cursorPromptAt === pools.length ? cursorPrompt : null} {notices.length > 0 || failedLabels.length > 0 ? ( ([]); - const refresh = async (automatic = false) => { + const refresh = async (automatic = false, afterPending = false) => { const connected = [...presentations].filter( ([environmentId, presentation]) => presentation.connection.phase === "connected" && @@ -307,6 +307,7 @@ export function useRefreshLimits( environmentId, () => refreshProviders({ environmentId, input: {} }), automatic, + afterPending, ); if (result === undefined) return; setFailedEnvironments((previous) => [ @@ -354,5 +355,11 @@ export function useRefreshLimits( selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), ) .map(({ label }) => label); - return { now, refreshing, failedLabels, refresh: refreshManually }; + return { + now, + refreshing, + failedLabels, + refresh: refreshManually, + refreshAfterEnable: () => refresh(false, true), + }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 7494ac9b34bd..2d5e5398bc51 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -25,9 +25,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SegmentedControl } from "../../components/SegmentedControl"; import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; @@ -59,6 +62,7 @@ const METRIC_OPTIONS = [ ] as const satisfies readonly { value: UsageChartMetric; label: string }[]; const CHART_HEIGHT = 180; +const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; /** * Two tabs over one screen. Usage is the transcript-derived spend for a @@ -97,6 +101,29 @@ export function UsageRouteScreen() { ); const isFocused = useIsFocused(); const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); + const cursorAccessEnvironments = selectedEnvironments.filter((environment) => + environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), + ); + const refreshAfterCursorEnable = () => { + void refresh(); + void limits.refreshAfterEnable(); + }; + const sourceMessages = [ + ...new Set( + selectedEnvironments.flatMap( + (environment) => + environment.summary?.sources.flatMap((source) => + source.message && + !source.action && + (source.status === "partial" || + source.status === "failed" || + source.fingerprint.provider === "cursor") + ? [source.message] + : [], + ) ?? [], + ), + ), + ]; const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -251,7 +278,6 @@ export function UsageRouteScreen() { } > - 0 ? ( + + ) : null + } /> ) : ( <> @@ -301,6 +335,11 @@ export function UsageRouteScreen() { ) : ( <> + {sourceMessages.map((message) => ( + + {message} + + ))} - + 1} + onCursorEnabled={refreshAfterCursorEnable} + /> @@ -324,6 +369,110 @@ export function UsageRouteScreen() { ); } +function CursorEnableAction({ + environmentId, + label, + onEnabled, + buttonText = "Enable", +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly onEnabled: () => void; + readonly buttonText?: string; +}) { + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "enable Cursor account usage", + }); + const [pending, setPending] = useState(false); + const enable = async () => { + setPending(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { cursorKeychainUsageEnabled: true } }, + }); + if (result._tag === "Success") onEnabled(); + } finally { + setPending(false); + } + }; + return ( + void enable()} + className="rounded-full bg-primary px-4 py-2" + > + {buttonText} + + ); +} + +function CursorEnableRow({ + environmentId, + label, + showEnvironment, + bordered, + onEnabled, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly showEnvironment: boolean; + readonly bordered: boolean; + readonly onEnabled: () => void; +}) { + const colors = useProviderColors(); + return ( + + + + + Cursor{showEnvironment ? ` · ${label}` : ""} + + + + + ); +} + +function CursorEnableLimits({ + environments, + onEnabled, +}: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly onEnabled: () => void; +}) { + return ( + + + + Cursor + + + {CURSOR_KEYCHAIN_COPY} + + {environments.map((environment) => ( + 1 ? `Enable on ${environment.label}` : "Enable"} + onEnabled={onEnabled} + /> + ))} + + + + ); +} + /** Headline figure, the animated daily chart, and its legend, in one card. */ function ChartCard(props: { readonly merged: MergedUsage; @@ -400,20 +549,53 @@ function ChartCard(props: { function ProviderSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric; + readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[]; + readonly showCursorEnvironment: boolean; + readonly onCursorEnabled: () => void; }) { const { merged, metric } = props; const colors = useProviderColors(); - if (merged.providers.length === 0) return null; + if (merged.providers.length === 0 && props.cursorAccessEnvironments.length === 0) return null; // Ranked by whatever the toggle is showing, so the rows always descend. // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. const ordered = [...merged.providers].sort((a, b) => metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, ); + const rows: Array< + | { readonly kind: "usage"; readonly provider: (typeof ordered)[number] } + | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } + > = ordered.map((provider) => ({ kind: "usage", provider })); + const cursorInsertAt = + Math.max( + ordered.findIndex((provider) => provider.provider === "codex"), + ordered.findIndex((provider) => provider.provider === "claude"), + ) + 1; + rows.splice( + cursorInsertAt, + 0, + ...props.cursorAccessEnvironments.map((environment) => ({ + kind: "enable" as const, + environment, + })), + ); return ( - {ordered.map((provider, index) => { + {rows.map((row, index) => { + if (row.kind === "enable") { + return ( + 0} + onEnabled={props.onCursorEnabled} + /> + ); + } + const provider = row.provider; const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( = { claude: "Claude Code", codex: "Codex", grok: "Grok Build", + cursor: "Cursor", + opencode: "OpenCode", + antigravity: "Antigravity", }; /** @@ -23,5 +33,8 @@ export function useProviderColors(): Record { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", grok: scheme === "dark" ? "#a1a1aa" : "#52525b", + cursor: "#8b8b8b", + opencode: "#5b9bbd", + antigravity: "#8c7bd1", }; } diff --git a/apps/server/package.json b/apps/server/package.json index b7f1bce88768..afa2ca18fdfc 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,6 +27,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.9.4", + "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", "diff": "8.0.3", "effect": "catalog:", diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index d2ad661b4af4..521c1b943e84 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -148,9 +148,15 @@ export const CursorDriver: ProviderDriver = { snapshot.auth.status === "authenticated" ), (snapshot) => - readCursorUsageLimits(effectiveConfig, processEnv).pipe( - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ), + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings; + const usageLimits = yield* readCursorUsageLimits( + effectiveConfig, + processEnv, + settings.cursorKeychainUsageEnabled, + ); + return { ...snapshot, usageLimits }; + }), ), Effect.map(stampIdentity), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 47826fcc8704..aa5207f3aef2 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -991,21 +991,21 @@ describe("Cursor usage limits", () => { { id: "totalPercentUsed", kind: "monthly", - label: "Monthly", + label: "Overall", usedPercent: 72.4, resetsAt: "2026-09-20T03:53:06.000Z", }, { id: "autoPercentUsed", kind: "monthly", - label: "Monthly · Auto", + label: "Cursor Models", usedPercent: 69.5, resetsAt: "2026-09-20T03:53:06.000Z", }, { id: "apiPercentUsed", kind: "monthly", - label: "Monthly · API", + label: "Other Models", usedPercent: 100, resetsAt: "2026-09-20T03:53:06.000Z", }, @@ -1019,10 +1019,10 @@ describe("Cursor usage limits", () => { ); expect( cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 0 }]); + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); expect( cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 100 }]); + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); }); it("reads the instance's credentials and endpoint even when usage enabled is false", async () => { @@ -1079,6 +1079,10 @@ describe("Cursor usage limits", () => { AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), }, + false, + async () => { + throw new Error("must not read Keychain before opt-in"); + }, ).pipe( Effect.provideService(HostProcessPlatform, platform), Effect.provideService( @@ -1108,6 +1112,71 @@ describe("Cursor usage limits", () => { } }); + it("reads the default macOS Cursor login from Keychain for limits", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read a stale credential file"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.headers.authorization).toBe("Bearer keychain-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ planUsage: { totalPercentUsed: 42 } }), + ), + ); + }), + ), + ), + ); + expect(limits.windows[0]?.usedPercent).toBe(42); + }); + + it("reports a Keychain initialization failure without failing the provider refresh", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { + throw new Error("Keychain initialization failed"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request limits without a login")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("probeFailed"); + }); + + it("does not read Keychain or send its token to a custom endpoint", async () => { + for (const [apiEndpoint, environment] of [ + ["http://localhost:3000", {}], + ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], + ["https://cursor-proxy.example", {}], + ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], + ] as const) { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { + throw new Error("must not read Keychain for a custom endpoint"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("unsupported"); + expect(limits.unavailable?.message).toContain("default Cursor endpoint"); + } + }); + it("reports failed requests without exposing credentials or response bodies", async () => { const limits = await runNode( readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts index 2fbaec59adb0..e0611e0c50ab 100644 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -13,8 +14,10 @@ import { makeUnavailableUsageLimits, makeUsageLimits, } from "../providerUsageLimits.ts"; +import { readMacCursorAccessToken } from "../cursorCredentialStore.ts"; const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); +const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); const CursorUsageResponse = Schema.Struct({ billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), @@ -39,15 +42,11 @@ export function cursorUsageResponseToLimits( : undefined; const windows: ServerProviderUsageWindow[] = []; if (response.planUsage) { - for (const [key, label] of [ - ["totalPercentUsed", "Monthly"], - ["autoPercentUsed", "Monthly · Auto"], - ["apiPercentUsed", "Monthly · API"], - ] as const) { - const usedPercent = response.planUsage[key]; + for (const { id, label } of CURSOR_USAGE_WINDOWS) { + const usedPercent = response.planUsage[id]; if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; windows.push({ - id: key, + id, kind: "monthly", label, usedPercent: clampPercent(usedPercent), @@ -63,30 +62,49 @@ export function cursorUsageResponseToLimits( export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( settings: Pick, environment: NodeJS.ProcessEnv = process.env, + allowKeychain = false, + keychainToken: () => Promise = readMacCursorAccessToken, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; + const endpoint = ( + settings.apiEndpoint.trim() || + environment.CURSOR_API_ENDPOINT?.trim() || + DEFAULT_CURSOR_API_ENDPOINT + ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); // An explicit API key can name a different account from the stored login. if (!token && environment.CURSOR_API_KEY?.trim()) { return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); } const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - if ( - !token && - (credentialStore === "memory" || (platform === "darwin" && credentialStore !== "file")) - ) { - // Cursor's default macOS login lives in the keychain; a leftover file may be another account. + if (!token && credentialStore === "memory") { return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported", - message: "Cursor usage requires a file-based login or CURSOR_AUTH_TOKEN.", + message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", }); } - if (!token) { + if (!token && platform === "darwin" && credentialStore !== "file") { + if (!allowKeychain) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Enable Cursor account usage in T3 Code to read its Keychain login.", + }); + } + if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", + }); + } + token = (yield* Effect.tryPromise(keychainToken))?.trim(); + } else if (!token) { const home = (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); const directory = @@ -106,11 +124,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function } if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); const client = yield* HttpClient.HttpClient; - const endpoint = ( - settings.apiEndpoint.trim() || - environment.CURSOR_API_ENDPOINT?.trim() || - "https://api2.cursor.sh" - ).replace(/\/$/, ""); const response = yield* client.execute( HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( HttpClientRequest.bearerToken(token), diff --git a/apps/server/src/provider/cursorCredentialStore.test.ts b/apps/server/src/provider/cursorCredentialStore.test.ts new file mode 100644 index 000000000000..051cb5425c69 --- /dev/null +++ b/apps/server/src/provider/cursorCredentialStore.test.ts @@ -0,0 +1,22 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.ts"; + +describe("Cursor Keychain reader", () => { + it("shares concurrent reads and rechecks after the cache expires", async () => { + let reads = 0; + let time = 0; + const read = makeCachedCursorAccessTokenReader( + async () => { + reads++; + return `token-${reads}`; + }, + () => time, + ); + assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); + assert.strictEqual(await read(), "token-1"); + assert.strictEqual(reads, 1); + time = 5 * 60_000; + assert.strictEqual(await read(), "token-2"); + }); +}); diff --git a/apps/server/src/provider/cursorCredentialStore.ts b/apps/server/src/provider/cursorCredentialStore.ts new file mode 100644 index 000000000000..c071e2a88578 --- /dev/null +++ b/apps/server/src/provider/cursorCredentialStore.ts @@ -0,0 +1,29 @@ +const CACHE_MS = 5 * 60_000; + +/** Share one Keychain request across usage history and limits in this server process. */ +export function makeCachedCursorAccessTokenReader( + read: () => Promise, + now: () => number = Date.now, +): () => Promise { + let cached: { token: string; until: number } | null = null; + let pending: Promise | null = null; + return () => { + if (cached && cached.until > now()) return Promise.resolve(cached.token); + if (pending) return pending; + pending = read() + .then((token) => { + cached = token ? { token, until: now() + CACHE_MS } : null; + return token; + }) + .finally(() => { + pending = null; + }); + return pending; + }; +} + +/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ +export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { + const { AsyncEntry } = await import("@napi-rs/keyring"); + return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; +}); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index b391e213ab9a..a80a31f48ae6 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -3,10 +3,11 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { EnvironmentId, @@ -31,6 +32,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { @@ -82,9 +84,11 @@ const serviceLayers = (input: { /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; readonly environment?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -101,7 +105,12 @@ const serviceLayers = (input: { ), Layer.provideMerge( Layer.succeed(HostProcessEnvironment, { + HOME: input.home, GROK_HOME: NodePath.join(input.home, "grok"), + OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), + ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"), + XDG_CONFIG_HOME: NodePath.join(input.home, "config"), + APPDATA: NodePath.join(input.home, "config"), ...input.environment, }), ), @@ -112,6 +121,193 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-cursor-keychain-disabled", + home, + settings, + platform: "darwin", + environment: {}, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.status, "missing"); + assert.strictEqual(cursor?.action, "enableCursorKeychain"); + }).pipe(Effect.scoped), + ); + + it.live("ignores stale Cursor file logins when the active credential store differs", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + for (const [index, testCase] of [ + { + platform: "darwin" as const, + environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, + authPath: [".cursor", "auth.json"], + }, + { + platform: "linux" as const, + environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, + authPath: ["config", "cursor", "auth.json"], + }, + { + platform: "linux" as const, + environment: { CURSOR_API_KEY: "different-account" }, + authPath: ["config", "cursor", "auth.json"], + }, + ].entries()) { + const authPath = NodePath.join(home, ...testCase.authPath); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); + await NodeFSP.writeFile( + authPath, + encodeUnknownJsonString({ accessToken: "stale-token" }), + ); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: `usage-service-cursor-store-${index}`, + home, + settings, + platform: testCase.platform, + environment: testCase.environment, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.status, "missing"); + assert.include(cursor?.message ?? "", "Cursor CLI login"); + assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); + } + }).pipe(Effect.scoped), + ); + + it.live( + "includes OpenCode history but does not substitute desktop usage for an unavailable Cursor account", + () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const root = NodePath.join(home, "opencode"); + const message = yield* encodeUnknownJson({ + id: "msg_1", + sessionID: "session-1", + role: "assistant", + modelID: "example-model", + time: { created: Date.parse("2026-08-01T10:00:00Z") }, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 3 } }, + }); + const bubble = yield* encodeUnknownJson({ + type: 2, + createdAt: "2026-08-01T10:00:00Z", + modelInfo: { modelName: "example-model" }, + tokenCount: { inputTokens: 100, outputTokens: 20 }, + }); + yield* Effect.promise(async () => { + const directory = NodePath.join(root, "storage", "message", "session-1"); + await NodeFSP.mkdir(directory, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(directory, "msg_1.json"), message); + const desktop = NodePath.join(home, "config", "Cursor", "User", "globalStorage"); + await NodeFSP.mkdir(desktop, { recursive: true }); + const db = new NodeSqlite.DatabaseSync(NodePath.join(desktop, "state.vscdb")); + try { + db.exec("CREATE TABLE cursorDiskKV (key TEXT, value TEXT)"); + db.prepare("INSERT INTO cursorDiskKV VALUES (?, ?)").run( + "bubbleId:session:assistant", + bubble, + ); + } finally { + db.close(); + } + }); + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-opencode", home, settings })), + ); + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(summary.buckets[0]?.provider, "opencode"); + assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "cursor")?.status, + "missing", + ); + assert.strictEqual( + summary.buckets[0]?.sourcePath, + yield* Effect.promise(() => NodeFSP.realpath(root)), + ); + assert.strictEqual(summary.buckets[0]?.totals.outputTokens, 7); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "opencode") + ?.distinctSessions, + 1, + ); + assert.include( + summary.sources.find((source) => source.fingerprint.provider === "cursor")?.message ?? "", + "Cursor account history needs a Cursor CLI login", + ); + }).pipe(Effect.scoped), + ); + + it.live("counts aliased OpenCode and Antigravity directories once", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const opencode = NodePath.join(home, "opencode-store"); + const opencodeAlias = NodePath.join(home, "opencode-alias"); + const conversations = NodePath.join(home, "antigravity-conversations"); + const antigravityA = NodePath.join(home, "antigravity-a"); + const antigravityB = NodePath.join(home, "antigravity-b"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(opencode); + await NodeFSP.symlink(opencode, opencodeAlias, "junction"); + await NodeFSP.mkdir(conversations); + await NodeFSP.mkdir(antigravityA); + await NodeFSP.mkdir(antigravityB); + await NodeFSP.symlink( + conversations, + NodePath.join(antigravityA, "conversations"), + "junction", + ); + await NodeFSP.symlink( + conversations, + NodePath.join(antigravityB, "conversations"), + "junction", + ); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-aliased-roots-test", + home, + settings, + environment: { + OPENCODE_DATA_DIR: `${opencode},${opencodeAlias}`, + ANTIGRAVITY_DATA_DIR: `${antigravityA},${antigravityB}`, + }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const sourcesFor = (provider: "opencode" | "antigravity") => + summary.sources.filter((source) => source.fingerprint.provider === provider); + assert.strictEqual(sourcesFor("opencode").length, 1); + assert.strictEqual(sourcesFor("antigravity").length, 1); + assert.strictEqual( + sourcesFor("opencode")[0]?.fingerprint.resolvedHomePath, + yield* Effect.promise(() => NodeFSP.realpath(opencode)), + ); + assert.strictEqual( + sourcesFor("antigravity")[0]?.fingerprint.resolvedHomePath, + yield* Effect.promise(() => NodeFSP.realpath(conversations)), + ); + }).pipe(Effect.scoped), + ); + it.live("reads configured and disabled accounts once across shared and aliased homes", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -258,9 +454,12 @@ describe("UsageService", () => { const service = yield* UsageService.make; const first = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(first), 7); + const configuredProjects = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(configured, "projects")), + ); assert.include( first.sources.map((source) => source.fingerprint.resolvedHomePath), - NodePath.join(configured, "projects"), + configuredProjects, ); yield* settingsService.updateSettings({ providerInstances: { @@ -275,9 +474,12 @@ describe("UsageService", () => { }); const second = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(second), 8); + const environmentProjects = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(environmentHome, "projects")), + ); assert.include( second.sources.map((source) => source.fingerprint.resolvedHomePath), - NodePath.join(environmentHome, "projects"), + environmentProjects, ); }).pipe( Effect.provide( @@ -505,6 +707,9 @@ describe("UsageService", () => { Effect.gen(function* () { const { transcript, settings, home } = yield* setup; yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model"))); + const transcriptDir = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(home, "claude", "projects")), + ); yield* Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; @@ -519,7 +724,7 @@ describe("UsageService", () => { exists: (path) => fileSystem.exists(path).pipe( Effect.tap(() => { - if (path !== NodePath.join(home, "claude", "projects")) return Effect.void; + if (path !== transcriptDir) return Effect.void; homeProbes += 1; return Deferred.succeed( homeProbes === 1 ? firstScanStarted : secondScanStarted, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 1bf3e6f8c202..6930c7307d8c 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,14 +1,14 @@ /** * UsageService - scans provider transcripts and returns priced usage buckets. * - * The scan reads the provider CLIs' own session files (Claude Code, Codex, and - * Grok Build) rather than T3 Code's orchestration projections, so usage covers - * turns driven outside T3 Code too. This is the approach `ccusage` takes. + * The scan reads native session files and databases, including work driven + * outside T3 Code. Cursor's local records provide only partial coverage. * - * Transcripts are append-only, so parsed records are memoised per file by + * JSONL transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm * scans only reparse files that changed, and a file that merely grew resumes * from its cached parse position so only the appended bytes are read. + * SQLite readers query live databases each scan so WAL writes remain visible. * * @module UsageService */ @@ -19,6 +19,7 @@ import { CodexSettings, type ProviderInstanceConfig, USAGE_CONTRACT_VERSION, + ProviderInstanceId, type ServerSettings as ServerSettingsValue, type UsageProviderKind, type UsageSource, @@ -27,10 +28,11 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -46,7 +48,11 @@ import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts"; import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; +import { readAntigravityUsage } from "./antigravityUsageReader.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -144,12 +150,14 @@ export const layerTest = Layer.succeed( ); export const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig; const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; + const platform = yield* HostProcessPlatform; const fileCache: ScanCache = new Map(); const sourceCache = new Map(); @@ -446,6 +454,10 @@ export const make = Effect.gen(function* () { readonly provider: UsageProviderKind; readonly dir: string; readonly volumeId: string; + readonly hostId?: string; + readonly status?: UsageSource["status"]; + readonly message?: string; + readonly action?: UsageSource["action"]; /** Parsed records per file, or `null` when the directory does not exist. */ readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] @@ -481,6 +493,171 @@ export const make = Effect.gen(function* () { } scanned.push({ provider, dir, volumeId, files: parsedFiles }); } + + const home = NodeOS.homedir(); + const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) { + const roots = hostEnvironment[key] + ?.split(",") + .map((value) => value.trim()) + .filter(Boolean); + const canonical = new Set(); + for (const root of roots?.length ? roots : defaults) { + const resolved = path.resolve(expandHomePath(root)); + canonical.add( + yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)), + ); + } + return [...canonical]; + }); + const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim(); + for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [ + path.join( + dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"), + "opencode", + ), + ])) { + const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs)); + scanned.push({ + provider: "opencode", + dir, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), + files: result.missing && !result.error ? null : result.files, + status: result.error ? "partial" : "ok", + ...(result.error ? { message: "Some OpenCode history could not be read." } : {}), + }); + } + const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [ + ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) => + path.join(home, ".gemini", name), + ), + path.join(home, ".config", "antigravity"), + ]); + for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { + if (instance.driver === "antigravity") { + const directories = yield* resolveAntigravityInstanceDirectories( + config.stateDir, + ProviderInstanceId.make(instanceId), + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Antigravity profile directory could not be resolved.", + cause, + }), + ), + ); + antigravityRoots.push(path.join(directories.profile, "antigravity-acp")); + } + } + const antigravityDirs = new Set(); + for (const root of antigravityRoots) { + const resolvedRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + const nested = path.join(resolvedRoot, "conversations"); + const dir = (yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false)))) + ? nested + : resolvedRoot; + antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir))); + } + const antigravity = yield* Effect.promise(() => + readAntigravityUsage([...antigravityDirs], windowStartMs), + ); + for (const dir of antigravityDirs) { + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + const failed = antigravity.errors.some( + (error) => error === dir || error.startsWith(`${dir}${path.sep}`), + ); + scanned.push({ + provider: "antigravity", + dir, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), + files: !exists && !failed ? null : antigravity.files.filter((file) => file.root === dir), + status: failed ? "partial" : "ok", + ...(failed ? { message: "Some Antigravity history could not be read." } : {}), + }); + } + const cursorUserHome = + (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; + const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); + const cursorHome = + platform === "darwin" + ? path.join(cursorUserHome, "Library", "Application Support") + : platform === "win32" + ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming") + : configHome && path.isAbsolute(configHome) + ? configHome + : path.join(cursorUserHome, ".config"); + const cursorAuthPath = + platform === "darwin" + ? path.join(cursorUserHome, ".cursor", "auth.json") + : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); + const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; + const loginUnavailable = + Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || + Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || + credentialStore === "memory"; + if ( + platform === "darwin" && + credentialStore !== "file" && + !loginUnavailable && + !settings.cursorKeychainUsageEnabled + ) { + scanned.push({ + provider: "cursor", + dir: cursorAuthPath, + volumeId: "", + files: null, + message: "Cursor account usage is off on this environment.", + action: "enableCursorKeychain", + }); + return scanned; + } + const cursorUntilMs = yield* Clock.currentTimeMillis; + const account = loginUnavailable + ? { + accountKey: null, + records: [], + missing: true, + error: "Cursor account history needs a Cursor CLI login on this server.", + } + : yield* Effect.promise(() => + readCursorAccountUsage( + platform === "darwin" && credentialStore !== "file" + ? { kind: "keychain" } + : cursorAuthPath, + windowStartMs, + cursorUntilMs, + ), + ); + if (account.accountKey !== null && account.error === null && !account.missing) { + // The same account includes CLI and desktop history from every machine. + // A stable remote fingerprint prevents connected environments counting it twice. + const source = `cursor-account:${account.accountKey}`; + scanned.push({ + provider: "cursor", + dir: source, + hostId: "cursor.com", + volumeId: account.accountKey, + files: [{ path: source, records: account.records }], + status: "ok", + }); + return scanned; + } + scanned.push({ + provider: "cursor", + dir: cursorAuthPath, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), + // Never combine a local fallback with another server's account-wide history. + files: null, + message: + account.error ?? "Cursor account history needs a Cursor CLI login saved on this server.", + }); return scanned; }); @@ -555,7 +732,16 @@ export const make = Effect.gen(function* () { const sources: UsageSource[] = []; - for (const { provider, dir, volumeId, files } of scannedDirs) { + for (const { + provider, + dir, + volumeId, + files, + status, + message, + action, + hostId: sourceHostId, + } of scannedDirs) { const retainedFiles = [...(files ?? [])]; const livePaths = new Set(retainedFiles.map((file) => file.path)); // Cleanup may remove transcripts, but the usage we already saved still @@ -601,21 +787,23 @@ export const make = Effect.gen(function* () { } // Only sessions contributing in-window count; the mtime slack can // admit boundary files whose records fall outside the range. - if (aggregator.add(usageRecord) && record.sessionId.length > 0) { + if (aggregator.add(usageRecord, dir) && record.sessionId.length > 0) { sessionIds.add(record.sessionId); } } } sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId }, // Clients exclude missing sources, so saved records remain an available source. - status: files === null && scannedFiles === 0 ? "missing" : "ok", + status: files === null && scannedFiles === 0 ? "missing" : (status ?? "ok"), scannedFiles, skippedFiles, malformedRecords: 0, distinctSessions: sessionIds.size, - message: files === null ? "No transcript directory on this environment." : null, + message: + message ?? (files === null ? "No transcript directory on this environment." : null), + ...(action ? { action } : {}), }); } @@ -650,6 +838,7 @@ export const make = Effect.gen(function* () { const scanKey = ( input: UsageSummaryInput, priceOverrides: ServerSettingsValue["usagePriceOverrides"], + cursorKeychainUsageEnabled: boolean, ): string => JSON.stringify([ input.timeZone, @@ -659,11 +848,12 @@ export const make = Effect.gen(function* () { input.sinceTime ?? null, input.untilTime ?? null, priceOverrides, + cursorKeychainUsageEnabled, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { const settings = yield* readSettings; - const key = scanKey(input, settings.usagePriceOverrides); + const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts new file mode 100644 index 000000000000..54c00b804b7a --- /dev/null +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -0,0 +1,374 @@ +// node:sqlite reads live conversation databases while Node fs discovers them. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeTimersPromises from "node:timers/promises"; + +import type { UsageRecord } from "./usageTranscripts.ts"; + +type FieldValue = number | bigint | Uint8Array; +type Fields = Map; + +/** Antigravity stores usage metadata as protobuf, independently of conversation text. */ +function fields(bytes: Uint8Array): Fields { + let offset = 0; + const result: Fields = new Map(); + const varint = () => { + let value = 0n; + for (let shift = 0n; shift < 70n; shift += 7n) { + const byte = bytes[offset++]; + if (byte === undefined || (shift === 63n && byte > 1)) { + throw new Error("Invalid Antigravity protobuf varint"); + } + value |= BigInt(byte & 127) << shift; + if (byte < 128) { + return value > BigInt(Number.MAX_SAFE_INTEGER) ? value : Number(value); + } + } + throw new Error("Invalid Antigravity protobuf varint"); + }; + while (offset < bytes.length) { + const tag = varint(); + if (typeof tag !== "number") throw new Error("Invalid protobuf field"); + const number = Math.floor(tag / 8); + const wire = tag % 8; + if (number === 0) throw new Error("Invalid protobuf field"); + let value: FieldValue; + if (wire === 0) { + value = varint(); + } else if (wire === 1 || wire === 5 || wire === 2) { + const length = wire === 2 ? varint() : wire === 1 ? 8 : 4; + if (typeof length !== "number") throw new Error("Invalid protobuf field length"); + if (length > bytes.length - offset) throw new Error("Truncated protobuf field"); + value = bytes.subarray(offset, offset + length); + offset += length; + if (wire !== 2) continue; + } else { + throw new Error("Unsupported protobuf wire type"); + } + const entries = result.get(number) ?? []; + entries.push(value); + result.set(number, entries); + } + return result; +} + +const numberAt = (value: Fields, key: number) => { + const entry = value.get(key)?.[0]; + return typeof entry === "number" ? entry : 0; +}; +const bytesAt = (value: Fields, key: number) => { + const entry = value.get(key)?.[0]; + return entry instanceof Uint8Array ? entry : undefined; +}; +const nested = (value: Fields, key: number) => { + const bytes = bytesAt(value, key); + return bytes === undefined ? new Map() : fields(bytes); +}; +const textAt = (value: Fields, key: number) => { + const bytes = bytesAt(value, key); + return bytes === undefined ? "" : new TextDecoder("utf-8", { fatal: true }).decode(bytes).trim(); +}; +const timestamp = (value: Fields) => { + const seconds = numberAt(value, 1); + return seconds > 0 ? seconds * 1000 + Math.floor(numberAt(value, 2) / 1_000_000) : null; +}; + +const MODEL_IDS: Record = { + 246: "gemini-2.5-pro", + 312: "gemini-2.5-flash", + 313: "gemini-2.5-flash-thinking", + 329: "gemini-2.5-flash-thinking", + 330: "gemini-2.5-flash-lite", + 281: "claude-sonnet-4", + 282: "claude-sonnet-4", + 290: "claude-opus-4", + 291: "claude-opus-4", + 333: "claude-sonnet-4-5", + 334: "claude-sonnet-4-5", + 340: "claude-haiku-4-5", + 341: "claude-haiku-4-5", + 1026: "claude-opus-4-6", + 1035: "claude-sonnet-4-6", + 1016: "gemini-3.1-pro", + 1036: "gemini-3.1-pro", + 1037: "gemini-3.1-pro", + 1018: "gemini-3-flash-preview", + 1084: "gemini-3-flash-preview", + 1047: "gemini-3-flash-preview", +}; + +function modelName(name: string, id: number): string { + if (name) { + const normalized = name + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, "") + .replaceAll(" ", "-"); + if (normalized.startsWith("claude-")) { + return normalized + .replace(/^claude-(4(?:\.\d+)?)-(sonnet|opus|haiku)/, "claude-$2-$1") + .replaceAll(".", "-"); + } + return normalized; + } + return MODEL_IDS[id] ?? (id > 0 ? `antigravity-model-${id}` : ""); +} + +interface Metadata { + model: string; + timestampMs: number | null; + usages: Fields[]; +} + +function metadata(bytes: Uint8Array, step: boolean): Metadata { + const root = fields(bytes); + if (!step && bytesAt(root, 1) === undefined) { + throw new Error("Missing Antigravity generation metadata"); + } + const data = step ? root : nested(root, 1); + const model = step ? nested(data, 24) : data; + const usage = bytesAt(data, step ? 9 : 4); + const usages = usage === undefined ? [] : [fields(usage)]; + for (const retry of data.get(step ? 28 : 17) ?? []) { + if (!(retry instanceof Uint8Array)) throw new Error("Invalid retry metadata"); + const retryUsage = bytesAt(fields(retry), 2); + if (retryUsage !== undefined) usages.push(fields(retryUsage)); + } + return { + model: modelName( + textAt(model, step ? 12 : 19) || textAt(model, step ? 8 : 21), + numberAt(model, step ? 1 : 3), + ), + timestampMs: step + ? (timestamp(nested(data, 8)) ?? timestamp(nested(data, 1))) + : timestamp(nested(nested(data, 9), 4)), + usages, + }; +} + +function blob(value: unknown): Uint8Array { + if (!(value instanceof Uint8Array)) throw new Error("Invalid Antigravity metadata blob"); + return value; +} + +interface UsageCandidate { + record: UsageRecord; + keys: readonly string[]; + timestampQuality: number; +} + +async function readDatabase(path: string, fallbackTimestamp: number): Promise { + const db = new NodeSqlite.DatabaseSync(path, { readOnly: true }); + try { + db.exec("PRAGMA busy_timeout = 100; BEGIN"); + const tables = new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name), + ); + if (!tables.has("gen_metadata") && !tables.has("steps")) { + throw new Error("Missing Antigravity usage tables"); + } + const readMetadata = async (query: string, column: string, step: boolean) => { + const entries: Array<{ idx: number; entry: Metadata }> = []; + for (const row of db.prepare(query).iterate()) { + if (typeof row.idx !== "number") throw new Error("Invalid Antigravity metadata index"); + entries.push({ idx: row.idx, entry: metadata(blob(row[column]), step) }); + if (entries.length % 256 === 0) await NodeTimersPromises.setImmediate(); + } + return entries; + }; + const generations = tables.has("gen_metadata") + ? await readMetadata("SELECT idx, data FROM gen_metadata ORDER BY idx", "data", false) + : []; + let trajectoryTimestamp: number | null = null; + if (tables.has("trajectory_metadata_blob")) { + for (const row of db.prepare("SELECT data FROM trajectory_metadata_blob").iterate()) { + trajectoryTimestamp ??= timestamp(nested(fields(blob(row.data)), 2)); + } + } + const steps = tables.has("steps") + ? await readMetadata( + "SELECT idx, metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx", + "metadata", + true, + ) + : []; + const sessionId = NodePath.basename(path, ".db"); + const records: UsageCandidate[] = []; + const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry.model])); + for (const [source, entries] of [ + ["step", steps], + ["generation", generations], + ] as const) { + for (const [index, { idx, entry }] of entries.entries()) { + for (const [usageIndex, usage] of entry.usages.entries()) { + const outputTokens = Math.max( + numberAt(usage, 3), + numberAt(usage, 9) + numberAt(usage, 10), + ); + const totals = { + uncachedInputTokens: numberAt(usage, 2), + cachedInputTokens: numberAt(usage, 5), + cacheCreationTokens: numberAt(usage, 4), + outputTokens, + reasoningTokens: Math.min(outputTokens, numberAt(usage, 9)), + }; + if ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + outputTokens === + 0 + ) + continue; + const keys = ([11, 12, 7] as const).flatMap((key) => { + const id = textAt(usage, key); + return id ? [`antigravity:${key}:${id}`] : []; + }); + const record: UsageRecord = { + provider: "antigravity", + sessionId, + timestampMs: entry.timestampMs ?? trajectoryTimestamp ?? fallbackTimestamp, + model: + MODEL_IDS[numberAt(usage, 1)] || + entry.model || + (source === "step" ? generationModels.get(idx) : "") || + modelName("", numberAt(usage, 1)) || + "antigravity-unknown", + totals, + reportedCostUsd: null, + fast: false, + dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`, + }; + records.push({ + record, + keys, + timestampQuality: entry.timestampMs !== null ? 2 : trajectoryTimestamp !== null ? 1 : 0, + }); + } + } + } + return records; + } finally { + db.close(); + } +} + +/** Reads and merges aliases across every configured Antigravity store before date filtering. */ +export async function readAntigravityUsage( + conversationsDirectories: string | readonly string[], + sinceMs: number, +) { + const roots = + typeof conversationsDirectories === "string" + ? [conversationsDirectories] + : conversationsDirectories; + const files: Array<{ root: string; path: string; records: UsageRecord[] }> = []; + const errors: string[] = []; + const identities = new Map(); + const groups: Array< + UsageCandidate & { parent: number; size: number; owner: number; fileIndex: number } + > = []; + const find = (index: number): number => { + let root = index; + while (groups[root]!.parent !== root) root = groups[root]!.parent; + while (index !== root) { + const parent = groups[index]!.parent; + groups[index]!.parent = root; + index = parent; + } + return root; + }; + const merge = (left: number, right: number): number => { + let a = find(left); + let b = find(right); + if (a === b) return a; + if (groups[a]!.size < groups[b]!.size) [a, b] = [b, a]; + const target = groups[a]!; + const source = groups[b]!; + const first = target.owner < source.owner ? target : source; + const bestTime = + source.timestampQuality > target.timestampQuality || + (source.timestampQuality === target.timestampQuality && + source.record.timestampMs < target.record.timestampMs) + ? source + : target; + const x = target.record.totals; + const y = source.record.totals; + target.record = { + ...first.record, + model: + first.record.model === "antigravity-unknown" + ? first === target + ? source.record.model + : target.record.model + : first.record.model, + timestampMs: bestTime.record.timestampMs, + totals: { + uncachedInputTokens: Math.max(x.uncachedInputTokens, y.uncachedInputTokens), + cachedInputTokens: Math.max(x.cachedInputTokens, y.cachedInputTokens), + cacheCreationTokens: Math.max(x.cacheCreationTokens, y.cacheCreationTokens), + outputTokens: Math.max(x.outputTokens, y.outputTokens), + reasoningTokens: Math.max(x.reasoningTokens, y.reasoningTokens), + }, + }; + target.timestampQuality = bestTime.timestampQuality; + target.owner = first.owner; + target.fileIndex = first.fileIndex; + target.size += source.size; + source.parent = a; + return a; + }; + const append = (candidate: UsageCandidate, fileIndex: number) => { + const index = groups.length; + groups.push({ ...candidate, parent: index, size: 1, owner: index, fileIndex }); + for (const key of candidate.keys) { + const existing = identities.get(key); + if (existing !== undefined) merge(index, existing); + identities.set(key, index); + } + }; + const visited = new Set(); + const walk = async (directory: string, root: string): Promise => { + let entries; + try { + entries = await NodeFSP.readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push(directory); + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const path = NodePath.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(path, root); + } else if (entry.isFile() && entry.name.endsWith(".db")) { + try { + const canonical = await NodeFSP.realpath(path); + if (visited.has(canonical)) continue; + visited.add(canonical); + const stat = await NodeFSP.stat(path); + const candidates = await readDatabase(path, stat.mtimeMs); + const fileIndex = files.length; + files.push({ root, path, records: [] }); + for (const [index, candidate] of candidates.entries()) { + append(candidate, fileIndex); + if (index % 256 === 255) await NodeTimersPromises.setImmediate(); + } + } catch { + errors.push(path); + } + } + } + }; + for (const root of roots) await walk(root, root); + for (const [index, group] of groups.entries()) { + if (group.parent === index && group.record.timestampMs >= sinceMs) { + files[group.fileIndex]!.records.push(group.record); + } + } + return { files, errors }; +} diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts new file mode 100644 index 000000000000..81c680afff0b --- /dev/null +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -0,0 +1,254 @@ +// Node fs reads CLI credentials, and crypto hashes account IDs for deduplication. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeCrypto from "node:crypto"; +import * as NodeTimersPromises from "node:timers/promises"; + +import type { UsageRecord } from "./usageTranscripts.ts"; +import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts"; + +function object(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function tokens(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +export interface CursorAccountUsageReadResult { + readonly accountKey: string | null; + readonly records: readonly UsageRecord[]; + readonly missing: boolean; + readonly error: string | null; +} + +const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** Find the longest exact suffix/prefix overlap in linear time. */ +function boundaryOverlap(previous: readonly string[], current: readonly string[]): number { + const sequence = [...current, "", ...previous]; + const lengths = Array.from({ length: sequence.length }, () => 0); + for (let index = 1; index < sequence.length; index++) { + let length = lengths[index - 1]!; + while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!; + if (sequence[index] === sequence[length]) length++; + lengths[index] = length; + } + return lengths.at(-1) ?? 0; +} + +/** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */ +export async function readCursorAccountUsage( + credentialSource: string | { readonly kind: "keychain" }, + sinceMs: number, + endDate: number, + request: (url: string, init: RequestInit) => Promise = globalThis.fetch, + keychainToken: () => Promise = readMacCursorAccessToken, +): Promise { + let accessToken: unknown; + try { + accessToken = + typeof credentialSource === "string" + ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken + : await keychainToken(); + } catch (cause) { + const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT"; + return { + accountKey: null, + records: [], + missing, + error: missing + ? null + : typeof credentialSource === "string" + ? "Cursor credentials could not be read." + : "Cursor Keychain credentials could not be read.", + }; + } + if (typeof accessToken !== "string" || !accessToken) { + return { + accountKey: null, + records: [], + missing: true, + error: + typeof credentialSource === "string" + ? null + : "Cursor account history needs a macOS Keychain CLI login on this server.", + }; + } + let accountKey: string | null = null; + try { + const payload = accessToken.split(".")[1]; + const subject = object( + JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")), + ).sub; + if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication"); + const userId = subject.split("|").at(-1); + if (!userId) throw new Error("Invalid authentication"); + accountKey = accountHash(subject); + if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) + throw new Error("Invalid date window"); + const deadline = AbortSignal.timeout(60_000); + const records: UsageRecord[] = []; + const occurrences = new Map(); + const pages: unknown[][] = []; + let completed = false; + const pageSize = 1000; + let total: number | undefined; + for (let page = 1; ; page++) { + // A count can include overlapping page boundaries. Allow room to + // reconcile them without imposing a fixed account-size limit. + if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { + throw new Error("Account usage page limit exceeded"); + } + const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { + method: "POST", + redirect: "error", + signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]), + headers: { + "Content-Type": "application/json", + Origin: "https://cursor.com", + Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, + }, + body: JSON.stringify({ + page, + pageSize, + startDate: String(sinceMs), + endDate: String(endDate), + }), + }); + if (response.status === 401 || response.status === 403) { + return { + accountKey, + records: [], + missing: false, + error: "Sign in to Cursor again to read account usage.", + }; + } + if (!response.ok) throw new Error("Account usage request failed"); + const parsed: unknown = await response.json(); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid account usage page"); + } + const body = object(parsed); + const keys = Object.keys(body); + if ("error" in body || "message" in body || "code" in body) + throw new Error("Account usage error response"); + const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; + const events = + keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") + ? [] + : body.usageEventsDisplay; + if ( + (count !== undefined && + (typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 0 || + (total !== undefined && count !== total))) || + !Array.isArray(events) || + events.length > pageSize || + (count === undefined && !Array.isArray(body.usageEventsDisplay)) + ) { + throw new Error("Inconsistent account usage page"); + } + if (typeof count === "number") total = count; + pages.push(events); + if (events.length < pageSize) { + completed = true; + break; + } + } + if (!completed) throw new Error("Account usage page limit exceeded"); + const rawCount = pages.reduce((sum, page) => sum + page.length, 0); + if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages"); + let removalsRemaining = total === undefined ? 0 : rawCount - total; + let previousKeys: string[] = []; + for (const events of pages) { + const eventKeys = + removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : []; + const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys)); + removalsRemaining -= removalCount; + previousKeys = eventKeys; + for (const raw of events.slice(removalCount)) { + const event = object(raw); + const usage = object(event.tokenUsage); + if (event.tokenUsage === undefined || event.tokenUsage === null) continue; + for (const key of [ + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "totalCents", + ]) { + const value = usage[key]; + if ( + value !== undefined && + (typeof value !== "number" || !Number.isFinite(value) || value < 0) + ) { + throw new Error("Invalid account usage totals"); + } + } + const timestampMs = + typeof event.timestamp === "string" && event.timestamp.trim() !== "" + ? Number(event.timestamp) + : event.timestamp; + if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + typeof event.model !== "string" || + !event.model + ) + throw new Error("Invalid account usage event"); + if (timestampMs < sinceMs || timestampMs > endDate) continue; + const totals = { + uncachedInputTokens: tokens(usage.inputTokens), + cachedInputTokens: tokens(usage.cacheReadTokens), + cacheCreationTokens: tokens(usage.cacheWriteTokens), + outputTokens: tokens(usage.outputTokens), + reasoningTokens: 0, + }; + const reportedCostUsd = + typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; + const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; + // No event ID is provided. Preserve identical billed rows with an occurrence index. + const key = accountHash( + JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]), + ); + const occurrence = occurrences.get(key) ?? 0; + occurrences.set(key, occurrence + 1); + records.push({ + provider: "cursor", + timestampMs, + model: event.model, + sessionId, + totals, + reportedCostUsd, + fast: false, + dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, + }); + } + await NodeTimersPromises.setImmediate(); + } + if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries"); + return { accountKey, records, missing: false, error: null }; + } catch { + return { + accountKey, + records: [], + missing: false, + error: "Cursor account usage could not be read.", + }; + } +} diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts new file mode 100644 index 000000000000..45d6ef33a584 --- /dev/null +++ b/apps/server/src/usage/opencodeUsageReader.ts @@ -0,0 +1,188 @@ +// node:sqlite reads live OpenCode databases; Node fs walks legacy JSON history. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeTimersPromises from "node:timers/promises"; + +import { totalTokens, type UsageRecord } from "./usageTranscripts.ts"; + +function object(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function tokens(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** OpenCode stores uncached input and reasoning separately from input/output. */ +function parseOpenCodeMessage( + source: string, + fallback: { + readonly id?: string; + readonly sessionId?: string; + readonly timestampMs?: number; + } = {}, +): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return null; + } + const message = object(parsed); + if (message.role !== undefined && message.role !== "assistant") return null; + const usage = object(message.tokens); + const cache = object(usage.cache); + const modelReference = object(message.model); + const model = text(modelReference.id) || text(modelReference.modelID) || text(message.modelID); + const timestampMs = object(message.time).created ?? fallback.timestampMs; + if (!model || typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return null; + const reasoningTokens = tokens(usage.reasoning); + const totals = { + uncachedInputTokens: tokens(usage.input), + cachedInputTokens: tokens(cache.read), + cacheCreationTokens: tokens(cache.write), + outputTokens: tokens(usage.output) + reasoningTokens, + reasoningTokens, + }; + if (totalTokens(totals) === 0) return null; + const id = fallback.id || text(message.id); + const cost = message.cost; + return { + provider: "opencode", + timestampMs, + model, + sessionId: fallback.sessionId || text(message.sessionID), + totals, + // OpenCode writes zero for models without a known rate, including paid + // subscription models. Let the shared price table estimate those records. + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null, + fast: false, + dedupeKey: id ? `opencode:${id}` : null, + }; +} + +export interface OpenCodeUsageReadResult { + readonly files: readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]; + readonly missing: boolean; + readonly error: boolean; +} + +/** Reads current SQLite and pre-migration JSON stores without modifying either. */ +export async function readOpenCodeUsage( + root: string, + sinceMs: number, +): Promise { + const files: { path: string; records: UsageRecord[] }[] = []; + const seen = new Set(); + let found = false; + let error = false; + const append = (records: UsageRecord[], record: UsageRecord | null) => { + if (record === null || record.timestampMs < sinceMs) return; + if (record.dedupeKey !== null) { + if (seen.has(record.dedupeKey)) return; + seen.add(record.dedupeKey); + } + records.push(record); + }; + + let databases: string[] = []; + try { + databases = (await NodeFSP.readdir(root, { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(entry.name)) + .map((entry) => entry.name) + .sort((a, b) => (a === "opencode.db" ? -1 : b === "opencode.db" ? 1 : a.localeCompare(b))); + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + for (const name of databases) { + found = true; + const file = { path: NodePath.join(root, name), records: [] as UsageRecord[] }; + files.push(file); + let database: NodeSqlite.DatabaseSync | undefined; + try { + database = new NodeSqlite.DatabaseSync(NodePath.join(root, name), { readOnly: true }); + // A busy live provider should fail this source promptly rather than + // stalling the server while SQLite waits for its writer. + database.exec("PRAGMA busy_timeout = 100"); + const tables = new Set( + database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name), + ); + if (!tables.has("message") && !tables.has("session_message")) error = true; + for (const table of ["message", "session_message"] as const) { + if (!tables.has(table)) continue; + const columns = new Set( + database + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((row) => row.name), + ); + const timestamp = columns.has("time_created") ? "time_created" : "NULL"; + const predicates = table === "session_message" ? ["type = 'assistant'"] : []; + if (timestamp !== "NULL") predicates.push("time_created >= ?"); + const where = predicates.length > 0 ? ` WHERE ${predicates.join(" AND ")}` : ""; + const statement = database.prepare( + `SELECT id, session_id, data, ${timestamp} AS created FROM ${table}${where}`, + ); + let count = 0; + for (const row of statement.iterate(...(timestamp === "NULL" ? [] : [sinceMs]))) { + append( + file.records, + parseOpenCodeMessage(text(row.data), { + id: text(row.id), + sessionId: text(row.session_id), + ...(typeof row.created === "number" ? { timestampMs: row.created } : {}), + }), + ); + if (++count % 256 === 0) await NodeTimersPromises.setImmediate(); + } + } + } catch { + error = true; + } finally { + database?.close(); + } + } + + // Do not follow symlinks, including cycles. Database records win over their + // old JSON copies when OpenCode has migrated a store in place. + const directories = [NodePath.join(root, "storage", "message")]; + while (directories.length > 0) { + const directory = directories.pop()!; + try { + for (const entry of await NodeFSP.readdir(directory, { withFileTypes: true })) { + const path = NodePath.join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(path); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + found = true; + const id = entry.name.slice(0, -5); + if (seen.has(`opencode:${id}`)) continue; + const file = { path, records: [] as UsageRecord[] }; + files.push(file); + try { + append( + file.records, + parseOpenCodeMessage(await NodeFSP.readFile(path, "utf8"), { id }), + ); + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + } + } + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + } + return { files, missing: !found && !error, error }; +} diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 684f0a520614..92abfef74108 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -112,7 +112,7 @@ export class UsageAggregator { * can derive per-window facts (distinct sessions, for one) from the records * that landed rather than everything the mtime prefilter happened to admit. */ - add(record: UsageRecord): boolean { + add(record: UsageRecord, sourcePath?: string): boolean { if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -146,7 +146,7 @@ export class UsageAggregator { this.#hourlyWindow.sinceTimeMs + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`; + const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourcePath ?? ""}`; let bucket = this.#buckets.get(key); if (bucket === undefined) { bucket = { @@ -180,12 +180,14 @@ export class UsageAggregator { finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000"); + const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = + key.split("\u0000"); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), provider: provider as UsageBucket["provider"], model, + ...(sourcePath === "" ? {} : { sourcePath }), totals: bucket.totals, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 5feb68b2ff58..6c95a36df19b 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -4,13 +4,40 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; import { readTranscriptRecords } from "./usageTranscriptReader.ts"; +import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; +import { readAntigravityUsage } from "./antigravityUsageReader.ts"; let dir: string; +function protoNumber(field: number, value: number): number[] { + const varint = (number: number) => { + const bytes: number[] = []; + do { + const byte = number % 128; + number = Math.floor(number / 128); + bytes.push(byte + (number > 0 ? 128 : 0)); + } while (number > 0); + return bytes; + }; + return [...varint(field * 8), ...varint(value)]; +} + +function protoBytes(field: number, bytes: readonly number[]): number[] { + const encoded = protoNumber(field, bytes.length); + encoded[0] = encoded[0]! + 2; + return [...encoded, ...bytes]; +} + +function protoText(field: number, value: string): number[] { + return protoBytes(field, [...Buffer.from(value)]); +} + beforeEach(async () => { dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); }); @@ -208,3 +235,530 @@ describe("readTranscriptRecords resume", () => { assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); }); }); + +describe("SQLite usage readers", () => { + it("reads Cursor account history with the default macOS Keychain login", async () => { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + let keychainReads = 0; + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1781000000000, + async (_url, init) => { + assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A"); + return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] }); + }, + async () => { + keychainReads++; + return accessToken; + }, + ); + assert.strictEqual(keychainReads, 1); + assert.isNull(result.error); + assert.isFalse(result.missing); + assert.isNotNull(result.accountKey); + }); + + it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const pages: number[] = []; + const signals: AbortSignal[] = []; + const request = async (url: string, init: RequestInit) => { + assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events"); + assert.strictEqual(init?.redirect, "error"); + const headers = new Headers(init?.headers); + assert.strictEqual(headers.get("origin"), "https://cursor.com"); + assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); + const body = JSON.parse(String(init?.body)); + pages.push(body.page); + if (init.signal) signals.push(init.signal); + return Response.json({ + totalUsageEventsCount: 1001, + usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ + timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000), + model: "claude-sonnet-4-5", + conversationId: `conversation-${body.page}`, + isHeadless: body.page === 2, + chargedCents: 0, + tokenUsage: { + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 30, + cacheWriteTokens: 2, + totalCents: 25, + }, + })), + }); + }; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request); + assert.isNull(result.error); + assert.deepStrictEqual(pages, [1, 2]); + assert.lengthOf(signals, 2); + assert.notStrictEqual(signals[0], signals[1]); + assert.strictEqual(result.records.length, 1001); + assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); + assert.deepStrictEqual(result.records[0]?.totals, { + uncachedInputTokens: 10, + cachedInputTokens: 30, + cacheCreationTokens: 2, + outputTokens: 5, + reasoningTokens: 0, + }); + assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); + assert.isFalse(result.accountKey?.includes("demo") ?? true); + }); + + it("reads Cursor account history beyond 100 pages", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests += 1; + return Response.json({ + totalUsageEventsCount: 100_001, + usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }], + }); + }); + assert.isNull(result.error); + assert.strictEqual(requests, 101); + assert.deepStrictEqual(result.records, []); + }); + + it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + for (const body of [ + {}, + { totalUsageEventsCount: 0 }, + { totalUsageEventsCount: 0, usageEventsDisplay: [] }, + ]) { + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json(body), + ); + assert.isNull(result.error); + assert.deepStrictEqual(result.records, []); + assert.isFalse(result.missing); + } + for (const body of [ + { error: "upstream error" }, + { detail: "unknown error envelope" }, + { totalUsageEventsCount: 0, error: "upstream error" }, + null, + [], + "invalid", + 0, + ]) { + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json(body), + ); + assert.isNotNull(result.error); + assert.deepStrictEqual(result.records, []); + } + }); + + it("requires a terminal Cursor page after a full page reaches the reported count", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json( + requests === 1 + ? { + totalUsageEventsCount: 1000, + usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({ + timestamp: String(1780000000000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 10, outputTokens: 5 }, + })), + } + : { totalUsageEventsCount: 1000 }, + ); + }); + assert.isNull(result.error); + assert.strictEqual(result.records.length, 1000); + assert.strictEqual(requests, 2); + }); + + it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const event = (index: number) => ({ + timestamp: String(1780000000000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, + }); + for (const total of [2000, 2001]) { + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json({ + totalUsageEventsCount: total, + usageEventsDisplay: + requests === 1 + ? Array.from({ length: 1000 }, (_, index) => event(index)) + : requests === 2 + ? Array.from({ length: 1000 }, (_, index) => event(999 + index)) + : [event(1999)], + }); + }); + assert.isNull(result.error); + assert.strictEqual(result.records.length, total); + assert.strictEqual(requests, 3); + assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999); + assert.strictEqual( + result.records.filter((record) => record.timestampMs === 1780000000999).length, + total === 2000 ? 1 : 2, + ); + assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); + } + let requests = 0; + const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json({ + totalUsageEventsCount: 1001, + usageEventsDisplay: + requests === 1 + ? Array.from({ length: 1000 }, (_, index) => event(index)) + : [event(500), event(1000)], + }); + }); + assert.isNotNull(inconsistent.error); + assert.deepStrictEqual(inconsistent.records, []); + }); + + it("does not present truncated Cursor account pages or authentication failures as complete history", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), + ); + assert.isNotNull(truncated.error); + assert.deepStrictEqual(truncated.records, []); + const denied = await readCursorAccountUsage( + authPath, + 0, + 1781000000000, + async () => new Response(accessToken, { status: 401 }), + ); + assert.isNotNull(denied.error); + assert.isFalse(denied.error?.includes(accessToken) ?? true); + assert.deepStrictEqual(denied.records, []); + let requested = false; + const missing = await readCursorAccountUsage( + NodePath.join(dir, "missing.json"), + 0, + 1781000000000, + async () => { + requested = true; + return Response.json({}); + }, + ); + assert.isTrue(missing.missing); + assert.isFalse(requested); + }); + + it("counts migrated OpenCode messages once and sees subsequent WAL writes", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "opencode.db")); + try { + db.exec( + "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; CREATE TABLE message (id TEXT, session_id TEXT, data TEXT)", + ); + const message = { + id: "msg-1", + sessionID: "session-1", + role: "assistant", + modelID: "claude-sonnet-4-5", + time: { created: 1780000000000 }, + cost: 0.25, + tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 30, write: 10 } }, + }; + const insert = db.prepare("INSERT INTO message VALUES (?, ?, ?)"); + insert.run(message.id, message.sessionID, JSON.stringify(message)); + const legacy = NodePath.join(dir, "storage", "message", message.sessionID); + await NodeFSP.mkdir(legacy, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(legacy, "msg-1.json"), JSON.stringify(message)); + const first = await readOpenCodeUsage(dir, 0); + assert.isFalse(first.error); + const records = first.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 1); + assert.deepStrictEqual(records[0]?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 30, + cacheCreationTokens: 10, + outputTokens: 25, + reasoningTokens: 5, + }); + assert.strictEqual(records[0]?.reportedCostUsd, 0.25); + insert.run( + "msg-2", + message.sessionID, + JSON.stringify({ ...message, id: "msg-2", time: { created: 1780000001000 } }), + ); + const next = await readOpenCodeUsage(dir, 1780000001000); + assert.isFalse(next.error); + assert.deepStrictEqual( + next.files.flatMap((file) => file.records).map((record) => record.dedupeKey), + ["opencode:msg-2"], + ); + assert.isAbove((await NodeFSP.stat(NodePath.join(dir, "opencode.db-wal"))).size, 0); + } finally { + db.close(); + } + }); + + it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db")); + const stamp = protoNumber(1, 1780000000); + const usage = [ + ...protoNumber(2, 100), + ...protoNumber(3, 40), + ...protoNumber(4, 5), + ...protoNumber(5, 20), + ...protoNumber(9, 10), + ...protoText(11, "response-1"), + ]; + const retry = [ + ...protoNumber(1, 1026), + ...protoNumber(2, 12), + ...protoNumber(3, 3), + ...protoText(11, "retry-1"), + ]; + const generation = protoBytes(1, [ + ...protoBytes(4, usage), + ...protoText(19, "Gemini 3 Pro"), + ...protoBytes(9, protoBytes(4, stamp)), + ]); + const step = [ + ...protoBytes(9, usage), + ...protoBytes(8, stamp), + ...protoBytes(28, protoBytes(2, retry)), + ]; + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, new Uint8Array(generation)); + db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(step)); + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 2); + const main = records.find((record) => record.model === "gemini-3-pro"); + assert.isDefined(main); + assert.strictEqual(main?.timestampMs, 1780000000000); + assert.strictEqual(main?.sessionId, "session-1"); + assert.deepStrictEqual(main?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 40, + reasoningTokens: 10, + }); + assert.strictEqual( + records.find((record) => record.model === "claude-opus-4-6")?.totals.uncachedInputTokens, + 12, + ); + assert.deepStrictEqual( + (await readAntigravityUsage(dir, 1780000000001)).files.flatMap((file) => file.records), + [], + ); + }); + + it("uses the matching Antigravity generation model for each model-less step", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "model-switch.db")); + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + const generation = db.prepare("INSERT INTO gen_metadata VALUES (?, ?)"); + const step = db.prepare("INSERT INTO steps VALUES (?, ?)"); + for (const [idx, name] of ["Gemini 3 Pro", "Claude Opus 4.6"].entries()) { + generation.run(idx, new Uint8Array(protoBytes(1, protoText(19, name)))); + step.run(idx, new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx)))); + } + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.deepStrictEqual( + result.files.flatMap((file) => file.records).map((record) => record.model), + ["gemini-3-pro", "claude-opus-4-6"], + ); + }); + + it("merges Antigravity aliases that bridge previously separate step records", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "bridge.db")); + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + const step = db.prepare("INSERT INTO steps VALUES (?, ?)"); + step.run( + 0, + new Uint8Array(protoBytes(9, [...protoNumber(2, 100), ...protoText(11, "response")])), + ); + step.run( + 1, + new Uint8Array(protoBytes(9, [...protoNumber(3, 40), ...protoText(12, "provider")])), + ); + db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run( + 0, + new Uint8Array( + protoBytes(1, [ + ...protoText(19, "Gemini 3 Pro"), + ...protoBytes(4, [ + ...protoNumber(2, 50), + ...protoNumber(5, 20), + ...protoText(11, "response"), + ...protoText(12, "provider"), + ]), + ]), + ), + ); + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 1); + assert.deepStrictEqual(records[0]?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 20, + cacheCreationTokens: 0, + outputTokens: 40, + reasoningTokens: 0, + }); + }); + + it("merges Antigravity provider and message aliases across configured roots while keeping original ownership", async () => { + const roots = [NodePath.join(dir, "first"), NodePath.join(dir, "second")]; + for (const [index, root] of roots.entries()) { + await NodeFSP.mkdir(root); + const db = new NodeSqlite.DatabaseSync(NodePath.join(root, `session-${index}.db`)); + try { + db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + for (const identity of [7, 12]) { + const usage = [ + ...protoNumber(1, 246), + ...protoNumber(2, index === 0 ? 100 : 150), + ...protoText(11, `response-${index}-${identity}`), + ...protoText(identity, `shared-${identity}`), + ]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run( + identity, + new Uint8Array(protoBytes(9, usage)), + ); + } + } finally { + db.close(); + } + } + const result = await readAntigravityUsage(roots, 0); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0]?.root, roots[0]); + assert.strictEqual(result.files[0]?.records.length, 2); + assert.strictEqual(result.files[1]?.records.length, 0); + assert.deepStrictEqual( + result.files[0]?.records.map((record) => record.totals.uncachedInputTokens), + [150, 150], + ); + assert.isTrue(result.files[0]?.records.every((record) => record.sessionId === "session-0")); + }); + + it("upgrades Antigravity fallback timestamps before applying the date window", async () => { + for (const fallback of ["mtime", "trajectory"]) { + const path = NodePath.join(dir, `${fallback}.db`); + const db = new NodeSqlite.DatabaseSync(path); + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + if (fallback === "trajectory") { + db.exec("CREATE TABLE trajectory_metadata_blob (data BLOB)"); + db.prepare("INSERT INTO trajectory_metadata_blob VALUES (?)").run( + new Uint8Array(protoBytes(2, protoNumber(1, 1780000200))), + ); + } + for (const [index, seconds] of [1780000000, 1780000200].entries()) { + const usage = [...protoNumber(2, 10), ...protoText(11, `${fallback}-${index}`)]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run( + index, + new Uint8Array(protoBytes(9, usage)), + ); + db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run( + index, + new Uint8Array( + protoBytes(1, [ + ...protoBytes(4, usage), + ...protoBytes(9, protoBytes(4, protoNumber(1, seconds))), + ]), + ), + ); + } + } finally { + db.close(); + } + await NodeFSP.utimes(path, 1780000000, 1780000000); + } + const result = await readAntigravityUsage(dir, 1780000100000); + assert.deepStrictEqual(result.errors, []); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 2); + assert.deepStrictEqual( + records.map((record) => record.timestampMs), + [1780000200000, 1780000200000], + ); + }); + + it("reads Antigravity step-only stores and reports malformed databases", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "steps.db")); + try { + db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...protoNumber(3, 5)]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run( + 0, + new Uint8Array([...protoBytes(9, usage), ...protoBytes(8, protoNumber(1, 1780000000))]), + ); + } finally { + db.close(); + } + await NodeFSP.writeFile(NodePath.join(dir, "broken.db"), "not a sqlite database"); + const result = await readAntigravityUsage(dir, 0); + assert.strictEqual(result.errors.length, 1); + assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.model, "gemini-2.5-pro"); + assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.totals.outputTokens, 5); + }); + + it("ignores large values in unused Antigravity protobuf fields", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "large-varint.db")); + try { + db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + const unusedField = [...protoNumber(99, 0).slice(0, -1), ...Array(9).fill(0xff), 0x01]; + const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...unusedField]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(protoBytes(9, usage))); + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual( + result.files.flatMap((file) => file.records)[0]?.totals.uncachedInputTokens, + 10, + ); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index acec3be8387f..6722969075a1 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -314,7 +314,25 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { hasServerConfig: environment.serverConfig !== null, }), )?.environmentId; + const searchableCursorEnvironmentId = options.find( + (environment) => + environment.serverConfig?.environment.platform.os === "darwin" && + isProviderSettingsEnvironmentAvailable({ + connectionPhase: environment.connection.phase, + hasServerConfig: true, + }), + )?.environmentId; useEffect(() => { + if ( + !target.scoped && + searchTargetId === searchableSetting("cursor-keychain-usage").id && + (!selectedEnvironmentCanRenderSettings || + selectedEnvironment?.serverConfig?.environment.platform.os !== "darwin") && + searchableCursorEnvironmentId !== undefined + ) { + setSelectedEnvironmentId(searchableCursorEnvironmentId); + return; + } if ( !target.scoped && (searchTargetId === searchableSetting("provider-health-check-interval").id || @@ -326,7 +344,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { } }, [ searchTargetId, + searchableCursorEnvironmentId, searchableEnvironmentId, + selectedEnvironment, selectedEnvironmentCanRenderSettings, target.scoped, ]); @@ -1102,6 +1122,7 @@ export function EnvironmentProviderSettings({ environmentId={environmentId} environmentLabel={environmentLabel} sources={settings.usageLimitSources} + cursorKeychainUsageEnabled={settings.cursorKeychainUsageEnabled} readOnly={readOnly} /> diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index e2ea39c761f2..142972cf1ece 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -116,7 +116,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); - const searchableItems = useAvailableSettingsSearchItems(); + const searchableItems = useAvailableSettingsSearchItems(scopeSearch); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index cf2887f39684..46ed099b166e 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -1,8 +1,11 @@ import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { AlertDialog, AlertDialogClose, @@ -13,6 +16,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; import { searchableSetting } from "./settingsSearch"; import { SettingsRow, SettingsSection } from "./settingsLayout"; @@ -22,17 +26,43 @@ export function UsageProviderSettings({ environmentId, environmentLabel, sources, + cursorKeychainUsageEnabled, readOnly, }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly sources: UnifiedSettings["usageLimitSources"]; + readonly cursorKeychainUsageEnabled: boolean; readonly readOnly: boolean; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); + const updateCursorSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "update Cursor account usage", + }); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const platform = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.environment + .platform; const [adding, setAdding] = useState(false); + const [updatingCursor, setUpdatingCursor] = useState(false); const entries = Object.entries(sources); + const setCursorUsageEnabled = async (enabled: boolean) => { + setUpdatingCursor(true); + try { + const result = await updateCursorSettings({ + environmentId, + input: { patch: { cursorKeychainUsageEnabled: enabled } }, + }); + if (result._tag === "Success") { + await refreshProviders({ environmentId, input: {} }); + } + } finally { + setUpdatingCursor(false); + } + }; + return ( <> + {platform?.os === "darwin" ? ( + void setCursorUsageEnabled(enabled)} + /> + } + /> + ) : null} {entries.length === 0 ? ( - + ) : ( entries.map(([id, source]) => { const label = source.label?.trim() || source.url; diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 569d736a62de..9bd4f9906acd 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -154,6 +154,7 @@ describe("searchSettings", () => { hasCloudPublicConfig: false, hasEnvironment: false, hasProviderSettingsEnvironment: false, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: false, @@ -165,6 +166,7 @@ describe("searchSettings", () => { "network-access", "publish-agent-activity", "provider-health-check-interval", + "cursor-keychain-usage", "source-control-writer-model", "source-control-writing-style", "t3-connect", @@ -177,11 +179,31 @@ describe("searchSettings", () => { expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("offers Cursor Keychain settings only when a macOS provider environment is available", () => { + const availability = { + hasCloudPublicConfig: false, + hasEnvironment: true, + hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, + }; + const itemIds = (macAvailable: boolean) => + filterAvailableSettingsSearchItems({ + ...availability, + hasMacProviderSettingsEnvironment: macAvailable, + }).map((item) => item.id); + expect(itemIds(false)).not.toContain("cursor-keychain-usage"); + expect(itemIds(true)).toContain("cursor-keychain-usage"); + }); + it("keeps the local toggle searchable without offering hidden host publishing controls", () => { const availability = { hasCloudPublicConfig: true, hasEnvironment: true, hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: false, @@ -204,6 +226,7 @@ describe("searchSettings", () => { hasCloudPublicConfig: false, hasEnvironment: false, hasProviderSettingsEnvironment: false, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: true, @@ -330,6 +353,7 @@ describe("searchSettings", () => { hasCloudPublicConfig: false, hasEnvironment: true, hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: true, @@ -424,6 +448,7 @@ describe("auto-settlement search availability", () => { hasCloudPublicConfig: false, hasEnvironment: true, hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index acc9b7eb7dbe..539e780baaa8 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -55,6 +55,7 @@ export interface SettingsSearchItem { readonly cloudOnly?: boolean; readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; + readonly macProviderSettingsOnly?: boolean; readonly localBackendManagementOnly?: boolean; readonly localEnvironmentOnly?: boolean; readonly wslAvailableOnly?: boolean; @@ -71,6 +72,7 @@ export interface SettingsSearchAvailability { readonly hasCloudPublicConfig: boolean; readonly hasEnvironment: boolean; readonly hasProviderSettingsEnvironment: boolean; + readonly hasMacProviderSettingsEnvironment: boolean; readonly canManageLocalBackend: boolean; readonly isWslSettingsRowVisible: boolean; readonly hasThreadAutoSettlement: boolean; @@ -546,6 +548,14 @@ export const SETTINGS_SEARCH_ITEMS = [ ], providerSettingsOnly: true, }, + { + id: "cursor-keychain-usage", + title: "Cursor account usage", + to: "/settings/providers", + searchTerms: ["cursor macOS keychain usage tokens cost limits permission"], + providerSettingsOnly: true, + macProviderSettingsOnly: true, + }, { id: "provider-health-check-interval", title: "Health check interval", @@ -943,6 +953,7 @@ export function filterAvailableSettingsSearchItems( (!item.cloudOnly || availability.hasCloudPublicConfig) && (!item.environmentOnly || availability.hasEnvironment) && (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && + (!item.macProviderSettingsOnly || availability.hasMacProviderSettingsEnvironment) && (!item.localBackendManagementOnly || availability.canManageLocalBackend) && (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) && (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index a0601e41729f..a5be950b81ab 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -10,12 +10,13 @@ import { useEnvironmentQuery } from "~/state/query"; import { usePrimarySessionState } from "~/environments/primary"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; +import type { SettingsScopeSearch } from "./settingsScope"; import { filterAvailableSettingsSearchItems, getThreadAutoSettlementSearchAvailability, } from "./settingsSearch"; -export function useAvailableSettingsSearchItems() { +export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) { const { environments } = useEnvironments(); const primarySessionState = usePrimarySessionState(); const localEnvironmentDisabled = isLocalEnvironmentDisabled(); @@ -41,6 +42,16 @@ export function useAvailableSettingsSearchItems() { hasServerConfig: environment.serverConfig !== null, }), ), + hasMacProviderSettingsEnvironment: environments.some( + (environment) => + (scopeSearch.machine === undefined || + environment.environmentId === scopeSearch.machine) && + environment.serverConfig?.environment.platform.os === "darwin" && + isProviderSettingsEnvironmentAvailable({ + connectionPhase: environment.connection.phase, + hasServerConfig: true, + }), + ), canManageLocalBackend, isWslSettingsRowVisible: isWslSettingsRowVisible({ state: desktopWsl.data, @@ -55,6 +66,7 @@ export function useAvailableSettingsSearchItems() { desktopWsl.error, environments, localEnvironmentDisabled, + scopeSearch.machine, ], ); } diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 05c91739e251..585bd6ccf100 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -17,7 +17,7 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; -import { Fragment, useState } from "react"; +import { Fragment, type ReactNode, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; import { environmentPresentations } from "../../state/presentation"; @@ -322,14 +322,16 @@ export function ResetCredits({ export function UsageLimitsSection({ selectedEnvironmentIds, now, + cursorPrompt, }: { readonly selectedEnvironmentIds: ReadonlySet | null; readonly now: number; + readonly cursorPrompt?: ReactNode; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const selected = selectedEnvironmentIds === null ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); - return ; + return ; } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 70858262b6a3..bdb291836328 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -2,7 +2,8 @@ import { collectLimitAccounts, collectLimitNotices, collectLimitPools, - formatDuration, + cursorUsageWindowDetails, + displayLimitWindows, formatResetsIn, type LimitAccount, type LimitPool, @@ -11,7 +12,7 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { AlertTriangleIcon, TicketIcon } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { Fragment, type ReactNode, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -224,6 +225,7 @@ function PoolSegment({ color, now, index, + showAccountName, }: { readonly account: LimitAccount; readonly window: LimitPoolMember["window"]; @@ -232,6 +234,7 @@ function PoolSegment({ readonly now: number; /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */ readonly index: number; + readonly showAccountName: boolean; }) { const [open, setOpen] = useState(false); const remaining = remainingPercent(window); @@ -274,7 +277,12 @@ function PoolSegment({ {index}
- + {showAccountName ? ( + + ) : null} {remaining}% {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} @@ -463,6 +471,7 @@ function PoolBar({ color={color} now={now} index={position + 1} + showAccountName={pool.columns.length > 1} /> ) : null, )} @@ -479,17 +488,21 @@ function PoolWindowCard({ pool, color, now, + label, + description, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; + readonly label?: string | undefined; + readonly description?: string | undefined; }) { // The soonest reset that hands anything back; an untouched account resets to no effect. const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); return (
- {pool.label} + {label ?? pool.label} {pool.remainingPercent}% @@ -497,14 +510,16 @@ function PoolWindowCard({ left {pool.pace ? : null} - {nextRefill ? ( - - ↻ +{nextRefill.restoresPercent}%{" "} - {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + {nextRefill && pool.columns.length > 1 ? ( + + ↻ +{nextRefill.restoresPercent}% ) : null}
+ {description ? ( +

{description}

+ ) : null}
); } @@ -512,6 +527,7 @@ function PoolWindowCard({ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { const color = barColor(pool.driver); const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); + const windows = displayLimitWindows(pool); return (

@@ -524,9 +540,19 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu /> {label}

- {pool.windows.map((window) => ( - - ))} + {windows.map((window) => { + const details = pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; + return ( + + ); + })}
); } @@ -539,22 +565,33 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu export function UsageLimitsPooled({ presentations, now, + cursorPrompt, }: { readonly presentations: Parameters[0]; readonly now: number; + readonly cursorPrompt?: ReactNode; }) { const pools = collectLimitPools(collectLimitAccounts(presentations), now); const notices = collectLimitNotices(presentations); + const cursorPromptAt = + Math.max( + pools.findIndex((pool) => pool.driver === "codex"), + pools.findIndex((pool) => pool.driver === "claudeAgent"), + ) + 1; return (
- {pools.length === 0 && notices.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && !cursorPrompt ? (

No provider on the selected environments reports subscription limits.

) : null} - {pools.map((pool) => ( - + {pools.map((pool, index) => ( + + {index === cursorPromptAt ? cursorPrompt : null} + + ))} + {cursorPromptAt === pools.length ? cursorPrompt : null}
); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index b3748e120d1d..1387bb3a9d9e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,6 +1,7 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { + ProviderDriverKind, USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageProviderKind, @@ -40,6 +41,7 @@ import { makeWindow, } from "@t3tools/shared/usageFormat"; import { Button, InlineButton } from "../ui/button"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { Menu, MenuCheckboxItem, @@ -53,6 +55,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { SidebarInset } from "../ui/sidebar"; import { Skeleton } from "../ui/skeleton"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, @@ -118,6 +121,25 @@ export function UsagePage() { selectedEnvironmentIds, ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const cursorAccessEnvironments = selectedEnvironments.filter((environment) => + environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), + ); + const sourceMessages = [ + ...new Set( + selectedEnvironments.flatMap( + (environment) => + environment.summary?.sources.flatMap((source) => + source.message && + !source.action && + (source.status === "partial" || + source.status === "failed" || + source.fingerprint.provider === "cursor") + ? [source.message] + : [], + ) ?? [], + ), + ), + ]; const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -147,6 +169,17 @@ export function UsagePage() { [breakdown, merged.models, metric], ); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); + const summaryRows: Array< + | { readonly kind: "usage"; readonly provider: UsageProviderKind } + | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } + > = activeProviders.map((provider) => ({ kind: "usage", provider })); + const cursorInsertAt = + Math.max(activeProviders.indexOf("codex"), activeProviders.indexOf("claude")) + 1; + summaryRows.splice( + cursorInsertAt, + 0, + ...cursorAccessEnvironments.map((environment) => ({ kind: "enable" as const, environment })), + ); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; const selectWindow = (days: number) => { @@ -165,7 +198,7 @@ export function UsagePage() { setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); }; - const refreshLimits = async (automatic = false) => { + const refreshLimits = async (automatic = false, afterPending = false) => { try { await Promise.all( Array.from(presentations, ([environmentId, presentation]) => { @@ -175,6 +208,7 @@ export function UsagePage() { environmentId, () => refreshProviders({ environmentId, input: {} }), automatic, + afterPending, ); } }), @@ -380,11 +414,30 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + 0 ? ( + { + void refresh(); + void refreshLimits(false, true); + }} + /> + ) : null + } + /> ) : isPending ? ( ) : ( <> + {sourceMessages.map((message) => ( +

+ {message} +

+ ))}
@@ -404,7 +457,22 @@ export function UsagePage() {
- {activeProviders.map((provider) => { + {summaryRows.map((row) => { + if (row.kind === "enable") { + return ( + 1} + onEnabled={() => { + void refresh(); + void refreshLimits(false, true); + }} + /> + ); + } + const provider = row.provider; const totals = merged.providers.find((entry) => entry.provider === provider); const share = metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); @@ -636,6 +704,137 @@ export function UsagePage() { ); } +const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; + +function CursorEnableButton({ + environmentId, + label, + onEnabled, + tooltip, + buttonText = "Enable", +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly onEnabled: () => void; + readonly tooltip: boolean; + readonly buttonText?: string; +}) { + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "enable Cursor account usage", + }); + const [pending, setPending] = useState(false); + const enable = async () => { + setPending(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { cursorKeychainUsageEnabled: true } }, + }); + if (result._tag === "Success") onEnabled(); + } finally { + setPending(false); + } + }; + const button = tooltip ? ( + void enable()} + > + {buttonText} + + ) : ( + + ); + if (!tooltip) return button; + return ( + + + {CURSOR_KEYCHAIN_COPY} + + ); +} + +function CursorEnableRow({ + environmentId, + label, + showEnvironment, + onEnabled, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly showEnvironment: boolean; + readonly onEnabled: () => void; +}) { + return ( +
+ + + + Cursor{showEnvironment ? ` · ${label}` : ""} + + +
+ ); +} + +function CursorEnableLimits({ + environments, + onEnabled, +}: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly onEnabled: () => void; +}) { + return ( +
+

+ + Cursor +

+
+

{CURSOR_KEYCHAIN_COPY}

+
+ {environments.map((environment) => ( + 1 ? `Enable on ${environment.label}` : "Enable"} + onEnabled={onEnabled} + tooltip={false} + /> + ))} +
+
+
+ ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 622d73d13844..e3060dbaefd3 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -89,6 +89,9 @@ describe("buildPeriodColumns", () => { { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, { provider: "grok", value: 0 }, + { provider: "cursor", value: 0 }, + { provider: "opencode", value: 0 }, + { provider: "antigravity", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index efad95e531ad..38b78cbcdb04 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,6 +1,14 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; +import { + AntigravityIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -30,6 +38,9 @@ export const PROVIDER_PRESENTATION = { color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))", mark: GrokIcon, }, + cursor: { label: "Cursor", color: "#8b8b8b", mark: CursorIcon }, + opencode: { label: "OpenCode", color: "#5b9bbd", mark: OpenCodeIcon }, + antigravity: { label: "Antigravity", color: "#8c7bd1", mark: AntigravityIcon }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/docs/user/usage.md b/docs/user/usage.md index 5da542b26a26..ab77f70a6b3a 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -6,13 +6,25 @@ desktop when the terminal is not focused. Customize `usage.open` in ## Understand your usage -**Usage** combines Codex, Claude Code, and Grok Build session history from your connected +**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent cost. These estimates are not your subscription bill. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. +OpenCode reads its SQLite database and older JSON history. Antigravity reads local conversation +databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the +server to read a different data directory; comma-separated paths read multiple directories. + +Cursor reads account usage from Cursor's dashboard API using the CLI login saved on the server. +This includes headless T3 sessions and desktop usage across machines; the same account counts +once across connected environments. Without an accessible CLI login, T3 shows a +notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. +On macOS, choose **Enable Cursor usage** on Usage to allow T3 to read your existing CLI login +from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask +you to allow access on the server Mac. + Usage includes each configured account's history, including disabled accounts. Custom homes follow the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment 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 OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials belong to the remote server. Cursor reports -its monthly allowance, including separate Auto and API usage, using a file-based CLI login or -`CURSOR_AUTH_TOKEN`. Cursor's default macOS keychain login does not currently report limits. -On macOS, use `AGENT_CLI_CREDENTIAL_STORE=file` when signing in and in the provider's environment -to use a file-based login. +its monthly allowance, including separate Auto and API usage, using the CLI login or +`CURSOR_AUTH_TOKEN`. On macOS, this includes the default Keychain login after you enable Cursor +usage. Keychain login is used for limits only with Cursor's default API endpoint. If you configure +a custom Cursor endpoint, use an explicit token or file-based CLI login for limits. Grok reports the remaining subscription allowance and reset time for its current billing period after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index a7476ec17157..5919c0af263a 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -933,6 +933,14 @@ export function createServerEnvironmentAtoms( ); }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)), ); + const usageScanSettingsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + JSON.stringify([ + get(usagePricesAtom(environmentId)), + get(settingsValueAtom(environmentId))?.cursorKeychainUsageEnabled ?? false, + ]), + ).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), + ); const providersValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( Atom.withLabel(`environment-data:server:providers:${environmentId}`), @@ -1050,7 +1058,7 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:usage-summary", tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, - refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), + refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId), }), configProjection, welcome, diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 98eb46e13ca8..75977d20de74 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -184,6 +184,19 @@ describe("manual usage refresh", () => { }); describe("limits refresh cooldown", () => { + it("runs a fresh check after an in-flight check when settings change", async () => { + const id = EnvironmentId.make("limits-after-enable"); + const oldCheck = Promise.withResolvers(); + const first = refreshUsageLimits(id, () => oldCheck.promise, true); + const newCheck = vi.fn(async () => "new limits"); + const afterEnable = refreshUsageLimits(id, newCheck, false, true); + expect(newCheck).not.toHaveBeenCalled(); + oldCheck.resolve("old limits"); + expect(await first).toBe("old limits"); + expect(await afterEnable).toBe("new limits"); + expect(newCheck).toHaveBeenCalledTimes(1); + }); + it("joins manual calls and gates automatic refreshes after success or failure", async () => { const clock = vi.spyOn(Date, "now").mockReturnValue(1_000); try { diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 8a2b1a44a951..11cfd26fbe6c 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -16,9 +16,18 @@ export async function refreshUsageLimits( environmentId: EnvironmentId, refresh: () => Promise, automatic = false, + afterPending = false, ): Promise { const pending = limitsRefreshes.get(environmentId); if (pending !== undefined) { + if (afterPending) { + try { + await pending; + } catch { + // The new check still needs to run if the earlier one failed. + } + return refreshUsageLimits(environmentId, refresh, false, true); + } // Manual refresh waits for the current check; automatic refresh does not repeat it. return automatic ? undefined : ((await pending) as A); } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 94bc27734923..a8cce4487549 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1277,6 +1277,10 @@ export const ServerSettings = Schema.Struct({ usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** Allows this server to read the Cursor CLI's macOS Keychain login for account usage. */ + cursorKeychainUsageEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), /** Exact model IDs, applied to past and future usage on this environment. */ usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -1550,6 +1554,7 @@ export const ServerSettingsPatch = Schema.Struct({ usageLimitSources: Schema.optionalKey( Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), ), + cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), /** Each entry replaces one model's rates; `null` restores automatic pricing. */ usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 29fb75bf949c..a6431330558a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -1,13 +1,10 @@ /** * Usage reporting contract. * - * Each environment scans the provider CLIs' own on-disk session transcripts - * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, - * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own - * orchestration projections, so usage stays complete even for turns that were - * never driven through T3 Code. This mirrors the approach `ccusage` takes. + * Each environment scans native session files and databases, including work + * driven outside T3 Code. Source status describes gaps in local coverage. * - * Environments return pre-aggregated `(day, hourStart?, provider, model)` + * Environments return pre-aggregated `(day, hourStart?, provider, model, sourcePath?)` * buckets. Raw transcript records never cross the wire. * * @module usage @@ -21,18 +18,24 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 5 as const; +export const USAGE_CONTRACT_VERSION = 6 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * - * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets - * remain valid, so mixed-version environments keep those totals instead of - * treating every older server as stale. + * v5/v6 add providers and optional source attribution; v4 Claude/Codex + * buckets remain valid in mixed-version environments. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); +export const UsageProviderKind = Schema.Literals([ + "claude", + "codex", + "grok", + "cursor", + "opencode", + "antigravity", +]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** @@ -93,6 +96,8 @@ export const UsageBucket = Schema.Struct({ hourStart: Schema.optional(TrimmedNonEmptyString), provider: UsageProviderKind, model: TrimmedNonEmptyString, + /** Source directory, so overlapping multi-home environments merge once per source. */ + sourcePath: Schema.optional(TrimmedNonEmptyString), totals: UsageTokenTotals, costUsd: Schema.Number, /** @@ -151,6 +156,8 @@ export const UsageSource = Schema.Struct({ */ distinctSessions: NonNegativeInt, message: Schema.NullOr(TrimmedNonEmptyString), + /** An action the client can offer to make this source available. */ + action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")), }); export type UsageSource = typeof UsageSource.Type; diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 3e92d0b3cb85..3a47ede3bcc8 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -5,10 +5,22 @@ import { enumerateHourStarts, formatDateTimeShort, formatHourShort, + formatPercent, formatRelativeHourShort, makeWindow, } from "./usageFormat.ts"; +describe("formatPercent", () => { + it("distinguishes a small positive share from zero", () => { + expect(formatPercent(0)).toBe("0.0%"); + expect(formatPercent(0.0004)).toBe("<0.1%"); + expect(formatPercent(0.0009)).toBe("<0.1%"); + expect(formatPercent(0.001)).toBe("0.1%"); + expect(formatPercent(0.023)).toBe("2.3%"); + expect(formatPercent(0.00004, 2)).toBe("<0.01%"); + }); +}); + describe("hourly usage formatting", () => { it("keeps requested zones separate when formatting repeated calls", () => { const instant = "2026-08-11T12:37:00.000Z"; diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 442687308323..328e0cf814e7 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -43,7 +43,10 @@ function trim(value: number): string { } export function formatPercent(share: number, digits = 1): string { - return `${(share * 100).toFixed(digits)}%`; + const percent = share * 100; + const smallest = 10 ** -digits; + if (percent > 0 && percent < smallest) return `<${smallest.toFixed(digits)}%`; + return `${percent.toFixed(digits)}%`; } /** `2026-08-07` to `Aug 7`. */ diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0ef53b1e7f55..0646953a5f08 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -16,6 +16,7 @@ import { collectLimitAccounts, collectLimitNotices, collectLimitPools, + displayLimitWindows, elapsedShare, formatResetsIn, limitsNotice, @@ -687,6 +688,53 @@ describe("pooled account columns", () => { }); }); +describe("Cursor limit presentation", () => { + const cursorAccount: LimitAccount = { + key: "cursor", + driver: ProviderDriverKind.make("cursor"), + displayName: "Cursor", + email: undefined, + plan: undefined, + accentColor: undefined, + environments: [], + sourceLabel: "Cursor", + redeem: null, + limits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [ + { id: "apiPercentUsed", kind: "monthly", label: "Other Models", usedPercent: 49 }, + { id: "autoPercentUsed", kind: "monthly", label: "Cursor Models", usedPercent: 9 }, + { id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 15 }, + ], + }, + }; + + it("hides the combined percentage and orders the two pools", () => { + const [pool] = collectLimitPools([cursorAccount], now); + const display = displayLimitWindows(pool!); + expect(display.map((window) => window.id)).toEqual(["autoPercentUsed", "apiPercentUsed"]); + }); + + it("keeps the combined percentage as a card if either allowance is missing", () => { + const [pool] = collectLimitPools( + [ + { + ...cursorAccount, + limits: { + ...cursorAccount.limits, + windows: cursorAccount.limits.windows.filter( + (window) => window.id !== "apiPercentUsed", + ), + }, + }, + ], + now, + ); + const display = displayLimitWindows(pool!); + expect(display.map((window) => window.id)).toEqual(["totalPercentUsed", "autoPercentUsed"]); + }); +}); + describe("collectLimitNotices", () => { const checkedAt = "2026-09-03T11:00:00.000Z"; const claude = ProviderDriverKind.make("claudeAgent"); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 17ba1feab5d6..f2b5cfe53b84 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -24,6 +24,33 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export const CURSOR_USAGE_WINDOWS = [ + { + id: "totalPercentUsed", + label: "Overall", + description: "Combined usage across both allowances, not a third quota.", + }, + { + id: "autoPercentUsed", + label: "Cursor Models", + description: "Grok and Composer use this first. Auto can use either pool.", + }, + { + id: "apiPercentUsed", + label: "Other Models", + description: "Claude, GPT, and Gemini use this pool. Grok and Composer fall back here.", + }, +] as const; + +export function cursorUsageWindowDetails(id: string) { + return CURSOR_USAGE_WINDOWS.find((window) => window.id === id); +} + +function cursorUsageWindowRank(id: string): number { + const rank = CURSOR_USAGE_WINDOWS.findIndex((window) => window.id === id); + return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank; +} + /** * Providers that belong on the Limits view: enabled, installed, and one whose * driver reports subscription usage at all. A driver with no notion of usage @@ -282,6 +309,17 @@ export interface LimitPool { readonly windows: readonly LimitPoolWindow[]; } +/** Show Cursor's two usable pools instead of a combined percentage when both are available. */ +export function displayLimitWindows(pool: LimitPool) { + if (pool.driver !== "cursor") return pool.windows; + const hasAuto = pool.windows.some((window) => window.id === "autoPercentUsed"); + const hasApi = pool.windows.some((window) => window.id === "apiPercentUsed"); + const hasBothPools = hasAuto && hasApi; + return pool.windows + .filter((window) => !hasBothPools || window.id !== "totalPercentUsed") + .sort((left, right) => cursorUsageWindowRank(left.id) - cursorUsageWindowRank(right.id)); +} + const WINDOW_KIND_ORDER: Record = { session: 0, weekly: 1, diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 668d8bcc2723..178282238482 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -1,5 +1,6 @@ import { USAGE_CONTRACT_VERSION, + USAGE_MERGE_COMPATIBLE_SINCE, type EnvironmentId, type UsageBucket, type UsageDay, @@ -74,6 +75,41 @@ function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { } describe("mergeUsage", () => { + it("counts a Cursor account once across servers while retaining each server's other providers", () => { + const account = { + provider: "cursor" as const, + hostId: "cursor.com", + homePath: "cursor-account:account-hash", + volumeId: "account-hash", + }; + const merged = mergeUsage( + [ + environment( + "mac", + summary([bucket({ provider: "cursor", sourcePath: account.homePath })], [account]), + ), + environment( + "linux", + summary( + [ + bucket({ provider: "cursor", sourcePath: account.homePath }), + bucket({ provider: "opencode", sourcePath: "/opencode" }), + ], + [account, { provider: "opencode", hostId: "linux", homePath: "/opencode" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + expect( + merged.providers.map((provider) => [provider.provider, provider.costUsd]).sort(), + ).toEqual([ + ["cursor", 10], + ["opencode", 10], + ]); + expect(merged.duplicateSources).toHaveLength(1); + }); + it("sums environments that read different transcript directories", () => { const merged = mergeUsage( [ @@ -146,6 +182,31 @@ describe("mergeUsage", () => { ).toEqual({ claude: 1, codex: 1 }); }); + it("counts overlapping provider roots once while keeping each environment's unique root", () => { + const source = (homePath: string) => ({ + provider: "opencode" as const, + hostId: "host", + homePath, + }); + const usage = (sourcePath: string, costUsd: number) => + bucket({ provider: "opencode", sourcePath, costUsd }); + const merged = mergeUsage( + [ + environment( + "env-a", + summary([usage("/shared", 10), usage("/a", 2)], [source("/shared"), source("/a")]), + ), + environment( + "env-b", + summary([usage("/shared", 10), usage("/b", 3)], [source("/shared"), source("/b")]), + ), + ], + USAGE_CONTRACT_VERSION, + ); + expect(merged.costUsd).toBe(15); + expect(merged.sessions).toBe(3); + }); + it("uses the newest scan when environments share the same transcript directory", () => { const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; const environments = [ @@ -166,6 +227,104 @@ describe("mergeUsage", () => { } }); + it("prefers a complete scan over a newer partial scan of the same directory", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const incomplete = summary([bucket({ costUsd: 4, records: 2 })], [source]); + const partial = environment("new", { + ...incomplete, + readAt: "2026-08-07T01:00:00.000Z", + sources: incomplete.sources.map((entry) => ({ ...entry, status: "partial" as const })), + }); + const complete = environment("old", summary([bucket()], [source])); + + for (const ordered of [ + [partial, complete], + [complete, partial], + ]) { + const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.contributingEnvironments).toEqual(["old"]); + expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]); + } + expect(mergeUsage([partial], USAGE_CONTRACT_VERSION).costUsd).toBe(4); + }); + + it("keeps new cells from a later partial scan without recounting older cells", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const complete = environment( + "old", + summary([bucket()], [source], USAGE_MERGE_COMPATIBLE_SINCE), + ); + const partialSummary = summary( + [ + bucket({ sourcePath: source.homePath, costUsd: 4, records: 2 }), + bucket({ + day: "2026-08-08" as UsageDay, + sourcePath: source.homePath, + costUsd: 3, + records: 1, + }), + ], + [{ ...source, distinctSessions: 2 }], + ); + const partial = environment("new", { + ...partialSummary, + readAt: "2026-08-08T01:00:00.000Z", + sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })), + }); + + for (const ordered of [ + [complete, partial], + [partial, complete], + ]) { + const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(13); + expect(merged.records).toBe(6); + expect(merged.sessions).toBe(2); + expect(merged.daily.map(({ day, costUsd }) => [day, costUsd])).toEqual([ + ["2026-08-07", 10], + ["2026-08-08", 3], + ]); + expect(merged.contributingEnvironments).toEqual( + ordered.map(({ environmentId }) => environmentId), + ); + expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]); + } + }); + + it("retains a complete cell when a larger partial cell may have skipped old records", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const complete = environment("old", summary([bucket()], [source])); + const partialSummary = summary( + [ + bucket({ + costUsd: 4, + records: 6, + totals: { + uncachedInputTokens: 80, + cachedInputTokens: 500, + cacheCreationTokens: 10, + outputTokens: 30, + reasoningTokens: 0, + }, + }), + ], + [{ ...source, distinctSessions: 2 }], + ); + const partial = environment("new", { + ...partialSummary, + readAt: "2026-08-07T01:00:00.000Z", + sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })), + }); + + const merged = mergeUsage([complete, partial], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.totalTokens).toBe(1160); + expect(merged.records).toBe(5); + expect(merged.sessions).toBe(1); + expect(merged.contributingEnvironments).toEqual(["old"]); + }); + it("excludes an environment reporting an older contract version", () => { const merged = mergeUsage( [ @@ -178,7 +337,7 @@ describe("mergeUsage", () => { summary( [bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 2, + USAGE_MERGE_COMPATIBLE_SINCE - 1, ), ), ], diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index fee8e9c95666..c4a56829a420 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -11,6 +11,7 @@ import { type EnvironmentId, type UsageBucket, type UsageProviderKind, + type UsageSource, type UsageSourceFingerprint, type UsageSummary, } from "@t3tools/contracts"; @@ -113,20 +114,46 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { ].join(" "); } +function bucketsForSource(summary: UsageSummary, source: UsageSource): readonly UsageBucket[] { + const providerSources = summary.sources.filter( + (entry) => entry.fingerprint.provider === source.fingerprint.provider, + ); + return summary.buckets.filter( + (bucket) => + bucket.provider === source.fingerprint.provider && + (bucket.sourcePath === source.fingerprint.resolvedHomePath || + (bucket.sourcePath === undefined && providerSources.length === 1)), + ); +} + +function bucketKey(bucket: UsageBucket): string { + return JSON.stringify([bucket.day, bucket.hourStart ?? null, bucket.provider, bucket.model]); +} + /** * Decides which environment owns each physical transcript directory. * * Several environments on one machine (worktree servers, for instance) resolve * the same provider home and would otherwise double count every token. The - * most recently read summary claims a fingerprint; the rest have that provider's - * buckets dropped. Environment ids break ties so the winner is stable when - * summaries have the same read time. + * Complete scans claim a fingerprint ahead of partial scans, then the most + * recently read scan wins within each status. A newer partial scan can still + * contribute cells absent from an older complete scan. Environment ids break + * ties so the result is stable when summaries have the same read time. */ function claimSources(environments: readonly EnvironmentUsage[]): { readonly ownerByFingerprint: ReadonlyMap; + readonly supplementalBucketsByEnvironment: ReadonlyMap>; + readonly sessionsByFingerprint: ReadonlyMap; readonly duplicates: readonly string[]; } { const ownerByFingerprint = new Map(); + const ownerScanByFingerprint = new Map< + string, + { environment: EnvironmentUsage; source: UsageSource } + >(); + const seenBucketKeysByFingerprint = new Map>(); + const supplementalBucketsByEnvironment = new Map>(); + const sessionsByFingerprint = new Map(); const duplicates: string[] = []; const ordered = [...environments].sort( @@ -135,30 +162,82 @@ function claimSources(environments: readonly EnvironmentUsage[]): { a.environmentId.localeCompare(b.environmentId), ); + // A complete scan takes precedence over a newer partial scan of the same + // directory. Partial history still contributes when no complete copy exists. + for (const status of ["ok", "partial", "failed"] as const) { + for (const environment of ordered) { + for (const source of environment.summary.sources) { + if (source.status !== status) continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.has(key)) { + duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); + continue; + } + ownerByFingerprint.set(key, environment.environmentId); + ownerScanByFingerprint.set(key, { environment, source }); + sessionsByFingerprint.set(key, source.distinctSessions); + } + } + } + + // A newer partial scan may contain usage recorded after an older complete + // scan. Keep cells absent from the complete scan. Aggregated cells do not + // reveal enough to reconcile overlapping records without double counting. for (const environment of ordered) { for (const source of environment.summary.sources) { - if (source.status === "missing") continue; + if (source.status !== "partial") continue; const key = fingerprintKey(source.fingerprint); - if (ownerByFingerprint.has(key)) { - duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); + const owner = ownerScanByFingerprint.get(key); + if ( + owner?.source.status !== "ok" || + Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt) + ) { continue; } - ownerByFingerprint.set(key, environment.environmentId); + let seen = seenBucketKeysByFingerprint.get(key); + if (seen === undefined) { + seen = new Set(bucketsForSource(owner.environment.summary, owner.source).map(bucketKey)); + seenBucketKeysByFingerprint.set(key, seen); + } + const supplemental = + supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(); + let added = false; + for (const bucket of bucketsForSource(environment.summary, source)) { + const cell = bucketKey(bucket); + if (seen.has(cell)) continue; + seen.add(cell); + supplemental.add(bucket); + added = true; + } + if (!added) continue; + supplementalBucketsByEnvironment.set(environment.environmentId, supplemental); + sessionsByFingerprint.set( + key, + Math.max(sessionsByFingerprint.get(key) ?? 0, source.distinctSessions), + ); } } - return { ownerByFingerprint, duplicates }; + return { + ownerByFingerprint, + supplementalBucketsByEnvironment, + sessionsByFingerprint, + duplicates, + }; } /** Sources this environment owns after fingerprint claims, plus their buckets. */ function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, + supplementalBuckets: ReadonlySet, + sessionsByFingerprint: ReadonlyMap, ): { readonly buckets: readonly UsageBucket[]; readonly sessionsByProvider: ReadonlyMap; } { const ownedProviders = new Set(); + const ownedSources = new Set(); const sessionsByProvider = new Map(); for (const source of environment.summary.sources) { if (source.status === "missing") continue; @@ -166,16 +245,24 @@ function ownedContribution( if (ownerByFingerprint.get(key) === environment.environmentId) { const provider = source.fingerprint.provider; ownedProviders.add(provider); + ownedSources.add(`${provider}\u0000${source.fingerprint.resolvedHomePath}`); // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. sessionsByProvider.set( provider, - (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, + (sessionsByProvider.get(provider) ?? 0) + + (sessionsByFingerprint.get(key) ?? source.distinctSessions), ); } } return { - buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + buckets: environment.summary.buckets.filter( + (bucket) => + supplementalBuckets.has(bucket) || + (bucket.sourcePath === undefined + ? ownedProviders.has(bucket.provider) + : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`)), + ), sessionsByProvider, }; } @@ -246,7 +333,12 @@ export function mergeUsage( } } - const { ownerByFingerprint, duplicates } = claimSources(current); + const { + ownerByFingerprint, + supplementalBucketsByEnvironment, + sessionsByFingerprint, + duplicates, + } = claimSources(current); let costUsd = 0; let uncachedInputTokens = 0; @@ -295,7 +387,12 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); + const { buckets, sessionsByProvider } = ownedContribution( + environment, + ownerByFingerprint, + supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(), + sessionsByFingerprint, + ); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); for (const [providerKind, providerSessions] of sessionsByProvider) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1090902dab8b..d512900e23bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -516,6 +516,9 @@ importers: '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8) + '@napi-rs/keyring': + specifier: ^1.3.0 + version: 1.3.0 '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index ebcc81020bfe..5168e770a114 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -49,6 +49,7 @@ describe("shouldBundleCliDependency", () => { "ffi-rs", "@yuuang/ffi-rs-win32-x64-msvc", "@ff-labs/fff-node", + "@napi-rs/keyring", "@clerk/electron-passkeys", "node-addon-api", ]) { @@ -82,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => { it("selects every external root declared by the server", () => { assert.deepStrictEqual( Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), - ["@ff-labs/fff-node", "node-pty"], + ["@ff-labs/fff-node", "@napi-rs/keyring", "node-pty"], ); }); }); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 5cafee960675..c158ccaff7f4 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -30,6 +30,7 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "ffi-rs", "@yuuang/", "@ff-labs/", + "@napi-rs/keyring", "@clerk/electron-passkeys", "node-gyp-build", "node-addon-api", From d06f0ff1048d42a156824765316c5a2dc54f5bca Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:20:04 +0530 Subject: [PATCH 04/73] fix(sqlite): retry failed statement preparations (#10584) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- packages/shared/src/nodeSqliteClient.test.ts | 48 ++++++++++++++++++++ packages/shared/src/nodeSqliteClient.ts | 8 ++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/nodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts index 25b0ba2d38e8..bbdaa4624901 100644 --- a/packages/shared/src/nodeSqliteClient.test.ts +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -1,6 +1,10 @@ +import * as NodeSqlite from "node:sqlite"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqliteClient from "./nodeSqliteClient.ts"; @@ -8,6 +12,21 @@ import * as SqliteClient from "./nodeSqliteClient.ts"; const layer = it.layer(SqliteClient.layer({ filename: ":memory:" })); layer("NodeSqliteClient", (it) => { + it.effect("retries preparing a query after the missing schema becomes available", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const select = sql<{ name: string }>`SELECT name FROM created_after_prepare_failure`; + const error = yield* select.pipe(Effect.flip); + assert.equal(error._tag, "SqlError"); + assert.equal(error.reason.operation, "prepare"); + + yield* sql`CREATE TABLE created_after_prepare_failure(name TEXT NOT NULL)`; + yield* sql`INSERT INTO created_after_prepare_failure VALUES ('recovered')`; + assert.deepEqual(yield* select, [{ name: "recovered" }]); + assert.deepEqual(yield* select.values, [["recovered"]]); + }), + ); + it.effect("runs prepared queries and returns positional values", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -54,3 +73,32 @@ it.effect("returns a typed failure when the database cannot be opened", () => assert.equal(error.reason.operation, "open"); }), ); + +it.effect( + "recovers a prepared query immediately after an exclusive database lock is released", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-prepare-" }); + const filename = path.join(directory, "state.sqlite"); + const blocker = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(filename)), + (database) => Effect.sync(() => database.close()), + ); + yield* Effect.sync(() => { + blocker.exec("CREATE TABLE entries(value TEXT); INSERT INTO entries VALUES ('retained')"); + }); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* Effect.sync(() => blocker.exec("BEGIN EXCLUSIVE")); + const select = sql`SELECT value FROM entries`; + const error = yield* select.values.pipe(Effect.flip); + assert.equal(error._tag, "SqlError"); + assert.equal(error.reason.operation, "prepare"); + yield* Effect.sync(() => blocker.exec("ROLLBACK")); + assert.deepEqual(yield* select.values, [["retained"]]); + assert.deepEqual(yield* select, [{ value: "retained" }]); + }).pipe(Effect.provide(SqliteClient.layer({ filename }))); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index 71ba11884626..047de403ed8c 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -9,6 +9,7 @@ import * as NodeSqlite from "node:sqlite"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; @@ -144,10 +145,11 @@ const make = Effect.fn("makeWithDatabase")(function* ( }), }); - const prepareCache = yield* Cache.make({ + const prepareCache = yield* Cache.makeWith(prepare, { capacity: options.prepareCacheSize ?? 200, - timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), - lookup: prepare, + // A transient prepare failure must not outlive the lock or missing schema. + timeToLive: (exit) => + Exit.isSuccess(exit) ? (options.prepareCacheTTL ?? Duration.minutes(10)) : Duration.zero, }); const runStatement = ( From 7a12aff471ffe2b22b9fee495b04b32c43f45a37 Mon Sep 17 00:00:00 2001 From: Nick Anisimov Date: Fri, 25 Sep 2026 21:14:50 +0400 Subject: [PATCH 05/73] fix(mobile): scale Android controls with appearance text size (#13356) --- .../src/components/AndroidAnchoredMenu.tsx | 22 ++++--- .../src/components/AndroidScreenHeader.tsx | 17 +++-- .../src/components/CompactBrandTitle.tsx | 21 +++++-- .../components/ComposerAttachmentButton.tsx | 4 +- .../mobile/src/components/ComposerToolbar.tsx | 25 +++++--- apps/mobile/src/components/ControlPill.tsx | 4 +- .../src/components/MaterialButton.android.tsx | 6 +- .../MaterialFloatingActionButton.android.tsx | 12 +++- .../components/MaterialIconButton.android.tsx | 10 +-- .../mobile/src/components/MaterialListRow.tsx | 4 +- .../components/MaterialMenuPopup.android.tsx | 63 +++++++++++-------- .../src/components/MaterialMenuPopup.tsx | 1 + .../MaterialScrollComposeButton.android.tsx | 58 +++++++++++++---- .../src/components/MaterialSearchField.tsx | 22 +++++-- .../src/components/ScreenHeader.android.tsx | 54 ++++++++++------ .../src/components/useAndroidControlSizing.ts | 13 ++++ .../components/useMaterialToolbarHeight.ts | 11 ---- .../components/useMaterialToolbarLayout.ts | 21 +++++++ .../features/files/MaterialFilesHeader.tsx | 11 ++-- .../features/home/AndroidHomeFab.android.tsx | 17 ++++- apps/mobile/src/features/home/HomeScreen.tsx | 4 +- .../home/MaterialThreadListToolbar.tsx | 16 ++--- .../home/WorkspaceConnectionTitle.tsx | 24 ++++--- .../settings/appearance/useScaledTextRole.ts | 21 ++----- .../features/threads/NewTaskDraftScreen.tsx | 37 +++++++---- .../src/features/threads/ThreadComposer.tsx | 6 +- .../threads/ThreadNavigationSidebar.tsx | 10 +-- .../src/lib/androidControlSizing.test.ts | 22 +++++++ apps/mobile/src/lib/androidControlSizing.ts | 23 +++++++ apps/mobile/src/lib/appearancePreferences.ts | 10 +++ 30 files changed, 390 insertions(+), 179 deletions(-) create mode 100644 apps/mobile/src/components/useAndroidControlSizing.ts delete mode 100644 apps/mobile/src/components/useMaterialToolbarHeight.ts create mode 100644 apps/mobile/src/components/useMaterialToolbarLayout.ts create mode 100644 apps/mobile/src/lib/androidControlSizing.test.ts create mode 100644 apps/mobile/src/lib/androidControlSizing.ts diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index dfa0dea8d05a..4c465a9f9ef4 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -7,9 +7,9 @@ import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; import { OverlayPortal } from "./OverlayPortal"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { MaterialMenuPopup } from "./MaterialMenuPopup"; -const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; const ANCHOR_GAP = 6; @@ -55,6 +55,7 @@ export type AndroidAnchoredMenuProps = { * menus use the native popup for placement, animation and dismissal. */ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { + const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); // Height of the modal's root view, in the modal's own coordinate space. @@ -68,6 +69,10 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { // are converted into this frame, so the menu lands correctly no matter // where the portal host sits (status bar, keyboard resize, etc.). const [overlay, setOverlay] = useState(null); + const menuWidth = + overlay === null + ? desiredMenuWidth + : Math.min(desiredMenuWidth, Math.max(0, overlay.width - 2 * SCREEN_MARGIN)); const anchorRef = useRef(null); const overlayRef = useRef(null); @@ -131,14 +136,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { ? 0 : local.x + local.width / 2 <= overlay.width / 2 ? local.x - : local.x + local.width - MENU_WIDTH; + : local.x + local.width - menuWidth; const left = overlay === null ? 0 - : Math.min( - Math.max(preferredLeft, SCREEN_MARGIN), - overlay.width - MENU_WIDTH - SCREEN_MARGIN, - ); + : Math.min(Math.max(preferredLeft, SCREEN_MARGIN), overlay.width - menuWidth - SCREEN_MARGIN); // The keyboard stays up while the menu is open (in-window overlay, no // focus change), so the space it covers is not usable — without this the // composer-pill menus "open down" into the IME and can't be tapped. @@ -201,6 +203,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length; @@ -51,16 +51,13 @@ export function AndroidScreenHeader(props: { return ( setHeaderWidth(event.nativeEvent.layout.width)} - className="border-b border-header-border bg-header px-2 pb-2" + className="border-b border-header-border bg-header px-2" style={{ - paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), + ...headerPadding, borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > - + {props.onBack ? ( - + Code - + {stageLabel} diff --git a/apps/mobile/src/components/ComposerAttachmentButton.tsx b/apps/mobile/src/components/ComposerAttachmentButton.tsx index 8ea70a45c020..b6b3d6e78a49 100644 --- a/apps/mobile/src/components/ComposerAttachmentButton.tsx +++ b/apps/mobile/src/components/ComposerAttachmentButton.tsx @@ -1,6 +1,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import { Pressable } from "react-native"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { SymbolView } from "./AppSymbol"; import { ControlPillMenu } from "./ControlPill"; @@ -15,6 +16,7 @@ export function ComposerAttachmentButton(props: { readonly onPickMedia: () => Promise; readonly onPickFiles: () => Promise; }) { + const { scale } = useAndroidControlSizing(); const button = ( ["name"]; - readonly iconNode?: ReactNode; + readonly renderIcon?: (size: number) => ReactNode; readonly label: string; readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; @@ -39,6 +41,7 @@ export function ComposerInlineControl(props: { readonly chevronDirection?: "down" | "right"; readonly showChevron?: boolean; }) { + const { scale, smallIconSize } = useAndroidControlSizing(); return ( - {props.iconNode ? ( - {props.iconNode} + {props.renderIcon ? ( + + {props.renderIcon(smallIconSize)} + ) : props.icon ? ( void; readonly variant?: "primary" | "danger"; }) { + const { scale, smallIconSize } = useAndroidControlSizing(); + const circleSize = Math.round(30 * scale); return ( { @@ -133,7 +135,7 @@ export function ControlPill(props: { ) : props.icon ? ( diff --git a/apps/mobile/src/components/MaterialButton.android.tsx b/apps/mobile/src/components/MaterialButton.android.tsx index f2df6464f103..f7a62f1ecb0b 100644 --- a/apps/mobile/src/components/MaterialButton.android.tsx +++ b/apps/mobile/src/components/MaterialButton.android.tsx @@ -14,10 +14,12 @@ import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import type { MaterialButtonProps } from "./MaterialButton"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function MaterialButton(props: MaterialButtonProps) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const typography = useScaledTextRole("footnote"); + const { scale, mediumIconSize } = useAndroidControlSizing(); const tone = props.tone ?? "secondary"; const Component = tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button; @@ -77,11 +79,11 @@ export function MaterialButton(props: MaterialButtonProps) { {props.loading ? ( <> - + ) : null} {props.label} diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx index 2882d0d91ec0..29f396fc9d6d 100644 --- a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx +++ b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx @@ -6,8 +6,9 @@ import { LargeFloatingActionButton, Text, } from "@expo/ui/jetpack-compose"; -import { size } from "@expo/ui/jetpack-compose/modifiers"; +import { defaultMinSize, height, size, width } from "@expo/ui/jetpack-compose/modifiers"; import { View, type StyleProp, type ViewStyle } from "react-native"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { SymbolView, type AppSymbolName } from "./AppSymbol"; @@ -24,6 +25,8 @@ export function MaterialFloatingActionButton(props: { }) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const typography = useScaledTextRole("footnote"); + const { scale, iconSize: standardIconSize, fabSize, largeFabSize } = useAndroidControlSizing(); + const buttonSize = props.variant === "large" ? largeFabSize : fabSize; const primary = props.tone === "primary"; const containerColor = colors[primary ? "--color-primary" : "--color-secondary"]; const contentColor = @@ -34,7 +37,7 @@ export function MaterialFloatingActionButton(props: { : props.variant === "large" ? LargeFloatingActionButton : FloatingActionButton; - const iconSize = props.variant === "large" ? 36 : 24; + const iconSize = props.variant === "large" ? Math.round(36 * scale) : standardIconSize; return ( { if (!props.disabled) props.onPress?.(); }} - style={{ width: 48, height: 48 }} + style={{ width: buttonSize, height: buttonSize }} > diff --git a/apps/mobile/src/components/MaterialListRow.tsx b/apps/mobile/src/components/MaterialListRow.tsx index 539472e4bd96..cef104a02f50 100644 --- a/apps/mobile/src/components/MaterialListRow.tsx +++ b/apps/mobile/src/components/MaterialListRow.tsx @@ -5,6 +5,7 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear import { cn } from "../lib/cn"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; /** Shared geometry for Material navigation and selection lists. Group rows in one card. */ export function MaterialListRow({ @@ -23,6 +24,7 @@ export function MaterialListRow({ readonly trailing?: ReactNode; }) { const { themeVariables } = useAppearancePreferences(); + const { smallIconSize } = useAndroidControlSizing(); return ( + ) : null} ); diff --git a/apps/mobile/src/components/MaterialMenuPopup.android.tsx b/apps/mobile/src/components/MaterialMenuPopup.android.tsx index c49db1815039..4f9fe3d1ada4 100644 --- a/apps/mobile/src/components/MaterialMenuPopup.android.tsx +++ b/apps/mobile/src/components/MaterialMenuPopup.android.tsx @@ -7,9 +7,11 @@ import { RNHostView, Text, } from "@expo/ui/jetpack-compose"; -import { padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; +import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; import { View } from "react-native"; +import { resolveScaledTextRole } from "../lib/appearancePreferences"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import type { MaterialMenuPopupProps } from "./MaterialMenuPopup"; import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol"; @@ -19,12 +21,16 @@ function MenuIcon(props: { readonly destructive?: boolean; readonly disabled?: boolean; }) { + const { iconSize } = useAndroidControlSizing(); return ( - - + + {props.parent ? ( - + - + {props.parent.title} ) : props.title ? ( - + {props.title} ) : null} @@ -66,19 +81,22 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { props.onPress(action)} > + {action.image && isAppSymbolName(action.image) ? ( + + + + ) : null} {action.subtitle ? ( - + {action.subtitle} ) : null} - {action.image && isAppSymbolName(action.image) ? ( - - - - ) : null} {(action.subactions?.length ?? 0) > 0 ? ( @@ -124,7 +133,7 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { colorScheme={themeAppearance} ignoreSafeAreaKeyboardInsets matchContents - style={{ width: 250 }} + style={{ width: props.menuWidth }} > {items} diff --git a/apps/mobile/src/components/MaterialMenuPopup.tsx b/apps/mobile/src/components/MaterialMenuPopup.tsx index 3a23e5de7ab4..06019a0e1512 100644 --- a/apps/mobile/src/components/MaterialMenuPopup.tsx +++ b/apps/mobile/src/components/MaterialMenuPopup.tsx @@ -1,6 +1,7 @@ import type { MenuAction } from "@react-native-menu/menu"; export interface MaterialMenuPopupProps { + readonly menuWidth: number; readonly anchor: { readonly x: number; readonly y: number; diff --git a/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx index 018dbdc19b42..8c653e67ee9a 100644 --- a/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx @@ -1,9 +1,17 @@ import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose"; -import { fillMaxWidth, onSizeChanged, size } from "@expo/ui/jetpack-compose/modifiers"; +import { + defaultMinSize, + fillMaxWidth, + graphicsLayer, + height, + onSizeChanged, + size, +} from "@expo/ui/jetpack-compose/modifiers"; import { useCallback, useState } from "react"; import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { resolveScaledTextRole } from "../lib/appearancePreferences"; /** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */ export function MaterialScrollComposeButton(props: { @@ -12,14 +20,23 @@ export function MaterialScrollComposeButton(props: { readonly className?: string; readonly style?: StyleProp; }) { - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); - const typography = useScaledTextRole("footnote"); - const [expandedWidth, setExpandedWidth] = useState(56); + const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = resolveScaledTextRole("footnote", appearance.baseFontSize); + const { iconSize, fabSize } = useAndroidControlSizing(); + // Scale the native 56dp minimum; keep text and icons at their requested sizes. + const nativeSize = Math.max(56, fabSize); + const scale = fabSize / nativeSize; + const nativeIconSize = Math.round(iconSize / scale); + const [buttonWidth, setButtonWidth] = useState(nativeSize); const rememberWidth = useCallback(({ width }: { width: number }) => { - setExpandedWidth((previous) => Math.max(previous, width)); + setButtonWidth((previous) => Math.max(previous, width)); }, []); return ( - + - + @@ -45,7 +72,11 @@ export function MaterialScrollComposeButton(props: { New thread @@ -65,8 +96,9 @@ export function MaterialScrollComposeButton(props: { right: 0, top: 0, bottom: 0, - width: props.expanded ? expandedWidth : 56, - borderRadius: 16, + // Release the label area as soon as collapse starts, before native measurements arrive. + width: props.expanded ? buttonWidth * scale : fabSize, + borderRadius: 16 * scale, overflow: "hidden", }} /> diff --git a/apps/mobile/src/components/MaterialSearchField.tsx b/apps/mobile/src/components/MaterialSearchField.tsx index ab96440f5f09..3609430a6e1a 100644 --- a/apps/mobile/src/components/MaterialSearchField.tsx +++ b/apps/mobile/src/components/MaterialSearchField.tsx @@ -2,6 +2,7 @@ import type { RefObject } from "react"; import { Pressable, TextInput, View } from "react-native"; import { SymbolView } from "./AppSymbol"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function MaterialSearchField({ inputRef, @@ -18,9 +19,21 @@ export function MaterialSearchField({ readonly value: string; readonly onChangeText: (value: string) => void; }) { + const { scale, mediumIconSize } = useAndroidControlSizing(); return ( - - + + @@ -49,7 +63,7 @@ export function MaterialSearchField({ > diff --git a/apps/mobile/src/components/ScreenHeader.android.tsx b/apps/mobile/src/components/ScreenHeader.android.tsx index 61a38c3a00c2..1d3232fd1641 100644 --- a/apps/mobile/src/components/ScreenHeader.android.tsx +++ b/apps/mobile/src/components/ScreenHeader.android.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout"; import { NativeStackScreenOptions } from "../native/StackHeader"; import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -11,10 +11,12 @@ import { ControlPillMenu } from "./ControlPill"; import { MaterialSearchField } from "./MaterialSearchField"; import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; import type { ScreenHeaderProps } from "./ScreenHeader.types"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function ScreenHeader(props: ScreenHeaderProps) { const { search } = props; - const insets = useSafeAreaInsets(); + const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); + const { scale, buttonSize, iconSize, smallIconSize } = useAndroidControlSizing(); const { themeVariables } = useAppearancePreferences(); const inputRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); @@ -48,11 +50,12 @@ export function ScreenHeader(props: ScreenHeaderProps) { @@ -80,30 +83,43 @@ export function ScreenHeader(props: ScreenHeaderProps) { <> {options} - + {props.onBack ? ( ) : null} - + @@ -112,9 +128,14 @@ export function ScreenHeader(props: ScreenHeaderProps) { autoCapitalize="none" onChangeText={search.onChangeText} value={search.value} - placeholder={search.placeholder} + placeholder={ + search.compactToolbar + ? (search.compactPlaceholder ?? search.placeholder) + : search.placeholder + } placeholderTextColorClassName="accent-placeholder" - className="flex-1 py-2 text-base font-sans text-header-foreground" + className="flex-1 text-base font-sans text-header-foreground" + style={{ paddingVertical: 7 * scale }} /> {menuView} @@ -173,11 +194,8 @@ export function ScreenHeader(props: ScreenHeaderProps) { {header} {searching ? ( - - + + void; readonly leading?: ReactNode; }) { - const insets = useSafeAreaInsets(); + const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; @@ -77,11 +77,8 @@ export function MaterialFilesHeader(props: { /> {searching ? ( - - + + ) { const insets = useSafeAreaInsets(); + const { appearance } = useAppearancePreferences(); + const { fontScale } = useWindowDimensions(); + const [layoutWidth, setLayoutWidth] = useState(null); const { state } = useWorkspaceState(); const [expanded, setExpanded] = useState(true); const scrollState = useRef({ anchor: 0, expanded: true }); @@ -23,11 +32,13 @@ export function AndroidHomeFabLayout(props: ComponentProps + setLayoutWidth(event.nativeEvent.layout.width)}> {props.children} - {state.hasConnections ? ( + {state.hasConnections && layoutWidth !== null ? ( (null); const insets = useSafeAreaInsets(); + const { fabClearance } = useAndroidControlSizing(); const iosBottomToolbarClearance = Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT @@ -983,7 +985,7 @@ export function HomeScreen(props: HomeScreenProps) { paddingBottom: Platform.OS === "ios" ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance - : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), + : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88), }} /> diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 2d852f8f7be2..6cea625aced9 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -12,7 +12,8 @@ import { MaterialSearchField } from "../../components/MaterialSearchField"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; import { useWorkspaceState } from "../../state/workspace"; -import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; +import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; /** One toolbar height for the compact list and expanded sidebar, including search. */ export function MaterialThreadListToolbar(props: { @@ -28,7 +29,8 @@ export function MaterialThreadListToolbar(props: { readonly onRequestVisibility?: () => void; }) { const insets = useSafeAreaInsets(); - const toolbarHeight = useMaterialToolbarHeight(); + const { fabSize } = useAndroidControlSizing(); + const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout(); const { state } = useWorkspaceState(); const { onRequestVisibility, onSearchQueryChange } = props; const searchRef = useRef(null); @@ -76,11 +78,9 @@ export function MaterialThreadListToolbar(props: { {searching ? ( @@ -116,14 +116,14 @@ export function MaterialThreadListToolbar(props: { )} - {/* Sit 8dp above the 56dp extended New thread FAB. */} + {/* Keep the filter above the New thread FAB at every text size. */} {state.hasConnections ? ( diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index 9b9333b46c7f..d2b068a07c59 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,9 +1,10 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { useEffect, useRef, useState, type ReactNode } from "react"; -import { ActivityIndicator, Animated, Pressable, View } from "react-native"; +import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import { brandTitleOffset, CompactBrandTitle, @@ -105,6 +106,7 @@ export function WorkspaceConnectionTitle(props: { }) { const status = useDelayedConnectionStatus(); const size = props.size ?? "navbar"; + const { scale } = useAndroidControlSizing(); if (status === null) { return props.grow ? ( @@ -126,26 +128,28 @@ export function WorkspaceConnectionTitle(props: { hitSlop={8} onPress={props.onPress} className="flex-row items-center gap-2" - style={{ flexShrink: 1, marginLeft: props.statusOffset ?? 0 }} + style={[ + { flexShrink: 1, marginLeft: props.statusOffset ?? 0 }, + Platform.OS === "android" && { gap: 7 * scale }, + ]} > {status.showsProgress ? ( - + ) : ( )} {status.label} diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts index 62f918a0e6b0..561cc53e44a7 100644 --- a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts +++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts @@ -1,10 +1,6 @@ import { useMemo } from "react"; -import { - DEFAULT_BASE_FONT_SIZE, - normalizeBaseFontSize, - scaledTypographyLineHeight, -} from "../../../lib/appearancePreferences"; +import { resolveScaledTextRole } from "../../../lib/appearancePreferences"; import { MOBILE_TYPOGRAPHY } from "../../../lib/typography"; import { useAppearancePreferences } from "./AppearancePreferencesProvider"; @@ -20,15 +16,8 @@ export interface ScaledTextRole { */ export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole { const { appearance } = useAppearancePreferences(); - return useMemo(() => { - const baseFontSize = normalizeBaseFontSize(appearance.baseFontSize); - const typography = MOBILE_TYPOGRAPHY[role]; - return { - fontSize: Math.max( - 8, - Math.round(typography.fontSize * (baseFontSize / DEFAULT_BASE_FONT_SIZE)), - ), - lineHeight: scaledTypographyLineHeight(typography, baseFontSize), - }; - }, [appearance.baseFontSize, role]); + return useMemo( + () => resolveScaledTextRole(role, appearance.baseFontSize), + [appearance.baseFontSize, role], + ); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 681f6c5ee52d..7e90b0743374 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -132,30 +132,40 @@ import { fileRoutePathSegments } from "../files/filePath"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; readonly worktreePath: string | null; + readonly size: number; }) { if (props.workspaceMode === "local" && props.worktreePath === null) { return ( ); } + const boxSize = (14 * props.size) / 16; return ( - + - + @@ -1476,13 +1486,13 @@ export function NewTaskDraftScreen(props: { accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`} chevronDirection="right" disabled={isComposerInteractionLocked || voiceInput.isBusy} - iconNode={ + renderIcon={(size) => ( - } + )} label={`on ${selectedEnvironmentLabel}`} maxWidth={260} onPress={ @@ -1517,12 +1527,13 @@ export function NewTaskDraftScreen(props: { accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} accessibilityLabel={workspaceLabel} disabled={isComposerInteractionLocked || voiceInput.isBusy} - iconNode={ + renderIcon={(size) => ( - } + )} label={workspaceLabel} maxWidth={flow.workspaceMode === "local" ? 220 : 148} onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} @@ -1681,12 +1692,12 @@ export function NewTaskDraftScreen(props: { accessibilityLabel="Model and reasoning settings" disabled={isComposerInteractionLocked} emphasized - iconNode={ + renderIcon={(size) => ( - } + )} label={flow.selectedModelOption?.label ?? "Choose model"} maxWidth="100%" onPress={settingsSheetPresentation.open} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 23ddc65896b9..d131dfaa51f1 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -961,9 +961,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer - } + renderIcon={(size) => ( + + )} label={currentModelOption?.label ?? currentModelSelection.model} maxWidth="100%" onPress={openSettings} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index c478bae6312a..d9e165bd0107 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,3 +1,4 @@ +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { computeThreadMoveAvailability } from "./threadOrder"; import type { @@ -50,7 +51,7 @@ import { } from "../home/WorkspaceConnectionTitle"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar"; -import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; +import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; @@ -133,6 +134,7 @@ function ThreadNavigationSidebarPane( const drawerColor = materialTheme["--color-drawer"]; const insets = useSafeAreaInsets(); + const { fabClearance } = useAndroidControlSizing(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -603,14 +605,14 @@ function ThreadNavigationSidebarPane( ); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); - const materialToolbarHeight = useMaterialToolbarHeight(); + const { height, paddingTop, paddingBottom } = useMaterialToolbarLayout(); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no // hardcoded per-variant constants. const stickyHeaderHeight = measuredHeaderHeight ?? (Platform.OS === "android" - ? Math.max(insets.top, 12) + materialToolbarHeight + 8 + ? paddingTop + height + paddingBottom : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT); const topListInset = stickyHeaderHeight + 6; const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { @@ -1039,7 +1041,7 @@ function ThreadNavigationSidebarPane( { paddingBottom: Platform.OS === "android" - ? Math.max(insets.bottom, 16) + 148 - insets.bottom + ? Math.max(insets.bottom, 16) + fabClearance - insets.bottom : 16 + insets.bottom, paddingTop: Platform.OS === "android" ? 6 : topListInset, }, diff --git a/apps/mobile/src/lib/androidControlSizing.test.ts b/apps/mobile/src/lib/androidControlSizing.test.ts new file mode 100644 index 000000000000..8b9fd9751cbb --- /dev/null +++ b/apps/mobile/src/lib/androidControlSizing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAndroidControlSizing } from "./androidControlSizing"; + +describe("Android control sizing", () => { + it.each([ + [11, 17, 48, 48, 172, 48], + [16, 24, 48, 56, 250, 48], + [22, 33, 66, 77, 344, 66], + ])( + "scales controls at %ipt", + (fontSize, iconSize, buttonSize, fabSize, menuWidth, menuItemHeight) => { + expect(resolveAndroidControlSizing(fontSize)).toMatchObject({ + iconSize, + buttonSize, + fabSize, + menuWidth, + menuItemHeight, + }); + }, + ); +}); diff --git a/apps/mobile/src/lib/androidControlSizing.ts b/apps/mobile/src/lib/androidControlSizing.ts new file mode 100644 index 000000000000..f6868ad07bdd --- /dev/null +++ b/apps/mobile/src/lib/androidControlSizing.ts @@ -0,0 +1,23 @@ +import { DEFAULT_BASE_FONT_SIZE, normalizeBaseFontSize } from "./appearancePreferences"; + +/** Android controls follow the app's text size; buttons and menu rows retain a 48dp touch target. */ +export function resolveAndroidControlSizing(baseFontSize: number) { + const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; + const iconSize = Math.round(24 * scale); + const buttonSize = Math.max(48, Math.round(48 * scale)); + const fabSize = Math.max(48, Math.round(56 * scale)); + + return { + scale, + iconSize, + smallIconSize: Math.round(16 * scale), + mediumIconSize: Math.round(18 * scale), + buttonSize, + fabSize, + largeFabSize: Math.round(96 * scale), + menuWidth: Math.round(250 * scale), + menuItemHeight: Math.max(48, Math.round(48 * scale)), + // Two floating actions, their gap, and the space below the lower action. + fabClearance: fabSize * 2 + 36, + }; +} diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index 2ce6a8b5a367..981b35808a54 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -219,6 +219,16 @@ export function scaledTypographyLineHeight( return Math.max(10, Math.round(role.lineHeight * scale)); } +/** Text dimensions shared by React Native and Compose consumers of an appearance role. */ +export function resolveScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY, baseFontSize: number) { + const typography = MOBILE_TYPOGRAPHY[role]; + const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; + return { + fontSize: Math.max(8, Math.round(typography.fontSize * scale)), + lineHeight: scaledTypographyLineHeight(typography, baseFontSize), + }; +} + export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography { const fontSizes = resolveMarkdownFontSizes(baseFontSize); return { From a4f6078bebcb3283e53e1cea247257136ce82cc8 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Fri, 25 Sep 2026 13:43:35 -0600 Subject: [PATCH 06/73] fix(web): return focus to the composer after saving a citation note (#13450) --- .../components/ComposerPromptEditorTiptap.tsx | 55 +++++++++++++++++++ .../components/chat/AssistantCitationChip.tsx | 20 +++++++ 2 files changed, 75 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 74e8736f04e7..2a5fe2ba0588 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -29,6 +29,7 @@ import { useMemo, useRef, useState, + type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; @@ -371,6 +372,16 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { .run(); }, [editor, nodePos]); + // Put the caret right after the chip so Enter sends and typing continues the prompt. + const onRestoreFocus = useCallback(() => { + if (!editor.isEditable) return; + const pos = nodePos(); + if (pos === null) return; + const current = editor.state.doc.nodeAt(pos); + if (!current || current.type.name !== "composer-citation") return; + editor.commands.focus(pos + current.nodeSize); + }, [editor, nodePos]); + return ( ) => { + // Tab from the comment button returns to the caret after the chip. + if ( + !editor.isEditable || + event.key !== "Tab" || + event.shiftKey || + event.altKey || + event.metaKey || + event.ctrlKey || + !(event.target instanceof HTMLElement) || + event.target.dataset.citationCommentTrigger === undefined + ) { + return; + } + event.preventDefault(); + onRestoreFocus(); + }} > @@ -856,6 +885,32 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return true; } } + // Shift+Tab from just after a citation reaches its comment button, which + // native tab order skips because the chip lives inside the editor. + if ( + event.key === "Tab" && + event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + view.state.selection.empty + ) { + const { $from } = view.state.selection; + const citation = $from.nodeBefore; + if (citation?.type.name === "composer-citation") { + const chip = view.nodeDOM($from.pos - citation.nodeSize); + const commentButton = + chip instanceof HTMLElement + ? chip.querySelector("[data-citation-comment-trigger]") + : null; + if (commentButton) { + event.preventDefault(); + event.stopPropagation(); + commentButton.focus(); + return true; + } + } + } if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) { event.stopPropagation(); return true; diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 6baf14b18887..e03872553a88 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -39,10 +39,13 @@ export function AssistantCitationChip({ onCancel?: () => void; onSave: (comment: string) => boolean; onSaveAndSend?: (comment: string) => boolean; + /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */ + onRestoreFocus?: () => void; }; }) { const navigate = useNavigate(); const commentInputRef = useRef(null); + const commentPopupRef = useRef(null); const draftCommentRef = useRef(null); const [unavailableSourceAnchor, setUnavailableSourceAnchor] = useState(null); @@ -155,6 +158,7 @@ export function AssistantCitationChip({ > } > { + readonly value: A | undefined; + readonly warning?: string; +} + +/** + * Reads one variable. Blank reads as unset, and a value `parse` rejects warns + * without echoing it, since these variables carry credentials. + */ +const readOrWarn = ( + name: string, + parse: (raw: string) => Option.Option, + warning: string, +): Config.Config> => + Config.String(name).pipe( + Config.option, + Config.map((option): Setting => { + const raw = blankAsUnset(Option.getOrUndefined(option)); + if (raw === undefined) { + return { value: undefined }; + } + return Option.match(parse(raw), { + onNone: () => ({ value: undefined, warning }), + onSome: (value) => ({ value }), + }); + }), + ); + +const parseHttpUrl = (raw: string) => + Option.liftThrowable((value: string) => new URL(value))(raw).pipe( + Option.filter((url) => url.protocol === "http:" || url.protocol === "https:"), + ); + +const NOT_EXPORTED = "so the signals it configures are not exported"; + +const endpoint = (name: string) => + readOrWarn(name, parseHttpUrl, `${name} is not an http or https URL, ${NOT_EXPORTED}`); + +// The specification reads enum values case-insensitively. +const protocol = (name: string) => + readOrWarn( + name, + (raw) => Schema.decodeUnknownOption(OtlpProtocol)(raw.toLowerCase()), + `${name} is not http/protobuf or http/json, ${NOT_EXPORTED}`, + ); + +const headers = (name: string) => + readOrWarn( + name, + Schema.decodeUnknownOption(OtlpHeadersFromString), + `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`, + ); + +interface Settings { + readonly endpoint: Setting; + readonly protocol: Setting; + readonly headers: Setting>>; +} + +const settings = (prefix: string): Config.Config => + Config.all({ + endpoint: endpoint(`${prefix}ENDPOINT`), + protocol: protocol(`${prefix}PROTOCOL`), + headers: headers(`${prefix}HEADERS`), + }); + +/** The signal's own variable claims the signal once set, valid or not. */ +const isClaimed = (setting: Setting) => + setting.value !== undefined || setting.warning !== undefined; + +const claimed = (own: Setting, generic: Setting) => (isClaimed(own) ? own : generic); + +/** Appends the signal's path, keeping the query an intake may take its API key in. */ +const withSignalPath = (signal: OtlpSignalName, base: URL) => { + const url = new URL(base); + const slash = url.pathname.endsWith("/") ? "" : "/"; + url.pathname += `${slash}v1/${signal.toLowerCase()}`; + return url; +}; + +interface ResolvedSignal { + readonly signal: OtelSignal; + /** The settings this signal read, whose warnings are the signal's to report. */ + readonly used: ReadonlyArray>; +} + +/** + * A signal whose endpoint, protocol, or headers do not read is not exported + * rather than sent somewhere, in a format, or without the credentials its + * collector expects. + */ +const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { + const ownEndpoint = isClaimed(own.endpoint); + const endpoint = ownEndpoint ? own.endpoint : generic.endpoint; + if (endpoint.value === undefined) { + const signal = endpoint.warning === undefined ? OtelSignal.Unset() : OtelSignal.Off(); + return { signal, used: [endpoint] }; + } + const protocol = claimed(own.protocol, generic.protocol); + const headers = claimed(own.headers, generic.headers); + const used = [endpoint, protocol, headers]; + if (protocol.warning !== undefined || headers.warning !== undefined) { + return { signal: OtelSignal.Off(), used }; + } + const url = ownEndpoint ? endpoint.value : withSignalPath(name, endpoint.value); + return { + signal: OtelSignal.Export({ + url: url.toString(), + protocol: protocol.value ?? "http/protobuf", + headers: headers.value, + }), + used, + }; +}; + export const load: Effect.Effect = Config.all({ t3: flag( "T3CODE_OTEL_SDK_DISABLED", @@ -111,12 +259,31 @@ export const load: Effect.Effect = Config.all({ `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`, ), resource: resourceAttributes, + generic: settings("OTEL_EXPORTER_OTLP_"), + traces: settings("OTEL_EXPORTER_OTLP_TRACES_"), + metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"), + logs: settings("OTEL_EXPORTER_OTLP_LOGS_"), }).pipe( - Effect.map(({ t3, spec, resource }) => { + Effect.map(({ t3, spec, resource, generic, ...own }) => { const disabled = t3.value ?? spec.value ?? false; - const warnings = [t3.warning, spec.warning, resource.warning].filter( - (warning) => warning !== undefined, + // The kill switch wins outright, so the signals say nothing once it is set. + const signals = disabled + ? undefined + : { + traces: signal("TRACES", own.traces, generic), + metrics: signal("METRICS", own.metrics, generic), + logs: signal("LOGS", own.logs, generic), + }; + // A generic variable read by several signals warns once. + const used = new Set( + signals === undefined ? [] : Object.values(signals).flatMap((resolved) => resolved.used), ); + const warnings = [ + t3.warning, + spec.warning, + resource.warning, + ...Array.from(used, (setting) => setting.warning), + ].filter((warning) => warning !== undefined); if (disabled) { warnings.push( t3.value @@ -124,12 +291,58 @@ export const load: Effect.Effect = Config.all({ : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway", ); } - return { disabled, warnings, resourceAttributes: resource.value }; + return { + disabled, + warnings, + resourceAttributes: resource.value, + traces: signals?.traces.signal ?? OtelSignal.Unset(), + metrics: signals?.metrics.signal ?? OtelSignal.Unset(), + logs: signals?.logs.signal ?? OtelSignal.Unset(), + }; }), // Every read above falls back instead of failing, so this cannot happen. Effect.orDie, ); +export type SignalName = "traces" | "metrics" | "logs"; + +export interface SignalEndpoint { + readonly url: string; + readonly export: SignalExport; +} + +/** + * Where one signal exports and how. `T3CODE_OTLP_*_URL` wins outright with + * T3 Code's own export, then an OTEL endpoint with its own headers and + * protocol, since `T3CODE_OTLP_HEADERS` was written for a different + * collector, then the first of `fallbackUrls` with T3 Code's own export. + */ +export const resolveSignalEndpoint = ( + otel: OtelEnvironment, + signal: SignalName, + t3: { readonly url: string | undefined; readonly export: SignalExport }, + ...fallbackUrls: ReadonlyArray +): SignalEndpoint | undefined => { + if (otel.disabled) { + return undefined; + } + const t3Url = blankAsUnset(t3.url); + if (t3Url !== undefined) { + return { url: t3Url, export: t3.export }; + } + return OtelSignal.$match(otel[signal], { + Export: ({ url, protocol, headers }): SignalEndpoint => ({ + url, + export: { protocol, headers, exportIntervalMs: t3.export.exportIntervalMs }, + }), + Off: () => undefined, + Unset: () => { + const url = fallbackUrls.map(blankAsUnset).find((candidate) => candidate !== undefined); + return url === undefined ? undefined : { url, export: t3.export }; + }, + }); +}; + /** * Provide this around Effect's OTLP exporters, which read * `OTEL_RESOURCE_ATTRIBUTES` for themselves and die when it does not decode, @@ -154,4 +367,7 @@ export const none: OtelEnvironment = { disabled: false, warnings: [], resourceAttributes: {}, + traces: OtelSignal.Unset(), + metrics: OtelSignal.Unset(), + logs: OtelSignal.Unset(), }; From 1a0c915c41024ddb47e16befd9fc730c1173af02 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 13:09:30 -0700 Subject: [PATCH 08/73] fix(terminal): settling a thread closes its idle shells (#13673) Co-authored-by: Claude Opus 5.5 (1M context) --- .../OrchestrationEngineHarness.integration.ts | 2 + .../Layers/ProviderCommandReactor.test.ts | 75 ++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 15 ++-- .../src/project/AgentSessionImporter.test.ts | 2 + .../project/ProjectSetupScriptRunner.test.ts | 74 +++++++++++++++++- .../src/project/ProjectSetupScriptRunner.ts | 13 +++- apps/server/src/terminal/Manager.test.ts | 67 ++++++++++++++++ apps/server/src/terminal/Manager.ts | 77 ++++++++++++++++++- docs/user/thread-sidebar.md | 4 +- 9 files changed, 318 insertions(+), 11 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 0df54be2f701..1e116a91fea7 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -26,6 +26,7 @@ import * as Tracer from "effect/Tracer"; import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; import { TextGeneration } from "../src/textGeneration/TextGeneration.ts"; +import * as TerminalManager from "../src/terminal/Manager.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; @@ -340,6 +341,7 @@ export const makeOrchestrationIntegrationHarness = ( tryHandlePromptCommand: () => Effect.succeed(false), }), ), + Layer.provide(Layer.mock(TerminalManager.TerminalManager)({ closeIdle: () => Effect.void })), Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c7d9417bc75b..254aec4bde7e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -55,6 +55,7 @@ import { import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; +import { TerminalManager } from "../../terminal/Manager.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -307,6 +308,7 @@ describe("ProviderCommandReactor", () => { }), ); const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); + const closeIdleTerminals = vi.fn((_: { readonly threadId: string }) => Effect.void); const createWorktree = vi.fn( (input: { readonly refName: string; readonly path: string | null }) => Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), @@ -490,6 +492,7 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), + Layer.provideMerge(Layer.mock(TerminalManager)({ closeIdle: closeIdleTerminals })), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), @@ -621,6 +624,7 @@ describe("ProviderCommandReactor", () => { renameBranch, pruneWorktrees, createWorktree, + closeIdleTerminals, refreshStatus, generateBranchName, generateThreadTitle, @@ -4339,6 +4343,77 @@ describe("ProviderCommandReactor", () => { expect(thread?.settledOverride).toBe("settled"); expect(thread?.session?.status).toBe("stopped"); expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + expect(harness.closeIdleTerminals).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + }); }), ); + + effectIt.effect("closes idle terminals when a thread without a session settles", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const terminalsClosed = yield* Deferred.make(); + harness.closeIdleTerminals.mockImplementation(() => + Deferred.succeed(terminalsClosed, undefined).pipe(Effect.asVoid), + ); + + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-without-session"), + threadId: ThreadId.make("thread-1"), + }); + yield* Deferred.await(terminalsClosed); + yield* Effect.promise(() => harness.drain()); + + expect(harness.closeIdleTerminals).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + }); + expect(harness.stopSession).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect( + "keeps terminals when the thread is un-settled before its settle event runs", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = ThreadId.make("thread-1"); + const firstCloseStarted = yield* Deferred.make(); + const releaseFirstClose = yield* Deferred.make(); + harness.closeIdleTerminals.mockImplementationOnce(() => + Deferred.succeed(firstCloseStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstClose)), + ), + ); + + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-first"), + threadId, + }); + // The reactor is busy with the first settle while the user changes their mind. + yield* Deferred.await(firstCloseStarted); + yield* harness.engine.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-first"), + threadId, + reason: "user", + }); + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-second"), + threadId, + }); + yield* harness.engine.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-second"), + threadId, + reason: "user", + }); + yield* Deferred.succeed(releaseFirstClose, undefined); + yield* Effect.promise(() => harness.drain()); + + expect(harness.closeIdleTerminals).toHaveBeenCalledTimes(1); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bff5a6096421..f6794135a925 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -65,6 +65,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); @@ -222,6 +223,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const terminalManager = yield* TerminalManager.TerminalManager; /** Environment settings with the thread's project overrides applied. */ const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { const settings = yield* serverSettingsService.getSettings; @@ -1822,11 +1824,14 @@ const make = Effect.gen(function* () { return; case "thread.settled": { const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); - if ( - Option.isNone(thread) || - thread.value.session == null || - thread.value.session.status === "stopped" - ) { + // A thread re-engaged before this event ran keeps its shells and session. + if (Option.isNone(thread) || thread.value.settledOverride !== "settled") { + return; + } + // Idle shells close so they stop holding the worktree. A terminal that + // runs a command (a dev server, an editor) stays for the user to close. + yield* terminalManager.closeIdle({ threadId: event.payload.threadId }); + if (thread.value.session == null || thread.value.session.status === "stopped") { return; } yield* orchestrationEngine.dispatch({ diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 7d623c91b326..2438edca8b1b 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -56,6 +56,7 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr import { ServerSettingsService } from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { TerminalManager } from "../terminal/Manager.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; @@ -931,6 +932,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { Layer.provide(Layer.mock(GitWorkflowService)({})), Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), Layer.provide(Layer.mock(TextGeneration)({})), + Layer.provide(Layer.mock(TerminalManager)({ closeIdle: () => Effect.void })), Layer.provide(ServerSettingsService.layerTest()), ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index dd341a7f7859..650f304e8481 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -58,7 +58,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => }); type TerminalOverrides = Pick & - Partial>; + Partial>; const makeTerminalManagerLayer = (overrides: TerminalOverrides) => Layer.succeed(TerminalManager.TerminalManager, { @@ -67,6 +67,7 @@ const makeTerminalManagerLayer = (overrides: TerminalOverrides) => clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, + closeIdle: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), ...overrides, @@ -262,6 +263,7 @@ describe("ProjectSetupScriptRunner", () => { listener = null; }); }); + const closeIdle = vi.fn(() => Effect.void); const project = makeProject([ { id: "setup", @@ -329,14 +331,82 @@ describe("ProjectSetupScriptRunner", () => { ]); // The subscription is torn down once the sentinel arrives. expect(listener).toBeNull(); + // A failed run keeps its shell open for a look. + expect(closeIdle).not.toHaveBeenCalled(); }).pipe( - Effect.provide(testLayer(project, { open, write, subscribe })), + Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), ); }, ); + it.effect("closes the idle setup shell after a clean exit", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + let written = ""; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void (written = input.data)), + ); + let listener: ((event: TerminalEvent) => Effect.Effect) | null = null; + const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect) => { + listener = next; + return Effect.succeed(() => { + listener = null; + }); + }); + const closeIdle = vi.fn(() => Effect.void); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }); + if (result.status !== "started" || !result.completion) { + return yield* Effect.die("expected an observed setup run"); + } + const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0]; + yield* listener!({ + threadId: "thread-1", + terminalId: "setup-setup", + type: "output", + data: `${sentinel}0\r\n`, + }); + + expect((yield* result.completion).exitCode).toBe(0); + expect(closeIdle).toHaveBeenCalledWith({ threadId: "thread-1", terminalId: "setup-setup" }); + }).pipe( + Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), + ); + }); + it.effect("unsubscribes from terminal output when the command cannot be written", () => { const open = vi.fn(() => Effect.succeed({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 16cbfaa59496..74bc41e5e8cd 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -36,6 +36,7 @@ export interface ProjectSetupScriptRunnerResultStarted { * Resolves when the script's shell prints the completion sentinel. The * exit code is null when the terminal exited or was closed before the * sentinel arrived. Only present when `observeCompletion` was requested. + * An exit code of 0 closes the setup shell if it has nothing left running. */ readonly completion?: Effect.Effect; } @@ -412,6 +413,16 @@ export const make = Effect.gen(function* () { Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), ); + // A clean run leaves only an idle prompt behind; its output stays in the + // terminal history. A failed run keeps its shell open for a look. + const completion = observed?.completion.pipe( + Effect.tap(({ exitCode }) => + exitCode === 0 + ? terminalManager.closeIdle({ threadId: input.threadId, terminalId }) + : Effect.void, + ), + ); + return { status: "started", scriptId: script.id, @@ -420,7 +431,7 @@ export const make = Effect.gen(function* () { terminalId, cwd, async: script.async !== false, - ...(observed ? { completion: observed.completion } : {}), + ...(completion ? { completion } : {}), } as const; }); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 80ab2c43e42c..f04161cfbd3c 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1230,6 +1230,73 @@ it.layer( }), ); + it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () => + Effect.gen(function* () { + // FakePtyAdapter assigns pids from 9000 in open order. + const { manager, ptyAdapter } = yield* createManager(5, { + processTable: Effect.succeed([ + { pid: 9000, ppid: 1, name: "zsh" }, + // An async prompt worker: a copy of the shell with no children. + { pid: 100, ppid: 9000, name: "zsh" }, + { pid: 9001, ppid: 1, name: "zsh" }, + { pid: 200, ppid: 9001, name: "node" }, + { pid: 9002, ppid: 1, name: "zsh" }, + // A subshell with a child is real work. + { pid: 300, ppid: 9002, name: "zsh" }, + { pid: 301, ppid: 300, name: "sleep" }, + { pid: 9003, ppid: 1, name: "zsh" }, + ]), + }).pipe(Effect.provide(withHostPlatform("linux"))); + yield* manager.open(openInput({ terminalId: "idle" })); + yield* manager.open(openInput({ terminalId: "dev-server" })); + yield* manager.open(openInput({ terminalId: "subshell" })); + yield* manager.open(openInput({ threadId: "thread-2" })); + + yield* manager.closeIdle({ threadId: "thread-1" }); + + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([ + true, + false, + false, + false, + ]); + }), + ); + + it.effect("keeps terminals that get input or output while closeIdle checks them", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + // The typed command's process misses the snapshot, but its input or echo lands. + let duringCheck: (pid: number) => Effect.Effect = () => Effect.void; + const { manager, getEvents } = yield* createManager(5, { + ptyAdapter, + subprocessPollIntervalMs: 60_000, + subprocessInspector: (pid) => + duringCheck(pid).pipe( + Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + ), + }); + yield* manager.open(openInput({ terminalId: "typed" })); + yield* manager.open(openInput({ terminalId: "echoed" })); + const [typed, echoed] = ptyAdapter.processes; + duringCheck = (pid) => + pid === typed!.pid + ? manager + .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" }) + .pipe(Effect.orDie) + : Effect.gen(function* () { + echoed!.emitData("make build\r\n"); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "output")), + ); + }).pipe(Effect.orDie); + + yield* manager.closeIdle({ threadId: "thread-1" }); + + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]); + }), + ); + it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () => Effect.gen(function* () { const fallbackCalls: Array = []; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 1a65587430d5..75d592c00c0d 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -198,6 +198,17 @@ export class TerminalManager extends Context.Service< */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** + * Close a thread's terminals that wait at an idle shell prompt. A terminal + * that runs a command stays open. When `terminalId` is set, only that + * terminal is considered. Used when a thread settles and when a setup + * script finishes. + */ + readonly closeIdle: (input: { + readonly threadId: string; + readonly terminalId?: string; + }) => Effect.Effect; + /** * Subscribe to terminal runtime events with a direct callback. * @@ -275,6 +286,8 @@ interface TerminalSessionState { exitSignal: number | null; updatedAt: string; eventSequence: number; + /** Counts writes, so closeIdle can see input that has not echoed yet. */ + inputCount: number; cols: number; rows: number; process: PtyAdapter.PtyProcess | null; @@ -692,7 +705,17 @@ function deriveSubprocessInspectResult( terminalPid: number, platform: NodeJS.Platform, ): TerminalSubprocessInspectResult { - const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0]; + const commandName = (pid: number) => + normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform); + const shellName = commandName(terminalPid); + // Async prompt themes fork the shell into a helper that waits with no + // children of its own. That copy is not a command the user started. + const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find( + (pid) => + shellName === null || + commandName(pid) !== shellName || + (snapshot.childrenByParent.get(pid)?.length ?? 0) > 0, + ); if (childPid === undefined) { return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } @@ -707,7 +730,7 @@ function deriveSubprocessInspectResult( pending.push(pid); } } - const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform); + const normalized = commandName(childPid); return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, @@ -2537,6 +2560,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, + inputCount: 0, cols, rows, process: null, @@ -2877,6 +2901,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId, }); } + session.inputCount += 1; yield* Effect.try({ try: () => process.write(input.data), catch: (cause) => @@ -2958,6 +2983,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, + inputCount: 0, cols, rows, process: null, @@ -3042,6 +3068,52 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const running = (yield* sessionsForThread(input.threadId)).filter( + (session): session is TerminalSessionState & { pid: number } => + session.status === "running" && + Number.isInteger(session.pid) && + (input.terminalId === undefined || session.terminalId === input.terminalId), + ); + if (running.length === 0) return; + // A command started during the process check can miss the snapshot, + // but its input or echo still lands. Both counters only grow, so the + // sum changes when either one does. + const activityMark = (session: TerminalSessionState) => + session.eventSequence + session.inputCount; + const marks = new Map( + running.map((session) => [session.terminalId, activityMark(session)]), + ); + // Inspect now instead of trusting the last poll, so a command started + // since then keeps its terminal. + const { inspector } = yield* acquireSubprocessInspector; + yield* Effect.forEach( + running, + (session) => + inspector(session.pid).pipe( + Effect.flatMap((result) => + result.hasRunningSubprocess || + activityMark(session) !== marks.get(session.terminalId) + ? Effect.void + : closeSession(input.threadId, session.terminalId, false), + ), + ), + { discard: true }, + ); + }), + ).pipe( + // The process check failed, so every terminal stays open. + Effect.catch((error) => + Effect.logWarning("failed to close idle terminals", { + threadId: input.threadId, + error: error.message, + }), + ), + ); + return TerminalManager.of({ open, attachStream, @@ -3050,6 +3122,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func clear, restart, close, + closeIdle, subscribe, subscribeMetadata, }); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 0d7cfb736774..4a19bac401b9 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -90,7 +90,9 @@ Choose **Settle thread** from its menu to move finished work out of the active l without deleting the conversation. **Un-settle thread** restores it to active work and prevents automatic settlement until new activity resumes the usual rules. Manually settling an idle thread dismisses unanswered async questions without -sending an answer or restarting the agent. +sending an answer or restarting the agent. Settling also closes the thread's +terminals that wait at an idle prompt, and keeps their output. A terminal that +runs a command, such as a dev server, stays open. By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle From f5bd2fddbf00021b29c1dbe32210240886806718 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 25 Sep 2026 15:17:22 -0500 Subject: [PATCH 09/73] fix(server): load Cursor keyring with createRequire (#13678) --- apps/server/src/provider/cursorCredentialStore.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/cursorCredentialStore.ts b/apps/server/src/provider/cursorCredentialStore.ts index c071e2a88578..9d7d1c3bccff 100644 --- a/apps/server/src/provider/cursorCredentialStore.ts +++ b/apps/server/src/provider/cursorCredentialStore.ts @@ -1,5 +1,9 @@ +import * as NodeModule from "node:module"; + const CACHE_MS = 5 * 60_000; +const requireForKeyring = NodeModule.createRequire(import.meta.url); + /** Share one Keychain request across usage history and limits in this server process. */ export function makeCachedCursorAccessTokenReader( read: () => Promise, @@ -24,6 +28,6 @@ export function makeCachedCursorAccessTokenReader( /** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { - const { AsyncEntry } = await import("@napi-rs/keyring"); + const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; }); From 4293433eccbe6c6661acd020e584ae5d2234bf1e Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:47:54 +0530 Subject: [PATCH 10/73] perf(server): avoid rereading unchanged files in review previews (#13395) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 38 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 5 +-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 4a5dff870fd2..47ca4e178700 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1449,6 +1449,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + for (const splitIndex of [false, true]) { + it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".gitattributes", "stable.txt filter=probe\n"); + yield* writeTextFile(cwd, "stable.txt", "unchanged\n"); + yield* writeTextFile( + cwd, + ".git/filter.cjs", + 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(cwd, ["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fs.utimes(path.join(cwd, "stable.txt"), 1_699_999_999.5, 1_699_999_999.5); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "cache stable file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + const indexPath = path.join(cwd, ".git", "index"); + yield* fs.utimes(indexPath, 1_700_000_000, 1_700_000_000); + const originalIndex = yield* fs.readFile(indexPath); + const originalMtime = (yield* fs.stat(indexPath)).mtime; + yield* writeTextFile(cwd, ".git/filter-runs", ""); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + assert.deepStrictEqual( + preview.sources.find((source) => source.kind === "working-tree")!.files, + [{ path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }], + ); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/filter-runs")), ""); + assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); + }), + ); + } + for (const [timestamp, splitIndex] of [ [1_700_000_000, false], [1_700_000_000.9999, false], diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b15cd28b4e92..8ec274a46611 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2358,9 +2358,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (indexExists) { const { mtime } = yield* fileSystem.stat(indexPath); yield* fileSystem.copyFile(indexPath, tempIndexPath); - // A newer copy timestamp hides racily clean edits. Round down before Git reads or rewrites it. + // Node FileSystem.stat truncates bigint timestamps to milliseconds before creating its Date. + // Flooring preserves the source second without making preceding-second files racy. const indexTime = Option.isSome(mtime) - ? Math.max(0, Math.floor((mtime.value.getTime() - 1) / 1000)) + ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) : 0; yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); } From 2598ce7a5ab0b25e32e0b67b084a9ec23cd8144f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 16:42:12 -0400 Subject: [PATCH 11/73] feat(desktop): honor the standard OTLP variables in the main process and WSL backends (#13641) Signed-off-by: Yordis Prieto --- .../src/app/DesktopObservability.test.ts | 105 ++++++++++++++++-- apps/desktop/src/app/DesktopObservability.ts | 64 +++++++---- .../DesktopBackendConfiguration.test.ts | 76 ++++++++++++- .../backend/DesktopBackendConfiguration.ts | 40 ++++--- docs/operations/observability.md | 23 ++-- 5 files changed, 254 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 215998631e92..0e4c4d7a969d 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -93,6 +93,9 @@ const collectorLayer = (requests: Array) => ), ); +// A developer's own OTEL_* variables would otherwise pick the endpoints. +const emptyEnv = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); + const encodeObservabilitySettingsFile = Schema.encodeSync( Schema.fromJsonString( Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }), @@ -182,7 +185,7 @@ describe("DesktopObservability", () => { assert.isFalse(yield* fileSystem.exists(logPath)); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -260,7 +263,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -300,7 +303,7 @@ describe("DesktopObservability", () => { assert.equal(records.at(-1)?.annotations.details, "code=1"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -344,7 +347,7 @@ describe("DesktopObservability", () => { assert.isFalse(text.includes("y")); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -378,7 +381,7 @@ describe("DesktopObservability", () => { assert.equal(lines.length, 258); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -424,7 +427,93 @@ describe("DesktopObservability", () => { assert.lengthOf(record?.events ?? [], 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), + ); + }); + + it.effect("exports to an OTEL endpoint over Settings, with its own headers and protocol", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_HEADERS: "x-scope=desktop", + }); + yield* writeObservabilitySettings(environmentLayer, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop otel export").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 1); + const [request] = requests; + assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); + assert.strictEqual(request?.headers["x-otel"], "desktop"); + assert.strictEqual(request?.headers["x-scope"], undefined); + assert.strictEqual(request?.headers["content-type"], "application/json"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json", + }, + }), + ), + ), + ), + ); + }); + + it.effect("exports nothing to Settings for logs an unusable OTEL endpoint claimed", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_HEADERS: "x-scope=desktop", + }); + yield* writeObservabilitySettings(environmentLayer, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop otel off").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc", + }, + }), + ), + ), + ), ); }); @@ -495,7 +584,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); @@ -519,7 +608,7 @@ describe("DesktopObservability", () => { assert.lengthOf(requests, 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index d39b517759ad..0bcb4d602102 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -3,6 +3,7 @@ import { makeLocalFileTracer, makeTraceSink, otlpSerializationLayer, + type SignalExport, } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { @@ -350,8 +351,10 @@ const readPersistedObservabilitySettings: Effect.Effect< }); /** - * Settings is read once for every signal, so the main process cannot - * resolve traces against one revision of the file and logs against another. + * Resolved as the server resolves them, with persisted Settings as the + * fallback. Settings is read once for every signal, so the main process + * cannot resolve traces against one revision of the file and logs against + * another. */ const resolveOtlpEndpoints = Effect.gen(function* () { const otel = yield* OtelEnvironment.load; @@ -367,10 +370,30 @@ const resolveOtlpEndpoints = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedObservabilitySettings; + const signalExport: SignalExport = { + protocol: environment.otlpProtocol, + headers: Option.getOrUndefined(environment.otlpHeaders), + exportIntervalMs: environment.otlpExportIntervalMs, + }; return { - traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl, - metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl, - logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl, + traces: OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: Option.getOrUndefined(environment.otlpTracesUrl), export: signalExport }, + persisted.otlpTracesUrl, + ), + metrics: OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: Option.getOrUndefined(environment.otlpMetricsUrl), export: signalExport }, + persisted.otlpMetricsUrl, + ), + logs: OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: Option.getOrUndefined(environment.otlpLogsUrl), export: signalExport }, + persisted.otlpLogsUrl, + ), warnings: otel.warnings, resourceAttributes: otel.resourceAttributes, }; @@ -602,8 +625,6 @@ const telemetryLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; - const headers = Option.getOrUndefined(environment.otlpHeaders); - const serializationLayer = otlpSerializationLayer(environment.otlpProtocol); const resource = { serviceName: "desktop", attributes: { @@ -629,14 +650,19 @@ const telemetryLayer = Layer.unwrap( : [ Logger.consolePretty(), OtlpLogger.make({ - url: endpoints.logs, - exportInterval: `${environment.otlpExportIntervalMs} millis`, - headers, + url: endpoints.logs.url, + exportInterval: `${endpoints.logs.export.exportIntervalMs} millis`, + headers: endpoints.logs.export.headers, resource, }), ], { mergeWithExisting: false }, - ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provide(serializationLayer)); + ).pipe( + Layer.provide(OtlpExporter.layerFlusher), + Layer.provide( + otlpSerializationLayer(endpoints.logs?.export.protocol ?? environment.otlpProtocol), + ), + ); const tracerLayer = Layer.unwrap( Effect.gen(function* () { @@ -651,11 +677,11 @@ const telemetryLayer = Layer.unwrap( endpoints.traces === undefined ? undefined : yield* OtlpTracer.make({ - url: endpoints.traces, - exportInterval: `${environment.otlpExportIntervalMs} millis`, - headers, + url: endpoints.traces.url, + exportInterval: `${endpoints.traces.export.exportIntervalMs} millis`, + headers: endpoints.traces.export.headers, resource, - }).pipe(Effect.provide(serializationLayer)); + }).pipe(Effect.provide(otlpSerializationLayer(endpoints.traces.export.protocol))); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, @@ -679,11 +705,11 @@ const telemetryLayer = Layer.unwrap( // endpoints.metrics === undefined // ? Layer.empty // : OtlpMetrics.layer({ - // url: endpoints.metrics, - // exportInterval: `${environment.otlpExportIntervalMs} millis`, - // headers, + // url: endpoints.metrics.url, + // exportInterval: `${endpoints.metrics.export.exportIntervalMs} millis`, + // headers: endpoints.metrics.export.headers, // resource, - // }).pipe(Layer.provide(serializationLayer)); + // }).pipe(Layer.provide(otlpSerializationLayer(endpoints.metrics.export.protocol))); // Logged once the loggers above are installed, so the warnings use them. const otelWarningsLayer = Layer.effectDiscard( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 8d297750f784..07644a8af47c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -780,12 +780,11 @@ describe("DesktopBackendConfiguration", () => { const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); // No settings.json exists here: the endpoints come from the desktop - // process's env, which a WSL child cannot inherit, so the bootstrap - // has to carry them or log export stays off inside the distro. + // environment, and the bootstrap carries them for a WSL child that + // lacks the variables. assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL"); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( @@ -996,6 +995,73 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect( + "resolveWsl carries the standard OTLP endpoint, headers, and protocol into the distro", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const standard = { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com:4318/base?api_key=secret", + OTEL_EXPORTER_OTLP_LOGS_HEADERS: "authorization=Bearer%20token", + T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces", + }; + const previousWslEnv = process.env.WSLENV; + // A developer's own OTLP variables would be forwarded too. + const ambientOtel = Object.entries(process.env).filter( + ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"), + ); + try { + for (const [name] of ambientOtel) delete process.env[name]; + delete process.env.WSLENV; + Object.assign(process.env, standard); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal( + config.env.OTEL_EXPORTER_OTLP_ENDPOINT, + "https://collector.example.com:4318/base?api_key=secret", + ); + assert.equal( + config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, + "authorization=Bearer%20token", + ); + // Without a flag, WSL passes the values through untranslated. + const wslEnv = (config.env.WSLENV ?? "").split(":"); + assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT"); + assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS"); + assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://t3.example.com:4318/v1/traces"); + assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + for (const name of Object.keys(standard)) delete process.env[name]; + restoreEnv("WSLENV", previousWslEnv); + for (const [name, value] of ambientOtel) restoreEnv(name, value); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1008,7 +1074,10 @@ describe("DesktopBackendConfiguration", () => { const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; + // A developer's own OTEL_* variables would be forwarded too. + const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_")); try { + for (const [name] of ambientOtel) delete process.env[name]; process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; @@ -1065,6 +1134,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); + for (const [name, value] of ambientOtel) restoreEnv(name, value); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d400c7a3a878..b3bec8830ec6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -90,9 +90,8 @@ const DESKTOP_BACKEND_ENV_NAMES = [ ] as const; // Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. The dev-server URL is handled -// separately via a `--dev-url` CLI flag because WSLENV translation of -// URL-shaped values (colons / slashes) is unreliable. +// across the wsl.exe boundary without WSLENV. The dev-server URL travels as +// the `--dev-url` CLI flag instead. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -101,6 +100,24 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_SDK_DISABLED", "T3CODE_OTLP_HEADERS", "T3CODE_OTLP_PROTOCOL", + // Forwarded without a WSLENV flag, so the values arrive untranslated. The + // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs + // travel as variables to keep winning inside the distro as they do on Windows. + "T3CODE_OTLP_TRACES_URL", + "T3CODE_OTLP_METRICS_URL", + "T3CODE_OTLP_LOGS_URL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; @@ -221,12 +238,11 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { }; }); -// The bootstrap is the only channel that carries an OTLP endpoint to every -// backend. A Windows-native child inherits the desktop process's env, but a -// WSL child gets nothing across wsl.exe that WSLENV does not declare, and -// WSLENV translation of URL-shaped values is unreliable, so the endpoints are -// deliberately not forwarded that way. Env beats the persisted settings file, -// matching the precedence resolveServerConfig and DesktopObservability apply. +// The bootstrap carries the OTLP endpoints to every backend, including a WSL +// child that lacks the variables. The T3 URLs also travel as variables in +// WSL_FORWARDED_ENV_NAMES so they outrank a forwarded OTEL endpoint. Env beats +// the persisted settings file, matching the precedence resolveServerConfig and +// DesktopObservability apply. const readBackendObservabilitySettings = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedBackendObservabilitySettings; @@ -742,10 +758,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl }; // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. Inheriting through - // WSLENV is unreliable in practice (URL-shaped values with colons / - // slashes get translated unpredictably depending on flags), and the - // packaged build leaves devServerUrl as None anyway. + // config resolution lands in dev/ instead of userdata/. The packaged build + // leaves devServerUrl as None. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 0bdb0eb0c0f8..abe82d680b97 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -552,17 +552,18 @@ OTLP export: `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. - `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` -The server also reads the standard `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic -`OTEL_EXPORTER_OTLP_ENDPOINT` (with `/v1/traces`, `/v1/metrics`, or `/v1/logs` appended), for a -collector expecting those instead. A non-blank `T3CODE_OTLP_*_URL` wins over either, and a -per-signal endpoint wins over the generic one for its signal. A blank value counts as unset. A -signal with an OTEL endpoint takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` and its protocol -from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`, read case-insensitively), and a -per-signal `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic -one for its signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint -that is not an `http` or `https` URL, a protocol other than `http/protobuf` or `http/json` such as -`grpc`, or headers that are not `key=value` pairs with percent-encoded values turn that signal's -export off with a startup warning, rather than sending it to the Settings endpoint. +The server and the desktop app also read the standard +`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic `OTEL_EXPORTER_OTLP_ENDPOINT` (with +`/v1/traces`, `/v1/metrics`, or `/v1/logs` appended), for a collector expecting those instead. A +non-blank `T3CODE_OTLP_*_URL` wins over either, and a per-signal endpoint wins over the generic one +for its signal. A blank value counts as unset. A signal with an OTEL endpoint takes its headers from +`OTEL_EXPORTER_OTLP_HEADERS` and its protocol from `OTEL_EXPORTER_OTLP_PROTOCOL` (default +`http/protobuf`, read case-insensitively), and a per-signal +`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic one for its +signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint that is not +an `http` or `https` URL, a protocol other than `http/protobuf` or `http/json` such as `grpc`, or +headers that are not `key=value` pairs with percent-encoded values turn that signal's export off +with a startup warning, rather than sending it to the Settings endpoint. If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. From ed809f7ad274ebccdfbf029ccfd7ff2582fd486a Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:29:42 -0700 Subject: [PATCH 12/73] fix(web): restore compact provider instance badges (#13700) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/chat/ModelPickerSidebar.tsx | 2 +- apps/web/src/components/chat/ProviderInstanceIcon.tsx | 2 +- apps/web/src/components/chat/ProviderModelPicker.test.tsx | 3 --- apps/web/src/components/chat/ProviderModelPicker.tsx | 2 +- apps/web/src/components/settings/ProviderInstanceCard.tsx | 2 +- apps/web/src/index.css | 7 ++++++- apps/web/src/lib/utils.ts | 2 +- 8 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7cbae330c318..4b8027c14108 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1965,7 +1965,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { showBadge={showInstanceBadge} // Glyph dims, badge stays saturated; offset matches the composer trigger. iconClassName="size-3.5 opacity-60" - badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-3xs" + badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-5xs" /> ) : null} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 80b06cbc680d..d043c053a073 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -210,7 +210,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ? "var(--background)" : "color-mix(in oklab, var(--muted) 30%, transparent)" } - {...(entry.accentColor ? { badgeClassName: "h-3 min-w-3 px-0.5 text-3xs" } : {})} + {...(entry.accentColor ? { badgeClassName: "h-3 min-w-3 px-0.5 text-5xs" } : {})} /> {showNewBadge ? ( diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 4f6d83eb92bf..6c36087a1e29 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -55,7 +55,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { {props.showBadge ? ( { ); expect(markup).toContain(">CP"); - expect(markup).toContain("size-4"); - expect(markup).toContain("h-3"); - expect(markup).toContain("text-3xs"); }); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index c24a3189435a..fbc178965378 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -250,7 +250,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { iconClassName={cn("size-4", props.activeProviderIconClassName)} indicatorBackground={props.instanceIndicatorBackground ?? "var(--contrast-input)"} badgeClassName={cn( - "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-3xs", + "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs", size === "xs" && "shadow-none", )} /> diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 4e2405c138b1..ff0546f5012d 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -570,7 +570,7 @@ export function ProviderInstanceCard({ showBadge={Boolean(accentColor)} className="size-5" iconClassName="size-4 text-foreground/80" - badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-3xs" + badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs" /> ) : FallbackIconComponent ? ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9caf42ca2d6c..1039f23b19b8 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -161,12 +161,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil @theme inline { /* Two steps below Tailwind's text-xs (12px) for dense UI: metadata rows, badges, counters, keyboard hints and diagnostics tables. 2xs is 11px, - 3xs is 10px and the floor of the scale. Registered with tailwind-merge in + 3xs is 10px. Provider icon overlays use 4xs (8px) and 5xs (7px) to fit + their smaller badges. Registered with tailwind-merge in lib/utils.ts so cn() treats them as font sizes, not colours. */ --text-2xs: 0.6875rem; --text-2xs--line-height: calc(1 / 0.6875); --text-3xs: 0.625rem; --text-3xs--line-height: calc(0.875 / 0.625); + --text-4xs: 0.5rem; + --text-4xs--line-height: 1; + --text-5xs: 0.4375rem; + --text-5xs--line-height: 1; /* Motion. drawer is the decelerating curve for surfaces that slide or pop in (composer context, pills). */ --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 43d55682e12d..ca9a3a6340c2 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -6,7 +6,7 @@ import { DraftId } from "../composerDraftStore"; // The theme's extra font sizes (index.css). Unregistered, tailwind-merge reads // text-2xs as a colour and drops it next to text-muted-foreground. -const twMerge = extendTailwindMerge({ extend: { theme: { text: ["2xs", "3xs"] } } }); +const twMerge = extendTailwindMerge({ extend: { theme: { text: ["2xs", "3xs", "4xs", "5xs"] } } }); export function cn(...inputs: CxOptions) { return twMerge(cx(inputs)); From 525af2d1ad4b6de847ea658c37a081fd7afe8ef6 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 25 Sep 2026 18:23:19 -0600 Subject: [PATCH 13/73] perf(mobile): lighter Home rows away from the viewport (#13702) --- apps/mobile/src/features/home/HomeScreen.tsx | 62 +++++++++++++++-- .../home/swipe-row-activation.test.ts | 68 +++++++++++++++++++ .../src/features/home/swipe-row-activation.ts | 62 +++++++++++++++++ .../features/home/thread-swipe-actions.tsx | 25 ++++++- .../features/threads/thread-list-v2-items.tsx | 5 ++ 5 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/features/home/swipe-row-activation.test.ts create mode 100644 apps/mobile/src/features/home/swipe-row-activation.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d1d108d3bed0..c57ac15b1ee5 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,7 +1,7 @@ import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import type { ThreadMoveDestination } from "../threads/threadOrder"; import { computeThreadMoveAvailability } from "../threads/threadOrder"; -import { LegendList } from "@legendapp/list/react-native"; +import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -18,7 +18,14 @@ import { import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, View } from "react-native"; +import { + ActivityIndicator, + Platform, + View, + type GestureResponderEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -59,6 +66,7 @@ import { sortHomeProjectScopes, type HomeProjectSortOrder, } from "./homeThreadList"; +import { createSwipeRowActivation } from "./swipe-row-activation"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; import { useMaterialFabScroll } from "./MaterialFabScrollContext"; @@ -266,8 +274,45 @@ export function HomeScreen(props: HomeScreenProps) { openSwipeableRef.current?.close(); }, []); const onMaterialFabScroll = useMaterialFabScroll(); + const listRef = useRef(null); + const swipeRowActivation = useMemo(() => createSwipeRowActivation(), []); + const activateVisibleRows = useCallback( + (rows: ReadonlyArray) => { + const state = listRef.current?.getState(); + if (state === undefined || !(state.end >= 0)) return; + swipeRowActivation.activate( + rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key), + ); + }, + [swipeRowActivation], + ); + // Status-bar, accessibility and programmatic scrolls never arm the scroll + // gate, so every scroll also activates the visible rows once it settles. + const activationTimerRef = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(activationTimerRef.current), []); + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + onMaterialFabScroll?.(event); + clearTimeout(activationTimerRef.current); + activationTimerRef.current = setTimeout( + () => activateVisibleRows(listRef.current?.getState().data ?? []), + 200, + ); + }, + [activateVisibleRows, onMaterialFabScroll], + ); + const trackListTouches = useCallback( + (event: GestureResponderEvent, started: boolean) => { + const { changedTouches, touches } = event.nativeEvent; + swipeRowActivation.trackTouches( + started ? changedTouches.map((touch) => touch.identifier) : [], + touches.map((touch) => touch.identifier), + ); + }, + [swipeRowActivation], + ); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: onMaterialFabScroll, + onScroll: handleListScroll, onScrollBeginDrag: handleScrollBeginDrag, }); @@ -657,6 +702,9 @@ export function HomeScreen(props: HomeScreenProps) { ); useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); + useEffect(() => { + if (swipeEnabled) activateVisibleRows(threadListV2Items); + }, [activateVisibleRows, swipeEnabled, threadListV2Items]); const renderV2Item = useCallback( ({ item }: { readonly item: ThreadListV2ListItem }) => { @@ -764,6 +812,7 @@ export function HomeScreen(props: HomeScreenProps) { onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} + activationKey={item.key} /> ); }, @@ -952,8 +1001,13 @@ export function HomeScreen(props: HomeScreenProps) { {/* Shared with the iPad sidebar: cells are reused across data rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated shell update) from re-rendering untouched rows. */} - + activateVisibleRows(threadListV2Items)} + onTouchStart={(event) => trackListTouches(event, true)} + onTouchEnd={(event) => trackListTouches(event, false)} + onTouchCancel={(event) => trackListTouches(event, false)} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} diff --git a/apps/mobile/src/features/home/swipe-row-activation.test.ts b/apps/mobile/src/features/home/swipe-row-activation.test.ts new file mode 100644 index 000000000000..43f8dcf0ca7e --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createSwipeRowActivation } from "./swipe-row-activation"; + +describe("createSwipeRowActivation", () => { + it("activates exactly the requested rows and notifies only on change", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + activation.subscribe(listener); + + activation.activate(["a", "b"]); + activation.activate(["b", "a"]); + + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("c")).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("defers changes while a finger is on the list so a press is never remounted", () => { + const activation = createSwipeRowActivation(); + activation.activate(["a"]); + + activation.trackTouches(["1"], ["1"]); + activation.activate(["b"]); + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + + activation.trackTouches([], []); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("b")).toBe(false); + expect(activation.isActive("c")).toBe(true); + }); + + it("ignores fingers that did not start on the list", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1", "2"]); + activation.activate(["a"]); + + // The list finger lifts while finger 2 stays on another control. + activation.trackTouches([], ["2"]); + expect(activation.isActive("a")).toBe(true); + }); + + it("drops a list finger whose end event never arrived", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1"]); + activation.activate(["a"]); + + activation.trackTouches(["2"], ["2"]); + activation.trackTouches([], []); + expect(activation.isActive("a")).toBe(true); + }); + + it("stops notifying after unsubscribe", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + const unsubscribe = activation.subscribe(listener); + unsubscribe(); + activation.activate(["a"]); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts new file mode 100644 index 000000000000..d56d12cba56f --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -0,0 +1,62 @@ +import { createContext, use, useSyncExternalStore } from "react"; + +/** + * Full swipe rows (pan gesture, animated actions, hidden action buttons) only + * exist around the viewport. Every other Home row renders a dormant frame that + * paints the same content with a fraction of the native views, so a row the + * list rebuilds while scrolling is cheap. The scroll gate already disables + * swipes while the list moves, so rows are activated once it rests. + */ +export function createSwipeRowActivation() { + let activeKeys = new Set(); + // Swapping a row's frame remounts it, which would cancel a press or long + // press in progress, so changes wait until every finger that started on the + // list has lifted. + const listTouches = new Set(); + let pendingKeys: ReadonlyArray | null = null; + const listeners = new Set<() => void>(); + const apply = (keys: ReadonlyArray) => { + if (keys.length === activeKeys.size && keys.every((key) => activeKeys.has(key))) return; + activeKeys = new Set(keys); + for (const listener of listeners) listener(); + }; + return { + subscribe(listener: () => void) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + isActive: (key: string) => activeKeys.has(key), + activate(keys: ReadonlyArray) { + if (listTouches.size > 0) pendingKeys = keys; + else apply(keys); + }, + /** + * `started` are touches that just began on the list; `onScreen` is every + * finger still down anywhere. A finger on another control never holds + * changes, and one whose end event went missing is dropped here. + */ + trackTouches(started: ReadonlyArray, onScreen: ReadonlyArray) { + for (const id of started) listTouches.add(id); + for (const id of listTouches) if (!onScreen.includes(id)) listTouches.delete(id); + if (listTouches.size > 0 || pendingKeys === null) return; + const keys = pendingKeys; + pendingKeys = null; + apply(keys); + }, + }; +} + +export type SwipeRowActivation = ReturnType; + +export const SwipeRowActivationContext = createContext(null); + +const subscribeNever = () => () => {}; + +/** Rows outside an activation provider (e.g. the iPad sidebar) stay live. */ +export function useSwipeRowDormant(key: string | undefined): boolean { + const activation = use(SwipeRowActivationContext); + return useSyncExternalStore( + activation?.subscribe ?? subscribeNever, + () => activation !== null && key !== undefined && !activation.isActive(key), + ); +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 44f3c36e6655..e62935c28861 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -40,6 +40,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; // Wide enough for the longest action label ("Unarchive"). @@ -129,11 +130,14 @@ const SwipeableScrollGateContext = createContext(true); export function SwipeableScrollGateProvider(props: { readonly enabled: boolean; + readonly activation?: SwipeRowActivation; readonly children: ReactNode; }) { return ( - {props.children} + + {props.children} + ); } @@ -260,13 +264,32 @@ interface ThreadSwipeableProps { * open/mid-drag state can't leak onto another row. */ readonly resetKey?: string; + /** Paints the row without swipe machinery; see swipe-row-activation. */ + readonly dormant?: boolean; readonly simultaneousWithExternalGesture?: ComponentProps< typeof ReanimatedSwipeable >["simultaneousWithExternalGesture"]; readonly threadTitle: string; } +const closeDormant = () => {}; + export function ThreadSwipeable(props: ThreadSwipeableProps) { + if (props.dormant) { + // Mirrors ReanimatedSwipeable's container and children views. + return ( + + + {props.children(closeDormant)} + + + ); + } // Recycled content gets fresh native and animation state. Late callbacks // from the previous row retain its action, never the replacement's action. return ; diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 31039388dc09..eb089b95060a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -35,6 +35,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; +import { useSwipeRowDormant } from "../home/swipe-row-activation"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { @@ -518,6 +519,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** List key checked against the Home swipe row activation. */ + readonly activationKey?: string; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< @@ -545,6 +548,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; + const dormant = useSwipeRowDormant(props.activationKey); const pr = useThreadPr(thread); @@ -1180,6 +1184,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> )} Date: Fri, 25 Sep 2026 19:27:37 -0500 Subject: [PATCH 14/73] fix(usage): price Cursor cache savings by base model (#13731) --- apps/server/src/usage/cursorUsageReader.ts | 13 +++++++++++++ apps/server/src/usage/usagePricing.test.ts | 21 +++++++++++++++++++++ apps/server/src/usage/usagePricing.ts | 10 +++++++--- apps/server/src/usage/usageTranscripts.ts | 5 +++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 81c680afff0b..1e07fcc7a825 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -17,6 +17,18 @@ function tokens(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; } +/** + * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`, + * `claude-fable-5-1-thinking-high`) to the base model's rate-table key. + * Grok resolves through xAI's first-party entry, which has no bare alias. + */ +export function cursorRateModel(model: string): string { + const base = model + .replace(/^cursor-/, "") + .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, ""); + return base.startsWith("grok-") ? `xai/${base}` : base; +} + export interface CursorAccountUsageReadResult { readonly accountKey: string | null; readonly records: readonly UsageRecord[]; @@ -232,6 +244,7 @@ export async function readCursorAccountUsage( provider: "cursor", timestampMs, model: event.model, + rateModel: cursorRateModel(event.model), sessionId, totals, reportedCostUsd, diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index ca340a44a64e..db4f68c2cb42 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; +import { cursorRateModel } from "./cursorUsageReader.ts"; import { cacheSavingsUsd, createOverrideRateTable, @@ -49,6 +50,26 @@ describe("usage pricing", () => { expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(1.5); }); + it("prices Cursor cache savings at the base model rate", () => { + const table = parseRateTable({ + "claude-fable-5-1": rate(10e-6, 1e-6), + "xai/grok-4.7": rate(2e-6, 0.5e-6), + "openrouter/x-ai/grok-4.7": rate(3e-6, 0.5e-6), + }); + const cursorRecord = (model: string) => ({ + ...record(model, 0.25), + rateModel: cursorRateModel(model), + }); + + expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9); + expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5); + expect(cacheSavingsUsd(table, cursorRecord("default"))).toBe(0); + expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toEqual({ + costUsd: 0.25, + costSource: "providerReported", + }); + }); + it("prices unknown models offline and uses input prices for omitted cache rates", () => { const table = parseRateTable({}); const overrides = createOverrideRateTable({ diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 60bf31b93e31..78f6cf2c5cd9 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -184,7 +184,10 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null { } /** The parts of a transcript record that decide its price. */ -export type PricedRecord = Pick; +export type PricedRecord = Pick< + UsageRecord, + "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd" +>; export interface PricedUsage { readonly costUsd: number; @@ -208,7 +211,7 @@ export function priceUsage( return { costUsd: reportedCostUsd, costSource: "providerReported" }; } - const rate = override ?? lookupRate(table, model); + const rate = override ?? lookupRate(table, record.rateModel ?? model); if (rate === null) return { costUsd: 0, costSource: "unpriced" }; const standardCostUsd = @@ -232,7 +235,8 @@ export function cacheSavingsUsd( record: PricedRecord, overrides?: RateTable, ): number { - const rate = overrides?.get(record.model.trim()) ?? lookupRate(table, record.model); + const rate = + overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model); if (rate === null) return 0; return ( record.totals.cachedInputTokens * diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 5da13168a1af..6e01c2c5a8ed 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -12,6 +12,11 @@ export interface UsageRecord { readonly provider: UsageProviderKind; readonly timestampMs: number; readonly model: string; + /** + * Rate-table key when the provider's display name carries tiers the table + * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`. + */ + readonly rateModel?: string; readonly sessionId: string; readonly totals: UsageTokenTotals; readonly reportedCostUsd: number | null; From 595a1e1f537f403eba408817c2aba35e61c2ac29 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 25 Sep 2026 18:35:33 -0600 Subject: [PATCH 15/73] perf(mobile): render Home rows further ahead while scrolling (#13705) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/features/home/HomeScreen.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index c57ac15b1ee5..75be079f0de8 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -138,6 +138,10 @@ interface HomeScreenProps { // measured-height pool expansion. The old tallest-card estimate (~92) fired // that warning on every ordinary shelf expand, so the average wins. const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; +// Rows away from the viewport are cheap dormant frames (see +// swipe-row-activation), so render further ahead: a fast fling then reaches +// rows that are already built instead of rows still being rebuilt. +const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android @@ -1014,7 +1018,7 @@ export function HomeScreen(props: HomeScreenProps) { getItemType={(item) => item.type} itemsAreEqual={threadListV2ListItemsAreEqual} estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} - drawDistance={500} + drawDistance={THREAD_LIST_V2_DRAW_DISTANCE} recycleItems extraData={v2ExtraData} ListHeaderComponent={v2ListHeader} From 2a9832b8019039f812706639c11ccc24ce4c4e05 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 25 Sep 2026 20:49:17 -0400 Subject: [PATCH 16/73] refactor(observability): name each service after its application (#13699) Signed-off-by: Yordis Prieto --- .../src/app/DesktopObservability.test.ts | 43 +++++++++++++++++++ apps/desktop/src/app/DesktopObservability.ts | 3 +- .../src/features/observability/tracing.ts | 2 +- apps/server/src/bin.test.ts | 1 - apps/server/src/cli/config.test.ts | 1 - apps/server/src/cli/config.ts | 2 - apps/server/src/cli/pair.ts | 1 - apps/server/src/cloud/relayTracing.ts | 4 +- apps/server/src/config.ts | 5 +-- .../src/environment/ServerEnvironment.test.ts | 1 - apps/server/src/persistence/Layers/Sqlite.ts | 2 +- apps/server/src/server.test.ts | 11 +++-- apps/server/src/serverLogger.test.ts | 31 ++++++++++++- apps/web/src/lib/runtime.ts | 2 +- apps/web/src/observability/clientTracing.ts | 3 +- docs/operations/observability.md | 24 ++++++----- infra/relay/src/observability.test.ts | 9 ++++ infra/relay/src/observability.ts | 3 +- packages/shared/src/relayTracing.test.ts | 2 + packages/shared/src/relayTracing.ts | 1 + 20 files changed, 116 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 0e4c4d7a969d..e23d78aa2161 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -477,6 +477,49 @@ describe("DesktopObservability", () => { ); }); + it.effect("keeps its service name while OTEL resource attributes add dimensions", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop service name").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 1); + const body = requests[0]?.body ?? ""; + assert.include(body, '"stringValue":"t3code-desktop"'); + assert.include(body, "deployment.environment.name"); + assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); + assert.notInclude(body, "renamed"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_SERVICE_NAME: "renamed", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + }, + }), + ), + ), + ), + ); + }); + it.effect("exports nothing to Settings for logs an unusable OTEL endpoint claimed", () => { const requests: Array = []; return Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 0bcb4d602102..ce8233e56612 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -626,8 +626,9 @@ const telemetryLayer = Layer.unwrap( const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; const resource = { - serviceName: "desktop", + serviceName: "t3code-desktop", attributes: { + "service.namespace": "t3code", "service.runtime": "desktop", "service.mode": environment.isDevelopment ? "development" : "packaged", }, diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts index eb73abba292b..ae204413e777 100644 --- a/apps/mobile/src/features/observability/tracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -25,7 +25,7 @@ export function resolveTracingConfig(): TracingConfig | null { export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) { return makeRelayClientTracingLayer(config, { - serviceName: "t3-mobile-relay-client", + serviceName: "t3code-mobile", serviceVersion: resource.serviceVersion, runtime: "react-native", client: `mobile-${resource.appVariant}`, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 31a96304c3e5..aecf82eeac67 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -106,7 +106,6 @@ const makeCliTestServerConfig = (baseDir: string) => otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "web", port: 0, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 6b42756ad175..ef7cdd578899 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -56,7 +56,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, devAllowedOrigins: [], } as const; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 9c1ceac82101..d724730c953f 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -108,7 +108,6 @@ const EnvServerConfig = Config.all({ otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), - otlpServiceName: Config.String("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -431,7 +430,6 @@ export const resolveServerConfig = ( otlpTracesExport: traces?.export ?? signalExport, otlpMetricsExport: metrics?.export ?? signalExport, otlpLogsExport: logs?.export ?? signalExport, - otlpServiceName: env.otlpServiceName, otelEnvironment: otel, mode, port, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 6b29ef60927f..04ce0332c14c 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -326,7 +326,6 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "web", port: state.port, diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index e35c94545a5e..eeea28a2b68f 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -7,14 +7,14 @@ const relayClientTracingConfig = resolveRelayClientTracingConfig(); export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer( relayClientTracingConfig, { - serviceName: "t3-headless-relay-client", + serviceName: "t3code-server", runtime: "node", client: "headless-cli", }, ); export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, { - serviceName: "t3-server", + serviceName: "t3code-server", runtime: "node", client: "environment-server", component: "relay-broker", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 344619c0eb93..74ffde8efef0 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -82,7 +82,6 @@ export class ServerConfig extends Context.Service< readonly otlpTracesExport: SignalExport; readonly otlpMetricsExport: SignalExport; readonly otlpLogsExport: SignalExport; - readonly otlpServiceName: string; readonly otelEnvironment: OtelEnvironment.OtelEnvironment; readonly mode: RuntimeMode; readonly port: number; @@ -119,8 +118,9 @@ export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config) * logs report the same service identity to the collector. */ export const otlpResource = (config: ServerConfig["Service"]) => ({ - serviceName: config.otlpServiceName, + serviceName: "t3code-server", attributes: { + "service.namespace": "t3code", "service.runtime": "t3-server", "service.mode": config.mode, }, @@ -222,7 +222,6 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd, baseDir, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 12ae321c1cf3..6eb95c84b477 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -59,7 +59,6 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd: process.cwd(), baseDir, diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 88342cbf1fad..032b44645089 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -32,7 +32,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( filename: dbPath, spanAttributes: { "db.name": path.basename(dbPath), - "service.name": "t3-server", + "service.name": "t3code-server", }, }), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 12f37780701d..6836d28e25c1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -489,7 +489,7 @@ const makeBrowserOtlpPayload = (spanName: string) => url: collector.url, exportInterval: "10 millis", resource: { - serviceName: "t3-web", + serviceName: "t3code-web", attributes: { "service.runtime": "t3-web", "service.mode": "browser", @@ -588,7 +588,6 @@ const buildAppUnderTest = (options?: { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "desktop", port: 0, @@ -5283,7 +5282,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { attributes: [ { key: "service.name", - value: { stringValue: "t3-web" }, + value: { stringValue: "t3code-web" }, }, ], }, @@ -5425,7 +5424,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "rpc.method": "server.getSettings", }, resourceAttributes: { - "service.name": "t3-web", + "service.name": "t3code-web", }, scope: { name: "effect", @@ -5556,7 +5555,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { // the stub's utf8 decode even though the surrounding bytes don't. assert.notEqual(forwarded.body[0], "{"); assert.include(forwarded.body, "client.protobuf.test"); - assert.include(forwarded.body, "t3-web"); + assert.include(forwarded.body, "t3code-web"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5657,7 +5656,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(record.links, []); assert.equal(record.scope.name, scopeSpan.scope.name); assert.deepEqual(record.scope.attributes, {}); - assert.equal(record.resourceAttributes["service.name"], "t3-web"); + assert.equal(record.resourceAttributes["service.name"], "t3code-web"); assert.equal(record.status?.code, String(span.status.code)); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index a5437582d2e3..43843b249eea 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -1,6 +1,7 @@ import * as NodePath from "@effect/platform-node/NodePath"; import { assert, describe, it } from "@effect/vitest"; import * as NodeOS from "node:os"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -57,7 +58,6 @@ const configLayer = (overrides: Partial) = otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd: baseDir, baseDir, @@ -146,11 +146,38 @@ describe("ServerLoggerLive", () => { const [request] = requests; assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); assert.include(request?.body ?? "", "server logger under test"); - assert.include(request?.body ?? "", "t3-server"); + assert.include(request?.body ?? "", "t3code-server"); assert.include(request?.body ?? "", "service.runtime"); }), ); + it.effect("keeps its service name while OTEL resource attributes add dimensions", () => + Effect.gen(function* () { + const requests = yield* logThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_SERVICE_NAME: "renamed", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + }, + }), + ), + ), + ); + + assert.lengthOf(requests, 1); + const body = requests[0]?.body ?? ""; + assert.include(body, '"stringValue":"t3code-server"'); + assert.include(body, "deployment.environment.name"); + assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); + assert.notInclude(body, "renamed"); + }), + ); + it.effect("stays off the network when no logs endpoint is configured", () => Effect.gen(function* () { const requests = yield* logThrough({}); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 866f38875c76..fd4f2b194096 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -19,7 +19,7 @@ function configuredRelayUrl(): string { const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { - serviceName: "t3-web-relay-client", + serviceName: "t3code-web", serviceVersion: import.meta.env.APP_VERSION, runtime: "browser", client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 2471de958615..e8ccd4173d35 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -15,8 +15,9 @@ import { APP_VERSION } from "~/branding"; const DEFAULT_EXPORT_INTERVAL_MS = 1_000; const CLIENT_TRACING_RESOURCE = { - serviceName: "t3-web", + serviceName: "t3code-web", attributes: { + "service.namespace": "t3code", "service.runtime": "t3-web", "service.mode": isElectron ? "electron" : "browser", "service.version": APP_VERSION, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index abe82d680b97..86a65702c270 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -129,7 +129,7 @@ Default Grafana login: export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics export T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs -export T3CODE_OTLP_SERVICE_NAME=t3-local +export OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development ``` Optional: @@ -169,7 +169,6 @@ macOS app bundle example: T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \ -T3CODE_OTLP_SERVICE_NAME=t3-desktop \ "/Applications/T3 Code.app/Contents/MacOS/T3 Code" ``` @@ -179,7 +178,6 @@ Direct binary example: T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \ -T3CODE_OTLP_SERVICE_NAME=t3-desktop \ ./path/to/your/desktop-app-binary ``` @@ -311,11 +309,13 @@ Recommended flow in Grafana: 2. Pick the `Tempo` data source. 3. Set the time range to something recent like `Last 15 minutes`. 4. Start broad. Do not begin with a very narrow query. -5. Look for spans from your configured service name, then narrow by span name or attributes. +5. Look for spans from the `t3code-server` or `t3code-desktop` service, then narrow by span name or + attributes. Good first searches: -- service name such as `t3-local`, `t3-dev`, or `t3-desktop` +- service name `t3code-server` or `t3code-desktop`, plus a resource attribute such as + `deployment.environment.name` - span names like `sendTurn` or a Git operation such as `GitVcsDriver.statusDetails.status` - Git spans whose `git.operation` attribute identifies the operation - orchestration spans with attributes like `orchestration.command_type` @@ -525,10 +525,10 @@ It provides: The desktop main process is a second producer, assembled in `apps/desktop/src/app/DesktopObservability.ts`. It reads the same `T3CODE_OTLP_*` names and the same Settings entries as the backend it supervises, and covers work the backend cannot see: app startup, -window and menu handling, backend supervision, and updates. It reports as service `desktop` -regardless of `T3CODE_OTLP_SERVICE_NAME`, so a collector shows it alongside the backend rather than -mixed into it. It exports traces and logs only; the main process records no metrics, so the metrics -endpoint applies to the backend alone. +window and menu handling, backend supervision, and updates. It reports as service +`t3code-desktop`, so a collector shows it alongside the backend rather than mixed into it. It +exports traces and logs only; the main process records no metrics, so the metrics endpoint applies +to the backend alone. ### Env Vars @@ -547,7 +547,6 @@ OTLP export: - `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint - `T3CODE_OTLP_LOGS_URL`: OTLP log endpoint - `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000` -- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` - `T3CODE_OTLP_HEADERS`: extra headers for all three exporters, same format as `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. - `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` @@ -565,6 +564,11 @@ an `http` or `https` URL, a protocol other than `http/protobuf` or `http/json` s headers that are not `key=value` pairs with percent-encoded values turn that signal's export off with a startup warning, rather than sending it to the Settings endpoint. +Service names are fixed: `t3code-server` for the backend and `t3code-desktop` for the desktop main +process, both in `service.namespace` `t3code`. `OTEL_SERVICE_NAME` and a `service.name` or +`service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` are ignored. Tell installations apart with other +resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development`. + If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. diff --git a/infra/relay/src/observability.test.ts b/infra/relay/src/observability.test.ts index 3958b28b95dd..d77c01277f1c 100644 --- a/infra/relay/src/observability.test.ts +++ b/infra/relay/src/observability.test.ts @@ -62,6 +62,11 @@ it.effect("exports schema error fields as span attributes", () => const request = yield* Deferred.await(exportedRequest).pipe(Effect.timeout("1 second")); const payload = (yield* decodeJson(request.body)) as OtlpTracer.TraceData; + const resourceAttributes = Object.fromEntries( + payload.resourceSpans + .flatMap((resourceSpan) => resourceSpan.resource.attributes) + .map((attribute) => [attribute.key, otlpAttributeValue(attribute.value)]), + ); const span = payload.resourceSpans .flatMap((resourceSpan) => resourceSpan.scopeSpans) .flatMap((scopeSpan) => scopeSpan.spans) @@ -75,6 +80,10 @@ it.effect("exports schema error fields as span attributes", () => expect(request.authorization).toBe("Bearer test-token"); expect(request.dataset).toBe("relay-test-traces"); + expect(resourceAttributes).toMatchObject({ + "service.name": "t3code-relay", + "service.namespace": "t3code", + }); expect(attributes).toMatchObject({ "error.type": "EnvironmentConnectNotAuthorized", "error.environmentId": "environment-1", diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index ca091d8cce6b..2325d7e0432a 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -222,8 +222,9 @@ export const makeRelayTraceLayer = (input: { OtlpTracer.make({ url: input.tracesEndpoint, resource: { - serviceName: "t3-code-relay-worker", + serviceName: "t3code-relay", attributes: { + "service.namespace": "t3code", "service.runtime": "cloudflare-worker", "service.component": "relay", }, diff --git a/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts index 3bb7f1ea1ac2..f96dfea8bb3b 100644 --- a/packages/shared/src/relayTracing.test.ts +++ b/packages/shared/src/relayTracing.test.ts @@ -97,6 +97,8 @@ describe("withRelayClientTracing", () => { const payload = new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array); expect(payload).toContain("relay request failed"); expect(payload).toContain("relay socket closed"); + expect(payload).toContain('"key":"service.name","value":{"stringValue":"relay-test"}'); + expect(payload).toContain('"key":"service.namespace","value":{"stringValue":"t3code"}'); }), ), ); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 907773bc15a5..76954558eb07 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -142,6 +142,7 @@ export function makeRelayClientTracingLayer( serviceName: resource.serviceName, serviceVersion: resource.serviceVersion, attributes: { + "service.namespace": "t3code", "service.runtime": resource.runtime, "service.component": resource.component ?? "relay-client", "t3.client.surface": resource.client, From c216ba4dade683bf3a7c96bf63b33a85afc1a16c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 19:11:58 -0700 Subject: [PATCH 17/73] perf(server): stop remapping every thread on each thread event (#13720) Co-authored-by: Claude --- apps/server/src/orchestration/projector.ts | 28 +++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 85d9db3fdfed..dd418b95b9bc 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -117,12 +117,26 @@ function settledTurnStateForSessionStatus( } } +// Runs for every thread event (including streaming deltas) against every +// thread the server has ever seen, so copy the array rather than map it. function updateThread( threads: ReadonlyArray, threadId: ThreadId, patch: ThreadPatch, -): OrchestrationThread[] { - return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); +): ReadonlyArray { + const index = threads.findIndex((thread) => thread.id === threadId); + return index === -1 ? threads : patchThreadAt(threads, index, patch); +} + +/** For callers that already located the thread and must not scan again. */ +function patchThreadAt( + threads: ReadonlyArray, + index: number, + patch: ThreadPatch, +): ReadonlyArray { + const next = threads.slice(); + next[index] = { ...threads[index]!, ...patch }; + return next; } /** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */ @@ -767,7 +781,8 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const threadIndex = nextBase.threads.findIndex((entry) => entry.id === payload.threadId); + const thread = nextBase.threads[threadIndex]; if (!thread) { return nextBase; } @@ -815,7 +830,7 @@ export function projectEvent( return { ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { + threads: patchThreadAt(nextBase.threads, threadIndex, { messages: cappedMessages, updatedAt: event.occurredAt, }), @@ -1055,7 +1070,8 @@ export function projectEvent( "payload", ).pipe( Effect.map((payload) => { - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const threadIndex = nextBase.threads.findIndex((entry) => entry.id === payload.threadId); + const thread = nextBase.threads[threadIndex]; if (!thread) { return nextBase; } @@ -1069,7 +1085,7 @@ export function projectEvent( return { ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { + threads: patchThreadAt(nextBase.threads, threadIndex, { activities, updatedAt: event.occurredAt, }), From 5660ab5cb55633a1657e715b0af9bd1e449c8a63 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 19:12:20 -0700 Subject: [PATCH 18/73] Remove unused items tracking from Claude adapter state (#13718) Co-authored-by: Claude --- .../src/provider/Layers/ClaudeAdapter.test.ts | 50 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 27 +++------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 502620359836..295926b26644 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -6691,6 +6691,56 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("completed turns keep their ids but not the SDK messages", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + const completedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-1", + uuid: "assistant-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-1", + content: [{ type: "text", text: "Hi" }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-1", + uuid: "result-1", + } as unknown as SDKMessage); + yield* Fiber.join(completedFiber); + + const snapshot = yield* adapter.readThread(session.threadId); + assert.deepEqual( + snapshot.turns.map((entry) => ({ id: String(entry.id), items: entry.items })), + [{ id: String(turn.turnId), items: [] }], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("rewinds Claude history when the fork omits retained system messages", () => { const forkCalls: Array>> = []; let firstTurnId = ""; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 11a5322b4eee..6e30f5241780 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -267,7 +267,6 @@ interface ClaudeTurnState { * steered instead (the queued message continues the same turn). */ readonly synthetic?: boolean; - readonly items: Array; readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; @@ -422,10 +421,11 @@ interface ClaudeSessionContext { resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; - readonly turns: Array<{ - id: TurnId; - items: Array; - }>; + /** Completed turn ids, reported by readThread and trimmed on rollback. + * SDK messages are not kept: rollback reads Claude's own history through + * turnStartMessageIds, and a long-lived session would otherwise hold every + * message it ever produced. */ + readonly turns: Array<{ readonly id: TurnId }>; readonly inFlightTools: Map; readonly claudeTasks: Map; readonly taskAgents: Map; @@ -2176,10 +2176,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } return { threadId, - turns: context.turns.map((turn) => ({ - id: turn.id, - items: [...turn.items], - })), + turns: context.turns.map((turn) => ({ id: turn.id, items: [] })), }; }); @@ -2808,10 +2805,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - context.turns.push({ - id: turnState.turnId, - items: [...turnState.items], - }); + context.turns.push({ id: turnState.turnId }); yield* emitThreadTokenUsage(context, usageSnapshot, { rawMethod: "claude/result", @@ -3175,10 +3169,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - if (context.turnState) { - context.turnState.items.push(message.message); - } - for (const toolResult of toolResultBlocksFromUserMessage(message)) { const toolEntry = Array.from(context.inFlightTools.entries()).find( ([, tool]) => tool.itemId === toolResult.toolUseId, @@ -3378,7 +3368,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( turnId, startedAt, synthetic: true, - items: [], assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), @@ -3461,7 +3450,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( cwd: path.resolve(context.session.cwd ?? "."), }); } - context.turnState.items.push(message.message); if ( normalizeClaudeActiveTokenUsage( message.message.usage, @@ -5205,7 +5193,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, - items: [], assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), From 20885cecf77e205b8931ca1b29f0a2832836a157 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 19:14:32 -0700 Subject: [PATCH 19/73] feat(observability): name the command on subprocess spans (#13701) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/processRunner.test.ts | 8 ++++++++ apps/server/src/processRunner.ts | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index e264ba7849da..d7f3799e103b 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -412,3 +412,11 @@ describe("isWindowsCommandNotFound", () => { }), ); }); + +describe("commandName", () => { + it("drops the directory from POSIX and Windows paths", () => { + expect(ProcessRunner.commandName("/Users/me/.local/bin/claude")).toBe("claude"); + expect(ProcessRunner.commandName("C:\\Program Files\\nodejs\\npx.cmd")).toBe("npx.cmd"); + expect(ProcessRunner.commandName("git")).toBe("git"); + }); +}); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 0a9bb9b04a43..36bb5b649f06 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -285,10 +285,14 @@ function finalizeRunProcess( ); } +/** The executable name without its directory, recorded as `process.command` on process spans. */ +export const commandName = (command: string) => command.replace(/^.*[\\/]/, ""); + const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], input: ProcessRunInput, ): Effect.fn.Return { + yield* Effect.annotateCurrentSpan("process.command", commandName(input.command)); const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; From 7c901a37c4e8f35c408a27dbd1bd2efea06a74f5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 19:15:06 -0700 Subject: [PATCH 20/73] fix(cli): t3 triage points agents at log files that exist (#13685) Co-authored-by: Claude Opus 5.5 (1M context) --- .github/triage/PLAYBOOK.md | 5 +++-- apps/server/src/cli/config.test.ts | 1 - apps/server/src/cli/triage.ts | 7 ++++++- apps/server/src/cli/triagePrompt.test.ts | 5 ++++- apps/server/src/cli/triagePrompt.ts | 11 +++++++---- apps/server/src/cloud/bootService.ts | 4 +++- apps/server/src/config.ts | 2 -- docs/operations/observability.md | 2 -- 8 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 39bf3ea01052..32def6bc0c16 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -59,8 +59,9 @@ different code depending on it: Then work from evidence, not assumption. In rough order of value: -- The server log and the trace file (`server.trace.ndjson`) around the time of the - problem. Recent failures usually leave a trail here. +- The trace file (`server.trace.ndjson`) around the time of the problem, plus the + service log or desktop backend logs from the context file if they exist. Recent + failures usually leave a trail here. - The provider event log, for problems with claude/codex/cursor sessions. - The SQLite database. Read it freely, but only write when a write is necessary to fix the problem the user described, and get their explicit permission diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index ef7cdd578899..42932b927008 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -508,7 +508,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { resolved.terminalLogsDir, resolved.attachmentsDir, resolved.worktreesDir, - path.dirname(resolved.serverLogPath), path.dirname(resolved.serverTracePath), ]) { expect(yield* fs.exists(directory)).toBe(true); diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index c621d331c404..b408e3550112 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -27,6 +27,7 @@ import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; @@ -201,7 +202,11 @@ export const triageCommand = Command.make("triage", { dbPath: paths.dbPath, settingsPath: paths.settingsPath, logsDir: paths.logsDir, - serverLogPath: paths.serverLogPath, + // The server writes no log file of its own. Service installs and the + // desktop app capture its output. The glob covers every desktop backend + // (such as WSL) and rotated copies; names come from DesktopObservability.ts. + serviceLogPath: path.join(paths.logsDir, BootService.BOOT_SERVICE_LOG_FILE), + desktopBackendLogGlob: path.join(paths.logsDir, "server-child*.log*"), serverTracePath: paths.serverTracePath, providerEventLogPath: paths.providerEventLogPath, terminalLogsDir: paths.terminalLogsDir, diff --git a/apps/server/src/cli/triagePrompt.test.ts b/apps/server/src/cli/triagePrompt.test.ts index bf1ac5dbbe5e..fe65bf0dc444 100644 --- a/apps/server/src/cli/triagePrompt.test.ts +++ b/apps/server/src/cli/triagePrompt.test.ts @@ -52,7 +52,8 @@ it("context file carries every path the playbook depends on", () => { dbPath: "/home/u/.t3/userdata/state.sqlite", settingsPath: "/home/u/.t3/userdata/settings.json", logsDir: "/home/u/.t3/userdata/logs", - serverLogPath: "/home/u/.t3/userdata/logs/server.log", + serviceLogPath: "/home/u/.t3/userdata/logs/boot-service.log", + desktopBackendLogGlob: "/home/u/.t3/userdata/logs/server-child*.log*", serverTracePath: "/home/u/.t3/userdata/logs/server.trace.ndjson", providerEventLogPath: "/home/u/.t3/userdata/logs/provider/events.log", terminalLogsDir: "/home/u/.t3/userdata/logs/terminals", @@ -63,6 +64,8 @@ it("context file carries every path the playbook depends on", () => { }); assert.include(context, "/home/u/.t3/userdata/state.sqlite"); assert.include(context, "/home/u/.t3/userdata/logs/server.trace.ndjson"); + assert.include(context, "/home/u/.t3/userdata/logs/boot-service.log"); + assert.include(context, "/home/u/.t3/userdata/logs/server-child*.log*"); assert.include(context, "/home/u/.t3/userdata/logs/provider/events.log"); assert.include(context, "/home/u/.t3/userdata/secrets"); assert.include(context, "/home/u/.t3/source"); diff --git a/apps/server/src/cli/triagePrompt.ts b/apps/server/src/cli/triagePrompt.ts index c2b93a1840a1..1df712854263 100644 --- a/apps/server/src/cli/triagePrompt.ts +++ b/apps/server/src/cli/triagePrompt.ts @@ -71,8 +71,9 @@ different code depending on it: Then work from evidence, not assumption. In rough order of value: -- The server log and the trace file (\`server.trace.ndjson\`) around the time of the - problem. Recent failures usually leave a trail here. +- The trace file (\`server.trace.ndjson\`) around the time of the problem, plus the + service log or desktop backend logs from the context file if they exist. Recent + failures usually leave a trail here. - The provider event log, for problems with claude/codex/cursor sessions. - The SQLite database. Read it freely, but only write when a write is necessary to fix the problem the user described, and get their explicit permission @@ -176,7 +177,8 @@ export interface TriageContextInput { readonly dbPath: string; readonly settingsPath: string; readonly logsDir: string; - readonly serverLogPath: string; + readonly serviceLogPath: string; + readonly desktopBackendLogGlob: string; readonly serverTracePath: string; readonly providerEventLogPath: string; readonly terminalLogsDir: string; @@ -205,7 +207,8 @@ Generated by \`t3 triage\` at ${input.generatedAt}. - Database (SQLite; write only with the user's explicit permission): ${input.paths.dbPath} - Settings: ${input.paths.settingsPath} - Logs dir: ${input.paths.logsDir} -- Server log: ${input.paths.serverLogPath} +- Service log (systemd/launchd service installs only): ${input.paths.serviceLogPath} +- Desktop backend logs (glob; one file per backend plus rotated copies; written only when a backend crashes or fails to start): ${input.paths.desktopBackendLogGlob} - Server trace (ndjson): ${input.paths.serverTracePath} - Provider event log: ${input.paths.providerEventLogPath} - Terminal logs: ${input.paths.terminalLogsDir} diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0a57ed822af4..fc8fc1549a6b 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -43,6 +43,8 @@ const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; +/** File in the logs dir that receives the service's stdout and stderr. `t3 triage` points agents at it. */ +export const BOOT_SERVICE_LOG_FILE = "boot-service.log"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ function escapeSystemdSpecifiers(value: string): string { @@ -599,7 +601,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { environmentPath, }); const unitPath = detectedManager?.unitPath ?? ""; - const logPath = path.join(input.logsDir, "boot-service.log"); + const logPath = path.join(input.logsDir, BOOT_SERVICE_LOG_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 74ffde8efef0..d8dad5ae4d24 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -44,7 +44,6 @@ export interface ServerDerivedPaths { /** Screenshots the agent asks the collaborative browser to keep for the user. */ readonly browserArtifactsDir: string; readonly logsDir: string; - readonly serverLogPath: string; readonly serverTracePath: string; readonly providerLogsDir: string; readonly providerEventLogPath: string; @@ -154,7 +153,6 @@ export const deriveServerPaths = Effect.fn(function* ( attachmentsDir, browserArtifactsDir: join(stateDir, "browser-artifacts"), logsDir, - serverLogPath: join(logsDir, "server.log"), serverTracePath: join(logsDir, "server.trace.ndjson"), providerLogsDir, providerEventLogPath: join(providerLogsDir, "events.log"), diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 86a65702c270..ffe80bbc81b1 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -603,5 +603,3 @@ Current high-value span and metric boundaries include: - logs outside spans are not persisted in the trace file; SSH-managed launch stdout/stderr is still captured in its launcher log - metrics are not snapshotted locally -- the old `serverLogPath` still exists in config for compatibility, but the trace file is the primary - structured persisted artifact From 8aa5be2f02c3b4b9b5b3b86acbf5f2b0467438f4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 19:28:02 -0700 Subject: [PATCH 21/73] fix(server): the SQLite WAL file shrinks back after large writes (#13684) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/persistence/Layers/Sqlite.test.ts | 32 ++++++++++++++++++- apps/server/src/persistence/Layers/Sqlite.ts | 6 ++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts index 0b64e4f7fdcb..5bcbd35e918c 100644 --- a/apps/server/src/persistence/Layers/Sqlite.test.ts +++ b/apps/server/src/persistence/Layers/Sqlite.test.ts @@ -10,7 +10,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts"; +import { + SqlitePersistenceMemory, + WAL_SIZE_LIMIT_BYTES, + makeSqlitePersistenceLive, +} from "./Sqlite.ts"; const lockHolderSource = ` const { DatabaseSync } = require("node:sqlite"); @@ -57,6 +61,32 @@ it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", ( ); }); +it.effect("shrinks the WAL file back to the size limit after a large write", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size; + // About 25% more 4 KB rows than the limit holds, in one transaction. + const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE wal_probe(payload BLOB)`; + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount}) + INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n + `; + assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES); + + // The auto-checkpoint after the large commit copied every frame into the + // database, so the next commit restarts the WAL and cuts the file back. + yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`; + assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES); + }).pipe( + Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + it.effect("applies busy_timeout in the shared persistence setup", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 032b44645089..56536087d69c 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -8,6 +8,9 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; +// Size the -wal file is cut back to on the first commit after a WAL reset. +export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; + const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -15,6 +18,9 @@ const setup = Layer.effectDiscard( yield* sql`PRAGMA busy_timeout = 5000;`; yield* sql`PRAGMA foreign_keys = ON;`; yield* sql`PRAGMA journal_mode = WAL;`; + // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its + // largest size until the last connection closes. + yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); }), ); From 75bf92e652f72cb0062c102236aeb130d1d714b3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 19:28:54 -0700 Subject: [PATCH 22/73] feat(cli): summarize the server trace file from the command line (#13698) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/bin.ts | 2 + apps/server/src/cli/config.ts | 16 +- apps/server/src/cli/trace.test.ts | 100 ++++++++ apps/server/src/cli/trace.ts | 227 ++++++++++++++++++ .../src/diagnostics/TraceDiagnostics.ts | 7 +- docs/operations/observability.md | 15 ++ 6 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/cli/trace.test.ts create mode 100644 apps/server/src/cli/trace.ts diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 0538b94fcde9..e30870aac873 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -24,6 +24,7 @@ import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { sshHelperCommand } from "./cli/sshHelper.ts"; import { themeCommand } from "./cli/theme.ts"; +import { traceCommand } from "./cli/trace.ts"; import { triageCommand } from "./cli/triage.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -71,6 +72,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => servicePreflightCommand, sshHelperCommand, themeCommand, + traceCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index d724730c953f..62461e286748 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -82,16 +82,22 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( Flag.optional, ); +// Trace file location, shared by the server and `t3 trace summary`. +export const traceFileConfig = Config.String("T3CODE_TRACE_FILE").pipe( + Config.option, + Config.map(Option.getOrUndefined), +); +export const traceMaxFilesConfig = Config.Int("T3CODE_TRACE_MAX_FILES").pipe( + Config.withDefault(10), +); + const EnvServerConfig = Config.all({ logLevel: Config.LogLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), traceMinLevel: Config.LogLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), traceTimingEnabled: Config.Boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), - traceFile: Config.String("T3CODE_TRACE_FILE").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), + traceFile: traceFileConfig, traceMaxBytes: Config.Int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), - traceMaxFiles: Config.Int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), + traceMaxFiles: traceMaxFilesConfig, traceBatchWindowMs: Config.Int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(1_000)), otlpTracesUrl: Config.String("T3CODE_OTLP_TRACES_URL").pipe( Config.option, diff --git a/apps/server/src/cli/trace.test.ts b/apps/server/src/cli/trace.test.ts new file mode 100644 index 000000000000..b81a3ed53f25 --- /dev/null +++ b/apps/server/src/cli/trace.test.ts @@ -0,0 +1,100 @@ +import { assert, it } from "@effect/vitest"; + +import { makeTraceSpanSummary } from "./trace.ts"; + +const MINUTE_MS = 60_000; + +function span(name: string, durationMs: number, endMs: number, exitTag = "Success") { + return JSON.stringify({ + type: "effect-span", + name, + traceId: "trace", + spanId: "span", + durationMs, + endTimeUnixNano: String(BigInt(endMs) * 1_000_000n), + exit: { _tag: exitTag, cause: "cause" }, + }); +} + +function browserSpan(name: string, status: { code: string; message?: string }) { + return JSON.stringify({ + type: "otlp-span", + name, + durationMs: 1, + endTimeUnixNano: "1000000", + status, + }); +} + +it("reports count, rate, percentiles, and exits per span name", () => { + // Ten `refresh` spans of 1..10 ms end over minutes 0..9, and one `probe` + // span ends at minute 10, so the recorded window is 10 minutes. + const refreshes = Array.from({ length: 10 }, (_, index) => + span( + "refresh", + index + 1, + index * MINUTE_MS, + index === 0 ? "Interrupted" : index === 1 ? "Failure" : "Success", + ), + ); + const summarizer = makeTraceSpanSummary(); + [...refreshes, span("probe", 2_500, 10 * MINUTE_MS)].forEach(summarizer.addLine); + const summary = summarizer.finish(); + + assert.strictEqual(summary.spanCount, 11); + assert.strictEqual(summary.minutes, 10); + assert.deepStrictEqual(summary.spans, [ + { + name: "refresh", + count: 10, + perMinute: 1, + p50Ms: 5, + p90Ms: 9, + maxMs: 10, + interrupted: 1, + failures: 1, + }, + { + name: "probe", + count: 1, + perMinute: 0.1, + p50Ms: 2_500, + p90Ms: 2_500, + maxMs: 2_500, + interrupted: 0, + failures: 0, + }, + ]); +}); + +it("drops spans that ended before the window and counts unreadable lines", () => { + const summarizer = makeTraceSpanSummary(MINUTE_MS); + [ + span("old", 1, 0), + "", + "{not json", + JSON.stringify({ name: "no-duration" }), + // Ends past the largest Date, so the report could not print it. + JSON.stringify({ name: "far-future", durationMs: 1, endTimeUnixNano: "9".repeat(22) }), + span("recent", 4, 5 * MINUTE_MS), + ].forEach(summarizer.addLine); + const summary = summarizer.finish(); + + assert.strictEqual(summary.skippedLineCount, 3); + assert.deepStrictEqual( + summary.spans.map((entry) => [entry.name, entry.count, entry.perMinute]), + [["recent", 1, undefined]], + ); +}); + +it("reads failures and interrupts of browser spans from their OTLP status", () => { + const summarizer = makeTraceSpanSummary(); + [ + browserSpan("render", { code: "2", message: "boom" }), + browserSpan("render", { code: "1", message: "Interrupted" }), + browserSpan("render", { code: "1" }), + ].forEach(summarizer.addLine); + + const [render] = summarizer.finish().spans; + assert.deepStrictEqual([render?.count, render?.interrupted, render?.failures], [3, 1, 1]); +}); diff --git a/apps/server/src/cli/trace.ts b/apps/server/src/cli/trace.ts new file mode 100644 index 000000000000..7b8a83592ef8 --- /dev/null +++ b/apps/server/src/cli/trace.ts @@ -0,0 +1,227 @@ +/** + * `t3 trace summary` - per-span counts, rates, and latency percentiles from + * the local server trace file and its rotated backups. It reads the files + * directly, so it works while the server is stalled or stopped. + */ +import { PositiveInt } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Command, Flag } from "effect/unstable/cli"; + +import * as ServerConfig from "../config.ts"; +import { toRotatedTracePaths, TraceFileReadError } from "../diagnostics/TraceDiagnostics.ts"; +import { resolveBaseDir } from "../os-jank.ts"; +import { baseDirFlag, DurationFromString, traceFileConfig, traceMaxFilesConfig } from "./config.ts"; + +// Only the fields the summary needs. Other record fields are ignored. +const decodeTraceSpanLine = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + name: Schema.String, + durationMs: Schema.Finite, + endTimeUnixNano: Schema.FiniteFromString, + // Server (`effect-span`) records. + exit: Schema.optional(Schema.Struct({ _tag: Schema.String })), + // Browser (`otlp-span`) records. Effect's OTLP tracer writes code "2" for + // errors and code "1" with message "Interrupted" for interrupts. + status: Schema.optional( + Schema.Struct({ + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), + ), + }), + ), +); + +/** + * Groups trace NDJSON by span name. Call `addLine` once per line as the files + * stream in, then `finish` for the summary. Spans that ended before `sinceMs` + * are left out. Rates are per minute between the first and last span end, + * since spans are written when they end. + */ +export function makeTraceSpanSummary(sinceMs = -Infinity) { + // Keep each span's duration (8 bytes, a few MB for the default 110 MB of + // rotated traces) for exact percentiles. A bounded sketch would save little + // and make p50 and p90 approximate. + const byName = new Map(); + let spanCount = 0; + let skippedLineCount = 0; + let firstEndMs = Infinity; + let lastEndMs = -Infinity; + + const addLine = (line: string) => { + if (line.trim().length === 0) return; + const span = Option.getOrUndefined(decodeTraceSpanLine(line)); + if (span === undefined) { + skippedLineCount += 1; + return; + } + const endMs = span.endTimeUnixNano / 1_000_000; + // The report prints end times as dates, so skip ones outside the Date range. + if (Option.isNone(DateTime.make(endMs))) { + skippedLineCount += 1; + return; + } + if (endMs < sinceMs) return; + + spanCount += 1; + firstEndMs = Math.min(firstEndMs, endMs); + lastEndMs = Math.max(lastEndMs, endMs); + const stats = byName.get(span.name) ?? { durations: [], interrupted: 0, failures: 0 }; + stats.durations.push(span.durationMs); + if (span.exit?._tag === "Interrupted" || span.status?.message === "Interrupted") { + stats.interrupted += 1; + } + if (span.exit?._tag === "Failure" || span.status?.code === "2") stats.failures += 1; + byName.set(span.name, stats); + }; + + const finish = () => { + const minutes = (lastEndMs - firstEndMs) / 60_000; + const spans = [...byName] + .map(([name, { durations, interrupted, failures }]) => { + const sorted = durations.toSorted((left, right) => left - right); + // Nearest-rank percentile. + const percentile = (p: number) => sorted[Math.ceil(p * sorted.length) - 1]!; + return { + name, + count: sorted.length, + perMinute: minutes > 0 ? sorted.length / minutes : undefined, + p50Ms: percentile(0.5), + p90Ms: percentile(0.9), + maxMs: sorted[sorted.length - 1]!, + interrupted, + failures, + }; + }) + .toSorted((left, right) => right.count - left.count || left.name.localeCompare(right.name)); + + return { spanCount, skippedLineCount, firstEndMs, lastEndMs, minutes, spans }; + }; + + return { addLine, finish }; +} + +const formatMs = (ms: number) => + ms < 1_000 ? `${Math.round(ms)}ms` : `${(ms / 1_000).toFixed(1)}s`; + +function formatTraceSummary( + summary: ReturnType["finish"]>, + limit: number, +) { + const header = ["span", "count", "/min", "p50", "p90", "max", "interrupted", "failed"]; + const rows = summary.spans + .slice(0, limit) + .map((span) => [ + span.name, + String(span.count), + span.perMinute === undefined + ? "-" + : span.perMinute < 0.1 + ? "<0.1" + : span.perMinute.toFixed(1), + formatMs(span.p50Ms), + formatMs(span.p90Ms), + formatMs(span.maxMs), + String(span.interrupted), + String(span.failures), + ]); + const table = [header, ...rows]; + const widths = header.map((_, column) => Math.max(...table.map((row) => row[column]!.length))); + const formatIso = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); + return [ + `${summary.spanCount} spans ended from ${formatIso(summary.firstEndMs)} to ${formatIso(summary.lastEndMs)} (${summary.minutes.toFixed(1)} min).`, + ...(summary.skippedLineCount > 0 + ? [`Skipped ${summary.skippedLineCount} lines that are not spans.`] + : []), + "", + ...table.map((row) => + row + .map((cell, column) => + column === 0 ? cell.padEnd(widths[column]!) : cell.padStart(widths[column]!), + ) + .join(" "), + ), + ...(summary.spans.length > limit + ? ["", `${summary.spans.length - limit} more span names. Use --limit to show more.`] + : []), + ].join("\n"); +} + +const traceSummaryCommand = Command.make("summary", { + baseDir: baseDirFlag, + since: Flag.String("since").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription("Only count spans that ended in this window, for example 30m or 2h."), + Flag.optional, + ), + limit: Flag.Int("limit").pipe( + Flag.withSchema(PositiveInt), + Flag.withDescription("Number of span names to show, busiest first."), + Flag.withDefault(25), + ), +}).pipe( + Command.withDescription("Summarize the local server trace file: counts, rates, and latency."), + Command.withHandler( + Effect.fn("cli.trace.summary")(function* (flags) { + const fs = yield* FileSystem.FileSystem; + // T3CODE_TRACE_FILE, else the userdata trace file for --base-dir or + // T3CODE_HOME. Implicit dev runs write elsewhere; set T3CODE_TRACE_FILE. + const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined(Option.orElse(flags.baseDir, () => envHome)), + ); + const traceFilePath = + (yield* traceFileConfig) ?? + (yield* ServerConfig.deriveServerPaths(baseDir, undefined)).serverTracePath; + const sinceMs = Option.isSome(flags.since) + ? (yield* Clock.currentTimeMillis) - Duration.toMillis(flags.since.value) + : undefined; + const summarizer = makeTraceSpanSummary(sinceMs); + // Stream each file so only one chunk of text is in memory at a time. + yield* Effect.forEach( + toRotatedTracePaths(traceFilePath, yield* traceMaxFilesConfig), + (path) => + fs.stream(path).pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEachArray((lines) => Effect.sync(() => lines.forEach(summarizer.addLine))), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.void + : Effect.fail( + new TraceFileReadError({ + traceFilePath: path, + causeTag: cause.reason._tag, + cause, + }), + ), + }), + ), + { discard: true }, + ); + const summary = summarizer.finish(); + + yield* Console.log( + summary.spanCount === 0 + ? `No spans found in ${traceFilePath} or its rotated files${sinceMs === undefined ? "" : " in that window"}.${summary.skippedLineCount > 0 ? ` Skipped ${summary.skippedLineCount} lines that are not spans.` : ""}` + : formatTraceSummary(summary, flags.limit), + ); + }), + ), +); + +export const traceCommand = Command.make("trace").pipe( + Command.withDescription("Inspect the local server trace file."), + Command.withSubcommands([traceSummaryCommand]), +); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index ca5552058f3f..afb7b4e923bb 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -81,7 +81,12 @@ interface TraceDiagnosticsErrorSummary { const DEFAULT_SLOW_SPAN_THRESHOLD_MS = 1_000; const TOP_LIMIT = 10; const RECENT_LIMIT = 20; -function toRotatedTracePaths(traceFilePath: string, maxFiles: number): ReadonlyArray { + +/** The trace file and its rotated backups, oldest first. */ +export function toRotatedTracePaths( + traceFilePath: string, + maxFiles: number, +): ReadonlyArray { const backupCount = Math.max(0, Math.floor(maxFiles)); const backups = Array.from( { length: backupCount }, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index ffe80bbc81b1..da020ac05153 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -62,6 +62,21 @@ far in the future for the environment server's allowed window. It can point to a date or time problem on either device, but it can also result from a delayed request. +#### Summarize the trace file + +`t3 trace summary` reads the trace file and its rotated backups directly, so it works while the +server is stalled or stopped. It prints counts, rates, and latency percentiles per span name. Use +it to measure background work or to compare two builds. + +```bash +t3 trace summary --since 30m --limit 40 +``` + +It reads `T3CODE_TRACE_FILE` if set, else `/userdata/logs/server.trace.ndjson` for +`--base-dir` or `T3CODE_HOME`, plus the `T3CODE_TRACE_MAX_FILES` rotated backups. For a dev run or +a copied file, set `T3CODE_TRACE_FILE`. `--since 30m` keeps spans that ended in the last 30 +minutes. The rate is per minute between the first and last span end. + ### Metrics Metrics are not written to a local file. From 6530de0339d2ca49957d0039133c49e3a08557f7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 19:29:13 -0700 Subject: [PATCH 23/73] perf(server): pull request sync reads only threads with linked pull requests (#13704) Co-authored-by: Claude Opus 5.5 (1M context) --- .../checkpointing/CheckpointDiffQuery.test.ts | 5 ++ .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 71 +++++++++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 66 +++++++++++++++++ .../PullRequestSyncReactor.test.ts | 14 +++- .../orchestration/PullRequestSyncReactor.ts | 16 ++--- .../Services/ProjectionSnapshotQuery.ts | 16 +++++ .../src/project/AgentSessionScanner.test.ts | 1 + .../project/ProjectSetupScriptRunner.test.ts | 1 + .../provider/Layers/ProviderService.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/serverRuntimeStartup.test.ts | 4 ++ 12 files changed, 187 insertions(+), 10 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 7fa6065109c3..6df7d7534130 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -84,6 +84,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -293,6 +295,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -370,6 +373,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), @@ -432,6 +436,7 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index f855771b8f01..9ccf78ca1744 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -435,6 +435,7 @@ describe("OrchestrationEngine", () => { updatedAt: projectionSnapshot.updatedAt, }), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 890c8ae55c53..6fa2c370badf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -3503,6 +3503,77 @@ it.effect("omits foreign-host PRs from legacy snapshots while preserving native }).pipe(Effect.provide(layer)); }); +it.effect( + "lists linked threads like the shell snapshot, in one query and without identities", + () => { + const resolved: string[] = []; + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (root) => + Effect.sync(() => { + resolved.push(root); + return null; + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'), + ('p2', 'Two', '/two', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at, archived_at, deleted_at, settled_override, settled_at) + VALUES + ('t-late', 'p1', 'Late', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z', NULL, NULL, 'settled', '2026-09-04T00:00:00Z'), + ('t-early', 'p2', 'Early', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL, NULL), + ('t-first', 'p1', 'First', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL, NULL, NULL), + ('t-plain', 'p1', 'Plain', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL, NULL, NULL), + ('t-archived', 'p1', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', '2026-09-05T00:00:00Z', NULL, NULL, NULL), + ('t-deleted', 'p1', 'Deleted', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL, '2026-09-05T00:00:00Z', NULL, NULL)`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at, snapshot_json) + VALUES + ('t-late', 'github.com', 'acme/web', 3, 'https://github.com/acme/web/pull/3', 'manual', '2026-09-03T00:00:00Z', NULL), + ('t-early', 'github.com', 'acme/api', 4, 'https://github.com/acme/api/pull/4', 'agent', '2026-09-01T00:00:00Z', NULL), + ('t-first', 'github.com', 'acme/web', 2, 'https://github.com/acme/web/pull/2', 'stack-dismissed', '2026-09-02T00:00:00Z', NULL), + ('t-first', 'github.com', 'acme/web', 1, 'https://github.com/acme/web/pull/1', 'created', '2026-09-02T00:00:00Z', + '{"state":"open","title":"One","headBranch":"one","baseBranch":"main","isDraft":false,"updatedAt":null,"syncedAt":"2026-09-02T00:00:00Z"}'), + ('t-archived', 'github.com', 'acme/web', 5, 'https://github.com/acme/web/pull/5', 'manual', '2026-09-02T00:00:00Z', NULL), + ('t-deleted', 'github.com', 'acme/web', 6, 'https://github.com/acme/web/pull/6', 'manual', '2026-09-02T00:00:00Z', NULL)`; + const expected = (yield* query.getShellSnapshot()).threads + .filter((thread) => thread.pullRequests.length > 0) + .map(({ id, projectId, settledOverride, settledAt, pullRequests }) => ({ + id, + projectId, + settledOverride, + settledAt, + pullRequests, + })); + resolved.length = 0; + + const counter = makeSqlStatementCounter(); + const threads = yield* query + .listThreadsWithPullRequests() + .pipe(Effect.withTracer(counter.tracer)); + assert.deepStrictEqual( + threads.map((thread) => [thread.id, thread.pullRequests.map((link) => link.number)]), + [ + ["t-first", [1, 2]], + ["t-late", [3]], + ["t-early", [4]], + ], + ); + assert.deepStrictEqual(threads, expected); + assert.strictEqual(counter.count(), 1); + assert.deepStrictEqual(resolved, []); + }).pipe(Effect.provide(layer)); + }, +); + projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => { it.effect("lists one kind across active threads only, without hydrating the threads", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 1b44054c7a32..52b9f75c69a4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -77,6 +77,7 @@ import { type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, type ProjectionThreadDetailQuery, + type ProjectionThreadPullRequests, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; @@ -802,6 +803,41 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // One row per link, in the shell snapshot's thread order and link order. + const listActiveThreadPullRequestSyncRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema.mapFields( + Struct.assign({ + projectId: ProjectionThread.fields.projectId, + settledOverride: ProjectionThread.fields.settledOverride, + settledAt: ProjectionThread.fields.settledAt, + }), + ), + execute: () => + sql` + SELECT + links.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.settled_override AS "settledOverride", + threads.settled_at AS "settledAt", + links.host, + links.repository, + links.number, + links.url, + links.source, + links.linked_at AS "linkedAt", + links.snapshot_json AS "snapshot", + links.stack_json AS "stack" + FROM projection_thread_pull_requests links + INNER JOIN projection_threads threads + ON threads.thread_id = links.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY threads.project_id ASC, threads.created_at ASC, threads.thread_id ASC, + links.linked_at ASC, links.number ASC + `, + }); + const listArchivedThreadPullRequestRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadPullRequestDbRowSchema, @@ -2783,6 +2819,35 @@ pending_approval_requests AS ( }), ); + const listThreadsWithPullRequests: ProjectionSnapshotQueryShape["listThreadsWithPullRequests"] = + () => + listActiveThreadPullRequestSyncRows(undefined).pipe( + Effect.map((rows) => { + const threads = new Map< + ThreadId, + ProjectionThreadPullRequests & { readonly pullRequests: Array } + >(); + for (const row of rows) { + const thread = threads.get(row.threadId) ?? { + id: row.threadId, + projectId: row.projectId, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + pullRequests: [], + }; + thread.pullRequests.push(mapPullRequestRow(row)); + threads.set(row.threadId, thread); + } + return [...threads.values()]; + }), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.listThreadsWithPullRequests:query", + "ProjectionSnapshotQuery.listThreadsWithPullRequests:decodeRows", + ), + ), + ); + const getArchivedShellSnapshot: ProjectionSnapshotQueryShape["getArchivedShellSnapshot"] = () => sql .withTransaction( @@ -3778,6 +3843,7 @@ pending_approval_requests AS ( listActivitiesByKind, getSnapshot, getShellSnapshot, + listThreadsWithPullRequests, getArchivedShellSnapshot, getDeletedWorktreeThreads, searchThreads, diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts index 5502376e691e..e6a18c2856a1 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -172,6 +172,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const snapshots = yield* Ref.make(options.snapshot); const events = yield* PubSub.unbounded(); const snapshotReads = yield* Queue.unbounded(); + const shellSnapshotReads = yield* Ref.make(0); const syncCommands = yield* Ref.make>([]); const linkCommands = yield* Ref.make>([]); const summaryCalls = yield* Ref.make>([]); @@ -209,8 +210,16 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ + listThreadsWithPullRequests: () => + Queue.offer(snapshotReads, undefined).pipe( + Effect.andThen(Ref.get(snapshots)), + Effect.map((snapshot) => snapshot.threads), + ), getShellSnapshot: () => - Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Ref.get(snapshots))), + Ref.update(shellSnapshotReads, (count) => count + 1).pipe( + Effect.andThen(Queue.offer(snapshotReads, undefined)), + Effect.andThen(Ref.get(snapshots)), + ), }), Layer.mock(PullRequestService)({ summary, @@ -235,6 +244,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: activation, snapshots, snapshotReads, + shellSnapshotReads, syncCommands, linkCommands, summaryCalls, @@ -511,6 +521,8 @@ describe("PullRequestSyncReactor", () => { ], ); assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + // Reads only linked threads, never the full shell snapshot of every thread. + assert.strictEqual(yield* Ref.get(fixture.shellSnapshotReads), 0); }).pipe(Effect.provide(fixture.layer)); }), ), diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 2bdf3ff9e71d..ffcc9fb3c26d 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -1,7 +1,6 @@ import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, - type OrchestrationThreadShell, type PullRequestSummary, type ThreadPullRequestKey, type ThreadPullRequestLink, @@ -36,7 +35,7 @@ const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; type SnapshotFields = Omit; interface LinkEntry { - readonly thread: OrchestrationThreadShell; + readonly thread: ProjectionSnapshotQuery.ProjectionThreadPullRequests; readonly link: ThreadPullRequestLink; } @@ -104,15 +103,15 @@ function stacksEqual( ); } -function isUnsettled(thread: OrchestrationThreadShell): boolean { +function isUnsettled(thread: ProjectionSnapshotQuery.ProjectionThreadPullRequests): boolean { return thread.settledOverride !== "settled" && thread.settledAt === null; } /** * Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads - * the shell snapshot, groups visible links by pull request so the host is asked once per PR - * no matter how many threads share it, and writes back only what changed. Native stacks the - * host reports are auto-linked to the thread as `source: "stack"`. + * only the active threads that have links, groups visible links by pull request so the host + * is asked once per PR no matter how many threads share it, and writes back only what + * changed. Native stacks the host reports are auto-linked to the thread as `source: "stack"`. */ export class PullRequestSyncReactor extends Context.Service< PullRequestSyncReactor, @@ -153,14 +152,13 @@ export const make = Effect.gen(function* () { Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (requestedKey?: string) { - const snapshot = yield* snapshots.getShellSnapshot(); + const threads = yield* snapshots.listThreadsWithPullRequests(); const now = yield* DateTime.now; const nowMs = DateTime.toEpochMillis(now); const nowIso = DateTime.formatIso(now); const groups = new Map>(); - for (const thread of snapshot.threads) { - if (thread.archivedAt !== null) continue; + for (const thread of threads) { for (const link of visibleThreadPullRequests(thread.pullRequests)) { const key = threadPullRequestKeyOf(link); const entries = groups.get(key) ?? []; diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index eac3ede9c1ee..48e63d6fc8b0 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -64,6 +64,12 @@ export interface ProjectionFullThreadDiffContext { readonly toCheckpointRef: CheckpointRef | null; } +/** The thread fields pull request sync reads, for a thread with at least one link. */ +export type ProjectionThreadPullRequests = Pick< + OrchestrationThreadShell, + "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests" +>; + export interface ProjectionThreadDetailQuery { /** * Limit activities before SQLite returns and decodes their payloads. @@ -131,6 +137,16 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Read active (not deleted, not archived) threads that have at least one pull + * request link, in shell snapshot order. Skips repository identity, so no + * legacy `linkedPullRequest` is derived. + */ + readonly listThreadsWithPullRequests: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** Durable worktree ownership retained after thread deletion, including across restarts. */ readonly getDeletedWorktreeThreads: () => Effect.Effect< ReadonlyArray<{ diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 792ef92310ff..2dde0643ed60 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -48,6 +48,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< updatedAt: "2026-01-01T00:00:00.000Z", }), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 650f304e8481..69c553a4deaf 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -34,6 +34,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 36d884d8ac98..547935a51e3c 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4969,6 +4969,7 @@ describe("agent browser access", () => { getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index d8226648e9f3..7b1fec90f867 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -239,6 +239,7 @@ describe("ProviderSessionReaper", () => { getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index df879a2cf307..4bbdb2e9c8b5 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -169,6 +169,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -298,6 +299,7 @@ it.effect.each([ getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -385,6 +387,7 @@ it.effect( getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -450,6 +453,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), From 1dc8cbe6d14a7b4e131a2806f43bde8b60d6aaa7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 20:20:21 -0700 Subject: [PATCH 24/73] feat(observability): write a server heap snapshot on SIGUSR2 (#13694) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/observability/HeapSnapshot.test.ts | 36 +++++++++++++ apps/server/src/observability/HeapSnapshot.ts | 52 +++++++++++++++++++ apps/server/src/server.ts | 2 + docs/operations/observability.md | 37 +++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 apps/server/src/observability/HeapSnapshot.test.ts create mode 100644 apps/server/src/observability/HeapSnapshot.ts diff --git a/apps/server/src/observability/HeapSnapshot.test.ts b/apps/server/src/observability/HeapSnapshot.test.ts new file mode 100644 index 000000000000..0beb466e5019 --- /dev/null +++ b/apps/server/src/observability/HeapSnapshot.test.ts @@ -0,0 +1,36 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests fake a failed write at the native v8 boundary. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeV8 from "node:v8"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { vi } from "vite-plus/test"; + +import { writeHeapSnapshot } from "./HeapSnapshot.ts"; + +vi.mock("node:v8", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, writeHeapSnapshot: vi.fn(actual.writeHeapSnapshot) }; +}); + +it.layer(NodeServices.layer)("writeHeapSnapshot", (it) => { + it.effect("removes the partial file when the write fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const logsDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-heap-snapshot-test-" }); + let partialPath: string | undefined; + vi.mocked(NodeV8.writeHeapSnapshot).mockImplementationOnce((path) => { + partialPath = path; + if (path) NodeFS.writeFileSync(path, "partial"); + throw new Error("ENOSPC: no space left on device"); + }); + + yield* writeHeapSnapshot(logsDir); + + assert.strictEqual(NodePath.dirname(partialPath ?? ""), logsDir); + assert.deepEqual(yield* fs.readDirectory(logsDir), []); + }), + ); +}); diff --git a/apps/server/src/observability/HeapSnapshot.ts b/apps/server/src/observability/HeapSnapshot.ts new file mode 100644 index 000000000000..827dfab2c6d6 --- /dev/null +++ b/apps/server/src/observability/HeapSnapshot.ts @@ -0,0 +1,52 @@ +// @effect-diagnostics nodeBuiltinImport:off - v8.writeHeapSnapshot has no Effect equivalent. +import * as NodePath from "node:path"; +import * as NodeV8 from "node:v8"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; + +/** + * Writes one V8 heap snapshot into `logsDir` and logs its path. A failed write + * logs a warning and removes any partial file, because that file can hold + * secrets and the failure is often a full disk. + */ +export const writeHeapSnapshot = Effect.fn("server.heapSnapshot", { root: true })( + function* (logsDir: string) { + const fs = yield* FileSystem.FileSystem; + const timestamp = DateTime.formatIso(yield* DateTime.now).replaceAll(":", "-"); + const path = NodePath.join(logsDir, `server-${process.pid}-${timestamp}.heapsnapshot`); + yield* Effect.annotateCurrentSpan({ path }); + yield* Effect.try(() => NodeV8.writeHeapSnapshot(path)).pipe( + Effect.tapError(() => fs.remove(path, { force: true }).pipe(Effect.ignore)), + ); + yield* Effect.logInfo("Wrote heap snapshot.", { path }); + }, + Effect.catch((cause) => Effect.logWarning("Failed to write heap snapshot.", { cause })), +); + +/** + * Writes a heap snapshot when the process gets SIGUSR2 (`kill -USR2 `), + * so a maintainer can see what a long-running server holds. See "Heap + * Snapshots" in docs/operations/observability.md. + * + * The write blocks the event loop, so two snapshots never overlap: a signal + * sent during a write waits until it finishes. Windows has no SIGUSR2, so the + * layer does nothing there. + */ +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const { logsDir } = yield* ServerConfig.ServerConfig; + const runFork = Effect.runForkWith(yield* Effect.context()); + const onSignal = () => void runFork(writeHeapSnapshot(logsDir)); + yield* Effect.acquireRelease( + Effect.sync(() => process.on("SIGUSR2", onSignal)), + () => Effect.sync(() => process.off("SIGUSR2", onSignal)), + ); + }), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4f264ae1cb0d..e64b0702eb83 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -119,6 +119,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; +import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; @@ -957,6 +958,7 @@ const makeServerLayer = Layer.unwrap( runtimeStateLayer.pipe(Layer.provide(launcherLayer)), tailscaleServeLayer, cloudDesiredLinkReconcileLayer, + HeapSnapshot.layer, ); return serverApplicationLayer.pipe( diff --git a/docs/operations/observability.md b/docs/operations/observability.md index da020ac05153..d9dac58e2c3b 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -618,3 +618,40 @@ Current high-value span and metric boundaries include: - logs outside spans are not persisted in the trace file; SSH-managed launch stdout/stderr is still captured in its launcher log - metrics are not snapshotted locally + +## Heap Snapshots + +To see what a long-running server holds in memory, send it `SIGUSR2`. The server writes a V8 heap +snapshot to its logs dir and logs the path. This works for desktop, `npx t3`, and service installs +on macOS and Linux. Windows has no `SIGUSR2`. + +Send the signal to the server pid in `server-runtime.json`, which sits in the server's state dir +next to the `logs` dir. For a dev server or a `--home-dir` launch, use that server's state dir from +[Traces](#traces). Do not send it to the desktop app or the service launcher: a process without the +handler exits on `SIGUSR2`. After a crash the file can keep a stale pid that now belongs to a +different process, so check the pid first. + +```bash +pid="$(jq .pid "${T3CODE_HOME:-$HOME/.t3}/userdata/server-runtime.json")" +ps -p "$pid" -o command= +``` + +If `ps` shows the T3 Code server, send the signal: + +```bash +kill -USR2 "$pid" +``` + +The file is `/server--.heapsnapshot`, next to `server.trace.ndjson`. To +open it, use the Memory tab in Chrome DevTools and select Load. + +Before you take one: + +- The server stops while it writes the file. For a large heap this can take a minute or more. + Connected clients can reconnect during the pause, and an event loop monitor, if the server has + one, records the pause as a stall. Send the signal once. A second signal sent during a write + takes another snapshot after the first one finishes. +- The write needs about as much free memory as the heap uses. On a machine that is already + swapping, it can make the problem worse or crash the server. +- The file contains everything in server memory, including tokens, secrets, and thread content. Do + not share it publicly. Delete it when you are done, because storage cleanup does not remove it. From 574b18090281225de3449816c8366f0ee9ab886c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 20:24:35 -0700 Subject: [PATCH 25/73] perf(server): shutdown no longer rewrites every stopped session row (#13688) Co-authored-by: Claude Opus 5.5 (1M context) --- .../provider/Layers/ProviderService.test.ts | 72 +++++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 20 +++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 547935a51e3c..242d0b55aa2f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -648,6 +648,78 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => }), ); +it.effect("ProviderServiceLive shutdown leaves settled session rows untouched", () => + Effect.gen(function* () { + const recordedAnalytics = makeRecordingAnalytics(); + const codex = makeFakeCodexAdapter(); + const persistence = yield* Layer.build( + ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistenceMemory))), + ), + ); + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide(persistence), + ); + const seed = (threadId: ThreadId, status: "running" | "stopped", activeTurnId: TurnId | null) => + directory.upsert({ + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + status, + runtimePayload: { cwd: "/repo", activeTurnId }, + }); + const readBindings = directory + .listBindings() + .pipe( + Effect.map((bindings) => new Map(bindings.map((binding) => [binding.threadId, binding]))), + ); + const settledId = asThreadId("shutdown-settled"); + const runningId = asThreadId("shutdown-running"); + const stoppedWithTurnId = asThreadId("shutdown-stopped-with-turn"); + yield* seed(settledId, "stopped", null); + yield* seed(runningId, "running", asTurnId("running-turn")); + yield* seed(stoppedWithTurnId, "stopped", asTurnId("stale-turn")); + const settledBefore = (yield* readBindings).get(settledId); + assert(settledBefore !== undefined); + + const scope = yield* Scope.make(); + yield* Layer.build( + makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide(Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory)), + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]), + ), + ), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(recordedAnalytics.layer), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + ).pipe(Scope.provide(scope)); + yield* TestClock.adjust("1 minute"); + yield* Scope.close(scope, Exit.void); + + const byThread = yield* readBindings; + assert.deepStrictEqual(byThread.get(settledId), settledBefore); + for (const threadId of [runningId, stoppedWithTurnId]) { + const binding = byThread.get(threadId); + assert.equal(binding?.status, "stopped"); + assert.propertyVal(binding?.runtimePayload, "activeTurnId", null); + assert.propertyVal(binding?.runtimePayload, "lastRuntimeEvent", "provider.stopAll"); + } + const [stoppedAll] = recordedAnalytics.eventsByName("provider.sessions.stopped_all"); + assert.equal(stoppedAll?.properties?.stoppedSessionCount, 2); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive flushes deferred completions during shutdown", () => Effect.gen(function* () { const recordedAnalytics = makeRecordingAnalytics(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 5e5052d5ef23..12f8869da486 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -427,6 +427,14 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +/** Stopped rows with no active turn are settled; shutdown leaves them untouched. */ +function isSettledBinding(binding: ProviderSessionDirectory.ProviderRuntimeBinding): boolean { + if (binding.status !== "stopped") return false; + const payload = binding.runtimePayload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return true; + return !("activeTurnId" in payload) || payload.activeTurnId == null; +} + const dieOnMissingBindingInstanceId = ( operation: string, payload: { @@ -2331,7 +2339,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return [completed, state] as const; }); yield* recordCompletedTurnProperties(properties); - const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => adapter.listSessions().pipe( @@ -2362,7 +2369,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); McpProviderSession.clearAllMcpProviderSessions(); - const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); + // Stopped rows stay for their resume cursors, so long-lived installs hold + // thousands. Only rewrite the ones this shutdown actually stops. + const bindings = yield* directory.listBindings().pipe( + Effect.map((all) => all.filter((binding) => !isSettledBinding(binding))), + Effect.orElseSucceed(() => []), + ); yield* Effect.forEach(bindings, (binding) => Effect.gen(function* () { const providerInstanceId = dieOnMissingBindingInstanceId( @@ -2382,8 +2394,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); }), ).pipe(Effect.asVoid); + // Not `sessionCount`: that older property counted every row, so a new name + // keeps the two meanings in separate series. yield* analytics.record("provider.sessions.stopped_all", { - sessionCount: threadIds.length, + stoppedSessionCount: bindings.length, }); yield* analytics.flush; }); From b6eefc926ae305f4cb85cca797668b010cee1136 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 20:28:03 -0700 Subject: [PATCH 26/73] perf(server): build the thread list snapshot without decoding it twice (#13693) Co-authored-by: Claude Opus 5.5 (1M context) --- .../Layers/ProjectionSnapshotQuery.ts | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 52b9f75c69a4..71ef1ad97321 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -12,7 +12,7 @@ import { OrchestrationProposedPlanId, OrchestrationReadModel, OrchestrationThreadSearchSource, - OrchestrationShellSnapshot, + type OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, ProjectScript, @@ -82,7 +82,6 @@ import { } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); -const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption( Schema.fromJsonString( @@ -2746,7 +2745,10 @@ pending_approval_requests AS ( ); const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); - const snapshot = { + // Built from schema-decoded rows, so no second decode here. The HTTP + // and RPC layers encode it against OrchestrationShellSnapshot on the + // way out, like the per-item shells from getThreadShellById. + return { snapshotSequence: computeSnapshotSequence(stateRows), projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null @@ -2800,15 +2802,7 @@ pending_approval_requests AS ( : Result.failVoid, ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; - - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", - ), - ), - ); + } satisfies OrchestrationShellSnapshot; }), ), Effect.mapError((error) => { @@ -2941,7 +2935,7 @@ pending_approval_requests AS ( sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), ); - const snapshot = { + return { snapshotSequence: computeSnapshotSequence(stateRows), projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null && activeProjectIds.has(row.projectId) @@ -2991,15 +2985,7 @@ pending_approval_requests AS ( planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), })), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; - - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", - ), - ), - ); + } satisfies OrchestrationShellSnapshot; }), ), Effect.mapError((error) => { From b2577d6eface9589d120bdc53e4ec0431b1e9331 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 20:28:35 -0700 Subject: [PATCH 27/73] fix(client): slow servers finish loading the thread list instead of loading it twice (#13683) Co-authored-by: Claude Opus 5.5 (1M context) --- packages/client-runtime/src/state/session.ts | 4 ++-- .../client-runtime/src/state/shellSnapshotHttp.ts | 10 +++++++--- .../client-runtime/src/state/threadSnapshotHttp.ts | 11 +++++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 5787bd0e60b7..15848d95d965 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -30,8 +30,8 @@ function initialConfigOption( ); } -// Bounded like the snapshot fetches: a wedged environment must not pin the -// permissions check (and with it the settings UI) in a loading state for long. +// Bounded so a wedged environment cannot pin the permissions check (and with it +// the settings UI) in a loading state for long. const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; /** diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index 84ab1a3f1d4b..fe5a3d5fcec1 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -12,9 +12,13 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts"; import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; -// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket -// fallback for long. The cached shell renders while this runs. -const DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS = 6_000; +// Long enough for a slow but alive server to finish. On timeout the socket asks +// the same server for the same full snapshot, so a short deadline only throws +// the first build away. The socket fallback is for setups where /api fails but +// /ws works, such as a proxy that blocks /api. A dead server is caught by the +// socket ping, which drops the session and interrupts this load. The cached +// shell renders while this runs. +const DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS = 20_000; /** * Load the environment shell snapshot (projects + thread shells) over HTTP diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 9582ad30567b..da1156cc83e0 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -13,10 +13,13 @@ import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; -// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket -// fallback for long. The cached thread renders while this runs, so the wait only -// delays the transition to live data on the first open, not the initial paint. -const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; +// Long enough for a slow but alive server to finish. On a cold open a timeout +// makes the socket ask the same server for the same snapshot again, and older +// turn pages have no fallback, so a short deadline only drops work. The socket +// fallback is for setups where /api fails but /ws works, such as a proxy that +// blocks /api. A dead server drops the socket session, which interrupts a +// cold-open load. Older turn pages wait for this deadline. +const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 20_000; /** * Load a thread's detail snapshot over HTTP instead of embedding it in the From 6989856aa630611dd7d6dcc8cfe9f97456816284 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 20:29:28 -0700 Subject: [PATCH 28/73] perf(web): hidden terminal drawers no longer keep full thread history in memory (#13686) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5c3825cee33..cacb432f870d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -917,7 +917,14 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + // Hidden drawers stay mounted (see MAX_HIDDEN_MOUNTED_TERMINAL_THREADS), so they read only + // the shell: a detail subscription would keep each hidden thread's history in memory. The + // active drawer shares ChatView's detail, which also covers archived threads (no shell). + const activeServerThread = useThread(active ? threadRef : null, { + waitForShell: draftThread !== null, + }); + const serverThreadShell = useThreadShell(threadRef); + const serverThread = activeServerThread ?? serverThreadShell; const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread From 3b0a495b0e05d11dbf88ff2148e7c21b49508172 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 20:31:29 -0700 Subject: [PATCH 29/73] perf(server): per-thread settlement and PR checks no longer rebuild the whole thread list (#13691) Co-authored-by: Claude Opus 5.5 (1M context) --- .../Layers/ProjectionSnapshotQuery.test.ts | 69 +++++++++++++++++++ .../ThreadPullRequestReactor.test.ts | 43 ++++++++++-- .../orchestration/ThreadPullRequestReactor.ts | 39 ++++++++++- .../ThreadSettlementReactor.test.ts | 31 +++++++-- .../orchestration/ThreadSettlementReactor.ts | 12 ++-- 5 files changed, 173 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 6fa2c370badf..b80e13726811 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -30,6 +30,7 @@ import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts"; +import { readSweepSnapshot } from "../ThreadPullRequestReactor.ts"; import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -3574,6 +3575,74 @@ it.effect( }, ); +it.effect("reads one sweep thread and its projects like the shell snapshot", () => { + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/web", + provider: "github", + displayName: "acme/web", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/acme/web.git", + }, + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'), + ('p2', 'Two', '/two', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z'), + ('p3', 'Three', '/three', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, branch_pull_request_json, latest_turn_id, latest_user_message_at, pending_approval_count, snoozed_until, snoozed_at, created_at, updated_at, settled_override, settled_at) + VALUES + ('t-linked', 'p1', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'feature', '/one/wt', NULL, 'turn-1', '2026-09-02T00:00:00Z', 1, NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL), + ('t-branch', 'p1', 'Branch', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'other', NULL, + '{"projectId":"p2","repository":"acme/web","number":8,"url":"https://github.com/acme/web/pull/8"}', + NULL, NULL, 0, '2026-09-10T00:00:00Z', '2026-09-02T00:00:00Z', '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', 'settled', '2026-09-03T00:00:00Z'), + ('t-other', 'p3', 'Other', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL)`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES ('t-linked', 'github.com', 'acme/web', 7, 'https://github.com/acme/web/pull/7', 'agent', '2026-09-02T00:00:00Z')`; + yield* sql`INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, started_at, completed_at, checkpoint_files_json) + VALUES ('t-linked', 'turn-1', 'completed', '2026-09-02T00:00:00Z', '2026-09-02T00:00:01Z', '2026-09-02T00:00:02Z', '[]')`; + yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, provider_name, active_turn_id, last_error, updated_at) + VALUES ('t-linked', 'ready', 'codex', NULL, NULL, '2026-09-02T00:00:03Z')`; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql`INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 9, '2026-09-02T00:00:03Z')`; + } + + const full = yield* query.getShellSnapshot(); + // The seeded fields must reach the snapshot, or the parity check is empty. + const linked = full.threads.find((thread) => thread.id === ThreadId.make("t-linked")); + assert.strictEqual(full.snapshotSequence, 9); + assert.strictEqual(linked?.linkedPullRequest?.number, 7); + assert.strictEqual(linked?.latestTurn?.turnId, asTurnId("turn-1")); + assert.strictEqual(linked?.session?.status, "ready"); + + for (const [threadId, projectIds] of [ + [ThreadId.make("t-linked"), [asProjectId("p1")]], + // Settlement also needs the project that the saved branch PR names. + [ThreadId.make("t-branch"), [asProjectId("p1"), asProjectId("p2")]], + ] as const) { + assert.deepStrictEqual(yield* readSweepSnapshot(query, threadId), { + snapshotSequence: full.snapshotSequence, + projects: full.projects.filter((project) => projectIds.includes(project.id)), + threads: full.threads.filter((thread) => thread.id === threadId), + }); + } + }).pipe(Effect.provide(layer)); +}); + projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => { it.effect("lists one kind across active threads only, without hydrating the threads", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index d6572083dad2..6e8b930a1ae6 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -21,6 +21,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -142,7 +143,8 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options threads: options.threads, updatedAt: NOW, }); - const reads = yield* Queue.unbounded(); + // Each shell read: a thread id for a one-thread read, null for a full read. + const reads = yield* Queue.unbounded(); const events = yield* PubSub.unbounded(); const commands = yield* Ref.make>([]); const branchCalls = yield* Ref.make< @@ -152,8 +154,24 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options let uuid = 0; const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ - getShellSnapshot: () => - Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, undefined))), + getShellSnapshot: () => Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, null))), + getSnapshotSequence: () => + Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))), + getThreadShellById: (threadId) => + Ref.get(snapshots).pipe( + Effect.map(({ threads }) => + Option.fromUndefinedOr( + threads.find((thread) => thread.id === threadId && thread.archivedAt === null), + ), + ), + Effect.tap(() => Queue.offer(reads, threadId)), + ), + getProjectShells: (projectIds) => + Ref.get(snapshots).pipe( + Effect.map(({ projects }) => + projects.filter((project) => projectIds?.includes(project.id) ?? true), + ), + ), }), Layer.mock(GitManager)({ branchPullRequest: (input, readOptions) => @@ -376,7 +394,7 @@ describe("ThreadPullRequestReactor", () => { : [checkpointEvent, sessionEvent]; for (const event of events) { yield* fixture.publish(event); - yield* Queue.take(fixture.reads); + expect(yield* Queue.take(fixture.reads)).toBe(current.id); yield* reactor.drain; } expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); @@ -516,6 +534,23 @@ describe("ThreadPullRequestReactor", () => { yield* Effect.gen(function* () { const reactor = yield* fixture.start(); expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + // A one-thread read cannot show that other pending threads are gone. + const gone = ThreadId.make("gone"); + yield* fixture.publish({ + type: "thread.unarchived", + sequence: 2, + eventId: EventId.make("gone-unarchived"), + aggregateKind: "thread", + aggregateId: gone, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId: gone, updatedAt: NOW }, + }); + expect(yield* Queue.take(fixture.reads)).toBe(gone); + yield* reactor.drain; yield* Ref.set(online, true); yield* TestClock.adjust("1 minute"); yield* Queue.take(fixture.reads); diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 17efc74e4f40..0d709a867e07 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -6,6 +6,7 @@ import { CommandId, type OrchestrationEvent, type OrchestrationProjectShell, + type OrchestrationShellSnapshot, type ThreadId, type ThreadLinkedPullRequest, } from "@t3tools/contracts"; @@ -16,11 +17,13 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts"; +import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { forkParked } from "../serverActivation.ts"; @@ -69,6 +72,35 @@ export function pullRequestMatchesProject( ); } +/** + * Read the shell state for a discovery or settlement sweep. A sweep for one + * thread reads that thread and the projects it names, not every thread. + */ +export const readSweepSnapshot = ( + snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQueryShape, + threadId: ThreadId | null, +): Effect.Effect< + Pick, + ProjectionRepositoryError +> => + threadId === null + ? snapshots.getShellSnapshot() + : Effect.gen(function* () { + // Read the sequence first. The thread is then at least this new, so a + // command guarded by the sequence is rejected rather than missing a change. + const { snapshotSequence } = yield* snapshots.getSnapshotSequence(); + const thread = yield* snapshots.getThreadShellById(threadId); + if (Option.isNone(thread)) return { snapshotSequence, projects: [], threads: [] }; + // Settlement also checks the project a saved pull request names. + const reference = thread.value.linkedPullRequest ?? thread.value.branchPullRequest; + const projects = yield* snapshots.getProjectShells( + reference == null + ? [thread.value.projectId] + : [thread.value.projectId, reference.projectId], + ); + return { snapshotSequence, projects, threads: [thread.value] }; + }); + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; @@ -97,7 +129,7 @@ export const make = Effect.gen(function* () { const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( request: RefreshRequest, ) { - const snapshot = yield* snapshots.getShellSnapshot(); + const snapshot = yield* readSweepSnapshot(snapshots, request.threadId); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); if (request.backfill) { for (const thread of snapshot.threads) { @@ -109,14 +141,15 @@ export const make = Effect.gen(function* () { } } } + // A single-thread read only shows whether its own thread is gone. const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); - for (const threadId of pendingBackfill.keys()) { + const checkedIds = request.threadId === null ? pendingBackfill.keys() : [request.threadId]; + for (const threadId of checkedIds) { if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); } const threads = snapshot.threads.filter( (thread) => thread.archivedAt === null && - (request.threadId === null || thread.id === request.threadId) && ((thread.settledOverride !== "settled" && thread.settledAt === null) || request.threadId !== null || pendingBackfill.has(thread.id)) && diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 07afbaf05e6a..bee487ceb721 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -24,6 +24,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -181,7 +182,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const activation = yield* Deferred.make(); const snapshots = yield* Ref.make(options.snapshot); const snapshotReadCount = yield* Ref.make(0); - const snapshotReads = yield* Queue.unbounded(); + // Each shell read: a thread id for a one-thread read, null for a full read. + const snapshotReads = yield* Queue.unbounded(); const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); const settingsReads = yield* Queue.unbounded(); const settingsChanges = yield* PubSub.unbounded(); @@ -255,10 +257,27 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ getShellSnapshot: () => - Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( - Effect.tap((count) => Queue.offer(snapshotReads, count)), + Ref.update(snapshotReadCount, (count) => count + 1).pipe( + Effect.andThen(Queue.offer(snapshotReads, null)), Effect.andThen(Ref.get(snapshots)), ), + getSnapshotSequence: () => + Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))), + getThreadShellById: (threadId) => + Ref.get(snapshots).pipe( + Effect.map(({ threads }) => + Option.fromUndefinedOr( + threads.find((thread) => thread.id === threadId && thread.archivedAt === null), + ), + ), + Effect.tap(() => Queue.offer(snapshotReads, threadId)), + ), + getProjectShells: (projectIds) => + Ref.get(snapshots).pipe( + Effect.map(({ projects }) => + projects.filter((project) => projectIds?.includes(project.id) ?? true), + ), + ), }), Layer.mock(GitManager)({ branchPullRequest, @@ -313,7 +332,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"], activation: Deferred.Deferred, - snapshotReads: Queue.Queue, + snapshotReads: Queue.Queue, ) { yield* reactor.start(); yield* Deferred.succeed(activation, undefined); @@ -443,7 +462,7 @@ describe("ThreadSettlementReactor", () => { updatedAt: NOW, }, }); - yield* Queue.take(fixture.snapshotReads); + assert.strictEqual(yield* Queue.take(fixture.snapshotReads), thread.id); yield* reactor.drain; } assert.deepStrictEqual( @@ -463,7 +482,7 @@ describe("ThreadSettlementReactor", () => { aggregateId: readySession.threadId, payload: { threadId: readySession.threadId, session: readySession }, }); - yield* Queue.take(fixture.snapshotReads); + assert.strictEqual(yield* Queue.take(fixture.snapshotReads), readySession.threadId); yield* reactor.drain; assert.deepStrictEqual( (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 4192896efed5..22d21ff27996 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -23,7 +23,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; -import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts"; +import { pullRequestMatchesProject, readSweepSnapshot } from "./ThreadPullRequestReactor.ts"; import { isAutoSettlementCandidate, resolveAutoSettlementAt, @@ -95,20 +95,16 @@ export const make = Effect.gen(function* () { if (!autoSettlementConfigured(settings)) { return; } - const snapshot = yield* snapshots.getShellSnapshot(); + const snapshot = yield* readSweepSnapshot(snapshots, threadId ?? null); const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); // A merge rechecks all candidates, including branches that discovery has // not linked yet. Those lookups can still have cached the PR as open. - const candidates = snapshot.threads.filter( - (thread) => - (threadId === undefined || thread.id === threadId) && - isAutoSettlementCandidate(thread, now), - ); + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); // Return the thread when it still needs a pull request decision. A rejected // dispatch skips it for this snapshot instead of retrying through a lookup. - const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( + const settleThread = Effect.fnUntraced( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { const settings = resolveProjectSettings( yield* settingsService.getSettings, From ecd3237b184e87af5d744a9dc28422cc1eb68e1a Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 25 Sep 2026 21:38:16 -0600 Subject: [PATCH 30/73] fix(mobile): running threads open at the latest message (#13530) --- apps/mobile/src/features/threads/ThreadFeed.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 20848cd2ad10..83b0bef9c022 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2477,7 +2477,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // content-inset override. Seed the fresh instance synchronously with the // current overlay height before the scroll integration's next reaction; // on Android the declarative contentInset floor covers this same window. - const listMountKey = `${feedThreadKey}:${presentedFeed.length === 0 ? "empty" : "filled"}`; + // The thinking row a running thread shows while its messages load is not + // content: the list must still remount, and so open at the end, when they + // arrive. + const listMountKey = `${feedThreadKey}:${presentedFeed.some((entry) => entry.type !== "thinking") ? "filled" : "empty"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { From ee18e56f902f2ccd74dffdc50363051be02ae0ab Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 20:38:56 -0700 Subject: [PATCH 31/73] feat(observability): record event loop stalls in the server trace (#13697) Co-authored-by: Claude Opus 5.5 (1M context) --- .../observability/EventLoopMonitor.test.ts | 77 ++++++++++ .../src/observability/EventLoopMonitor.ts | 135 ++++++++++++++++++ apps/server/src/server.ts | 4 +- docs/operations/observability.md | 33 +++++ 4 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/observability/EventLoopMonitor.test.ts create mode 100644 apps/server/src/observability/EventLoopMonitor.ts diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts new file mode 100644 index 000000000000..fbe30157aec2 --- /dev/null +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -0,0 +1,77 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; + +import { type EventLoopReadings, layerWith, stallMs } from "./EventLoopMonitor.ts"; + +const ms = (value: number) => value * 1e6; + +// Node reports a stall of S as a gap of up to S + 1 s, the histogram resolution. +const stalled: EventLoopReadings = { + delayMaxNs: ms(5_950), + activeMs: 6_200, + utilization: 0.176, + usage: { + userCPUTime: 310_400, + systemCPUTime: 95_600, + majorPageFault: 8_412, + minorPageFault: 20_031, + involuntaryContextSwitches: 57, + }, + rssBytes: 1536 * 1024 * 1024, +}; +// Over the threshold as read, but not once the resolution is subtracted. +const quiet: EventLoopReadings = { ...stalled, delayMaxNs: ms(2_950) }; + +describe("EventLoopMonitor", () => { + it.effect("records a warning span only for samples that saw a stall", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + // The first sample covers startup, so the monitor discards it. + const samples = [stalled, quiet, stalled]; + + yield* Effect.gen(function* () { + yield* Layer.build(layerWith(Effect.succeed(Effect.sync(() => samples.shift() ?? quiet)))); + yield* TestClock.adjust("60 seconds"); + assert.lengthOf(spans, 0); + yield* TestClock.adjust("30 seconds"); + }).pipe(Effect.scoped, Effect.withTracer(tracer)); + + assert.deepStrictEqual( + spans.map((span) => span.name), + ["server.eventLoop.stall"], + ); + const [span] = spans; + assert.deepStrictEqual(Object.fromEntries(span!.attributes), { + delayMaxMs: 4_950, + utilization: 0.18, + cpuUserMs: 310, + cpuSystemMs: 96, + majorPageFaults: 8_412, + minorPageFaults: 20_031, + involuntaryContextSwitches: 57, + rssMb: 1536, + }); + assert.deepStrictEqual( + span!.events.map(([name, , attributes]) => [name, attributes["effect.logLevel"]]), + [["event loop stalled for 4950 ms", "WARN"]], + ); + }), + ); + + it("ignores delay the loop spent idle, such as a system sleep", () => { + // Waking from sleep reads as a long gap, but the loop was idle in poll for it. + const asleep: EventLoopReadings = { ...stalled, delayMaxNs: ms(600_000), activeMs: 900 }; + assert.isUndefined(stallMs(asleep)); + assert.strictEqual(stallMs({ ...asleep, activeMs: 600_000 }), 599_000); + }); +}); diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts new file mode 100644 index 000000000000..13b7b48c4cf7 --- /dev/null +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -0,0 +1,135 @@ +// @effect-diagnostics nodeBuiltinImport:off - only node:perf_hooks exposes the event loop delay histogram. +import * as NodePerfHooks from "node:perf_hooks"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +// Node's delay histogram wakes a native timer every RESOLUTION_MS and records the +// gap between wakeups, so an idle loop reads about RESOLUTION_MS and a stall of S +// reads between S and S + RESOLUTION_MS. We subtract the resolution, so a delay can +// undercount a stall by up to RESOLUTION_MS. With these values every stall over 3 s +// is caught, at 1 wakeup per second that never enters JS. +const RESOLUTION_MS = 1000; +const STALL_THRESHOLD_MS = 2000; +const SAMPLE_INTERVAL = "30 seconds"; + +/** One sample interval as Node reports it. Delay in ns, active time in ms, CPU in µs. */ +export interface EventLoopReadings { + readonly delayMaxNs: number; + readonly activeMs: number; + readonly utilization: number; + readonly usage: Pick< + NodeJS.ResourceUsage, + | "userCPUTime" + | "systemCPUTime" + | "majorPageFault" + | "minorPageFault" + | "involuntaryContextSwitches" + >; + readonly rssBytes: number; +} + +// Enables the delay histogram for the layer's lifetime. Each read returns the +// readings since the previous read and resets the histogram. Node skips the first +// gap after a reset, so a stall right at a sample boundary can be missed. +const makeNodeSampler = Effect.gen(function* () { + const histogram = yield* Effect.acquireRelease( + Effect.sync(() => { + const histogram = NodePerfHooks.monitorEventLoopDelay({ resolution: RESOLUTION_MS }); + histogram.enable(); + return histogram; + }), + (histogram) => Effect.sync(() => histogram.disable()), + ); + let elu = NodePerfHooks.performance.eventLoopUtilization(); + let usage = process.resourceUsage(); + + // @effect-diagnostics-next-line returnEffectInGen:off - the read effect is the result. + return Effect.sync(() => { + const nextElu = NodePerfHooks.performance.eventLoopUtilization(); + const nextUsage = process.resourceUsage(); + const loop = NodePerfHooks.performance.eventLoopUtilization(nextElu, elu); + const readings: EventLoopReadings = { + delayMaxNs: histogram.max, + activeMs: loop.active, + utilization: loop.utilization, + usage: { + userCPUTime: nextUsage.userCPUTime - usage.userCPUTime, + systemCPUTime: nextUsage.systemCPUTime - usage.systemCPUTime, + majorPageFault: nextUsage.majorPageFault - usage.majorPageFault, + minorPageFault: nextUsage.minorPageFault - usage.minorPageFault, + involuntaryContextSwitches: + nextUsage.involuntaryContextSwitches - usage.involuntaryContextSwitches, + }, + rssBytes: process.memoryUsage.rss(), + }; + histogram.reset(); + elu = nextElu; + usage = nextUsage; + return readings; + }); +}); + +/** + * Returns the stall to report for one sample in ms, or undefined when there was none. + */ +export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { + const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; + // A stall is time the loop spent running code, so it counts as active time. libuv's + // clock keeps running while the system sleeps on macOS and Windows, so a sleep also + // reads as delay, but the loop spent it idle in poll. + if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; + return delayMs; +}; + +/** + * Samples event loop health every 30 s and records a `server.eventLoop.stall` span + * with a warning when the loop stalled for more than 2 s, so stalls land in + * the local trace file and Settings > Diagnostics without OTLP. Takes the sampler + * so tests can inject readings. + */ +export const layerWith = ( + makeSampler: Effect.Effect, never, Scope.Scope>, +) => + Layer.effectDiscard( + Effect.gen(function* () { + const sample = yield* makeSampler; + const tick = Effect.gen(function* () { + const readings = yield* sample; + const delayMaxMs = stallMs(readings); + if (delayMaxMs === undefined) return; + const { utilization, usage, rssBytes } = readings; + // Root, as the stall has no caller to attach to. Warn level keeps it when + // T3CODE_TRACE_MIN_LEVEL is raised to cut trace noise. + yield* Effect.logWarning(`event loop stalled for ${delayMaxMs} ms`).pipe( + Effect.withSpan("server.eventLoop.stall", { + root: true, + level: "Warn", + attributes: { + delayMaxMs, + utilization: Math.round(utilization * 100) / 100, + cpuUserMs: Math.round(usage.userCPUTime / 1000), + cpuSystemMs: Math.round(usage.systemCPUTime / 1000), + majorPageFaults: usage.majorPageFault, + minorPageFaults: usage.minorPageFault, + involuntaryContextSwitches: usage.involuntaryContextSwitches, + rssMb: Math.round(rssBytes / 1024 / 1024), + }, + }), + ); + }); + const wait = Effect.sleep(SAMPLE_INTERVAL); + // The layer builds before the rest of the server, so the first sample covers + // startup work such as migrations and projection bootstrap. That can block the + // loop for seconds on a large database, so skip it rather than warn at every + // launch. Layers build outside any span, so this fiber retains no parent span. + yield* wait.pipe( + Effect.andThen(sample), + Effect.andThen(wait.pipe(Effect.andThen(tick), Effect.forever)), + Effect.forkScoped, + ); + }), + ); + +export const layer = layerWith(makeNodeSampler); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e64b0702eb83..c22b48519da2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -120,6 +120,7 @@ import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; +import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; @@ -183,7 +184,8 @@ export const HTTP_ROUTER_CONFIG = { // those finalizers get a chance to run. const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; const ResourceAttributionLayerLive = ResourceAttribution.layer; -const ApplicationObservabilityLive = ObservabilityLive.pipe( +const ApplicationObservabilityLive = EventLoopMonitor.layer.pipe( + Layer.provideMerge(ObservabilityLive), Layer.provideMerge(ResourceAttributionLayerLive), ); diff --git a/docs/operations/observability.md b/docs/operations/observability.md index d9dac58e2c3b..c6537f6eac71 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -87,6 +87,38 @@ Metrics are not written to a local file. If OTLP is not configured, metrics still exist in-process, but you will not have a local artifact to inspect. +### Event Loop Stalls + +`apps/server/src/observability/EventLoopMonitor.ts` samples the server's event loop every 30 s. When +the loop stalled for more than 2 s since the previous sample, it records a root +`server.eventLoop.stall` span with a warning. The span has trace level `Warn`, so it stays when +`T3CODE_TRACE_MIN_LEVEL` is `Warn`. The warning shows in Settings > Diagnostics unless OTLP logs are +on. The span time is when the sample ran, not when the stall happened. + +Some delay is not recorded: + +- `delayMaxMs` is the longest stall, and can undercount it by up to 1 s. The 2 s threshold applies to + this value, so a stall over 3 s is normally recorded, and a shorter one can be missed. A stall + that ends just as a sample runs can be missed too. +- Time the computer spends asleep reads as delay on macOS and Windows. So a sample only counts when + the loop was busy, not waiting for events, for at least `delayMaxMs`. Busy time covers the whole + window, so a short sleep in an otherwise busy window can still record a false stall. The span then + shows CPU time far below `delayMaxMs`. +- The first sample after launch is skipped. Startup work such as migrations and projection bootstrap + can block the loop for seconds on a large database. + +CPU times and page faults cover the whole process over the whole window since the previous sample. +The window is nominally 30 s, but a long stall delays the sample and makes the window longer. Other +work in the window can hide a wait, so only CPU time far below `delayMaxMs` proves the thread was +waiting. Read CPU together with page faults: + +- High `cpuSystemMs` with many page faults means memory pressure. Major faults are reads from disk or swap. + On macOS, reads from compressed memory are minor faults plus system CPU. +- High `cpuUserMs` with few page faults means JavaScript work or garbage collection. +- Low CPU with few major page faults points at synchronous disk I/O, such as SQLite reads or trace + file writes. +- Many `involuntaryContextSwitches` mean other processes were competing for the CPU. + ### Related Artifacts Provider event NDJSON files still exist for provider runtime streams. Those are separate from the main server trace file. @@ -612,6 +644,7 @@ Current high-value span and metric boundaries include: - git command execution and git hook events - terminal session lifecycle - sqlite query execution +- event loop stalls (`server.eventLoop.stall`) ### Current Constraints From 999161ef844a7a38c8a6d08195c1240759169832 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 20:39:46 -0700 Subject: [PATCH 32/73] perf(server): stop re-running git for every project each minute (#13689) Co-authored-by: Claude Opus 5.5 (1M context) --- .../ThreadPullRequestReactor.test.ts | 45 +++++++++++++++ .../orchestration/ThreadPullRequestReactor.ts | 15 ++++- .../RepositoryIdentityResolver.test.ts | 10 +++- .../src/project/RepositoryIdentityResolver.ts | 56 +++++++++---------- apps/server/src/ws.ts | 10 +++- 5 files changed, 100 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index 6e8b930a1ae6..cefd5bcb42b4 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -406,6 +406,51 @@ describe("ThreadPullRequestReactor", () => { ), ); + it.effect("refreshes the project identity when a turn adds the remote", () => + Effect.scoped( + Effect.gen(function* () { + const current = thread("new-remote"); + const fixture = yield* makeHarness({ + threads: [current], + project: { ...project, repositoryIdentity: null }, + branchPullRequest: () => Effect.succeed(branchPullRequest()), + resolveRepositoryIdentity: (_cwd, options) => + Effect.succeed(options?.refresh ? project.repositoryIdentity : null), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + + yield* fixture.publish({ + type: "thread.turn-diff-completed", + sequence: 2, + eventId: EventId.make("checkpoint-finished"), + aggregateKind: "thread", + aggregateId: current.id, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + threadId: current.id, + turnId: TurnId.make("turn"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: NOW, + }, + }); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("uses live worktrees and falls back to the project for removed worktrees", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 0d709a867e07..2d9d92d64364 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -164,8 +164,19 @@ export const make = Effect.gen(function* () { (group) => Effect.gen(function* () { const first = group[0]!; - const project = projects.get(first.projectId); - if (project === undefined) return finishBackfill(group); + const snapshotProject = projects.get(first.projectId); + if (snapshotProject === undefined) return finishBackfill(group); + // A finished turn may have added the remote this PR lives on. A failed + // refresh resolves to null, so keep the snapshot's identity then. + const project = request.refresh + ? { + ...snapshotProject, + repositoryIdentity: + (yield* repositoryIdentities.resolve(snapshotProject.workspaceRoot, { + refresh: true, + })) ?? snapshotProject.repositoryIdentity, + } + : snapshotProject; const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (first.branch !== null && repository === null) return finishBackfill(group); const worktreeExists = diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index d6ddb0b9263f..58f199b834e2 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -94,6 +94,8 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const first = yield* resolver.resolve("/repo/packages/web"); rootPath = "/repo/packages/web"; + // Longer than the one-minute cadence of the background sweeps. + yield* TestClock.adjust(Duration.minutes(10)); const second = yield* resolver.resolve("/repo/packages/web"); expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); @@ -123,10 +125,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const unavailable = yield* resolver.resolve(rootPath, { refresh: true }); expect(unavailable?.webUrl).toBeUndefined(); expect(unavailable?.canonicalKey).toBe("ssh.forge.test/team/repo"); - }).pipe(Effect.provide(resolverLayer)); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), resolverLayer))); }); - it.effect("retries Git root discovery after a failed lookup", () => { + it.effect("retries Git root discovery after the negative TTL", () => { const calls: Array> = []; let rootAttempts = 0; const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { @@ -159,7 +161,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { return Effect.gen(function* () { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + yield* TestClock.adjust(Duration.minutes(1)); const recovered = yield* resolver.resolve("/repo/packages/web"); expect(recovered?.rootPath).toBe("/repo"); expect(calls).toEqual([ @@ -167,7 +171,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], ["-C", "/repo", "remote", "-v"], ]); - }).pipe(Effect.provide(resolverLayer)); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), resolverLayer))); }); it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 2d7f5d02d02e..5acafa47e2e2 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -13,7 +13,11 @@ import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; -const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); +// Background sweeps resolve every project each minute. A long TTL keeps them +// from spawning git each time. Clone, publish, and PR discovery (after a turn +// and before it saves links) resolve with `refresh: true`. +const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15); +// Short, so a folder that gains a repository or a remote shows up quickly. const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); export interface RepositoryIdentityResolverOptions { @@ -142,20 +146,23 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const processRunner = yield* ProcessRunner.ProcessRunner; const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; const refine = options.refine ?? Effect.succeed; + // Git errors and timeouts resolve to null, so they use the negative TTL like + // "no repository" or "no remote". Only interrupts and defects skip the cache. + const timeToLive = (exit: Exit.Exit) => + Exit.match(exit, { + onSuccess: (value) => + value === null + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }); const repositoryRootCache = yield* Cache.makeWith( (cwd) => resolveRepositoryIdentityCacheKey(cwd).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), - { - capacity: cacheCapacity, - timeToLive: Exit.match({ - onSuccess: (value) => - value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), - onFailure: () => Duration.zero, - }), - }, + { capacity: cacheCapacity, timeToLive }, ); const repositoryIdentityCache = yield* Cache.makeWith( @@ -167,27 +174,20 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( (identity) => refine(identity).pipe(Effect.orElseSucceed(() => identity)), ), ), - { - capacity: cacheCapacity, - timeToLive: Exit.match({ - onSuccess: (value) => - value === null - ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) - : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), - onFailure: () => Duration.zero, - }), - }, + { capacity: cacheCapacity, timeToLive }, ); - const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( - "RepositoryIdentityResolver.resolve", - )(function* (cwd, options) { - if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); - const cacheKey = yield* Cache.get(repositoryRootCache, cwd); - if (cacheKey === null) return null; - if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); - return yield* Cache.get(repositoryIdentityCache, cacheKey); - }); + // Untraced because almost every call is a cache hit. The lookups that spawn + // git keep their own spans. + const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fnUntraced( + function* (cwd, options) { + if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); + const cacheKey = yield* Cache.get(repositoryRootCache, cwd); + if (cacheKey === null) return null; + if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); + return yield* Cache.get(repositoryIdentityCache, cacheKey); + }, + ); return RepositoryIdentityResolver.of({ resolve }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 830bb35b86db..077087a7d84e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3038,9 +3038,13 @@ const makeWsRpcLayer = ( [WS_METHODS.sourceControlPublishRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlPublishRepository, - sourceControlRepositories - .publishRepository(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + sourceControlRepositories.publishRepository(input).pipe( + // A new remote can change the cached identity. Only the `cwd` entry + // refreshes, so after a publish from a linked worktree the project + // root entry waits for its TTL. + Effect.tap(() => repositoryIdentityResolver.resolve(input.cwd, { refresh: true })), + Effect.tap(() => refreshGitStatus(input.cwd)), + ), { "rpc.aggregate": "source-control", }, From 94c42162b443072ca59a0733c02ee54229a61fef Mon Sep 17 00:00:00 2001 From: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:41:48 -0300 Subject: [PATCH 33/73] fix(usage): hide the Cursor keychain prompt when Cursor isn't set up (#13714) --- .../src/features/usage/UsageRouteScreen.tsx | 4 +- apps/mobile/src/state/usage.ts | 10 +++- apps/web/src/components/usage/UsagePage.tsx | 4 +- apps/web/src/state/usage.test.tsx | 1 + apps/web/src/state/usage.ts | 10 +++- .../client-runtime/src/state/usage.test.ts | 50 ++++++++++++++++++- packages/client-runtime/src/state/usage.ts | 19 ++++++- 7 files changed, 88 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 2d5e5398bc51..10878179d2ac 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -101,8 +101,8 @@ export function UsageRouteScreen() { ); const isFocused = useIsFocused(); const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); - const cursorAccessEnvironments = selectedEnvironments.filter((environment) => - environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), + const cursorAccessEnvironments = selectedEnvironments.filter( + (environment) => environment.needsCursorKeychainAccess, ); const refreshAfterCursorEnable = () => { void refresh(); diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index d49c26a40a44..c5895d353fef 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,7 +16,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus { readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; + readonly needsCursorKeychainAccess: boolean; } /** @@ -50,13 +51,18 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, isConnected: presentation.connection.phase === "connected", error: result._tag === "Failure" ? "This environment could not report usage." : null, - summary: Option.getOrNull(AsyncResult.value(result)), + summary, + needsCursorKeychainAccess: needsCursorKeychainAccess( + summary, + get(serverEnvironment.providersValueAtom(environmentId)), + ), }); } return statuses; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1387bb3a9d9e..5f2d7dd45632 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -121,8 +121,8 @@ export function UsagePage() { selectedEnvironmentIds, ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const cursorAccessEnvironments = selectedEnvironments.filter((environment) => - environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), + const cursorAccessEnvironments = selectedEnvironments.filter( + (environment) => environment.needsCursorKeychainAccess, ); const sourceMessages = [ ...new Set( diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index e01478e6a71a..c80b642b76b7 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -23,6 +23,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU label: id, isPending: cost === null, error: null, + needsCursorKeychainAccess: false, summary: cost === null ? null diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 617ac93e4b7c..f61bb9060384 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -13,7 +13,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; @@ -29,6 +29,7 @@ export interface EnvironmentUsageStatus { readonly isPending: boolean; readonly error: string | null; readonly summary: UsageSummary | null; + readonly needsCursorKeychainAccess: boolean; } /** @@ -46,12 +47,17 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, error: result._tag === "Failure" ? "This environment could not report usage." : null, - summary: Option.getOrNull(AsyncResult.value(result)), + summary, + needsCursorKeychainAccess: needsCursorKeychainAccess( + summary, + get(serverEnvironment.providersValueAtom(environmentId)), + ), }); } return statuses; diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 75977d20de74..55fe46306ddd 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -1,7 +1,10 @@ import { EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, UsageDay, USAGE_CONTRACT_VERSION, + type ServerProvider, type UsageSummary, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -10,7 +13,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; -import { refreshUsage, refreshUsageLimits } from "./usage.ts"; +import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -238,3 +241,48 @@ describe("limits refresh cooldown", () => { } }); }); + +describe("needsCursorKeychainAccess", () => { + const cursorPrompt: UsageSummary = { + ...summary, + sources: [ + { + fingerprint: { + hostId: "host", + provider: "cursor", + resolvedHomePath: "/Users/me/.cursor/auth.json", + volumeId: "volume", + }, + status: "ok", + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 0, + message: "Cursor account usage is off on this environment.", + action: "enableCursorKeychain", + }, + ], + }; + const cursor = (status: ServerProvider["status"]): ServerProvider => ({ + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + enabled: status !== "disabled", + installed: status === "ready", + version: null, + status, + auth: { status: "unknown" }, + checkedAt: "2026-09-05T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }); + + it("offers access only when Cursor is ready on that environment", () => { + expect(needsCursorKeychainAccess(cursorPrompt, [cursor("ready")])).toBe(true); + expect(needsCursorKeychainAccess(cursorPrompt, [cursor("error")])).toBe(false); + expect(needsCursorKeychainAccess(cursorPrompt, [cursor("disabled")])).toBe(false); + expect(needsCursorKeychainAccess(cursorPrompt, [])).toBe(false); + expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false); + expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 11cfd26fbe6c..dc959c0fd8f0 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,4 +1,9 @@ -import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; +import type { + EnvironmentId, + ServerProvider, + UsageSummary, + UsageSummaryInput, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import type { AtomRegistry } from "effect/unstable/reactivity"; @@ -9,6 +14,18 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); +/** Offer the Cursor Keychain prompt only where a working Cursor provider could use it. */ +export function needsCursorKeychainAccess( + summary: UsageSummary | null, + providers: readonly ServerProvider[] | null, +): boolean { + return ( + summary?.sources.some((source) => source.action === "enableCursorKeychain") === true && + providers?.some((provider) => provider.driver === "cursor" && provider.status === "ready") === + true + ); +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); From 04c15f34b756aadbff4c93ff07a217fa2398e5d5 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Sat, 26 Sep 2026 00:47:20 -0300 Subject: [PATCH 34/73] feat(web): add chat width setting for wide screens (#11594) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../src/components/chat/ComposerSurface.tsx | 2 +- .../components/chat/MessagesTimeline.logic.ts | 53 +++++++++++++------ .../components/chat/MessagesTimeline.test.tsx | 38 +++++++++---- .../src/components/chat/MessagesTimeline.tsx | 35 +++++++++--- .../components/settings/SettingsPanels.tsx | 42 +++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ apps/web/src/index.css | 13 +++++ apps/web/src/routes/__root.tsx | 5 ++ packages/contracts/src/settings.test.ts | 17 ++++++ packages/contracts/src/settings.ts | 6 +++ 12 files changed, 185 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cacb432f870d..b08bf12176a5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10060,7 +10060,7 @@ export default function ChatView(props: ChatViewProps) { >
{isDraftHeroState ? (
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fafe24e43973..d168c6db4e5f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -6165,7 +6165,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (isInsideRestingComposerControlScope(event.target)) return; composerMentionDragHandlers.onDrop(event); }} - className="mx-auto w-full min-w-0 max-w-3xl" + className="mx-auto w-full min-w-0 max-w-(--chat-max-width)" data-chat-composer-form="true" > {composerControlsInStrip && restingControlsHost diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx index bcb994085eb0..83e94895eddc 100644 --- a/apps/web/src/components/chat/ComposerSurface.tsx +++ b/apps/web/src/components/chat/ComposerSurface.tsx @@ -13,7 +13,7 @@ function Shell({ data-slot="composer-shell" data-with-context={contextStrip || undefined} className={cn( - "@container/composer-surface group/composer-surface relative isolate mx-auto w-full max-w-3xl", + "@container/composer-surface group/composer-surface relative isolate mx-auto w-full max-w-(--chat-max-width)", "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]", "dark:[--chat-composer-glass-surface:var(--surface-raised)] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]", "[html[data-theme-id]_&]:[--chat-composer-glass-surface:var(--app-theme-surface-raised)] [html[data-theme-id]_&]:[--chat-composer-outline:var(--app-theme-toolbar-border)]", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 57ed45a89d1d..21508a9663ae 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -42,7 +42,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay"; const TIMELINE_MINIMAP_ITEM_SPACING = 8; export const TIMELINE_MINIMAP_MIN_ITEMS = 2; const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; -const TIMELINE_CONTENT_MAX_WIDTH = 768; const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; function singleToolCallLabel(entry: WorkLogEntry): string { @@ -233,14 +232,25 @@ export function resolveTimelineMinimapCurrentIndex(input: { return precedingIndex; } -export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean { - if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { - return false; +/** + * Side gutter between the viewport edge and the centered content column. + * `contentWidth` is the rendered column width, which follows the Chat width + * setting, so callers measure it rather than assume a fixed maximum. + */ +function resolveTimelineSideGutter(viewportWidth: number, contentWidth: number): number { + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0 || !Number.isFinite(contentWidth)) { + return 0; } + return Math.max(0, (viewportWidth - Math.min(viewportWidth, contentWidth)) / 2); +} - const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); - const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2); - return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; +export function resolveTimelineMinimapHasPersistentGutter( + viewportWidth: number, + contentWidth: number, +): boolean { + return ( + resolveTimelineSideGutter(viewportWidth, contentWidth) >= TIMELINE_MINIMAP_PERSISTENT_GUTTER + ); } const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; @@ -249,18 +259,16 @@ const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; /** * The minimap overlays the viewport's left edge while the content column is - * centered, so the side gutter between them shrinks under browser zoom or a - * narrow pane. A fixed-width hover strip would then sit on top of the message + * centered, so the side gutter between them shrinks under browser zoom, a + * narrow pane, or a wider Chat width setting. A fixed-width hover strip would then sit on top of the message * text and swallow its pointer events. Cap the strip's width so it never * extends past the gutter into the content column; 0 disables the strip. */ -export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number { - if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { - return 0; - } - - const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH); - const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2); +export function resolveTimelineMinimapHitStripWidth( + viewportWidth: number, + contentWidth: number, +): number { + const sideGutter = resolveTimelineSideGutter(viewportWidth, contentWidth); return Math.max( 0, Math.min( @@ -270,6 +278,19 @@ export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): numb ); } +// The prev/next buttons are centered 4px into the strip and 20px wide, so +// their hitbox reaches 14px past the strip's left edge. +const TIMELINE_MINIMAP_NAVIGATION_REACH = 14; + +/** + * The prev/next buttons hang outside the strip's height, so the strip's own + * width cap does not cover them. Keep them inert to the pointer unless the + * gutter can hold them; keyboard focus still reaches them. + */ +export function resolveTimelineMinimapNavigationInteractive(collapsedWidth: number): boolean { + return collapsedWidth >= TIMELINE_MINIMAP_NAVIGATION_REACH; +} + /** * Once the preview is open, keep the full preview and the space leading to it * interactive. The collapsed strip remains gutter-capped so it cannot block diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 016e621be1dd..a87dc2c2a444 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -571,6 +571,7 @@ describe("MessagesTimeline", () => { resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, + resolveTimelineMinimapNavigationInteractive, resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); @@ -655,22 +656,39 @@ describe("MessagesTimeline", () => { itemBounds: [{ top: 80, height: 20 }], }), ).toBeNull(); - expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); - expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); + // Comfortable width: the column is capped at 768px. + expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(863, 768)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(864, 768)).toBe(true); + // Wider Chat width settings consume the gutter the minimap relies on. + expect(resolveTimelineMinimapHasPersistentGutter(1400, 1152)).toBe(true); + expect(resolveTimelineMinimapHasPersistentGutter(1200, 1152)).toBe(false); + expect(resolveTimelineMinimapHasPersistentGutter(2560, 2560)).toBe(false); // No usable gutter (zoomed in / narrow pane): the strip must go inert // instead of overlaying the centered content column. - expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(768, 768)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(792, 768)).toBe(0); // Partial gutter: strip shrinks to what fits between the viewport edge // and the content column. - expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14); + expect(resolveTimelineMinimapHitStripWidth(820, 768)).toBe(14); // Full gutter: unchanged 40px-wide strip. - expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40); - expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40); - expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0); - expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40); + expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40); + // Full Chat width: the column spans the viewport, so the strip is inert + // however wide the window gets. + expect(resolveTimelineMinimapHitStripWidth(2560, 2560)).toBe(0); + // Wide Chat width on a window just wider than the column: partial strip. + expect(resolveTimelineMinimapHitStripWidth(1204, 1152)).toBe(14); + expect(resolveTimelineMinimapHitStripWidth(0, 0)).toBe(0); + expect(resolveTimelineMinimapHitStripWidth(Number.NaN, 768)).toBe(0); + + // Prev/next buttons reach 14px past the strip's left edge; a narrower + // strip means they would sit on the content column. + expect(resolveTimelineMinimapNavigationInteractive(40)).toBe(true); + expect(resolveTimelineMinimapNavigationInteractive(14)).toBe(true); + expect(resolveTimelineMinimapNavigationInteractive(8)).toBe(false); + expect(resolveTimelineMinimapNavigationInteractive(0)).toBe(false); // The collapsed target stays narrow, but an open preview keeps its full // 20rem width plus the 2rem offset from the minimap rail interactive. diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 755657e794ab..f812f67f2bd4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -189,6 +189,7 @@ import { resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, + resolveTimelineMinimapNavigationInteractive, resolveTimelineMinimapTopPercent, resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, @@ -239,6 +240,7 @@ import { chatMarkdownClipboardPayload } from "../../markdown-clipboard"; import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip"; import { createContextPresentationRegistry } from "../contextPresentationRegistry"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { useClientSettings } from "~/hooks/useSettings"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; @@ -346,7 +348,7 @@ function TimelineLoadEarlierHeader({ }) { return (
-
+
- ) : null} - {disconnectAction} - - ), - }); - } + items.push({ + id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, + variant: unavailableConnection.phase === "error" ? "error" : "warning", + icon: , + title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, + actions: ( + <> + {!environmentReconnecting ? ( + + ) : null} + {disconnectAction} + + ), + }); } if ( !automaticEnvironment && serverUpdateEnvironmentId && - !reconnectingThroughVersionSkew && (serverUpdateState.status === "idle" ? showVersionMismatchBanner : !serverUpdateFailureDismissed) From d110f9867041f0466bed9b032c15efcfcefcbfc5 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 26 Sep 2026 15:09:03 -0500 Subject: [PATCH 66/73] test(usage): assert contract mismatch details (#13861) --- packages/shared/src/usageMerge.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 7db9da394dac..81d1e8565a6c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -433,7 +433,7 @@ describe("mergeUsage", () => { const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); expect(merged.costUsd).toBe(10); expect(merged.totalTokens).toBe(1160); - expect(merged.staleEnvironments).toEqual([]); + expect(merged.contractMismatches).toEqual([]); }); it("normalizes model names during decoding before grouping usage", () => { @@ -479,7 +479,13 @@ describe("mergeUsage", () => { ); const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); expect(merged.costUsd).toBe(0); - expect(merged.staleEnvironments).toEqual(["env-a"]); + expect(merged.contractMismatches).toEqual([ + { + environmentId: "env-a", + direction: "clientBehind", + contractVersion: USAGE_CONTRACT_VERSION + 1, + }, + ]); }); it("derives provider shares and cost quality", () => { From ea7d46ac107e6a3461b81065ebc86dfd5483095f Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 26 Sep 2026 15:38:24 -0500 Subject: [PATCH 67/73] fix(build): validate Linux node-pty prebuilds in Windows artifacts (#13867) --- pnpm-lock.yaml | 17 ++----- scripts/build-desktop-artifact.test.ts | 63 +++++++++++++++++++++++++- scripts/build-desktop-artifact.ts | 13 ++++-- 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c001be981c10..f579bfa8a5a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10438,10 +10438,6 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} - engines: {node: '>=16'} - tough-cookie@6.0.2: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} @@ -18908,7 +18904,7 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.2 + lru-cache: 11.5.3 parse5: 8.0.1 saxes: 6.0.0 tough-cookie: 6.0.2 @@ -20013,7 +20009,7 @@ snapshots: rettime: 0.10.1 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.1 + tough-cookie: 6.0.2 type-fest: 5.7.0 until-async: 3.0.2 yargs: 17.7.2 @@ -20039,7 +20035,7 @@ snapshots: rettime: 0.10.1 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.1 + tough-cookie: 6.0.2 type-fest: 5.7.0 until-async: 3.0.2 yargs: 17.7.2 @@ -20516,7 +20512,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.5.2 + lru-cache: 11.5.3 minipass: 7.1.3 path-to-regexp@6.1.0: {} @@ -21953,11 +21949,6 @@ snapshots: totalist@3.0.1: {} - tough-cookie@6.0.1: - dependencies: - tldts: 7.4.2 - optional: true - tough-cookie@6.0.2: dependencies: tldts: 7.4.2 diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 4e97301ddba8..98ba0aaefbc8 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -175,6 +175,8 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu readonly copyUnpackedNatives: boolean; readonly serverEntrySource?: string; readonly wslRuntime?: "valid" | "loose-server-tree" | "missing-pty" | "bad-digest"; + readonly targetArch?: "x64" | "arm64"; + readonly ptyPrebuildArch?: "x64" | "arm64"; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -212,7 +214,7 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); if (input.wslRuntime !== undefined) { - const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64"); + const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, input.targetArch ?? "x64"); const sourceArchivePath = input.wslRuntime === "loose-server-tree" ? // The old hand-rolled runtime: apps/server/dist + node_modules at the @@ -226,9 +228,16 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu : yield* makeLinuxCliArchiveFixture({ root: path.join(tempDir, "wsl-runtime"), stem, - ...(input.wslRuntime === "missing-pty" + ...(input.wslRuntime === "missing-pty" || input.ptyPrebuildArch !== undefined ? { omitMembers: [`${stem}/node_modules/node-pty/build/Release/pty.node`] } : {}), + ...(input.ptyPrebuildArch !== undefined + ? { + extraMembers: [ + `${stem}/node_modules/node-pty/prebuilds/linux-${input.ptyPrebuildArch}/pty.node`, + ], + } + : {}), }); const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME); const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME); @@ -1206,6 +1215,55 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ).pipe(Effect.provideService(HostProcessPlatform, "linux")), ); + for (const targetArch of ["x64", "arm64"] as const) { + it.effect(`accepts an embedded archive with the Linux ${targetArch} node-pty prebuild`, () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "valid", + targetArch, + ptyPrebuildArch: targetArch, + }); + const result = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch, + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + expectWslRuntime: true, + }); + + assert.equal(result.packagedAppDir, fixture.packagedAppDir); + }), + ).pipe(Effect.provideService(HostProcessPlatform, "linux")), + ); + + it.effect( + `rejects a node-pty prebuild for the wrong architecture in a Linux ${targetArch} archive`, + () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "valid", + targetArch, + ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64", + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch, + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + expectWslRuntime: true, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "wsl-runtime-invalid"); + }), + ), + ); + } + it.effect("rejects an embedded archive built for a different release version", () => Effect.scoped( Effect.gen(function* () { @@ -1285,6 +1343,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(error.reason, "wsl-runtime-invalid"); assert.deepStrictEqual(error.missingFiles, [ `${wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64")}/node_modules/node-pty/build/Release/pty.node`, + `${wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64")}/node_modules/node-pty/prebuilds/linux-x64/pty.node`, ]); }), ), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index d2d9df767946..5b22fddbb39a 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -3291,13 +3291,16 @@ export const validateWindowsPackagedPayload = Effect.fn( ), ); } - const requiredMembers = [ - `${stem}/t3`, - `${stem}/client`, - `${stem}/node_modules`, + const requiredMembers = [`${stem}/t3`, `${stem}/client`, `${stem}/node_modules`]; + const missingMembers = requiredMembers.filter((member) => !members.includes(member)); + // node-pty can load a source build or the prebuild for the WSL target. + const ptyCandidates = [ `${stem}/node_modules/node-pty/build/Release/pty.node`, + `${stem}/node_modules/node-pty/prebuilds/linux-${input.targetArch}/pty.node`, ]; - const missingMembers = requiredMembers.filter((member) => !members.includes(member)); + if (!ptyCandidates.some((member) => members.includes(member))) { + missingMembers.push(...ptyCandidates); + } if (missingMembers.length > 0) { return yield* new WindowsPackagedPayloadValidationError({ reason: "wsl-runtime-invalid", From 679c34c096193446ceed62e633387d91fe2cfff8 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 26 Sep 2026 16:23:10 -0500 Subject: [PATCH 68/73] fix(clients): hide duplicate Cursor Keychain prompts (#13870) Co-authored-by: Claude Opus 5.5 --- .../src/features/usage/UsageRouteScreen.tsx | 5 +-- .../provider/cursorCredentialStore.test.ts | 26 ++++++++++++- .../src/provider/cursorCredentialStore.ts | 30 +++++++++++++-- .../src/usage/cursorUsageReader.test.ts | 22 +++++++++++ apps/server/src/usage/cursorUsageReader.ts | 9 ++++- apps/web/src/components/usage/UsagePage.tsx | 9 +++-- .../client-runtime/src/state/usage.test.ts | 37 ++++++++++++++++++- packages/client-runtime/src/state/usage.ts | 21 +++++++++++ 8 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/usage/cursorUsageReader.test.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 8fb2fe37aa8d..4582c3135690 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,6 +1,7 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; +import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -102,9 +103,7 @@ export function UsageRouteScreen() { ); const isFocused = useIsFocused(); const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); - const cursorAccessEnvironments = selectedEnvironments.filter( - (environment) => environment.needsCursorKeychainAccess, - ); + const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); const refreshAfterCursorEnable = () => { void refresh(); void limits.refreshAfterEnable(); diff --git a/apps/server/src/provider/cursorCredentialStore.test.ts b/apps/server/src/provider/cursorCredentialStore.test.ts index 051cb5425c69..f212ce110ea6 100644 --- a/apps/server/src/provider/cursorCredentialStore.test.ts +++ b/apps/server/src/provider/cursorCredentialStore.test.ts @@ -1,6 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; -import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.ts"; +import { + CursorKeychainTimeoutError, + makeCachedCursorAccessTokenReader, +} from "./cursorCredentialStore.ts"; describe("Cursor Keychain reader", () => { it("shares concurrent reads and rechecks after the cache expires", async () => { @@ -19,4 +22,25 @@ describe("Cursor Keychain reader", () => { time = 5 * 60_000; assert.strictEqual(await read(), "token-2"); }); + + it("gives up on an unanswered prompt and reuses it on the next read", async () => { + let reads = 0; + let allow: (token: string) => void = () => {}; + const read = makeCachedCursorAccessTokenReader( + () => { + reads++; + return new Promise((resolve) => { + allow = resolve; + }); + }, + () => 0, + 1, + ); + const error = await read().catch((cause: unknown) => cause); + assert.instanceOf(error, CursorKeychainTimeoutError); + const retry = read(); + allow("token"); + assert.strictEqual(await retry, "token"); + assert.strictEqual(reads, 1); + }); }); diff --git a/apps/server/src/provider/cursorCredentialStore.ts b/apps/server/src/provider/cursorCredentialStore.ts index 9d7d1c3bccff..2857683e4821 100644 --- a/apps/server/src/provider/cursorCredentialStore.ts +++ b/apps/server/src/provider/cursorCredentialStore.ts @@ -4,17 +4,31 @@ const CACHE_MS = 5 * 60_000; const requireForKeyring = NodeModule.createRequire(import.meta.url); -/** Share one Keychain request across usage history and limits in this server process. */ +/** Rejected when nobody answers the macOS Keychain prompt in time. */ +export class CursorKeychainTimeoutError extends Error { + constructor() { + super("Timed out waiting for Keychain access."); + } +} + +/** + * Share one Keychain request across usage history and limits in this server process. + * + * macOS shows the access prompt on the server's own screen, which a remote + * client cannot answer, so callers give up after `timeoutMs`. The read stays in + * flight: the next call reuses it instead of stacking a second prompt, and picks + * up the token once someone allows access. + */ export function makeCachedCursorAccessTokenReader( read: () => Promise, now: () => number = Date.now, + timeoutMs = 30_000, ): () => Promise { let cached: { token: string; until: number } | null = null; let pending: Promise | null = null; return () => { if (cached && cached.until > now()) return Promise.resolve(cached.token); - if (pending) return pending; - pending = read() + pending ??= read() .then((token) => { cached = token ? { token, until: now() + CACHE_MS } : null; return token; @@ -22,7 +36,15 @@ export function makeCachedCursorAccessTokenReader( .finally(() => { pending = null; }); - return pending; + const deadline = AbortSignal.timeout(timeoutMs); + return Promise.race([ + pending, + new Promise((_, reject) => { + deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { + once: true, + }); + }), + ]); }; } diff --git a/apps/server/src/usage/cursorUsageReader.test.ts b/apps/server/src/usage/cursorUsageReader.test.ts new file mode 100644 index 000000000000..4bfa647c0aff --- /dev/null +++ b/apps/server/src/usage/cursorUsageReader.test.ts @@ -0,0 +1,22 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { CursorKeychainTimeoutError } from "../provider/cursorCredentialStore.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; + +describe("readCursorAccountUsage", () => { + it("asks for Keychain approval when the prompt goes unanswered", async () => { + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1, + () => Promise.reject(new Error("no network expected")), + () => Promise.reject(new CursorKeychainTimeoutError()), + ); + assert.deepStrictEqual(result, { + accountKey: null, + records: [], + missing: false, + error: "Allow Keychain access on the Mac running T3 Code, then refresh.", + }); + }); +}); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 1e07fcc7a825..573506d818a1 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -5,7 +5,10 @@ import * as NodeCrypto from "node:crypto"; import * as NodeTimersPromises from "node:timers/promises"; import type { UsageRecord } from "./usageTranscripts.ts"; -import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts"; +import { + CursorKeychainTimeoutError, + readMacCursorAccessToken, +} from "../provider/cursorCredentialStore.ts"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -86,7 +89,9 @@ export async function readCursorAccountUsage( ? null : typeof credentialSource === "string" ? "Cursor credentials could not be read." - : "Cursor Keychain credentials could not be read.", + : cause instanceof CursorKeychainTimeoutError + ? "Allow Keychain access on the Mac running T3 Code, then refresh." + : "Cursor Keychain credentials could not be read.", }; } if (typeof accessToken !== "string" || !accessToken) { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index e978963ce9b5..3ced333c9633 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -13,7 +13,10 @@ import { SlidersHorizontalIcon, } from "lucide-react"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; +import { + cursorKeychainAccessEnvironments, + refreshUsageLimits, +} from "@t3tools/client-runtime/state/usage"; import { isCompatibleUsageContractVersion, @@ -129,9 +132,7 @@ export function UsagePage() { selectedEnvironmentIds, ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const cursorAccessEnvironments = selectedEnvironments.filter( - (environment) => environment.needsCursorKeychainAccess, - ); + const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); const sourceMessages = [ ...new Set( selectedEnvironments.flatMap( diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 55fe46306ddd..86914c768e3b 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -13,7 +13,12 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; -import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts"; +import { + cursorKeychainAccessEnvironments, + needsCursorKeychainAccess, + refreshUsage, + refreshUsageLimits, +} from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -285,4 +290,34 @@ describe("needsCursorKeychainAccess", () => { expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false); expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false); }); + + it("stops offering access once any environment reads the Cursor account", () => { + const off = { summary: cursorPrompt, needsCursorKeychainAccess: true }; + const account: UsageSummary = { + ...summary, + sources: [ + { + fingerprint: { + hostId: "cursor.com", + provider: "cursor", + resolvedHomePath: "cursor-account:abc", + volumeId: "abc", + }, + status: "ok", + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 1, + message: null, + }, + ], + }; + expect(cursorKeychainAccessEnvironments([off, off])).toEqual([off, off]); + expect( + cursorKeychainAccessEnvironments([ + off, + { summary: account, needsCursorKeychainAccess: false }, + ]), + ).toEqual([]); + }); }); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index dc959c0fd8f0..1f2377a89f42 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -26,6 +26,27 @@ export function needsCursorKeychainAccess( ); } +/** + * Environments to offer the Cursor Keychain prompt in the usage summary. + * + * Any environment reading a Cursor account already reports that account's + * history from every machine, so the prompt only adds duplicates there. A + * different account on another machine stays reachable from provider settings. + */ +export function cursorKeychainAccessEnvironments< + E extends { readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean }, +>(environments: readonly E[]): readonly E[] { + const hasCursorAccount = environments.some((environment) => + environment.summary?.sources.some( + (source) => + source.fingerprint.provider === "cursor" && source.fingerprint.hostId === "cursor.com", + ), + ); + return hasCursorAccount + ? [] + : environments.filter((environment) => environment.needsCursorKeychainAccess); +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>(); From 74ee5153e05eb183dabec413ff9025d83d2058e4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 26 Sep 2026 18:05:09 -0400 Subject: [PATCH 69/73] feat(observability): honor OTEL_*_EXPORTER=none per signal (#13736) Signed-off-by: Yordis Prieto --- .../backend/DesktopBackendConfiguration.ts | 3 + docs/operations/observability.md | 5 ++ packages/shared/src/otelEnvironment.test.ts | 86 +++++++++++++++++++ packages/shared/src/otelEnvironment.ts | 70 +++++++++++++-- 4 files changed, 155 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index b3bec8830ec6..c2012299802d 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -118,6 +118,9 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", + "OTEL_TRACES_EXPORTER", + "OTEL_METRICS_EXPORTER", + "OTEL_LOGS_EXPORTER", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; diff --git a/docs/operations/observability.md b/docs/operations/observability.md index c6537f6eac71..910bd018bcc7 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -631,6 +631,11 @@ machine that sets `OTEL_SDK_DISABLED` for everything else. It accepts the usual OpenTelemetry specification and only `true` disables export, so `OTEL_SDK_DISABLED=1` does not. Values are case-insensitive and trimmed. An unrecognized value is ignored with a startup warning. +`OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, or `OTEL_LOGS_EXPORTER` set to `none` turns off +just that signal, overriding an OTEL endpoint and the Settings endpoint. A `T3CODE_OTLP_*_URL` still +wins for its signal. `otlp` is the default, and any other exporter name, such as `console` or +`prometheus`, is ignored with a startup warning. + ### What Is Instrumented Today Current high-value span and metric boundaries include: diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts index cd4e683faa6b..e436c64c6360 100644 --- a/packages/shared/src/otelEnvironment.test.ts +++ b/packages/shared/src/otelEnvironment.test.ts @@ -204,6 +204,73 @@ describe("OtelEnvironment", () => { logs: "Unset", warnings: [T3_OFF], }, + { + name: "an exporter of none turns off only its signal", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_LOGS_EXPORTER: "none", + }, + traces: "https://collector:4318/v1/traces", + metrics: "https://collector:4318/v1/metrics", + logs: "Off", + warnings: [], + }, + { + name: "an exporter of none turns its signal off with no endpoint named", + env: { OTEL_METRICS_EXPORTER: " NONE " }, + traces: "Unset", + metrics: "Off", + logs: "Unset", + warnings: [], + }, + { + name: "an exporter of none leaves its signal's endpoint unread", + env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", OTEL_TRACES_EXPORTER: "none" }, + traces: "Off", + metrics: "Unset", + logs: "Unset", + warnings: [], + }, + { + name: "an exporter of otlp or blank is the default", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_TRACES_EXPORTER: "OTLP", + OTEL_LOGS_EXPORTER: " ", + }, + traces: "https://collector:4318/v1/traces", + metrics: "https://collector:4318/v1/metrics", + logs: "https://collector:4318/v1/logs", + warnings: [], + }, + { + name: "an exporter T3 Code does not have is ignored with a warning", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_METRICS_EXPORTER: "prometheus", + }, + traces: "https://collector:4318/v1/traces", + metrics: "https://collector:4318/v1/metrics", + logs: "https://collector:4318/v1/logs", + warnings: [ + "OTEL_METRICS_EXPORTER names prometheus, which T3 Code does not export to, so it was ignored", + ], + }, + { + name: "a list honors none and otlp and names what it ignored", + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318", + OTEL_TRACES_EXPORTER: "console,none", + OTEL_LOGS_EXPORTER: "otlp,console,otlpp", + }, + traces: "Off", + metrics: "https://collector:4318/v1/metrics", + logs: "https://collector:4318/v1/logs", + warnings: [ + "OTEL_TRACES_EXPORTER names console, which T3 Code does not export to, so it was ignored", + "OTEL_LOGS_EXPORTER names console, otlpp, which T3 Code does not export to, so they were ignored", + ], + }, ])("$name", ({ env, traces, metrics, logs, warnings }) => Effect.gen(function* () { const resolved = yield* load(env); @@ -396,6 +463,25 @@ describe("OtelEnvironment", () => { }); }); + it.effect("an exporter of none keeps the Settings endpoint from re-enabling its signal", () => + Effect.gen(function* () { + const otel = yield* load({ OTEL_LOGS_EXPORTER: "none" }); + const t3 = { + url: undefined, + export: { protocol: "http/json", headers: undefined, exportIntervalMs: 10_000 }, + } as const; + assert.strictEqual( + OtelEnvironment.resolveSignalEndpoint(otel, "logs", t3, "http://settings:4318/v1/logs"), + undefined, + ); + assert.strictEqual( + OtelEnvironment.resolveSignalEndpoint(otel, "traces", t3, "http://settings:4318/v1/traces") + ?.url, + "http://settings:4318/v1/traces", + ); + }), + ); + describe("layerResourceAttributes", () => { it.effect.each([ { name: "a list that does not decode", raw: "team=%zz", attributes: [] }, diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 05a787dcfede..c2496bd28a52 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -1,5 +1,5 @@ /** - * otelEnvironment: the OpenTelemetry kill switch and endpoint variables, + * otelEnvironment: the OpenTelemetry kill switch, exporter, and endpoint variables, * shared by the server and the desktop main process so both agree on what * turns export off and where it goes. * @@ -23,8 +23,9 @@ type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; /** * What the OTEL variables say about one signal. `Off` is a signal they - * claimed with an endpoint, protocol, or headers that do not read, so it is - * exported nowhere rather than to the bootstrap or Settings collector. + * claimed with an endpoint, protocol, or headers that do not read, or turned + * off with `OTEL__EXPORTER=none`, so it is exported nowhere rather + * than to the bootstrap or Settings collector. */ export type OtelSignal = Data.TaggedEnum<{ Unset: {}; @@ -179,6 +180,38 @@ const headers = (name: string) => `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`, ); +type Exporter = "otlp" | "none"; + +const EXPORTERS: ReadonlySet = new Set(["otlp", "none"]); + +const isExporter = (entry: string): entry is Exporter => EXPORTERS.has(entry); + +/** + * `OTEL__EXPORTER`, a case-insensitive list whose default is `otlp`. + * Entries T3 Code has no exporter for are named in a warning and dropped, and + * a list left with nothing to honor reads as unset, as the specification asks + * of any enum value an implementation does not recognize. + */ +const exporter = (name: string): Config.Config> => + Config.String(name).pipe( + Config.option, + Config.map((option): Setting => { + const entries = (Option.getOrUndefined(option) ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry !== ""); + const ignored = [...new Set(entries.filter((entry) => !isExporter(entry)))]; + const known = new Set(entries.filter(isExporter)); + const value = known.has("otlp") ? "otlp" : known.has("none") ? "none" : undefined; + return ignored.length === 0 + ? { value } + : { + value, + warning: `${name} names ${ignored.join(", ")}, which T3 Code does not export to, so ${ignored.length === 1 ? "it was" : "they were"} ignored`, + }; + }), + ); + interface Settings { readonly endpoint: Setting; readonly protocol: Setting; @@ -215,9 +248,23 @@ interface ResolvedSignal { /** * A signal whose endpoint, protocol, or headers do not read is not exported * rather than sent somewhere, in a format, or without the credentials its - * collector expects. + * collector expects. `none` turns the signal off before any of those are read, + * whether or not an endpoint was named. */ -const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { +const signal = ( + name: OtlpSignalName, + exporter: Setting, + own: Settings, + generic: Settings, +): ResolvedSignal => { + if (exporter.value === "none") { + return { signal: OtelSignal.Off(), used: [exporter] }; + } + const resolved = endpointSignal(name, own, generic); + return { signal: resolved.signal, used: [exporter, ...resolved.used] }; +}; + +const endpointSignal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { const ownEndpoint = isClaimed(own.endpoint); const endpoint = ownEndpoint ? own.endpoint : generic.endpoint; if (endpoint.value === undefined) { @@ -263,16 +310,21 @@ export const load: Effect.Effect = Config.all({ traces: settings("OTEL_EXPORTER_OTLP_TRACES_"), metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"), logs: settings("OTEL_EXPORTER_OTLP_LOGS_"), + exporters: Config.all({ + traces: exporter("OTEL_TRACES_EXPORTER"), + metrics: exporter("OTEL_METRICS_EXPORTER"), + logs: exporter("OTEL_LOGS_EXPORTER"), + }), }).pipe( - Effect.map(({ t3, spec, resource, generic, ...own }) => { + Effect.map(({ t3, spec, resource, generic, exporters, ...own }) => { const disabled = t3.value ?? spec.value ?? false; // The kill switch wins outright, so the signals say nothing once it is set. const signals = disabled ? undefined : { - traces: signal("TRACES", own.traces, generic), - metrics: signal("METRICS", own.metrics, generic), - logs: signal("LOGS", own.logs, generic), + traces: signal("TRACES", exporters.traces, own.traces, generic), + metrics: signal("METRICS", exporters.metrics, own.metrics, generic), + logs: signal("LOGS", exporters.logs, own.logs, generic), }; // A generic variable read by several signals warns once. const used = new Set( From c9a0e8a119271765196dec38ef91395713da4213 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sun, 27 Sep 2026 00:06:54 +0200 Subject: [PATCH 70/73] fix(web): align reasoning arrows with tool calls (#13850) --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a1d8176207ae..bf7faa735fed 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2783,7 +2783,7 @@ function ReasoningTraceBlock({ type="button" aria-expanded={expanded} onClick={() => ctx.onToggleReasoning(first.id, !expanded, anchorKey)} - className="flex min-h-6 cursor-pointer select-none items-center gap-1.5 rounded-md px-0.5 text-start text-sm leading-relaxed transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" + className="flex min-h-6 cursor-pointer select-none items-center gap-1.5 rounded-md ps-0.5 pe-2 text-start text-sm leading-relaxed transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > From cb97415948cc2c0662a5504ccfe7e97981825ed5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 26 Sep 2026 20:15:22 -0700 Subject: [PATCH 71/73] fix(server): let agents use simctl and adb alongside device tools (#13908) --- apps/server/src/mcp/toolkits/device/handlers.ts | 2 +- apps/server/src/provider/CodexDeveloperInstructions.ts | 2 +- docs/internals/devices.md | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index d2c23860c9b7..b6a8ed3d6323 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -59,7 +59,7 @@ export function agentDeviceQuickStart( ` ${executable} screenshot /tmp/shot.png ${target} # or call device_screenshot`, ` ${executable} install ${target}`, `Prefer snapshot refs over coordinates. Run ${executable} help for workflow guides and ${executable} --help for flags.`, - "Do not call simctl, adb, xcrun, or serve-sim directly while these tools are attached; use agent-device.", + "Prefer agent-device for driving this device. simctl, adb, and xcrun remain available for anything it does not cover.", "For remote hosts, arrange builds, app installation, and any Metro reverse forwarding yourself. T3 provides discovery, streaming, and control only.", "Keep the returned --config and --session flags on every command. Other hosts can be used concurrently; opening one does not switch these commands.", platformNotes, diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 2ed593d159bc..e134ebfc68cb 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -12,7 +12,7 @@ Do not switch to global browser skills, Chrome, Node REPL browser automation, st const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices -The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.`; +The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Prefer these tools and \`agent-device\` for opening and driving devices. Platform tools such as \`xcrun simctl\` and \`adb\` remain available for anything they do not cover, such as builds, logs, or port forwarding. If \`device_list\` reports a platform as unavailable, say so.`; export interface T3CodeToolAvailability { readonly browser: boolean; diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 9b34851439ca..0d37f4c379f9 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -69,7 +69,8 @@ already-running agent without the CLI. How to drive a device is returned from `device_open`, not kept in an always-loaded prompt or skill: it costs nothing in threads that never open a device and cannot drift from the pinned CLI version. The always-on prompt block -is a few lines that point at the tools and forbid raw `simctl` and `adb`. +is a few lines that point at the tools and prefer them over raw `simctl` and +`adb`, which stay available for what the tools do not cover. ## The viewer decodes both vendored protocols From ab099178a7b7f9728843e90fc95ed90bb61d710d Mon Sep 17 00:00:00 2001 From: Bear Huddleston Date: Sat, 26 Sep 2026 22:29:54 -0500 Subject: [PATCH 72/73] fix(web): environment status tooltip resizes when the status changes (#13845) --- .../settings/ConnectionsSettings.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index c00af9f88164..cf37a4167c4d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1549,6 +1549,18 @@ function SavedBackendListRow({ versionMismatch !== null && (serverUpdateState.status === "idle" || serverUpdateState.status === "failed"); + const statusTooltip = `${ + unsupported + ? (environment.connection.error ?? connectionStatusText(environment.connection)) + : enabled + ? connectionStatusText(environment.connection) + : "Switched off" + }${ + versionMismatch + ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` + : "" + }`; + return ( + {/* The status can change while the tooltip is open, and base-ui only + re-measures the popup when the trigger's payload changes. */} - {unsupported - ? (environment.connection.error ?? connectionStatusText(environment.connection)) - : enabled - ? connectionStatusText(environment.connection) - : "Switched off"} - {versionMismatch - ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` - : ""} + {statusTooltip} } From 6b50eb08ed57477b86e506173e52da446a1c54c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 06:59:29 +0000 Subject: [PATCH 73/73] fix(mobile): don't open Android markdown links on text-selection drag Nested selectable text reports an empty press rect, so finger-up still fired onPress after a selection drag. Track movement in the press handlers and ignore the press once it passes the selection slop. Fixes #13766 Co-authored-by: maco --- .../src/NativeMarkdownSelectableText.tsx | 93 +++++++-- .../src/androidLinkPress.test.ts | 129 ++++++++++++ .../t3-markdown-text/src/androidLinkPress.ts | 184 ++++++++++++++++++ 3 files changed, 389 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/src/androidLinkPress.test.ts create mode 100644 apps/mobile/modules/t3-markdown-text/src/androidLinkPress.ts diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index 1d9c2b93ede8..0ef49b9c6540 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useMemo } from "react"; +import { createContext, useCallback, useContext, useMemo, useRef } from "react"; import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { findNodeHandle, @@ -7,11 +7,14 @@ import { Platform, StyleSheet, Text as RNText, + type AccessibilityActionEvent, + type GestureResponderEvent, type TextStyle, useColorScheme, View, } from "react-native"; +import { stepAndroidLinkPress, type AndroidLinkGesture } from "./androidLinkPress"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; import { markdownLinkIconSource } from "./markdownLinkIcons"; @@ -192,6 +195,17 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle }; } +/** Opens a markdown link through the block callback, or the system URL handler. */ +function openMarkdownLink(href: string, onLinkPress: ((href: string) => void) | undefined) { + if (onLinkPress) onLinkPress(href); + else void Linking.openURL(href); +} + +/** + * Renders one selectable markdown text block. + * On Android, a link still opens on tap, and a finger that has moved far enough + * to select text does not open it. + */ export function NativeMarkdownSelectableText(props: { readonly runs: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; @@ -325,6 +339,45 @@ export function NativeMarkdownSelectableText(props: { props.textStyle.dividerColor, props.textStyle.contextChipBorderColor, ].join(":"); + // One gesture for the block: a nested link is not a real view, so only one + // press is live. Read it from the handlers below, never while rendering. + const androidLinkGesture = useRef(null); + + /** Records where an Android markdown link press started. */ + function beginAndroidMarkdownLinkPress(event: GestureResponderEvent) { + androidLinkGesture.current = stepAndroidLinkPress( + androidLinkGesture.current, + "start", + event, + ).gesture; + } + + /** Marks the press as a selection drag once the finger passes the slop. */ + function trackAndroidMarkdownLinkPress(event: GestureResponderEvent) { + androidLinkGesture.current = stepAndroidLinkPress( + androidLinkGesture.current, + "move", + event, + ).gesture; + } + + /** Drops a press the responder system cancelled before finger-up. */ + function cancelAndroidMarkdownLinkPress() { + androidLinkGesture.current = stepAndroidLinkPress(androidLinkGesture.current, "cancel").gesture; + } + + /** + * Opens a markdown link on finger-up. + * On Android, a selection drag leaves the native selection in place instead. + */ + function pressMarkdownLink(href: string, event: GestureResponderEvent) { + if (Platform.OS === "android") { + const transition = stepAndroidLinkPress(androidLinkGesture.current, "end", event); + androidLinkGesture.current = transition.gesture; + if (!transition.open) return; + } + openMarkdownLink(href, props.onLinkPress); + } return ( { const href = run.href; const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; - const onPress = href - ? () => { - if (props.onLinkPress) props.onLinkPress(href); - else void Linking.openURL(href); - } - : undefined; + const androidLink = href != null && Platform.OS === "android"; + /** Opens this link unless the Android gesture was a text-selection drag. */ + function openPressedLink(event: GestureResponderEvent) { + if (href) pressMarkdownLink(href, event); + } + /** Accessibility activate opens the link even when a drag is in progress. */ + function activateLink(event: AccessibilityActionEvent) { + if (event.nativeEvent.actionName === "activate" && href) { + openMarkdownLink(href, props.onLinkPress); + } + } return ( menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) @@ -394,15 +459,9 @@ export function NativeMarkdownSelectableText(props: { { - if (event.nativeEvent.actionName === "activate") onPress(); - } - : undefined - } + accessibilityRole={href ? "button" : "image"} + accessibilityActions={href ? [{ name: "activate" }] : undefined} + onAccessibilityAction={href ? activateLink : undefined} style={{ width: androidChip.width, height: androidChip.boxHeight }} > { + it("opens a tap and keeps a selection drag from opening the link", () => { + const gesture = gestureSlot(); + const opened: Array<"open"> = []; + const handlers = androidMarkdownLinkPressHandlers(gesture, () => { + opened.push("open"); + }); + + handlers.onPressIn(touch(100, 200)); + handlers.onResponderMove(touch(104, 203)); + handlers.onPress(release(104, 203)); + expect(opened).toEqual(["open"]); + + handlers.onPressIn(touch(100, 200)); + handlers.onResponderMove(touch(100 + ANDROID_LINK_SELECTION_DRAG_SLOP_DP + 8, 200)); + handlers.onResponderMove(touch(100, 200)); + handlers.onPress(release(100, 200)); + expect(opened).toEqual(["open"]); + + handlers.onPressIn(touch(10, 20)); + handlers.onPress(release(10 + ANDROID_LINK_SELECTION_DRAG_SLOP_DP, 20)); + expect(opened).toEqual(["open", "open"]); + + handlers.onPressIn(touch(10, 20)); + handlers.onPress(release(10 + ANDROID_LINK_SELECTION_DRAG_SLOP_DP + 1, 20)); + expect(opened).toEqual(["open", "open"]); + }); + + it("still opens the next tap after a drag, and opens an accessibility activate", () => { + const gesture = gestureSlot(); + let opened = 0; + const handlers = androidMarkdownLinkPressHandlers(gesture, () => { + opened += 1; + }); + + handlers.onPressIn(touch(0, 0)); + handlers.onResponderMove(touch(40, 0)); + handlers.onPress(release(40, 0)); + expect(opened).toBe(0); + + handlers.onPressIn(touch(0, 0)); + handlers.onPress(release(2, 1)); + expect(opened).toBe(1); + + handlers.onPress({ nativeEvent: {} }); + expect(opened).toBe(2); + expect(gesture.current).toBeNull(); + }); + + it("treats a diagonal past the slop as a selection drag", () => { + const gesture = gestureSlot(); + let opened = 0; + const handlers = androidMarkdownLinkPressHandlers(gesture, () => { + opened += 1; + }); + + handlers.onPressIn(touch(0, 0)); + handlers.onPress(release(7, 7)); + expect(opened).toBe(1); + + handlers.onPressIn(touch(0, 0)); + handlers.onPress(release(8, 8)); + expect(opened).toBe(1); + }); + + it("clears a cancelled drag so the next tap can open the link", () => { + const gesture = gestureSlot(); + let opened = 0; + const handlers = androidMarkdownLinkPressHandlers(gesture, () => { + opened += 1; + }); + + handlers.onPressIn(touch(0, 0)); + handlers.onResponderMove(touch(40, 0)); + handlers.onResponderTerminate(); + expect(gesture.current).toBeNull(); + + handlers.onPressIn(touch(8, 8)); + handlers.onPress(release(9, 9)); + expect(opened).toBe(1); + }); + + it("reads the press point from the touch Pressability uses", () => { + expect( + androidLinkPressPoint({ + nativeEvent: { pageX: 9, pageY: 9, touches: [{ pageX: 1, pageY: 2 }] }, + }), + ).toEqual({ pageX: 1, pageY: 2 }); + expect( + androidLinkPressPoint({ + nativeEvent: { changedTouches: [{ pageX: 3, pageY: 4 }] }, + }), + ).toEqual({ pageX: 3, pageY: 4 }); + expect(androidLinkPressPoint({ nativeEvent: { pageX: 5, pageY: 6 } })).toEqual({ + pageX: 5, + pageY: 6, + }); + expect(androidLinkPressPoint(undefined)).toBeNull(); + expect(androidLinkPressPoint({ nativeEvent: {} })).toBeNull(); + expect(androidLinkPressPoint({ nativeEvent: { pageX: Number.NaN, pageY: 1 } })).toBeNull(); + expect( + androidLinkPressPoint({ nativeEvent: { pageX: Number.POSITIVE_INFINITY, pageY: 1 } }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/androidLinkPress.ts b/apps/mobile/modules/t3-markdown-text/src/androidLinkPress.ts new file mode 100644 index 000000000000..ef3264015004 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/androidLinkPress.ts @@ -0,0 +1,184 @@ +/** + * Android markdown links are nested React Native `Text` nodes with `onPress`. + * Pressability fires that callback on finger-up while the touch is still + * inside the link. Nested text measures as an empty rect, so a move never + * counts as leaving, and there is no `onLongPress` to cancel the press. + * A selection drag that starts on the link therefore opens it and dismisses + * the selection. + * + * Pressability's own long-press deactivation distance is 10dp, but it bails + * out before applying that check when the measured rect is empty. Apply the + * same distance here: a tap still opens, a drag does not. + */ + +/** Distance, in dp, past which a link press is a text-selection drag. */ +export const ANDROID_LINK_SELECTION_DRAG_SLOP_DP = 10; + +/** Page coordinates of one Android link touch, in dp. */ +export interface AndroidLinkPressPoint { + readonly pageX: number; + readonly pageY: number; +} + +/** Where an Android link press began, and whether it has already passed the slop. */ +export interface AndroidLinkGesture { + pageX: number; + pageY: number; + moved: boolean; +} + +interface PressEventLike { + readonly nativeEvent?: { + readonly pageX?: number; + readonly pageY?: number; + readonly touches?: ReadonlyArray>; + readonly changedTouches?: ReadonlyArray>; + }; +} + +/** One step of an Android link gesture. `open` is only true on a tap's finger-up. */ +export interface AndroidLinkPressTransition { + readonly gesture: AndroidLinkGesture | null; + readonly open: boolean; +} + +/** Which edge of the gesture `stepAndroidLinkPress` should apply. */ +export type AndroidLinkPressPhase = "start" | "move" | "end" | "cancel"; + +/** Press handlers a nested Android `Text` link needs in order to ignore selection drags. */ +export interface AndroidMarkdownLinkPressHandlers { + /** Opens the link when the finished gesture was a tap. */ + onPress: (event?: PressEventLike) => void; + /** Records the finger-down point for a new gesture. */ + onPressIn: (event: PressEventLike) => void; + /** Marks the gesture moved once the finger passes the selection slop. */ + onResponderMove: (event: PressEventLike) => void; + /** Drops the gesture when the responder system cancels it. */ + onResponderTerminate: () => void; +} + +/** + * Reads the touch Pressability uses for hit testing: the active touch, then + * the changed touch, then the event's own page coordinates. + */ +export function androidLinkPressPoint( + event: PressEventLike | undefined, +): AndroidLinkPressPoint | null { + const native = event?.nativeEvent; + if (native == null) return null; + const touch = native.touches?.[0] ?? native.changedTouches?.[0] ?? native; + const { pageX, pageY } = touch; + if (typeof pageX !== "number" || typeof pageY !== "number") return null; + if (!Number.isFinite(pageX) || !Number.isFinite(pageY)) return null; + return { pageX, pageY }; +} + +/** Starts a link gesture at `point`. The finger has not moved yet. */ +export function beginAndroidLinkGesture(point: AndroidLinkPressPoint): AndroidLinkGesture { + return { pageX: point.pageX, pageY: point.pageY, moved: false }; +} + +/** True once the finger has traveled far enough to be a selection drag. */ +export function androidLinkPressMoved( + start: AndroidLinkPressPoint, + point: AndroidLinkPressPoint, + slopDp = ANDROID_LINK_SELECTION_DRAG_SLOP_DP, +): boolean { + if ( + !Number.isFinite(start.pageX) || + !Number.isFinite(start.pageY) || + !Number.isFinite(point.pageX) || + !Number.isFinite(point.pageY) || + !Number.isFinite(slopDp) + ) { + return false; + } + const dx = point.pageX - start.pageX; + const dy = point.pageY - start.pageY; + return dx * dx + dy * dy > slopDp * slopDp; +} + +/** Sets `gesture.moved` once and then leaves it set for the rest of the press. */ +export function trackAndroidLinkGestureMove( + gesture: AndroidLinkGesture, + point: AndroidLinkPressPoint, + slopDp = ANDROID_LINK_SELECTION_DRAG_SLOP_DP, +): void { + if (gesture.moved) return; + if (androidLinkPressMoved(gesture, point, slopDp)) gesture.moved = true; +} + +/** + * A link opens on finger-up when the gesture never moved past the slop. + * No tracked gesture (an accessibility activate) still opens. + */ +export function shouldOpenAndroidMarkdownLink( + gesture: AndroidLinkGesture | null, + end: AndroidLinkPressPoint | null, + slopDp = ANDROID_LINK_SELECTION_DRAG_SLOP_DP, +): boolean { + if (gesture == null) return true; + if (gesture.moved) return false; + if (end == null) return true; + return !androidLinkPressMoved(gesture, end, slopDp); +} + +/** + * Advances one Android link gesture. + * `open` is true only for a finger-up that stayed inside the selection slop. + */ +export function stepAndroidLinkPress( + gesture: AndroidLinkGesture | null, + phase: AndroidLinkPressPhase, + event?: PressEventLike, +): AndroidLinkPressTransition { + switch (phase) { + case "start": { + const point = androidLinkPressPoint(event); + return { gesture: point == null ? null : beginAndroidLinkGesture(point), open: false }; + } + case "move": { + if (gesture == null) return { gesture: null, open: false }; + const point = androidLinkPressPoint(event); + if (point != null) trackAndroidLinkGestureMove(gesture, point); + return { gesture, open: false }; + } + case "cancel": + return { gesture: null, open: false }; + case "end": + return { + gesture: null, + open: shouldOpenAndroidMarkdownLink(gesture, androidLinkPressPoint(event)), + }; + } +} + +/** + * Builds the nested-text handlers for one Android link. + * `slot` is plain mutable state owned by the caller, not a React ref. + */ +export function androidMarkdownLinkPressHandlers( + slot: { current: AndroidLinkGesture | null }, + openLink: () => void, +): AndroidMarkdownLinkPressHandlers { + return { + /** Records the finger-down point for a new gesture. */ + onPressIn(event) { + slot.current = stepAndroidLinkPress(slot.current, "start", event).gesture; + }, + /** Marks the gesture moved once the finger passes the selection slop. */ + onResponderMove(event) { + slot.current = stepAndroidLinkPress(slot.current, "move", event).gesture; + }, + /** Drops the gesture when the responder system cancels it. */ + onResponderTerminate() { + slot.current = stepAndroidLinkPress(slot.current, "cancel").gesture; + }, + /** Opens the link when the finished gesture was a tap. */ + onPress(event) { + const transition = stepAndroidLinkPress(slot.current, "end", event); + slot.current = transition.gesture; + if (transition.open) openLink(); + }, + }; +}