History

provider update advisories (#2312)

legs committed 6 months ago

Commit 9b604bc1 parent 35721d960 files · +5723 −593Browse files

Unified · Split

60 files
  1. apps/desktop/src/clientPersistence.test.ts +1 −0
  2. apps/server/src/provider/Drivers/ClaudeDriver.ts +40 −0
  3. apps/server/src/provider/Drivers/CodexDriver.ts +24 −0
  4. apps/server/src/provider/Drivers/CursorDriver.ts +24 −0
  5. apps/server/src/provider/Drivers/OpenCodeDriver.ts +39 −0
  6. apps/server/src/provider/Layers/CodexProvider.ts +0 −1
  7. apps/server/src/provider/Layers/CursorProvider.ts +55 −25
  8. apps/server/src/provider/Layers/OpenCodeProvider.ts +0 −1
  9. apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +5 −0
  10. apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +10 −0
  11. apps/server/src/provider/Layers/ProviderRegistry.test.ts +201 −1
  12. apps/server/src/provider/Layers/ProviderRegistry.ts +128 −4
  13. apps/server/src/provider/Services/ProviderRegistry.ts +30 −1
  14. apps/server/src/provider/Services/ServerProvider.ts +2 −0
  15. apps/server/src/provider/makeManagedServerProvider.test.ts +18 −0
  16. apps/server/src/provider/makeManagedServerProvider.ts +2 −0
  17. apps/server/src/provider/providerMaintenance.test.ts +487 −0
  18. apps/server/src/provider/providerMaintenance.ts +472 −0
  19. apps/server/src/provider/providerMaintenanceCommandCoordinator.ts +79 −0
  20. apps/server/src/provider/providerMaintenanceRunner.test.ts +640 −0
  21. apps/server/src/provider/providerMaintenanceRunner.ts +395 −0
  22. apps/server/src/provider/providerSnapshot.ts +12 −7
  23. apps/server/src/provider/providerStatusCache.ts +5 −3
  24. apps/server/src/server.test.ts +7 −0
  25. apps/server/src/stream/collectUint8StreamText.test.ts +38 −0
  26. apps/server/src/stream/collectUint8StreamText.ts +66 −0
  27. apps/server/src/vcs/VcsProcess.ts +6 −37
  28. apps/server/src/ws.ts +11 −0
  29. apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +673 −0
  30. apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +537 −0
  31. apps/web/src/components/ProviderUpdateLaunchNotification.tsx +300 −0
  32. apps/web/src/components/Sidebar.tsx +2 −0
  33. apps/web/src/components/settings/ProviderInstanceCard.tsx +226 −83
  34. apps/web/src/components/settings/SettingsPanels.browser.tsx +65 −2
  35. apps/web/src/components/settings/SettingsPanels.tsx +433 −402
  36. apps/web/src/components/settings/SettingsSidebarNav.tsx +10 −1
  37. apps/web/src/components/settings/providerStatus.ts +27 −1
  38. apps/web/src/components/settings/settingsLayout.tsx +8 −6
  39. apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +207 −0
  40. apps/web/src/components/ui/toast.tsx +63 −5
  41. apps/web/src/hooks/useSettings.ts +37 −1
  42. apps/web/src/index.css +13 −0
  43. apps/web/src/localApi.test.ts +31 −0
  44. apps/web/src/localApi.ts +4 −0
  45. apps/web/src/providerUpdateDismissal.test.ts +23 −0
  46. apps/web/src/providerUpdateDismissal.ts +92 −0
  47. apps/web/src/routeTree.gen.ts +21 −0
  48. apps/web/src/routes/__root.tsx +2 −0
  49. apps/web/src/routes/settings.general.tsx +5 −1
  50. apps/web/src/routes/settings.providers.tsx +11 −0
  51. apps/web/src/rpc/wsRpcClient.ts +3 −0
  52. packages/contracts/src/ipc.ts +2 −0
  53. packages/contracts/src/rpc.ts +10 −0
  54. packages/contracts/src/server.test.ts +28 −0
  55. packages/contracts/src/server.ts +58 −0
  56. packages/contracts/src/settings.ts +3 −0
  57. packages/effect-acp/src/client.test.ts +0 −1
  58. packages/effect-acp/src/protocol.test.ts +0 −1
  59. packages/shared/src/shell.test.ts +12 −0
  60. packages/shared/src/shell.ts +20 −9
apps/desktop/src/clientPersistence.test.ts+1−0
@@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { · 51 unmodified lines
5252 autoOpenPlanSidebar: false,
5353 confirmThreadArchive: true,
5454 confirmThreadDelete: false,
55+ dismissedProviderUpdateNotificationKeys: [],
5556 diffIgnoreWhitespace: true,
5657 diffWordWrap: true,
5758 favorites: [],
apps/server/src/provider/Drivers/ClaudeDriver.ts+40−0
@@ -14,6 +14,7 @@ · 13 unmodified lines
1414 */
1515 import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
1616 import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17+import { HttpClient } from "effect/unstable/http";
1718 import { ChildProcessSpawner } from "effect/unstable/process";
1819
1920 import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
@@ -34,15 +35,43 @@ import { · 14 unmodified lines
3435 } from "../ProviderDriver.ts";
3536 import type { ServerProviderDraft } from "../providerSnapshot.ts";
3637 import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38+import {
39+ enrichProviderSnapshotWithVersionAdvisory,
40+ makePackageManagedProviderMaintenanceResolver,
41+ normalizeCommandPath,
42+ resolveProviderMaintenanceCapabilitiesEffect,
43+} from "../providerMaintenance.ts";
3744 import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
3845
3946 const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
4047 const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
4148 const CAPABILITIES_PROBE_TTL = Duration.minutes(5);
4249
50+function isClaudeNativeCommandPath(commandPath: string): boolean {
51+ const normalized = normalizeCommandPath(commandPath);
52+ return (
53+ normalized.endsWith("/.local/bin/claude") ||
54+ normalized.endsWith("/.local/bin/claude.exe") ||
55+ normalized.includes("/.local/share/claude/")
56+ );
57+}
58+
59+const UPDATE = makePackageManagedProviderMaintenanceResolver({
60+ provider: DRIVER_KIND,
61+ npmPackageName: "@anthropic-ai/claude-code",
62+ homebrewFormula: "claude-code",
63+ nativeUpdate: {
64+ executable: "claude",
65+ args: ["update"],
66+ lockKey: "claude-native",
67+ isCommandPath: isClaudeNativeCommandPath,
68+ },
69+});
70+
4371 export type ClaudeDriverEnv =
4472 | ChildProcessSpawner.ChildProcessSpawner
4573 | FileSystem.FileSystem
74+ | HttpClient.HttpClient
4675 | Path.Path
4776 | ProviderEventLoggers
4877 | ServerConfig;
@@ -75,6 +104,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 26 unmodified lines
75104 Effect.gen(function* () {
76105 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
77106 const path = yield* Path.Path;
107+ const httpClient = yield* HttpClient.HttpClient;
78108 const eventLoggers = yield* ProviderEventLoggers;
79109 const processEnv = mergeProviderInstanceEnvironment(environment);
80110 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
@@ -82,6 +112,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 1 unmodified line
82112 instanceId,
83113 });
84114 const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;
115+ const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
116+ binaryPath: effectiveConfig.binaryPath,
117+ env: processEnv,
118+ });
85119 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig);
86120 const stampIdentity = withInstanceIdentity({
87121 instanceId,
@@ -121,11 +155,17 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 33 unmodified lines
121155 );
122156
123157 const snapshot = yield* makeManagedServerProvider<ClaudeSettings>({
158+ maintenanceCapabilities,
124159 getSettings: Effect.succeed(effectiveConfig),
125160 streamSettings: Stream.never,
126161 haveSettingsChanged: () => false,
127162 initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)),
128163 checkProvider,
164+ enrichSnapshot: ({ snapshot, publishSnapshot }) =>
165+ enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
166+ Effect.provideService(HttpClient.HttpClient, httpClient),
167+ Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
168+ ),
129169 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
130170 }).pipe(
131171 Effect.mapError(
apps/server/src/provider/Drivers/CodexDriver.ts+24−0
@@ -23,6 +23,7 @@ · 22 unmodified lines
2323 */
2424 import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
2525 import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
26+import { HttpClient } from "effect/unstable/http";
2627 import { ChildProcessSpawner } from "effect/unstable/process";
2728
2829 import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
@@ -35,6 +36,11 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; · 6 unmodified lines
3536 import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
3637 import type { ServerProviderDraft } from "../providerSnapshot.ts";
3738 import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
39+import {
40+ enrichProviderSnapshotWithVersionAdvisory,
41+ makePackageManagedProviderMaintenanceResolver,
42+ resolveProviderMaintenanceCapabilitiesEffect,
43+} from "../providerMaintenance.ts";
3844 import {
3945 codexContinuationIdentity,
4046 materializeCodexShadowHome,
@@ -43,6 +49,12 @@ import { · 2 unmodified lines
4349
4450 const DRIVER_KIND = ProviderDriverKind.make("codex");
4551 const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
52+const UPDATE = makePackageManagedProviderMaintenanceResolver({
53+ provider: DRIVER_KIND,
54+ npmPackageName: "@openai/codex",
55+ homebrewFormula: "codex",
56+ nativeUpdate: null,
57+});
4658
4759 /**
4860 * Services the driver needs to materialize an instance. Surfaced as the
@@ -52,6 +64,7 @@ const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); · 3 unmodified lines
5264 export type CodexDriverEnv =
5365 | ChildProcessSpawner.ChildProcessSpawner
5466 | FileSystem.FileSystem
67+ | HttpClient.HttpClient
5568 | Path.Path
5669 | ProviderEventLoggers
5770 | ServerConfig;
@@ -89,6 +102,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 31 unmodified lines
89102 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
90103 Effect.gen(function* () {
91104 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
105+ const httpClient = yield* HttpClient.HttpClient;
92106 const eventLoggers = yield* ProviderEventLoggers;
93107 const processEnv = mergeProviderInstanceEnvironment(environment);
94108 const homeLayout = yield* resolveCodexHomeLayout(config);
@@ -115,6 +129,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 20 unmodified lines
115129 enabled,
116130 homePath: homeLayout.effectiveHomePath ?? "",
117131 } satisfies CodexSettings;
132+ const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
133+ binaryPath: effectiveConfig.binaryPath,
134+ env: processEnv,
135+ });
118136
119137 // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error
120138 // channels at construction time — their failure modes are all on the
@@ -138,11 +156,17 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 17 unmodified lines
138156 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
139157 );
140158 const snapshot = yield* makeManagedServerProvider<CodexSettings>({
159+ maintenanceCapabilities,
141160 getSettings: Effect.succeed(effectiveConfig),
142161 streamSettings: Stream.never,
143162 haveSettingsChanged: () => false,
144163 initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)),
145164 checkProvider,
165+ enrichSnapshot: ({ snapshot, publishSnapshot }) =>
166+ enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
167+ Effect.provideService(HttpClient.HttpClient, httpClient),
168+ Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
169+ ),
146170 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
147171 }).pipe(
148172 Effect.mapError(
apps/server/src/provider/Drivers/CursorDriver.ts+24−0
@@ -14,6 +14,7 @@ · 13 unmodified lines
1414 */
1515 import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
1616 import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17+import { HttpClient } from "effect/unstable/http";
1718 import { ChildProcessSpawner } from "effect/unstable/process";
1819
1920 import { ServerConfig } from "../../config.ts";
@@ -34,13 +35,28 @@ import { · 14 unmodified lines
3435 } from "../ProviderDriver.ts";
3536 import type { ServerProviderDraft } from "../providerSnapshot.ts";
3637 import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38+import {
39+ makeProviderMaintenanceCapabilities,
40+ makeStaticProviderMaintenanceResolver,
41+ resolveProviderMaintenanceCapabilitiesEffect,
42+} from "../providerMaintenance.ts";
3743
3844 const DRIVER_KIND = ProviderDriverKind.make("cursor");
3945 const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
46+const UPDATE = makeStaticProviderMaintenanceResolver(
47+ makeProviderMaintenanceCapabilities({
48+ provider: DRIVER_KIND,
49+ packageName: null,
50+ updateExecutable: "agent",
51+ updateArgs: ["update"],
52+ updateLockKey: "cursor-agent",
53+ }),
54+);
4055
4156 export type CursorDriverEnv =
4257 | ChildProcessSpawner.ChildProcessSpawner
4358 | FileSystem.FileSystem
59+ | HttpClient.HttpClient
4460 | Path.Path
4561 | ProviderEventLoggers
4662 | ServerConfig;
@@ -74,6 +90,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { · 27 unmodified lines
7490 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
7591 const fileSystem = yield* FileSystem.FileSystem;
7692 const path = yield* Path.Path;
93+ const httpClient = yield* HttpClient.HttpClient;
7794 const eventLoggers = yield* ProviderEventLoggers;
7895 const processEnv = mergeProviderInstanceEnvironment(environment);
7996 const continuationIdentity = defaultProviderContinuationIdentity({
@@ -87,6 +104,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { · 7 unmodified lines
87104 continuationGroupKey: continuationIdentity.continuationKey,
88105 });
89106 const effectiveConfig = { ...config, enabled } satisfies CursorSettings;
107+ const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
108+ binaryPath: effectiveConfig.binaryPath,
109+ env: processEnv,
110+ });
90111
91112 const adapter = yield* makeCursorAdapter(effectiveConfig, {
92113 environment: processEnv,
@@ -103,6 +124,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { · 10 unmodified lines
103124 );
104125
105126 const snapshot = yield* makeManagedServerProvider<CursorSettings>({
127+ maintenanceCapabilities,
106128 getSettings: Effect.succeed(effectiveConfig),
107129 streamSettings: Stream.never,
108130 haveSettingsChanged: () => false,
@@ -117,8 +139,10 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { · 8 unmodified lines
117139 settings,
118140 environment: processEnv,
119141 snapshot: currentSnapshot,
142+ maintenanceCapabilities,
120143 publishSnapshot,
121144 stampIdentity,
145+ httpClient,
122146 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
123147 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
124148 }).pipe(
apps/server/src/provider/Drivers/OpenCodeDriver.ts+39−0
@@ -14,6 +14,7 @@ · 13 unmodified lines
1414 */
1515 import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
1616 import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
17+import { HttpClient } from "effect/unstable/http";
1718 import { ChildProcessSpawner } from "effect/unstable/process";
1819
1920 import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";
@@ -34,13 +35,40 @@ import { · 14 unmodified lines
3435 } from "../ProviderDriver.ts";
3536 import type { ServerProviderDraft } from "../providerSnapshot.ts";
3637 import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
38+import {
39+ enrichProviderSnapshotWithVersionAdvisory,
40+ makePackageManagedProviderMaintenanceResolver,
41+ normalizeCommandPath,
42+ resolveProviderMaintenanceCapabilitiesEffect,
43+} from "../providerMaintenance.ts";
3744
3845 const DRIVER_KIND = ProviderDriverKind.make("opencode");
3946 const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
4047
48+function isOpenCodeNativeCommandPath(commandPath: string): boolean {
49+ const normalized = normalizeCommandPath(commandPath);
50+ return (
51+ normalized.endsWith("/.opencode/bin/opencode") ||
52+ normalized.endsWith("/.opencode/bin/opencode.exe")
53+ );
54+}
55+
56+const UPDATE = makePackageManagedProviderMaintenanceResolver({
57+ provider: DRIVER_KIND,
58+ npmPackageName: "opencode-ai",
59+ homebrewFormula: "anomalyco/tap/opencode",
60+ nativeUpdate: {
61+ executable: "opencode",
62+ args: ["upgrade"],
63+ lockKey: "opencode-native",
64+ isCommandPath: isOpenCodeNativeCommandPath,
65+ },
66+});
67+
4168 export type OpenCodeDriverEnv =
4269 | ChildProcessSpawner.ChildProcessSpawner
4370 | FileSystem.FileSystem
71+ | HttpClient.HttpClient
4472 | OpenCodeRuntime
4573 | Path.Path
4674 | ProviderEventLoggers
@@ -74,6 +102,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 27 unmodified lines
74102 Effect.gen(function* () {
75103 const openCodeRuntime = yield* OpenCodeRuntime;
76104 const serverConfig = yield* ServerConfig;
105+ const httpClient = yield* HttpClient.HttpClient;
77106 const eventLoggers = yield* ProviderEventLoggers;
78107 const processEnv = mergeProviderInstanceEnvironment(environment);
79108 const continuationIdentity = defaultProviderContinuationIdentity({
@@ -87,6 +116,10 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 7 unmodified lines
87116 continuationGroupKey: continuationIdentity.continuationKey,
88117 });
89118 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;
119+ const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
120+ binaryPath: effectiveConfig.binaryPath,
121+ env: processEnv,
122+ });
90123
91124 const adapter = yield* makeOpenCodeAdapter(effectiveConfig, {
92125 instanceId,
@@ -102,11 +135,17 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 9 unmodified lines
102135 ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime));
103136
104137 const snapshot = yield* makeManagedServerProvider<OpenCodeSettings>({
138+ maintenanceCapabilities,
105139 getSettings: Effect.succeed(effectiveConfig),
106140 streamSettings: Stream.never,
107141 haveSettingsChanged: () => false,
108142 initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)),
109143 checkProvider,
144+ enrichSnapshot: ({ snapshot, publishSnapshot }) =>
145+ enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe(
146+ Effect.provideService(HttpClient.HttpClient, httpClient),
147+ Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
148+ ),
110149 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
111150 }).pipe(
112151 Effect.mapError(
apps/server/src/provider/Layers/CodexProvider.ts+0−1
@@ -15,7 +15,6 @@ import type { · 14 unmodified lines
1515 import { ServerSettingsError } from "@t3tools/contracts";
1616
1717 import { createModelCapabilities } from "@t3tools/shared/model";
18−
1918 import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts";
2019 import { expandHomePath } from "../../pathExpansion.ts";
2120 import { scopedSafeTeardown } from "./scopedSafeTeardown.ts";
apps/server/src/provider/Layers/CursorProvider.ts+55−25
@@ -12,6 +12,7 @@ import type { · 11 unmodified lines
1212 import { ProviderDriverKind } from "@t3tools/contracts";
1313 import type * as EffectAcpSchema from "effect-acp/schema";
1414 import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Result } from "effect";
15+import { HttpClient } from "effect/unstable/http";
1516 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1617 import {
1718 createModelCapabilities,
@@ -29,6 +30,10 @@ import { · 11 unmodified lines
2930 type CommandResult,
3031 type ServerProviderDraft,
3132 } from "../providerSnapshot.ts";
33+import {
34+ enrichProviderSnapshotWithVersionAdvisory,
35+ type ProviderMaintenanceCapabilities,
36+} from "../providerMaintenance.ts";
3237 import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
3338
3439 const PROVIDER = ProviderDriverKind.make("cursor");
@@ -1216,42 +1221,67 @@ export const enrichCursorSnapshot = (input: { · 1181 unmodified lines
12161221 readonly settings: CursorSettings;
12171222 readonly environment?: NodeJS.ProcessEnv;
12181223 readonly snapshot: ServerProvider;
1224+ readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
12191225 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
12201226 readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider;
1227+ readonly httpClient: HttpClient.HttpClient;
12211228 }): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {
12221229 const { settings, snapshot, publishSnapshot } = input;
12231230 const stampIdentity = input.stampIdentity ?? ((value) => value);
12241231
1225− if (
1226− !settings.enabled ||
1227− snapshot.auth.status === "unauthenticated" ||
1228− !hasUncapturedCursorModels(snapshot)
1229− ) {
1230− return Effect.void;
1231− }
1232+ const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory(
1233+ snapshot,
1234+ input.maintenanceCapabilities,
1235+ ).pipe(
1236+ Effect.provideService(HttpClient.HttpClient, input.httpClient),
1237+ Effect.flatMap((enrichedSnapshot) =>
1238+ publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)),
1239+ ),
1240+ Effect.catchCause((cause) =>
1241+ Effect.logWarning("Cursor version advisory enrichment failed", {
1242+ cause: Cause.pretty(cause),
1243+ }).pipe(Effect.as(snapshot)),
1244+ ),
1245+ );
12321246
1233− return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe(
1234− Effect.flatMap((discoveredModels) => {
1235− if (discoveredModels.length === 0) {
1247+ return enrichVersionAdvisory.pipe(
1248+ Effect.flatMap((baseSnapshot) => {
1249+ if (
1250+ !settings.enabled ||
1251+ baseSnapshot.auth.status === "unauthenticated" ||
1252+ !hasUncapturedCursorModels(baseSnapshot)
1253+ ) {
12361254 return Effect.void;
12371255 }
1238− return publishSnapshot(
1239− stampIdentity({
1240− ...snapshot,
1241− models: providerModelsFromSettings(
1242− discoveredModels,
1243− PROVIDER,
1244− settings.customModels,
1245− EMPTY_CAPABILITIES,
1246− ),
1256+
1257+ return discoverCursorModelCapabilitiesViaAcp(
1258+ settings,
1259+ baseSnapshot.models,
1260+ input.environment,
1261+ ).pipe(
1262+ Effect.flatMap((discoveredModels) => {
1263+ if (discoveredModels.length === 0) {
1264+ return Effect.void;
1265+ }
1266+ return publishSnapshot(
1267+ stampIdentity({
1268+ ...baseSnapshot,
1269+ models: providerModelsFromSettings(
1270+ discoveredModels,
1271+ PROVIDER,
1272+ settings.customModels,
1273+ EMPTY_CAPABILITIES,
1274+ ),
1275+ }),
1276+ );
12471277 }),
1278+ Effect.catchCause((cause) =>
1279+ Effect.logWarning("Cursor ACP background capability enrichment failed", {
1280+ models: baseSnapshot.models.map((model) => model.slug),
1281+ cause: Cause.pretty(cause),
1282+ }).pipe(Effect.asVoid),
1283+ ),
12481284 );
12491285 }),
1250− Effect.catchCause((cause) =>
1251− Effect.logWarning("Cursor ACP background capability enrichment failed", {
1252− models: snapshot.models.map((model) => model.slug),
1253− cause: Cause.pretty(cause),
1254− }).pipe(Effect.asVoid),
1255− ),
12561286 );
12571287 };
apps/server/src/provider/Layers/OpenCodeProvider.ts+0−1
@@ -7,7 +7,6 @@ import { · 6 unmodified lines
77 import { Cause, Data, Effect } from "effect";
88
99 import { createModelCapabilities } from "@t3tools/shared/model";
10−
1110 import {
1211 buildServerProvider,
1312 nonEmptyTrimmed,
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+5−0
@@ -14,6 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; · 13 unmodified lines
1414 import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
1515 import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
1616 import type { ProviderInstance } from "../ProviderDriver.ts";
17+import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
1718 import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
1819 import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
1920 import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -111,6 +112,10 @@ const makeFakeInstance = ( · 91 unmodified lines
111112 displayName: undefined,
112113 enabled: true,
113114 snapshot: {
115+ maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
116+ provider: driverKind,
117+ packageName: null,
118+ }),
114119 getSnapshot: Effect.succeed({} as unknown as ServerProvider),
115120 refresh: Effect.succeed({} as unknown as ServerProvider),
116121 streamChanges: Stream.empty,
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+10−0
@@ -34,6 +34,7 @@ import { · 33 unmodified lines
3434 ProviderInstanceId,
3535 } from "@t3tools/contracts";
3636 import { Effect, Layer } from "effect";
37+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3738
3839 import { ServerConfig } from "../../config.ts";
3940 import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
@@ -44,6 +45,13 @@ import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; · 4 unmodified lines
4445 import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
4546 import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
4647
48+const TestHttpClientLive = Layer.succeed(
49+ HttpClient.HttpClient,
50+ HttpClient.make((request) =>
51+ Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))),
52+ ),
53+);
54+
4755 const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
4856 enabled: false,
4957 binaryPath: "codex",
@@ -89,6 +97,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { · 39 unmodified lines
8997 prefix: "provider-instance-registry-test",
9098 }).pipe(
9199 Layer.provideMerge(NodeServices.layer),
100+ Layer.provideMerge(TestHttpClientLive),
92101 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
93102 );
94103
@@ -225,6 +234,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { · 130 unmodified lines
225234 prefix: "provider-instance-registry-all-drivers-test",
226235 }).pipe(
227236 Layer.provideMerge(infraLayer),
237+ Layer.provideMerge(TestHttpClientLive),
228238 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
229239 );
230240
apps/server/src/provider/Layers/ProviderRegistry.test.ts+201−1
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
1414 type ServerSettings as ContractServerSettings,
1515 } from "@t3tools/contracts";
1616 import * as PlatformError from "effect/PlatformError";
17+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1718 import { ChildProcessSpawner } from "effect/unstable/process";
1819 import { deepMerge } from "@t3tools/shared/Struct";
1920 import { createModelCapabilities } from "@t3tools/shared/model";
@@ -26,13 +27,17 @@ import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistr · 6 unmodified lines
2627 import {
2728 haveProvidersChanged,
2829 mergeProviderSnapshot,
30+ mergeProviderSnapshots,
2931 ProviderRegistryLive,
32+ selectProvidersByKind,
3033 } from "./ProviderRegistry.ts";
3134 import { ServerConfig } from "../../config.ts";
3235 import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
36+import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts";
3337 import type { ProviderInstance } from "../ProviderDriver.ts";
3438 import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
3539 import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
40+import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
3641
3742 const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
3843 const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
@@ -46,6 +51,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; · 7 unmodified lines
4651
4752 const encoder = new TextEncoder();
4853
54+const TestHttpClientLive = Layer.succeed(
55+ HttpClient.HttpClient,
56+ HttpClient.make((request) =>
57+ Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))),
58+ ),
59+);
60+
4961 function selectDescriptor(
5062 id: string,
5163 label: string,
@@ -246,7 +258,7 @@ function makeMutableServerSettingsService( · 194 unmodified lines
246258 });
247259 }
248260
249−it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
261+it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), TestHttpClientLive))(
250262 "ProviderRegistry",
251263 (it) => {
252264 describe("checkCodexProviderStatus", () => {
@@ -500,6 +512,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 247 unmodified lines
500512 ]);
501513 });
502514
515+ it("persists merged provider snapshots for the providers that were refreshed", () => {
516+ const previousProviders = [
517+ {
518+ instanceId: ProviderInstanceId.make("cursor"),
519+ driver: ProviderDriverKind.make("cursor"),
520+ status: "ready",
521+ enabled: true,
522+ installed: true,
523+ auth: { status: "authenticated" },
524+ checkedAt: "2026-04-14T00:00:00.000Z",
525+ version: "2026.04.09-f2b0fcd",
526+ models: [
527+ {
528+ slug: "claude-opus-4-6",
529+ name: "Opus 4.6",
530+ isCustom: false,
531+ capabilities: createModelCapabilities({
532+ optionDescriptors: [
533+ selectDescriptor("reasoning", "Reasoning", [
534+ { id: "high", label: "High", isDefault: true },
535+ ]),
536+ booleanDescriptor("fastMode", "Fast Mode"),
537+ booleanDescriptor("thinking", "Thinking"),
538+ ],
539+ }),
540+ },
541+ ],
542+ slashCommands: [],
543+ skills: [],
544+ },
545+ {
546+ instanceId: ProviderInstanceId.make("codex"),
547+ driver: ProviderDriverKind.make("codex"),
548+ status: "ready",
549+ enabled: true,
550+ installed: true,
551+ auth: { status: "authenticated" },
552+ checkedAt: "2026-04-14T00:00:00.000Z",
553+ version: "1.0.0",
554+ models: [],
555+ slashCommands: [],
556+ skills: [],
557+ },
558+ ] as const satisfies ReadonlyArray<ServerProvider>;
559+ const refreshedCursor = {
560+ ...previousProviders[0],
561+ checkedAt: "2026-04-14T00:01:00.000Z",
562+ models: [],
563+ } satisfies ServerProvider;
564+
565+ const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]);
566+ const persistedProviders = selectProvidersByKind(
567+ mergedProviders,
568+ new Set([ProviderDriverKind.make("cursor")]),
569+ );
570+
571+ assert.deepStrictEqual(persistedProviders, [
572+ {
573+ ...refreshedCursor,
574+ models: [...previousProviders[0].models],
575+ },
576+ ]);
577+ });
578+
579+ it.effect("persists the merged snapshot when a live update has empty models", () =>
580+ Effect.gen(function* () {
581+ const cursorDriver = ProviderDriverKind.make("cursor");
582+ const cursorInstanceId = ProviderInstanceId.make("cursor");
583+ const initialProvider = {
584+ instanceId: cursorInstanceId,
585+ driver: cursorDriver,
586+ status: "ready",
587+ enabled: true,
588+ installed: true,
589+ auth: { status: "authenticated" },
590+ checkedAt: "2026-04-14T00:00:00.000Z",
591+ version: "2026.04.09-f2b0fcd",
592+ models: [
593+ {
594+ slug: "claude-opus-4-6",
595+ name: "Opus 4.6",
596+ isCustom: false,
597+ capabilities: createModelCapabilities({
598+ optionDescriptors: [
599+ selectDescriptor("reasoning", "Reasoning", [
600+ { id: "high", label: "High", isDefault: true },
601+ ]),
602+ ],
603+ }),
604+ },
605+ ],
606+ slashCommands: [],
607+ skills: [],
608+ } as const satisfies ServerProvider;
609+ const refreshedProvider = {
610+ ...initialProvider,
611+ checkedAt: "2026-04-14T00:01:00.000Z",
612+ models: [],
613+ } satisfies ServerProvider;
614+ const changes = yield* PubSub.unbounded<ServerProvider>();
615+ const instance = {
616+ instanceId: cursorInstanceId,
617+ driverKind: cursorDriver,
618+ continuationIdentity: {
619+ driverKind: cursorDriver,
620+ continuationKey: "cursor:instance:cursor",
621+ },
622+ displayName: undefined,
623+ enabled: true,
624+ snapshot: {
625+ maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
626+ provider: cursorDriver,
627+ packageName: null,
628+ }),
629+ getSnapshot: Effect.succeed(initialProvider),
630+ refresh: Effect.succeed(refreshedProvider),
631+ streamChanges: Stream.fromPubSub(changes),
632+ },
633+ adapter: {} as ProviderInstance["adapter"],
634+ textGeneration: {} as ProviderInstance["textGeneration"],
635+ } satisfies ProviderInstance;
636+ const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, {
637+ getInstance: (instanceId) =>
638+ Effect.succeed(instanceId === cursorInstanceId ? instance : undefined),
639+ listInstances: Effect.succeed([instance]),
640+ listUnavailable: Effect.succeed([]),
641+ streamChanges: Stream.empty,
642+ subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
643+ PubSub.subscribe(pubsub),
644+ ),
645+ });
646+ const scope = yield* Scope.make();
647+ yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
648+ const runtimeServices = yield* Layer.build(
649+ ProviderRegistryLive.pipe(
650+ Layer.provideMerge(instanceRegistryLayer),
651+ Layer.provideMerge(
652+ ServerConfig.layerTest(process.cwd(), {
653+ prefix: "t3-provider-registry-merged-persist-",
654+ }),
655+ ),
656+ Layer.provideMerge(NodeServices.layer),
657+ ),
658+ ).pipe(Scope.provide(scope));
659+
660+ yield* Effect.gen(function* () {
661+ const registry = yield* ProviderRegistry;
662+ const config = yield* ServerConfig;
663+ const filePath = yield* resolveProviderStatusCachePath({
664+ cacheDir: config.providerStatusCacheDir,
665+ instanceId: cursorInstanceId,
666+ });
667+
668+ assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [
669+ ...initialProvider.models,
670+ ]);
671+ yield* PubSub.publish(changes, refreshedProvider);
672+
673+ let cachedProvider = yield* readProviderStatusCache(filePath);
674+ for (
675+ let attempt = 0;
676+ attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt;
677+ attempt += 1
678+ ) {
679+ yield* Effect.sleep("10 millis");
680+ cachedProvider = yield* readProviderStatusCache(filePath);
681+ }
682+
683+ assert.deepStrictEqual(cachedProvider, {
684+ ...refreshedProvider,
685+ models: [...initialProvider.models],
686+ });
687+ }).pipe(Effect.provide(runtimeServices));
688+ }),
689+ );
690+
503691 it.effect("returns the cached provider list when a manual refresh fails", () =>
504692 Effect.gen(function* () {
505693 const codexDriver = ProviderDriverKind.make("codex");
@@ -527,6 +715,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 21 unmodified lines
527715 displayName: undefined,
528716 enabled: true,
529717 snapshot: {
718+ maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
719+ provider: codexDriver,
720+ packageName: null,
721+ }),
530722 getSnapshot: Effect.succeed(cachedProvider),
531723 refresh: Effect.die(new Error("simulated refresh failure")),
532724 streamChanges: Stream.empty,
@@ -612,6 +804,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 79 unmodified lines
612804 displayName: undefined,
613805 enabled: true,
614806 snapshot: {
807+ maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
808+ provider: provider.driver,
809+ packageName: null,
810+ }),
615811 getSnapshot: Effect.succeed(provider),
616812 refresh: Effect.succeed(provider),
617813 streamChanges: Stream.empty,
@@ -748,6 +944,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 130 unmodified lines
748944 prefix: "t3-provider-registry-",
749945 }),
750946 ),
947+ Layer.provideMerge(TestHttpClientLive),
751948 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
752949 Layer.provideMerge(OpenCodeRuntimeLive),
753950 // NO spawner mock — `ChildProcessSpawner` is supplied by the
@@ -832,6 +1029,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 78 unmodified lines
8321029 prefix: "t3-provider-registry-",
8331030 }),
8341031 ),
1032+ Layer.provideMerge(TestHttpClientLive),
8351033 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
8361034 Layer.provideMerge(OpenCodeRuntimeLive),
8371035 // `it.live` does not inherit layers from the outer `it.layer`
@@ -936,6 +1134,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 98 unmodified lines
9361134 prefix: "t3-provider-registry-",
9371135 }),
9381136 ),
1137+ Layer.provideMerge(TestHttpClientLive),
9391138 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
9401139 Layer.provideMerge(OpenCodeRuntimeLive),
9411140 Layer.provideMerge(NodeServices.layer),
@@ -986,6 +1185,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 44 unmodified lines
9861185 prefix: "t3-provider-registry-",
9871186 }),
9881187 ),
1188+ Layer.provideMerge(TestHttpClientLive),
9891189 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
9901190 Layer.provideMerge(OpenCodeRuntimeLive),
9911191 Layer.provideMerge(
apps/server/src/provider/Layers/ProviderRegistry.ts+128−4
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
2727 ProviderDriverKind,
2828 type ProviderInstanceId,
2929 type ServerProvider,
30+ type ServerProviderUpdateState,
3031 } from "@t3tools/contracts";
3132 import { Cause, Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect";
3233 import * as Semaphore from "effect/Semaphore";
@@ -43,6 +44,7 @@ import { · 10 unmodified lines
4344 writeProviderStatusCache,
4445 } from "../providerStatusCache.ts";
4546 import type { ProviderInstance } from "../ProviderDriver.ts";
47+import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
4648 import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";
4749
4850 const loadProviders = (
@@ -59,6 +61,12 @@ const loadProviders = ( · 10 unmodified lines
5961 },
6062 );
6163
64+const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) =>
65+ makeManualOnlyProviderMaintenanceCapabilities({
66+ provider,
67+ packageName: null,
68+ });
69+
6270 const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
6371 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;
6472
@@ -96,6 +104,30 @@ export const mergeProviderSnapshot = ( · 31 unmodified lines
96104 models: mergeProviderModels(previousProvider.models, nextProvider.models),
97105 };
98106
107+export const mergeProviderSnapshots = (
108+ previousProviders: ReadonlyArray<ServerProvider>,
109+ nextProviders: ReadonlyArray<ServerProvider>,
110+): ReadonlyArray<ServerProvider> => {
111+ const mergedProviders = new Map(
112+ previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
113+ );
114+
115+ for (const provider of nextProviders) {
116+ mergedProviders.set(
117+ snapshotInstanceKey(provider),
118+ mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider),
119+ );
120+ }
121+
122+ return orderProviderSnapshots([...mergedProviders.values()]);
123+};
124+
125+export const selectProvidersByKind = (
126+ providers: ReadonlyArray<ServerProvider>,
127+ providerKinds: ReadonlySet<ProviderDriverKind>,
128+): ReadonlyArray<ServerProvider> =>
129+ providers.filter((provider) => providerKinds.has(provider.driver));
130+
99131 export const haveProvidersChanged = (
100132 previousProviders: ReadonlyArray<ServerProvider>,
101133 nextProviders: ReadonlyArray<ServerProvider>,
@@ -227,6 +259,9 @@ export const ProviderRegistryLive = Layer.effect( · 125 unmodified lines
227259 ),
228260 );
229261 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders);
262+ const maintenanceActionStatesRef = yield* Ref.make<
263+ ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }>
264+ >(new Map());
230265
231266 // Live-source registry — the dynamic counterpart to the boot-time
232267 // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance`
@@ -264,6 +299,21 @@ export const ProviderRegistryLive = Layer.effect( · 31 unmodified lines
264299 );
265300 });
266301
302+ const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* (
303+ provider: ServerProvider,
304+ ) {
305+ const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef);
306+ const updateState = maintenanceActionStates.get(provider.instanceId)?.update;
307+ if (!updateState) {
308+ const { updateState: _updateState, ...providerWithoutUpdateState } = provider;
309+ return providerWithoutUpdateState;
310+ }
311+ return {
312+ ...provider,
313+ updateState,
314+ };
315+ });
316+
267317 const upsertProviders = Effect.fn("upsertProviders")(function* (
268318 nextProviders: ReadonlyArray<ServerProvider>,
269319 options?: {
@@ -272,15 +322,24 @@ export const ProviderRegistryLive = Layer.effect( · 2 unmodified lines
272322 readonly replace?: boolean;
273323 },
274324 ) {
275− const [previousProviders, providers] = yield* Ref.modify(
325+ const nextProvidersWithUpdateState = yield* Effect.forEach(
326+ nextProviders,
327+ applyProviderUpdateState,
328+ {
329+ concurrency: "unbounded",
330+ },
331+ );
332+ const [previousProviders, providers, providersToPersist] = yield* Ref.modify(
276333 providersRef,
277334 (previousProviders) => {
278335 const mergedProviders = new Map(
279336 previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
280337 );
338+ const updatedKeys = new Set<ProviderInstanceId>();
281339
282− for (const provider of nextProviders) {
340+ for (const provider of nextProvidersWithUpdateState) {
283341 const key = snapshotInstanceKey(provider);
342+ updatedKeys.add(key);
284343 mergedProviders.set(
285344 key,
286345 options?.replace === true
@@ -290,13 +349,16 @@ export const ProviderRegistryLive = Layer.effect( · 3 unmodified lines
290349 }
291350
292351 const providers = orderProviderSnapshots([...mergedProviders.values()]);
293− return [[previousProviders, providers] as const, providers];
352+ const providersToPersist = providers.filter((provider) =>
353+ updatedKeys.has(snapshotInstanceKey(provider)),
354+ );
355+ return [[previousProviders, providers, providersToPersist] as const, providers];
294356 },
295357 );
296358
297359 if (haveProvidersChanged(previousProviders, providers)) {
298360 if (options?.persist !== false) {
299− yield* Effect.forEach(nextProviders, persistProvider, {
361+ yield* Effect.forEach(providersToPersist, persistProvider, {
300362 concurrency: "unbounded",
301363 discard: true,
302364 });
@@ -318,6 +380,45 @@ export const ProviderRegistryLive = Layer.effect( · 15 unmodified lines
318380 return yield* upsertProviders([provider], options);
319381 });
320382
383+ const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")(
384+ function* (input: {
385+ readonly instanceId: ProviderInstanceId;
386+ readonly action: "update";
387+ readonly state: ServerProviderUpdateState | null;
388+ }) {
389+ yield* Ref.update(maintenanceActionStatesRef, (previous) => {
390+ const previousActions = previous.get(input.instanceId);
391+ const nextActions = { ...previousActions };
392+ if (input.state === null || input.state.status === "idle") {
393+ delete nextActions[input.action];
394+ } else {
395+ nextActions[input.action] = input.state;
396+ }
397+
398+ const next = new Map(previous);
399+ if (Object.keys(nextActions).length === 0) {
400+ next.delete(input.instanceId);
401+ } else {
402+ next.set(input.instanceId, nextActions);
403+ }
404+ return next;
405+ });
406+
407+ const existingProviders = yield* Ref.get(providersRef);
408+ const matchingProvider = existingProviders.find(
409+ (candidate) => candidate.instanceId === input.instanceId,
410+ );
411+ if (!matchingProvider) {
412+ return existingProviders;
413+ }
414+
415+ const nextProvider = yield* applyProviderUpdateState(matchingProvider);
416+ return yield* upsertProviders([nextProvider], {
417+ persist: false,
418+ });
419+ },
420+ );
421+
321422 const refreshOneSource = Effect.fn("refreshOneSource")(function* (
322423 providerSource: ProviderSnapshotSource,
323424 ) {
@@ -365,6 +466,18 @@ export const ProviderRegistryLive = Layer.effect( · 41 unmodified lines
365466 return yield* refreshOneSource(providerSource);
366467 });
367468
469+ const getProviderMaintenanceCapabilitiesForInstance = Effect.fn(
470+ "getProviderMaintenanceCapabilitiesForInstance",
471+ )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) {
472+ const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find(
473+ (candidate) => candidate.instanceId === instanceId,
474+ );
475+ return (
476+ instance?.snapshot.maintenanceCapabilities ??
477+ makeManualProviderMaintenanceCapabilities(provider)
478+ );
479+ });
480+
368481 /**
369482 * Diff the aggregator's live-source set against the current
370483 * `ProviderInstanceRegistry` and:
@@ -472,6 +585,15 @@ export const ProviderRegistryLive = Layer.effect( · 101 unmodified lines
472585 if (haveProvidersChanged(previousProviders, providers)) {
473586 yield* PubSub.publish(changesPubSub, providers);
474587 }
588+ yield* Ref.update(maintenanceActionStatesRef, (previous) => {
589+ const next = new Map(previous);
590+ for (const instanceId of previous.keys()) {
591+ if (!knownInstanceIds.has(instanceId)) {
592+ next.delete(instanceId);
593+ }
594+ }
595+ return next;
596+ });
475597 }),
476598 );
477599 const syncLiveSourcesAndContinue = syncLiveSources.pipe(
@@ -555,6 +677,8 @@ export const ProviderRegistryLive = Layer.effect( · 77 unmodified lines
555677 refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
556678 refreshInstance: (instanceId: ProviderInstanceId) =>
557679 refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
680+ getProviderMaintenanceCapabilitiesForInstance,
681+ setProviderMaintenanceActionState,
558682 get streamChanges() {
559683 return Stream.fromPubSub(changesPubSub);
560684 },
apps/server/src/provider/Services/ProviderRegistry.ts+30−1
@@ -6,9 +6,17 @@ · 5 unmodified lines
66 *
77 * @module ProviderRegistry
88 */
9−import type { ProviderInstanceId, ProviderDriverKind, ServerProvider } from "@t3tools/contracts";
9+import type {
10+ ProviderInstanceId,
11+ ProviderDriverKind,
12+ ServerProvider,
13+ ServerProviderUpdateState,
14+} from "@t3tools/contracts";
1015 import { Context } from "effect";
1116 import type { Effect, Stream } from "effect";
17+import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
18+
19+export type ProviderMaintenanceActionKind = "update";
1220
1321 export interface ProviderRegistryShape {
1422 /**
@@ -39,6 +47,27 @@ export interface ProviderRegistryShape { · 24 unmodified lines
3947 instanceId: ProviderInstanceId,
4048 ) => Effect.Effect<ReadonlyArray<ServerProvider>>;
4149
50+ /**
51+ * Resolve the maintenance capabilities owned by one live provider instance.
52+ * Falls back to manual-only capabilities when the instance is not live.
53+ */
54+ readonly getProviderMaintenanceCapabilitiesForInstance: (
55+ instanceId: ProviderInstanceId,
56+ provider: ProviderDriverKind,
57+ ) => Effect.Effect<ProviderMaintenanceCapabilities>;
58+
59+ /**
60+ * Apply volatile maintenance-action state to one configured instance.
61+ * This state is never persisted to disk. Today only update actions are
62+ * projected onto `ServerProvider.updateState`; install/auth actions can
63+ * extend this action map without adding driver-scoped APIs.
64+ */
65+ readonly setProviderMaintenanceActionState: (input: {
66+ readonly instanceId: ProviderInstanceId;
67+ readonly action: ProviderMaintenanceActionKind;
68+ readonly state: ServerProviderUpdateState | null;
69+ }) => Effect.Effect<ReadonlyArray<ServerProvider>>;
70+
4271 /**
4372 * Stream of provider snapshot updates — one emission per aggregated
4473 * change. The array contains the full current state.
apps/server/src/provider/Services/ServerProvider.ts+2−0
@@ -1,7 +1,9 @@
11 import type { ServerProvider } from "@t3tools/contracts";
22 import type { Effect, Stream } from "effect";
3+import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
34
45 export interface ServerProviderShape {
6+ readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
57 readonly getSnapshot: Effect.Effect<ServerProvider>;
68 readonly refresh: Effect.Effect<ServerProvider>;
79 readonly streamChanges: Stream.Stream<ServerProvider>;
apps/server/src/provider/makeManagedServerProvider.test.ts+18−0
@@ -20,6 +20,20 @@ interface TestSettings { · 19 unmodified lines
2020 readonly enabled: boolean;
2121 }
2222
23+const maintenanceCapabilities = {
24+ provider: ProviderDriverKind.make("codex"),
25+ packageName: "@openai/codex",
26+ update: {
27+ command: "npm install -g @openai/codex@latest",
28+
29+ executable: "npm",
30+
31+ args: ["install", "-g", "@openai/codex@latest"],
32+
33+ lockKey: "npm-global",
34+ },
35+} as const;
36+
2337 const initialSnapshot: ServerProvider = {
2438 instanceId: ProviderInstanceId.make("codex"),
2539 driver: ProviderDriverKind.make("codex"),
@@ -90,6 +104,7 @@ describe("makeManagedServerProvider", () => { · 64 unmodified lines
90104 const checkCalls = yield* Ref.make(0);
91105 const releaseCheck = yield* Deferred.make<void>();
92106 const provider = yield* makeManagedServerProvider<TestSettings>({
107+ maintenanceCapabilities,
93108 getSettings: Effect.succeed({ enabled: true }),
94109 streamSettings: Stream.empty,
95110 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
@@ -131,6 +146,7 @@ describe("makeManagedServerProvider", () => { · 35 unmodified lines
131146 const releaseInitialCheck = yield* Deferred.make<void>();
132147 const releaseSettingsCheck = yield* Deferred.make<void>();
133148 const provider = yield* makeManagedServerProvider<TestSettings>({
149+ maintenanceCapabilities,
134150 getSettings: Ref.get(settingsRef),
135151 streamSettings: Stream.fromPubSub(settingsChanges),
136152 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
@@ -172,6 +188,7 @@ describe("makeManagedServerProvider", () => { · 35 unmodified lines
172188 const releaseEnrichment = yield* Deferred.make<void>();
173189 const releaseCheck = yield* Deferred.make<void>();
174190 const provider = yield* makeManagedServerProvider<TestSettings>({
191+ maintenanceCapabilities,
175192 getSettings: Effect.succeed({ enabled: true }),
176193 streamSettings: Stream.empty,
177194 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
@@ -212,6 +229,7 @@ describe("makeManagedServerProvider", () => { · 34 unmodified lines
212229 const secondCallbackReady = yield* Deferred.make<void>();
213230 const allowFirstRefresh = yield* Deferred.make<void>();
214231 const provider = yield* makeManagedServerProvider<TestSettings>({
232+ maintenanceCapabilities,
215233 getSettings: Effect.succeed({ enabled: true }),
216234 streamSettings: Stream.empty,
217235 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
apps/server/src/provider/makeManagedServerProvider.ts+2−0
@@ -13,6 +13,7 @@ interface ProviderSnapshotState { · 12 unmodified lines
1313 export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
1414 Settings,
1515 >(input: {
16+ readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"];
1617 readonly getSettings: Effect.Effect<Settings>;
1718 readonly streamSettings: Stream.Stream<Settings>;
1819 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;
@@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 124 unmodified lines
143144 );
144145
145146 return {
147+ maintenanceCapabilities: input.maintenanceCapabilities,
146148 getSnapshot: input.getSettings.pipe(
147149 Effect.flatMap(applySnapshot),
148150 Effect.tapError(Effect.logError),
apps/server/src/provider/providerMaintenance.test.ts+487−0
@@ -0,0 +1,487 @@
1+import { afterEach, describe, expect, it } from "@effect/vitest";
2+import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import os from "node:os";
5+import path from "node:path";
6+import { ProviderDriverKind } from "@t3tools/contracts";
7+import { Effect, Random } from "effect";
8+import {
9+ clearLatestProviderVersionCacheForTests,
10+ createProviderVersionAdvisory,
11+ makePackageManagedProviderMaintenanceResolver,
12+ makeProviderMaintenanceCapabilities,
13+ makeStaticProviderMaintenanceResolver,
14+ normalizeCommandPath,
15+ resolveProviderMaintenanceCapabilitiesEffect,
16+} from "./providerMaintenance.ts";
17+
18+const driver = (value: string) => ProviderDriverKind.make(value);
19+const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) {
20+ const id = yield* Random.nextUUIDv4;
21+ return path.join(os.tmpdir(), `${name}-${id}`);
22+});
23+const isNativeTestCommandPath =
24+ (expectedPathSegment: string) =>
25+ (commandPath: string): boolean =>
26+ normalizeCommandPath(commandPath).includes(expectedPathSegment);
27+const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({
28+ provider: driver("packageTool"),
29+ npmPackageName: "@example/package-tool",
30+ homebrewFormula: "package-tool",
31+ nativeUpdate: null,
32+});
33+const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({
34+ provider: driver("nativePackageTool"),
35+ npmPackageName: "@example/native-package-tool",
36+ homebrewFormula: "native-package-tool",
37+ nativeUpdate: {
38+ executable: "native-package-tool",
39+ args: ["update"],
40+ lockKey: "native-package-tool-native",
41+ isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"),
42+ },
43+});
44+const scopedPackageToolUpdate = makePackageManagedProviderMaintenanceResolver({
45+ provider: driver("scopedPackageTool"),
46+ npmPackageName: "@example/scoped-package-tool",
47+ homebrewFormula: "example/tap/scoped-package-tool",
48+ nativeUpdate: {
49+ executable: "scoped-package-tool",
50+ args: ["upgrade"],
51+ lockKey: "scoped-package-tool-native",
52+ isCommandPath: isNativeTestCommandPath("/.scoped-package-tool/bin/scoped-package-tool"),
53+ },
54+});
55+const staticToolUpdate = makeStaticProviderMaintenanceResolver(
56+ makeProviderMaintenanceCapabilities({
57+ provider: driver("staticTool"),
58+ packageName: null,
59+ updateExecutable: "static-tool",
60+ updateArgs: ["update"],
61+ updateLockKey: "static-tool",
62+ }),
63+);
64+
65+afterEach(() => {
66+ clearLatestProviderVersionCacheForTests();
67+});
68+
69+describe("providerMaintenance", () => {
70+ it("marks providers with unknown current versions as unknown", () => {
71+ expect(
72+ createProviderVersionAdvisory({
73+ driver: driver("packageTool"),
74+ currentVersion: null,
75+ latestVersion: "9.9.9",
76+ }),
77+ ).toMatchObject({
78+ status: "unknown",
79+ currentVersion: null,
80+ latestVersion: "9.9.9",
81+ });
82+ });
83+
84+ it("marks providers with unknown latest versions as unknown", () => {
85+ expect(
86+ createProviderVersionAdvisory({
87+ driver: driver("packageTool"),
88+ currentVersion: "1.0.0",
89+ latestVersion: null,
90+ }),
91+ ).toMatchObject({
92+ status: "unknown",
93+ currentVersion: "1.0.0",
94+ latestVersion: null,
95+ message: null,
96+ });
97+ });
98+
99+ it("marks installed providers behind latest when a newer provider version is available", () => {
100+ expect(
101+ createProviderVersionAdvisory({
102+ driver: driver("nativePackageTool"),
103+ currentVersion: "2.1.110",
104+ latestVersion: "2.1.117",
105+ maintenanceCapabilities: nativePackageToolUpdate.resolve(),
106+ }),
107+ ).toMatchObject({
108+ status: "behind_latest",
109+ currentVersion: "2.1.110",
110+ latestVersion: "2.1.117",
111+ updateCommand: "npm install -g @example/native-package-tool@latest",
112+ canUpdate: true,
113+ message: "Install the update now or review provider settings.",
114+ });
115+ });
116+
117+ it("keeps update commands owned by provider maintenance capabilities", () => {
118+ expect(staticToolUpdate.resolve()).toEqual({
119+ provider: driver("staticTool"),
120+ packageName: null,
121+ update: {
122+ command: "static-tool update",
123+
124+ executable: "static-tool",
125+
126+ args: ["update"],
127+
128+ lockKey: "static-tool",
129+ },
130+ });
131+ });
132+
133+ it.effect(
134+ "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin",
135+ () =>
136+ Effect.gen(function* () {
137+ const tempDir = yield* makeTempDir("t3-vite-plus-capabilities");
138+ const vitePlusBinDir = path.join(tempDir, ".vite-plus", "bin");
139+ mkdirSync(vitePlusBinDir, { recursive: true });
140+ const packageToolPath = path.join(vitePlusBinDir, "package-tool");
141+ writeFileSync(packageToolPath, "#!/bin/sh\n");
142+ chmodSync(packageToolPath, 0o755);
143+
144+ expect(
145+ packageToolUpdate.resolve({
146+ binaryPath: "package-tool",
147+ platform: "darwin",
148+ env: {
149+ PATH: vitePlusBinDir,
150+ },
151+ }),
152+ ).toEqual({
153+ provider: driver("packageTool"),
154+ packageName: "@example/package-tool",
155+ update: {
156+ command: "vp i -g @example/package-tool",
157+
158+ executable: "vp",
159+
160+ args: ["i", "-g", "@example/package-tool"],
161+
162+ lockKey: "vite-plus-global",
163+ },
164+ });
165+ }),
166+ );
167+
168+ it.effect(
169+ "switches package-managed providers to bun updates when the resolved binary lives in bun's global bin",
170+ () =>
171+ Effect.gen(function* () {
172+ const tempDir = yield* makeTempDir("t3-bun-capabilities");
173+ const bunBinDir = path.join(tempDir, ".bun", "bin");
174+ mkdirSync(bunBinDir, { recursive: true });
175+ writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ");
176+
177+ expect(
178+ nativePackageToolUpdate.resolve({
179+ binaryPath: "native-package-tool",
180+ platform: "win32",
181+ env: {
182+ PATH: bunBinDir,
183+ PATHEXT: ".COM;.EXE;.BAT;.CMD",
184+ },
185+ }),
186+ ).toEqual({
187+ provider: driver("nativePackageTool"),
188+ packageName: "@example/native-package-tool",
189+ update: {
190+ command: "bun i -g @example/native-package-tool@latest",
191+
192+ executable: "bun",
193+
194+ args: ["i", "-g", "@example/native-package-tool@latest"],
195+
196+ lockKey: "bun-global",
197+ },
198+ });
199+ }),
200+ );
201+
202+ it.effect(
203+ "switches package-managed providers to pnpm updates when the resolved binary lives in pnpm's global bin",
204+ () =>
205+ Effect.gen(function* () {
206+ const tempDir = yield* makeTempDir("t3-pnpm-capabilities");
207+ const pnpmHomeDir = path.join(tempDir, ".local", "share", "pnpm");
208+ mkdirSync(pnpmHomeDir, { recursive: true });
209+ const scopedPackageToolPath = path.join(pnpmHomeDir, "scoped-package-tool");
210+ writeFileSync(scopedPackageToolPath, "#!/bin/sh\n");
211+ chmodSync(scopedPackageToolPath, 0o755);
212+
213+ expect(
214+ scopedPackageToolUpdate.resolve({
215+ binaryPath: "scoped-package-tool",
216+ platform: "darwin",
217+ env: {
218+ PATH: pnpmHomeDir,
219+ },
220+ }),
221+ ).toEqual({
222+ provider: driver("scopedPackageTool"),
223+ packageName: "@example/scoped-package-tool",
224+ update: {
225+ command: "pnpm add -g @example/scoped-package-tool@latest",
226+
227+ executable: "pnpm",
228+
229+ args: ["add", "-g", "@example/scoped-package-tool@latest"],
230+
231+ lockKey: "pnpm-global",
232+ },
233+ });
234+ }),
235+ );
236+
237+ it("switches package-tool to Homebrew updates when the binary resolves through Homebrew", () => {
238+ expect(
239+ packageToolUpdate.resolve({
240+ binaryPath: "/opt/homebrew/bin/package-tool",
241+ platform: "darwin",
242+ env: {
243+ PATH: "",
244+ },
245+ }),
246+ ).toEqual({
247+ provider: driver("packageTool"),
248+ packageName: "@example/package-tool",
249+ update: {
250+ command: "brew upgrade package-tool",
251+
252+ executable: "brew",
253+
254+ args: ["upgrade", "package-tool"],
255+
256+ lockKey: "homebrew",
257+ },
258+ });
259+ });
260+
261+ it.effect(
262+ "switches native-package-tool to native updates when the binary resolves through the native installer",
263+ () =>
264+ Effect.gen(function* () {
265+ const tempDir = yield* makeTempDir("t3-native-package-tool-native-capabilities");
266+ const nativeBinDir = path.join(tempDir, ".local", "bin");
267+ mkdirSync(nativeBinDir, { recursive: true });
268+ const nativePackageToolPath = path.join(nativeBinDir, "native-package-tool");
269+ writeFileSync(nativePackageToolPath, "#!/bin/sh\n");
270+ chmodSync(nativePackageToolPath, 0o755);
271+
272+ expect(
273+ nativePackageToolUpdate.resolve({
274+ binaryPath: "native-package-tool",
275+ platform: "darwin",
276+ env: {
277+ PATH: nativeBinDir,
278+ },
279+ }),
280+ ).toEqual({
281+ provider: driver("nativePackageTool"),
282+ packageName: "@example/native-package-tool",
283+ update: {
284+ command: "native-package-tool update",
285+
286+ executable: "native-package-tool",
287+
288+ args: ["update"],
289+
290+ lockKey: "native-package-tool-native",
291+ },
292+ });
293+ }),
294+ );
295+
296+ it.effect(
297+ "switches scoped-package-tool to native upgrades when the binary resolves through the standalone installer",
298+ () =>
299+ Effect.gen(function* () {
300+ const tempDir = yield* makeTempDir("t3-scoped-package-tool-native-capabilities");
301+ const nativeBinDir = path.join(tempDir, ".scoped-package-tool", "bin");
302+ mkdirSync(nativeBinDir, { recursive: true });
303+ const scopedPackageToolPath = path.join(nativeBinDir, "scoped-package-tool");
304+ writeFileSync(scopedPackageToolPath, "#!/bin/sh\n");
305+ chmodSync(scopedPackageToolPath, 0o755);
306+
307+ expect(
308+ scopedPackageToolUpdate.resolve({
309+ binaryPath: "scoped-package-tool",
310+ platform: "darwin",
311+ env: {
312+ PATH: nativeBinDir,
313+ },
314+ }),
315+ ).toEqual({
316+ provider: driver("scopedPackageTool"),
317+ packageName: "@example/scoped-package-tool",
318+ update: {
319+ command: "scoped-package-tool upgrade",
320+
321+ executable: "scoped-package-tool",
322+
323+ args: ["upgrade"],
324+
325+ lockKey: "scoped-package-tool-native",
326+ },
327+ });
328+ }),
329+ );
330+
331+ it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => {
332+ expect(
333+ nativePackageToolUpdate.resolve({
334+ binaryPath: "/opt/homebrew/bin/native-package-tool",
335+ platform: "darwin",
336+ env: {
337+ PATH: "",
338+ },
339+ }),
340+ ).toEqual({
341+ provider: driver("nativePackageTool"),
342+ packageName: "@example/native-package-tool",
343+ update: {
344+ command: "brew upgrade native-package-tool",
345+
346+ executable: "brew",
347+
348+ args: ["upgrade", "native-package-tool"],
349+
350+ lockKey: "homebrew",
351+ },
352+ });
353+ });
354+
355+ it("switches scoped-package-tool to Homebrew updates when the binary resolves through Homebrew", () => {
356+ expect(
357+ scopedPackageToolUpdate.resolve({
358+ binaryPath: "/opt/homebrew/bin/scoped-package-tool",
359+ platform: "darwin",
360+ env: {
361+ PATH: "",
362+ },
363+ }),
364+ ).toEqual({
365+ provider: driver("scopedPackageTool"),
366+ packageName: "@example/scoped-package-tool",
367+ update: {
368+ command: "brew upgrade example/tap/scoped-package-tool",
369+
370+ executable: "brew",
371+
372+ args: ["upgrade", "example/tap/scoped-package-tool"],
373+
374+ lockKey: "homebrew",
375+ },
376+ });
377+ });
378+
379+ it.effect("keeps npm updates for binaries symlinked into npm's global node_modules tree", () =>
380+ Effect.gen(function* () {
381+ const tempDir = yield* makeTempDir("t3-npm-capabilities");
382+ const binDir = path.join(tempDir, "bin");
383+ const packageBinDir = path.join(
384+ tempDir,
385+ "lib",
386+ "node_modules",
387+ "@example",
388+ "package-tool",
389+ "bin",
390+ );
391+ mkdirSync(binDir, { recursive: true });
392+ mkdirSync(packageBinDir, { recursive: true });
393+ const packageBinPath = path.join(packageBinDir, "package-tool.js");
394+ const symlinkPath = path.join(binDir, "package-tool");
395+ writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
396+ chmodSync(packageBinPath, 0o755);
397+ symlinkSync(packageBinPath, symlinkPath);
398+
399+ const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
400+ binaryPath: symlinkPath,
401+ platform: "darwin",
402+ env: {
403+ PATH: "",
404+ },
405+ }).pipe(Effect.provide(NodeServices.layer));
406+
407+ expect(capabilities).toEqual({
408+ provider: driver("packageTool"),
409+ packageName: "@example/package-tool",
410+ update: {
411+ command: "npm install -g @example/package-tool@latest",
412+
413+ executable: "npm",
414+
415+ args: ["install", "-g", "@example/package-tool@latest"],
416+
417+ lockKey: "npm-global",
418+ },
419+ });
420+ }),
421+ );
422+
423+ it.effect("uses Effect FileSystem realPath when detecting pnpm global symlinks", () =>
424+ Effect.gen(function* () {
425+ const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities");
426+ const binDir = path.join(tempDir, "bin");
427+ const packageBinDir = path.join(
428+ tempDir,
429+ ".local",
430+ "share",
431+ "pnpm",
432+ "global",
433+ "5",
434+ "node_modules",
435+ "@example",
436+ "package-tool",
437+ "bin",
438+ );
439+ mkdirSync(binDir, { recursive: true });
440+ mkdirSync(packageBinDir, { recursive: true });
441+ const packageBinPath = path.join(packageBinDir, "package-tool.js");
442+ const symlinkPath = path.join(binDir, "package-tool");
443+ writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
444+ chmodSync(packageBinPath, 0o755);
445+ symlinkSync(packageBinPath, symlinkPath);
446+
447+ const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
448+ binaryPath: symlinkPath,
449+ platform: "darwin",
450+ env: {
451+ PATH: "",
452+ },
453+ }).pipe(Effect.provide(NodeServices.layer));
454+
455+ expect(capabilities).toEqual({
456+ provider: driver("packageTool"),
457+ packageName: "@example/package-tool",
458+ update: {
459+ command: "pnpm add -g @example/package-tool@latest",
460+
461+ executable: "pnpm",
462+
463+ args: ["add", "-g", "@example/package-tool@latest"],
464+
465+ lockKey: "pnpm-global",
466+ },
467+ });
468+ }),
469+ );
470+
471+ it("disables one-click updates for explicit custom binary paths it cannot safely map", () => {
472+ expect(
473+ packageToolUpdate.resolve({
474+ binaryPath: "C:\\Tools\\package-tool\\package-tool.exe",
475+ platform: "win32",
476+ env: {
477+ PATH: "",
478+ PATHEXT: ".COM;.EXE;.BAT;.CMD",
479+ },
480+ }),
481+ ).toEqual({
482+ provider: driver("packageTool"),
483+ packageName: "@example/package-tool",
484+ update: null,
485+ });
486+ });
487+});
apps/server/src/provider/providerMaintenance.ts+472−0
@@ -0,0 +1,472 @@
1+import {
2+ ProviderDriverKind,
3+ type ServerProvider,
4+ type ServerProviderVersionAdvisory,
5+} from "@t3tools/contracts";
6+import { resolveCommandPath } from "@t3tools/shared/shell";
7+import { DateTime, Effect, FileSystem, Option, Schema } from "effect";
8+import { HttpClient, HttpClientRequest } from "effect/unstable/http";
9+
10+import { compareCliVersions } from "./cliVersion.ts";
11+
12+const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
13+const LATEST_VERSION_TIMEOUT_MS = 4_000;
14+const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
15+
16+export interface ProviderMaintenanceCapabilities {
17+ readonly provider: ProviderDriverKind;
18+ readonly packageName: string | null;
19+ readonly update: ProviderMaintenanceCommandAction | null;
20+}
21+
22+export interface ProviderMaintenanceCommandAction {
23+ readonly command: string;
24+ readonly executable: string;
25+ readonly args: ReadonlyArray<string>;
26+ readonly lockKey: string;
27+}
28+
29+export interface ProviderMaintenanceCapabilityResolutionOptions {
30+ readonly binaryPath?: string | null;
31+ readonly env?: NodeJS.ProcessEnv;
32+ readonly platform?: NodeJS.Platform;
33+ readonly realCommandPath?: string | null;
34+}
35+
36+export interface ProviderMaintenanceCapabilitiesResolver {
37+ readonly resolve: (
38+ options?: ProviderMaintenanceCapabilityResolutionOptions,
39+ ) => ProviderMaintenanceCapabilities;
40+}
41+
42+export interface PackageManagedProviderMaintenanceDefinition {
43+ readonly provider: ProviderDriverKind;
44+ readonly npmPackageName: string;
45+ readonly homebrewFormula: string | null;
46+ readonly nativeUpdate: {
47+ readonly executable: string;
48+ readonly args: ReadonlyArray<string>;
49+ readonly lockKey: string;
50+ readonly isCommandPath: (commandPath: string) => boolean;
51+ } | null;
52+}
53+
54+interface LatestVersionCacheEntry {
55+ readonly expiresAt: number;
56+ readonly version: string | null;
57+}
58+
59+const latestVersionCache = new Map<string, LatestVersionCacheEntry>();
60+const NpmLatestVersionResponse = Schema.Struct({
61+ version: Schema.optional(Schema.String),
62+});
63+
64+export function clearLatestProviderVersionCacheForTests(): void {
65+ latestVersionCache.clear();
66+}
67+
68+function nonEmptyString(value: unknown): string | null {
69+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
70+}
71+
72+export function makeProviderMaintenanceCapabilities(input: {
73+ readonly provider: ProviderDriverKind;
74+ readonly packageName: string | null;
75+ readonly updateExecutable: string | null;
76+ readonly updateArgs: ReadonlyArray<string>;
77+ readonly updateLockKey: string | null;
78+}): ProviderMaintenanceCapabilities {
79+ const update =
80+ input.updateExecutable === null || input.updateLockKey === null
81+ ? null
82+ : {
83+ command: [input.updateExecutable, ...input.updateArgs].join(" "),
84+ executable: input.updateExecutable,
85+ args: input.updateArgs,
86+ lockKey: input.updateLockKey,
87+ };
88+ return {
89+ provider: input.provider,
90+ packageName: input.packageName,
91+ update,
92+ };
93+}
94+
95+export function makeManualOnlyProviderMaintenanceCapabilities(input: {
96+ readonly provider: ProviderDriverKind;
97+ readonly packageName: string | null;
98+}): ProviderMaintenanceCapabilities {
99+ return makeProviderMaintenanceCapabilities({
100+ provider: input.provider,
101+ packageName: input.packageName,
102+ updateExecutable: null,
103+ updateArgs: [],
104+ updateLockKey: null,
105+ });
106+}
107+
108+function makeNpmGlobalProviderMaintenanceCapabilities(
109+ definition: PackageManagedProviderMaintenanceDefinition,
110+): ProviderMaintenanceCapabilities {
111+ return makeProviderMaintenanceCapabilities({
112+ provider: definition.provider,
113+ packageName: definition.npmPackageName,
114+ updateExecutable: "npm",
115+ updateArgs: ["install", "-g", `${definition.npmPackageName}@latest`],
116+ updateLockKey: "npm-global",
117+ });
118+}
119+
120+function makeBunGlobalProviderMaintenanceCapabilities(
121+ definition: PackageManagedProviderMaintenanceDefinition,
122+): ProviderMaintenanceCapabilities {
123+ return makeProviderMaintenanceCapabilities({
124+ provider: definition.provider,
125+ packageName: definition.npmPackageName,
126+ updateExecutable: "bun",
127+ updateArgs: ["i", "-g", `${definition.npmPackageName}@latest`],
128+ updateLockKey: "bun-global",
129+ });
130+}
131+
132+function makePnpmGlobalProviderMaintenanceCapabilities(
133+ definition: PackageManagedProviderMaintenanceDefinition,
134+): ProviderMaintenanceCapabilities {
135+ return makeProviderMaintenanceCapabilities({
136+ provider: definition.provider,
137+ packageName: definition.npmPackageName,
138+ updateExecutable: "pnpm",
139+ updateArgs: ["add", "-g", `${definition.npmPackageName}@latest`],
140+ updateLockKey: "pnpm-global",
141+ });
142+}
143+
144+function makeVitePlusGlobalProviderMaintenanceCapabilities(
145+ definition: PackageManagedProviderMaintenanceDefinition,
146+): ProviderMaintenanceCapabilities {
147+ return makeProviderMaintenanceCapabilities({
148+ provider: definition.provider,
149+ packageName: definition.npmPackageName,
150+ updateExecutable: "vp",
151+ updateArgs: ["i", "-g", definition.npmPackageName],
152+ updateLockKey: "vite-plus-global",
153+ });
154+}
155+
156+function makeHomebrewProviderMaintenanceCapabilities(
157+ definition: PackageManagedProviderMaintenanceDefinition,
158+): ProviderMaintenanceCapabilities {
159+ if (!definition.homebrewFormula) {
160+ return makeManualOnlyProviderMaintenanceCapabilities({
161+ provider: definition.provider,
162+ packageName: definition.npmPackageName,
163+ });
164+ }
165+
166+ return makeProviderMaintenanceCapabilities({
167+ provider: definition.provider,
168+ packageName: definition.npmPackageName,
169+ updateExecutable: "brew",
170+ updateArgs: ["upgrade", definition.homebrewFormula],
171+ updateLockKey: "homebrew",
172+ });
173+}
174+
175+function makeNativeProviderMaintenanceCapabilities(
176+ definition: PackageManagedProviderMaintenanceDefinition,
177+): ProviderMaintenanceCapabilities | null {
178+ if (!definition.nativeUpdate) {
179+ return null;
180+ }
181+
182+ return makeProviderMaintenanceCapabilities({
183+ provider: definition.provider,
184+ packageName: definition.npmPackageName,
185+ updateExecutable: definition.nativeUpdate.executable,
186+ updateArgs: definition.nativeUpdate.args,
187+ updateLockKey: definition.nativeUpdate.lockKey,
188+ });
189+}
190+
191+export function hasPathSeparator(value: string): boolean {
192+ return value.includes("/") || value.includes("\\");
193+}
194+
195+export function normalizeCommandPath(commandPath: string): string {
196+ return commandPath.replaceAll("\\", "/").toLowerCase();
197+}
198+
199+function isBunGlobalCommandPath(commandPath: string): boolean {
200+ return normalizeCommandPath(commandPath).includes("/.bun/bin/");
201+}
202+
203+function isVitePlusGlobalCommandPath(commandPath: string): boolean {
204+ return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/");
205+}
206+
207+function isPnpmGlobalCommandPath(commandPath: string): boolean {
208+ const normalized = normalizeCommandPath(commandPath);
209+ return (
210+ normalized.includes("/.local/share/pnpm/") ||
211+ normalized.includes("/library/pnpm/") ||
212+ normalized.includes("/local/share/pnpm/") ||
213+ normalized.includes("/appdata/local/pnpm/") ||
214+ normalized.includes("/pnpm/global/")
215+ );
216+}
217+
218+function isNpmGlobalCommandPath(commandPath: string): boolean {
219+ const normalized = normalizeCommandPath(commandPath);
220+ return (
221+ normalized.includes("/node_modules/.bin/") ||
222+ normalized.includes("/lib/node_modules/") ||
223+ normalized.includes("/npm/node_modules/")
224+ );
225+}
226+
227+function isHomebrewCommandPath(commandPath: string): boolean {
228+ const normalized = normalizeCommandPath(commandPath);
229+ return (
230+ normalized.includes("/opt/homebrew/cellar/") ||
231+ normalized.includes("/usr/local/cellar/") ||
232+ normalized.includes("/homebrew/cellar/") ||
233+ normalized.includes("/opt/homebrew/caskroom/") ||
234+ normalized.includes("/usr/local/caskroom/") ||
235+ normalized.includes("/homebrew/caskroom/") ||
236+ normalized.startsWith("/opt/homebrew/bin/") ||
237+ normalized.startsWith("/usr/local/bin/")
238+ );
239+}
240+
241+export function resolvePackageManagedProviderMaintenance(
242+ definition: PackageManagedProviderMaintenanceDefinition,
243+ options?: ProviderMaintenanceCapabilityResolutionOptions,
244+): ProviderMaintenanceCapabilities {
245+ const binaryPath = nonEmptyString(options?.binaryPath);
246+ if (!binaryPath) {
247+ return makeNpmGlobalProviderMaintenanceCapabilities(definition);
248+ }
249+
250+ const resolvedCommandPath =
251+ resolveCommandPath(binaryPath, {
252+ ...(options?.platform ? { platform: options.platform } : {}),
253+ ...(options?.env ? { env: options.env } : {}),
254+ }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
255+
256+ if (resolvedCommandPath) {
257+ const commandPaths = [
258+ resolvedCommandPath,
259+ ...(options?.realCommandPath ? [options.realCommandPath] : []),
260+ ];
261+
262+ const nativeUpdate = definition.nativeUpdate;
263+ if (
264+ nativeUpdate &&
265+ commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))
266+ ) {
267+ return (
268+ makeNativeProviderMaintenanceCapabilities(definition) ??
269+ makeNpmGlobalProviderMaintenanceCapabilities(definition)
270+ );
271+ }
272+ if (commandPaths.some(isVitePlusGlobalCommandPath)) {
273+ return makeVitePlusGlobalProviderMaintenanceCapabilities(definition);
274+ }
275+ if (commandPaths.some(isBunGlobalCommandPath)) {
276+ return makeBunGlobalProviderMaintenanceCapabilities(definition);
277+ }
278+ if (commandPaths.some(isPnpmGlobalCommandPath)) {
279+ return makePnpmGlobalProviderMaintenanceCapabilities(definition);
280+ }
281+ if (commandPaths.some(isNpmGlobalCommandPath)) {
282+ return makeNpmGlobalProviderMaintenanceCapabilities(definition);
283+ }
284+ if (commandPaths.some(isHomebrewCommandPath)) {
285+ return makeHomebrewProviderMaintenanceCapabilities(definition);
286+ }
287+ }
288+
289+ if (!hasPathSeparator(binaryPath)) {
290+ return makeNpmGlobalProviderMaintenanceCapabilities(definition);
291+ }
292+
293+ return makeManualOnlyProviderMaintenanceCapabilities({
294+ provider: definition.provider,
295+ packageName: definition.npmPackageName,
296+ });
297+}
298+
299+export function makePackageManagedProviderMaintenanceResolver(
300+ definition: PackageManagedProviderMaintenanceDefinition,
301+): ProviderMaintenanceCapabilitiesResolver {
302+ return {
303+ resolve: (options) => resolvePackageManagedProviderMaintenance(definition, options),
304+ };
305+}
306+
307+export function makeStaticProviderMaintenanceResolver(
308+ capabilities: ProviderMaintenanceCapabilities,
309+): ProviderMaintenanceCapabilitiesResolver {
310+ return {
311+ resolve: () => capabilities,
312+ };
313+}
314+
315+function makeManualProviderMaintenanceCapabilities(
316+ provider: ProviderDriverKind,
317+): ProviderMaintenanceCapabilities {
318+ return makeManualOnlyProviderMaintenanceCapabilities({
319+ provider,
320+ packageName: null,
321+ });
322+}
323+
324+export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
325+ "resolveProviderMaintenanceCapabilitiesEffect",
326+)(function* (
327+ resolver: ProviderMaintenanceCapabilitiesResolver,
328+ options?: Omit<ProviderMaintenanceCapabilityResolutionOptions, "realCommandPath">,
329+) {
330+ const binaryPath = nonEmptyString(options?.binaryPath);
331+ if (!binaryPath) {
332+ return resolver.resolve(options);
333+ }
334+
335+ const resolvedCommandPath =
336+ resolveCommandPath(binaryPath, {
337+ ...(options?.platform ? { platform: options.platform } : {}),
338+ ...(options?.env ? { env: options.env } : {}),
339+ }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
340+ if (!resolvedCommandPath) {
341+ return resolver.resolve(options);
342+ }
343+
344+ const fileSystem = yield* FileSystem.FileSystem;
345+ const realCommandPath = yield* fileSystem
346+ .realPath(resolvedCommandPath)
347+ .pipe(Effect.catch(() => Effect.succeed(resolvedCommandPath)));
348+ return resolver.resolve({
349+ ...options,
350+ realCommandPath,
351+ });
352+});
353+
354+function deriveVersionAdvisory(input: {
355+ readonly currentVersion: string | null;
356+ readonly latestVersion: string | null;
357+}): Pick<ServerProviderVersionAdvisory, "status" | "message"> {
358+ if (!input.currentVersion) {
359+ return { status: "unknown", message: null };
360+ }
361+ if (!input.latestVersion) {
362+ return { status: "unknown", message: null };
363+ }
364+ if (compareCliVersions(input.currentVersion, input.latestVersion) < 0) {
365+ return {
366+ status: "behind_latest",
367+ message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE,
368+ };
369+ }
370+ return { status: "current", message: null };
371+}
372+
373+export function createProviderVersionAdvisory(input: {
374+ readonly driver: ProviderDriverKind;
375+ readonly currentVersion: string | null;
376+ readonly latestVersion?: string | null;
377+ readonly checkedAt?: string | null;
378+ readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities;
379+}): ServerProviderVersionAdvisory {
380+ const capabilities =
381+ input.maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(input.driver);
382+ const latestVersion = input.latestVersion ?? null;
383+ const advisory = deriveVersionAdvisory({
384+ currentVersion: input.currentVersion,
385+ latestVersion,
386+ });
387+
388+ return {
389+ status: advisory.status,
390+ currentVersion: input.currentVersion,
391+ latestVersion,
392+ updateCommand: capabilities.update?.command ?? null,
393+ canUpdate: capabilities.update !== null,
394+ checkedAt: input.checkedAt ?? null,
395+ message: advisory.message,
396+ };
397+}
398+
399+const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) {
400+ const client = yield* HttpClient.HttpClient;
401+ const request = HttpClientRequest.get(
402+ `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`,
403+ ).pipe(HttpClientRequest.setHeader("accept", "application/json"));
404+ const response = yield* client.execute(request).pipe(
405+ Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS),
406+ Effect.catch(() => Effect.succeed(Option.none())),
407+ );
408+ if (Option.isNone(response)) {
409+ return null;
410+ }
411+ const httpResponse = response.value;
412+ if (httpResponse.status < 200 || httpResponse.status >= 300) {
413+ return null;
414+ }
415+ const payload = yield* httpResponse.json.pipe(
416+ Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)),
417+ Effect.catch(() => Effect.succeed(null)),
418+ );
419+ return payload ? nonEmptyString(payload.version) : null;
420+});
421+
422+export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* (
423+ maintenanceCapabilities: ProviderMaintenanceCapabilities,
424+) {
425+ const packageName = maintenanceCapabilities.packageName;
426+ if (!packageName) {
427+ return null;
428+ }
429+
430+ const cached = latestVersionCache.get(packageName);
431+ const now = DateTime.toEpochMillis(yield* DateTime.now);
432+ if (cached && cached.expiresAt > now) {
433+ return cached.version;
434+ }
435+
436+ const version = yield* fetchNpmLatestVersion(packageName);
437+ latestVersionCache.set(packageName, {
438+ expiresAt: now + LATEST_VERSION_CACHE_TTL_MS,
439+ version,
440+ });
441+ return version;
442+});
443+
444+export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn(
445+ "enrichProviderSnapshotWithVersionAdvisory",
446+)(function* (snapshot: ServerProvider, maintenanceCapabilities?: ProviderMaintenanceCapabilities) {
447+ const capabilities =
448+ maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver);
449+ if (!snapshot.enabled || !snapshot.installed || !snapshot.version) {
450+ return {
451+ ...snapshot,
452+ versionAdvisory: createProviderVersionAdvisory({
453+ driver: snapshot.driver,
454+ currentVersion: snapshot.version,
455+ checkedAt: snapshot.checkedAt,
456+ maintenanceCapabilities: capabilities,
457+ }),
458+ };
459+ }
460+
461+ const latestVersion = yield* resolveLatestProviderVersion(capabilities);
462+ return {
463+ ...snapshot,
464+ versionAdvisory: createProviderVersionAdvisory({
465+ driver: snapshot.driver,
466+ currentVersion: snapshot.version,
467+ latestVersion,
468+ checkedAt: DateTime.formatIso(yield* DateTime.now),
469+ maintenanceCapabilities: capabilities,
470+ }),
471+ };
472+});
apps/server/src/provider/providerMaintenanceCommandCoordinator.ts+79−0
@@ -0,0 +1,79 @@
1+import { Effect, Ref } from "effect";
2+import * as Semaphore from "effect/Semaphore";
3+
4+export interface ProviderMaintenanceCommandCoordinatorShape<E> {
5+ readonly withCommandLock: <A, R>(input: {
6+ readonly targetKey: string;
7+ readonly lockKey: string;
8+ readonly onQueued?: Effect.Effect<void, E, R>;
9+ readonly run: Effect.Effect<A, E, R>;
10+ }) => Effect.Effect<A, E, R>;
11+}
12+
13+export const makeProviderMaintenanceCommandCoordinator = Effect.fn(
14+ "makeProviderMaintenanceCommandCoordinator",
15+)(function* <E>(input: { readonly makeAlreadyRunningError: (targetKey: string) => E }) {
16+ const runningTargetsRef = yield* Ref.make<ReadonlySet<string>>(new Set());
17+ const locksRef = yield* Ref.make<ReadonlyMap<string, Semaphore.Semaphore>>(new Map());
18+
19+ const acquireTarget = Effect.fn("acquireTarget")(function* (targetKey: string) {
20+ return yield* Ref.modify(runningTargetsRef, (runningTargets) => {
21+ if (runningTargets.has(targetKey)) {
22+ return [false, runningTargets] as const;
23+ }
24+ const next = new Set(runningTargets);
25+ next.add(targetKey);
26+ return [true, next] as const;
27+ });
28+ });
29+
30+ const releaseTarget = (targetKey: string) =>
31+ Ref.update(runningTargetsRef, (runningTargets) => {
32+ const next = new Set(runningTargets);
33+ next.delete(targetKey);
34+ return next;
35+ });
36+
37+ const getLock = Effect.fn("getProviderMaintenanceCommandLock")(function* (lockKey: string) {
38+ const existing = (yield* Ref.get(locksRef)).get(lockKey);
39+ if (existing) {
40+ return existing;
41+ }
42+
43+ const lock = yield* Semaphore.make(1);
44+ return yield* Ref.modify(locksRef, (locks) => {
45+ const current = locks.get(lockKey);
46+ if (current) {
47+ return [current, locks] as const;
48+ }
49+ const next = new Map(locks);
50+ next.set(lockKey, lock);
51+ return [lock, next] as const;
52+ });
53+ });
54+
55+ const withCommandLock: ProviderMaintenanceCommandCoordinatorShape<E>["withCommandLock"] = ({
56+ targetKey,
57+ lockKey,
58+ onQueued,
59+ run,
60+ }) =>
61+ Effect.gen(function* () {
62+ const acquired = yield* acquireTarget(targetKey);
63+ if (!acquired) {
64+ return yield* Effect.fail(input.makeAlreadyRunningError(targetKey));
65+ }
66+
67+ return yield* Effect.gen(function* () {
68+ const lock = yield* getLock(lockKey);
69+ if (onQueued) {
70+ yield* onQueued;
71+ }
72+ return yield* lock.withPermits(1)(run);
73+ }).pipe(Effect.ensuring(releaseTarget(targetKey)));
74+ });
75+
76+ return {
77+ withCommandLock,
78+ } satisfies ProviderMaintenanceCommandCoordinatorShape<E>;
79+});
apps/server/src/provider/providerMaintenanceRunner.test.ts+640−0
@@ -0,0 +1,640 @@
1+import { afterEach, describe, it, assert } from "@effect/vitest";
2+import {
3+ ProviderDriverKind,
4+ ProviderInstanceId,
5+ type ServerProvider,
6+ type ServerProviderUpdateState,
7+} from "@t3tools/contracts";
8+import { ServerProviderUpdateError } from "@t3tools/contracts";
9+import { Cause, Effect, Exit, Fiber, Layer, Ref, Schema, Sink, Stream } from "effect";
10+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
11+import { ChildProcessSpawner } from "effect/unstable/process";
12+
13+import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";
14+import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
15+import {
16+ clearLatestProviderVersionCacheForTests,
17+ makeProviderMaintenanceCapabilities,
18+ type ProviderMaintenanceCapabilities,
19+} from "./providerMaintenance.ts";
20+
21+const CODEX_DRIVER = ProviderDriverKind.make("codex");
22+const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
23+const OPENCODE_DRIVER = ProviderDriverKind.make("opencode");
24+const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex");
25+const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor");
26+const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
27+const encoder = new TextEncoder();
28+
29+afterEach(() => {
30+ clearLatestProviderVersionCacheForTests();
31+});
32+
33+function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities {
34+ if (provider === CURSOR_DRIVER) {
35+ return makeProviderMaintenanceCapabilities({
36+ provider,
37+ packageName: null,
38+ updateExecutable: "agent",
39+ updateArgs: ["update"],
40+ updateLockKey: "cursor-agent",
41+ });
42+ }
43+ return makeProviderMaintenanceCapabilities({
44+ provider,
45+ packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex",
46+ updateExecutable: "npm",
47+ updateArgs:
48+ provider === OPENCODE_DRIVER
49+ ? ["install", "-g", "opencode-ai@latest"]
50+ : ["install", "-g", "@openai/codex@latest"],
51+ updateLockKey: "npm-global",
52+ });
53+}
54+
55+const baseProvider: ServerProvider = {
56+ instanceId: CODEX_INSTANCE_ID,
57+ driver: CODEX_DRIVER,
58+ enabled: true,
59+ installed: true,
60+ version: null,
61+ status: "ready",
62+ auth: { status: "authenticated" },
63+ checkedAt: "2026-04-10T00:00:00.000Z",
64+ models: [],
65+ slashCommands: [],
66+ skills: [],
67+};
68+
69+const baseCursorProvider: ServerProvider = {
70+ ...baseProvider,
71+ instanceId: CURSOR_INSTANCE_ID,
72+ driver: CURSOR_DRIVER,
73+};
74+
75+const baseOpenCodeProvider: ServerProvider = {
76+ ...baseProvider,
77+ instanceId: OPENCODE_INSTANCE_ID,
78+ driver: OPENCODE_DRIVER,
79+};
80+
81+const latestVersionHttpClient = (version: string) =>
82+ Layer.succeed(
83+ HttpClient.HttpClient,
84+ HttpClient.make((request) =>
85+ Effect.succeed(
86+ HttpClientResponse.fromWeb(
87+ request,
88+ Response.json({ version }, { headers: { "content-type": "application/json" } }),
89+ ),
90+ ),
91+ ),
92+ );
93+
94+function mockHandle(result: {
95+ readonly stdout?: string;
96+ readonly stderr?: string;
97+ readonly code?: number;
98+ readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
99+}) {
100+ return ChildProcessSpawner.makeHandle({
101+ pid: ChildProcessSpawner.ProcessId(1),
102+ exitCode: result.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)),
103+ isRunning: Effect.succeed(false),
104+ kill: () => Effect.void,
105+ unref: Effect.succeed(Effect.void),
106+ stdin: Sink.drain,
107+ stdout: Stream.make(encoder.encode(result.stdout ?? "")),
108+ stderr: Stream.make(encoder.encode(result.stderr ?? "")),
109+ all: Stream.empty,
110+ getInputFd: () => Sink.drain,
111+ getOutputFd: () => Stream.empty,
112+ });
113+}
114+
115+function mockSpawnerLayer(
116+ handler: (
117+ command: string,
118+ args: ReadonlyArray<string>,
119+ ) => {
120+ readonly stdout?: string;
121+ readonly stderr?: string;
122+ readonly code?: number;
123+ readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
124+ },
125+) {
126+ return Layer.succeed(
127+ ChildProcessSpawner.ChildProcessSpawner,
128+ ChildProcessSpawner.make((command) => {
129+ const childProcess = command as unknown as {
130+ readonly command: string;
131+ readonly args: ReadonlyArray<string>;
132+ };
133+ return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
134+ }),
135+ );
136+}
137+
138+function makeRegistry(
139+ initialProviders: ServerProvider | ReadonlyArray<ServerProvider> = baseProvider,
140+) {
141+ return Effect.gen(function* () {
142+ const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(
143+ Array.isArray(initialProviders) ? initialProviders : [initialProviders],
144+ );
145+ const updateStatesRef = yield* Ref.make<ReadonlyArray<ServerProviderUpdateState>>([]);
146+
147+ const setProviderMaintenanceActionState = Effect.fn(
148+ "providerMaintenanceRunner.test.setProviderMaintenanceActionState",
149+ )(function* (input: {
150+ readonly instanceId: ProviderInstanceId;
151+ readonly action: "update";
152+ readonly state: ServerProviderUpdateState | null;
153+ }) {
154+ const updateState = input.state;
155+ if (updateState) {
156+ yield* Ref.update(updateStatesRef, (states) => [...states, updateState]);
157+ }
158+ return yield* Ref.updateAndGet(providersRef, (providers) =>
159+ providers.map((candidate) => {
160+ if (candidate.instanceId !== input.instanceId) {
161+ return candidate;
162+ }
163+ if (!updateState) {
164+ const { updateState: _updateState, ...providerWithoutUpdateState } = candidate;
165+ return providerWithoutUpdateState;
166+ }
167+ return {
168+ ...candidate,
169+ updateState,
170+ };
171+ }),
172+ );
173+ });
174+
175+ const registry: ProviderRegistryShape = {
176+ getProviders: Ref.get(providersRef),
177+ refresh: () => Ref.get(providersRef),
178+ refreshInstance: () => Ref.get(providersRef),
179+ getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
180+ Effect.succeed(lifecycleFor(provider)),
181+ setProviderMaintenanceActionState,
182+ streamChanges: Stream.empty,
183+ };
184+
185+ return {
186+ registry,
187+ updateStatesRef,
188+ };
189+ });
190+}
191+
192+const makeTestRunner = (registry: ProviderRegistryShape) =>
193+ Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe(
194+ Effect.provide(
195+ ProviderMaintenanceRunner.layer.pipe(
196+ Layer.provide(Layer.succeed(ProviderRegistry, registry)),
197+ ),
198+ ),
199+ );
200+
201+describe("providerMaintenanceRunner", () => {
202+ it.effect("runs the allowlisted provider update command and records success", () => {
203+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
204+ return Effect.gen(function* () {
205+ const { registry, updateStatesRef } = yield* makeRegistry(baseCursorProvider);
206+ const updater = yield* makeTestRunner(registry);
207+
208+ const result = yield* updater.updateProvider(CURSOR_DRIVER);
209+ assert.deepStrictEqual(calls, [
210+ {
211+ command: "agent",
212+ args: ["update"],
213+ },
214+ ]);
215+ assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
216+ assert.deepStrictEqual(
217+ (yield* Ref.get(updateStatesRef)).map((state) => state.status),
218+ ["queued", "running", "succeeded"],
219+ );
220+ }).pipe(
221+ Effect.provide(
222+ Layer.mergeAll(
223+ latestVersionHttpClient("0.0.0"),
224+ mockSpawnerLayer((command, args) => {
225+ calls.push({ command, args });
226+ return { stdout: "updated" };
227+ }),
228+ ),
229+ ),
230+ );
231+ });
232+
233+ it.effect("uses the resolved provider capabilities when choosing the update executable", () => {
234+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
235+ return Effect.gen(function* () {
236+ const { registry } = yield* makeRegistry({
237+ ...baseProvider,
238+ versionAdvisory: {
239+ status: "behind_latest",
240+ currentVersion: "2.0.14",
241+ latestVersion: "2.1.123",
242+ updateCommand: "bun i -g @anthropic-ai/claude-code@latest",
243+ canUpdate: true,
244+ checkedAt: "2026-04-30T12:00:00.000Z",
245+ message: "Update available.",
246+ },
247+ });
248+ const updater = yield* makeTestRunner({
249+ ...registry,
250+ getProviderMaintenanceCapabilitiesForInstance: () =>
251+ Effect.succeed(
252+ makeProviderMaintenanceCapabilities({
253+ provider: CODEX_DRIVER,
254+ packageName: "@openai/codex",
255+ updateExecutable: "bun",
256+ updateArgs: ["i", "-g", "@openai/codex@latest"],
257+ updateLockKey: "bun-global",
258+ }),
259+ ),
260+ });
261+
262+ yield* updater.updateProvider(CODEX_DRIVER);
263+ assert.deepStrictEqual(calls, [
264+ {
265+ command: "bun",
266+ args: ["i", "-g", "@openai/codex@latest"],
267+ },
268+ ]);
269+ }).pipe(
270+ Effect.provide(
271+ Layer.mergeAll(
272+ latestVersionHttpClient("0.0.0"),
273+ mockSpawnerLayer((command, args) => {
274+ calls.push({ command, args });
275+ return { stdout: "updated" };
276+ }),
277+ ),
278+ ),
279+ );
280+ });
281+
282+ it.effect(
283+ "runs update commands through Effect ChildProcess when no test runner is injected",
284+ () => {
285+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
286+ return Effect.gen(function* () {
287+ const { registry } = yield* makeRegistry(baseProvider);
288+ const runner = yield* makeTestRunner(registry);
289+
290+ const result = yield* runner.updateProvider(CODEX_DRIVER);
291+
292+ assert.deepStrictEqual(calls, [
293+ {
294+ command: "npm",
295+ args: ["install", "-g", "@openai/codex@latest"],
296+ },
297+ ]);
298+ assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
299+ }).pipe(
300+ Effect.provide(
301+ Layer.mergeAll(
302+ latestVersionHttpClient("0.0.0"),
303+ mockSpawnerLayer((command, args) => {
304+ calls.push({ command, args });
305+ return { stdout: "updated" };
306+ }),
307+ ),
308+ ),
309+ );
310+ },
311+ );
312+
313+ it.effect("updates a single provider instance without touching sibling instances", () => {
314+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
315+ return Effect.gen(function* () {
316+ const personalInstanceId = ProviderInstanceId.make("codex_personal");
317+ const workInstanceId = ProviderInstanceId.make("codex_work");
318+ const refreshedInstanceIds: Array<ProviderInstanceId> = [];
319+ const { registry } = yield* makeRegistry([
320+ {
321+ ...baseProvider,
322+ instanceId: personalInstanceId,
323+ version: "0.124.0-alpha.3",
324+ },
325+ {
326+ ...baseProvider,
327+ instanceId: workInstanceId,
328+ version: "0.124.0-alpha.3",
329+ },
330+ ]);
331+ const updater = yield* makeTestRunner({
332+ ...registry,
333+ getProviderMaintenanceCapabilitiesForInstance: (instanceId, provider) =>
334+ Effect.succeed(
335+ makeProviderMaintenanceCapabilities({
336+ provider,
337+ packageName: "@openai/codex-instance-test",
338+ updateExecutable: "vp",
339+ updateArgs: ["i", "-g", "@openai/codex"],
340+ updateLockKey: "vite-plus-global",
341+ }),
342+ ).pipe(
343+ Effect.tap(() => Effect.sync(() => assert.strictEqual(instanceId, personalInstanceId))),
344+ ),
345+ refreshInstance: (instanceId) =>
346+ registry.refreshInstance(instanceId).pipe(
347+ Effect.tap(() =>
348+ Effect.sync(() => {
349+ refreshedInstanceIds.push(instanceId);
350+ }),
351+ ),
352+ ),
353+ });
354+
355+ const result = yield* updater.updateProvider({
356+ provider: CODEX_DRIVER,
357+ instanceId: personalInstanceId,
358+ });
359+
360+ assert.deepStrictEqual(calls, [
361+ {
362+ command: "vp",
363+ args: ["i", "-g", "@openai/codex"],
364+ },
365+ ]);
366+ assert.deepStrictEqual(refreshedInstanceIds, [personalInstanceId]);
367+ assert.strictEqual(result.providers[0]?.instanceId, personalInstanceId);
368+ assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
369+ assert.strictEqual(result.providers[1]?.instanceId, workInstanceId);
370+ assert.strictEqual(result.providers[1]?.updateState, undefined);
371+ }).pipe(
372+ Effect.provide(
373+ Layer.mergeAll(
374+ latestVersionHttpClient("0.124.0-alpha.3"),
375+ mockSpawnerLayer((command, args) => {
376+ calls.push({ command, args });
377+ return { stdout: "updated" };
378+ }),
379+ ),
380+ ),
381+ );
382+ });
383+
384+ it.effect("records command failure output in provider update state", () =>
385+ Effect.gen(function* () {
386+ const { registry } = yield* makeRegistry();
387+ const updater = yield* makeTestRunner(registry);
388+
389+ const result = yield* updater.updateProvider(CODEX_DRIVER);
390+ const updateState = result.providers[0]?.updateState;
391+
392+ assert.strictEqual(updateState?.status, "failed");
393+ assert.strictEqual(updateState?.message, "Update command exited with code 1.");
394+ assert.include(updateState?.output ?? "", "permission denied");
395+ }).pipe(
396+ Effect.provide(
397+ Layer.mergeAll(
398+ latestVersionHttpClient("0.0.0"),
399+ mockSpawnerLayer(() => ({ stderr: "permission denied", code: 1 })),
400+ ),
401+ ),
402+ ),
403+ );
404+
405+ it.effect(
406+ "marks successful commands as unchanged when the refreshed provider is still outdated",
407+ () =>
408+ Effect.gen(function* () {
409+ const { registry } = yield* makeRegistry({
410+ ...baseProvider,
411+ installed: true,
412+ version: "0.1.0",
413+ });
414+ const updater = yield* makeTestRunner(registry);
415+
416+ const result = yield* updater.updateProvider(CODEX_DRIVER);
417+
418+ assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged");
419+ assert.include(result.providers[0]?.updateState?.message ?? "", "still detects");
420+ }).pipe(
421+ Effect.provide(
422+ Layer.mergeAll(
423+ latestVersionHttpClient("9.9.9"),
424+ mockSpawnerLayer(() => ({ stdout: "updated" })),
425+ ),
426+ ),
427+ ),
428+ );
429+
430+ it.effect("prevents concurrent updates for the same provider", () => {
431+ const startedLatch: { resolve: () => void } = { resolve: () => {} };
432+ const releaseLatch: { resolve: () => void } = { resolve: () => {} };
433+ const started = new Promise<void>((resolve) => {
434+ startedLatch.resolve = resolve;
435+ });
436+ const release = new Promise<void>((resolve) => {
437+ releaseLatch.resolve = resolve;
438+ });
439+ return Effect.gen(function* () {
440+ const { registry } = yield* makeRegistry();
441+ const updater = yield* makeTestRunner(registry);
442+
443+ const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped);
444+ yield* Effect.promise(() => started);
445+
446+ const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit);
447+ assert.strictEqual(Exit.isFailure(second), true);
448+ if (Exit.isFailure(second)) {
449+ const error = Cause.squash(second.cause);
450+ assert.strictEqual(Schema.is(ServerProviderUpdateError)(error), true);
451+ if (Schema.is(ServerProviderUpdateError)(error)) {
452+ assert.include(error.reason, "already running");
453+ }
454+ }
455+
456+ releaseLatch.resolve();
457+ yield* Fiber.join(first);
458+ }).pipe(
459+ Effect.provide(
460+ Layer.mergeAll(
461+ latestVersionHttpClient("0.0.0"),
462+ mockSpawnerLayer(() => {
463+ startedLatch.resolve();
464+ return {
465+ stdout: "updated",
466+ exitCode: Effect.promise(() => release).pipe(
467+ Effect.as(ChildProcessSpawner.ExitCode(0)),
468+ ),
469+ };
470+ }),
471+ ),
472+ ),
473+ );
474+ });
475+
476+ it.effect("serializes different providers that share the same update lock key", () => {
477+ const firstStartedLatch: { resolve: () => void } = { resolve: () => {} };
478+ const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} };
479+ const firstStarted = new Promise<void>((resolve) => {
480+ firstStartedLatch.resolve = resolve;
481+ });
482+ const releaseFirst = new Promise<void>((resolve) => {
483+ releaseFirstLatch.resolve = resolve;
484+ });
485+ const calls: Array<string> = [];
486+ return Effect.gen(function* () {
487+ const { registry } = yield* makeRegistry([baseProvider, baseOpenCodeProvider]);
488+ const updater = yield* makeTestRunner({
489+ ...registry,
490+ getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
491+ Effect.succeed(
492+ makeProviderMaintenanceCapabilities({
493+ provider,
494+ packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex",
495+ updateExecutable: "npm",
496+ updateArgs:
497+ provider === OPENCODE_DRIVER
498+ ? ["install", "-g", "opencode-ai@latest"]
499+ : ["install", "-g", "@openai/codex@latest"],
500+ updateLockKey: "npm-global",
501+ }),
502+ ),
503+ });
504+
505+ const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped);
506+ yield* Effect.promise(() => firstStarted);
507+
508+ const second = yield* updater.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped);
509+ let providersWhileQueued: ReadonlyArray<ServerProvider> = [];
510+ for (let attempt = 0; attempt < 20; attempt += 1) {
511+ providersWhileQueued = yield* registry.getProviders;
512+ const queuedStatus = providersWhileQueued.find(
513+ (provider) => provider.instanceId === OPENCODE_INSTANCE_ID,
514+ )?.updateState?.status;
515+ if (queuedStatus === "queued") {
516+ break;
517+ }
518+ yield* Effect.yieldNow;
519+ }
520+ assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]);
521+ assert.strictEqual(
522+ providersWhileQueued.find((provider) => provider.instanceId === OPENCODE_INSTANCE_ID)
523+ ?.updateState?.status,
524+ "queued",
525+ );
526+
527+ releaseFirstLatch.resolve();
528+ yield* Fiber.join(first);
529+ yield* Fiber.join(second);
530+ assert.deepStrictEqual(calls, [
531+ "install -g @openai/codex@latest",
532+ "install -g opencode-ai@latest",
533+ ]);
534+ }).pipe(
535+ Effect.provide(
536+ Layer.mergeAll(
537+ latestVersionHttpClient("0.0.0"),
538+ mockSpawnerLayer((_command, args) => {
539+ calls.push(args.join(" "));
540+ if (calls.length === 1) {
541+ firstStartedLatch.resolve();
542+ return {
543+ stdout: "updated",
544+ exitCode: Effect.promise(() => releaseFirst).pipe(
545+ Effect.as(ChildProcessSpawner.ExitCode(0)),
546+ ),
547+ };
548+ }
549+ return { stdout: "updated" };
550+ }),
551+ ),
552+ ),
553+ );
554+ });
555+
556+ it.effect("accepts arbitrary driver-provided update lock keys", () => {
557+ const calls: Array<string> = [];
558+ return Effect.gen(function* () {
559+ const { registry } = yield* makeRegistry(baseProvider);
560+ const updater = yield* makeTestRunner({
561+ ...registry,
562+ getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
563+ Effect.succeed(
564+ makeProviderMaintenanceCapabilities({
565+ provider,
566+ packageName: "@openai/codex",
567+ updateExecutable: "npm",
568+ updateArgs: ["install", "-g", "@openai/codex@latest"],
569+ updateLockKey: "unknown-lock-key",
570+ }),
571+ ),
572+ });
573+
574+ const result = yield* updater.updateProvider(CODEX_DRIVER);
575+ assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
576+ assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]);
577+ }).pipe(
578+ Effect.provide(
579+ Layer.mergeAll(
580+ latestVersionHttpClient("0.0.0"),
581+ mockSpawnerLayer((_command, args) => {
582+ calls.push(args.join(" "));
583+ return { stdout: "updated" };
584+ }),
585+ ),
586+ ),
587+ );
588+ });
589+
590+ it.effect(
591+ "releases the running-provider marker when interrupted after queuing but before the lock run starts",
592+ () =>
593+ Effect.gen(function* () {
594+ const { registry } = yield* makeRegistry(baseProvider);
595+ let blockQueuedState = true;
596+ const queuedStateWrittenLatch: { resolve: () => void } = { resolve: () => {} };
597+ const releaseQueuedStateLatch: { resolve: () => void } = { resolve: () => {} };
598+ const queuedStateWritten = new Promise<void>((resolve) => {
599+ queuedStateWrittenLatch.resolve = resolve;
600+ });
601+ const releaseQueuedState = new Promise<void>((resolve) => {
602+ releaseQueuedStateLatch.resolve = resolve;
603+ });
604+
605+ const updater = yield* makeTestRunner({
606+ ...registry,
607+ setProviderMaintenanceActionState: Effect.fn(
608+ "providerMaintenanceRunner.test.blockQueuedState",
609+ )(function* (input) {
610+ const providers = yield* registry.setProviderMaintenanceActionState(input);
611+ if (input.state?.status === "queued" && blockQueuedState) {
612+ queuedStateWrittenLatch.resolve();
613+ yield* Effect.promise(() => releaseQueuedState);
614+ }
615+ return providers;
616+ }),
617+ });
618+
619+ const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped);
620+ yield* Effect.promise(() => queuedStateWritten);
621+ blockQueuedState = false;
622+
623+ yield* Fiber.interrupt(first);
624+ releaseQueuedStateLatch.resolve();
625+
626+ const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit);
627+ assert.strictEqual(Exit.isSuccess(second), true);
628+ if (Exit.isSuccess(second)) {
629+ assert.strictEqual(second.value.providers[0]?.updateState?.status, "succeeded");
630+ }
631+ }).pipe(
632+ Effect.provide(
633+ Layer.mergeAll(
634+ latestVersionHttpClient("0.0.0"),
635+ mockSpawnerLayer(() => ({ stdout: "updated" })),
636+ ),
637+ ),
638+ ),
639+ );
640+});
apps/server/src/provider/providerMaintenanceRunner.ts+395−0
@@ -0,0 +1,395 @@
1+import {
2+ defaultInstanceIdForDriver,
3+ ProviderDriverKind,
4+ ServerProviderUpdateError,
5+ type ProviderInstanceId,
6+ type ServerProvider,
7+ type ServerProviderUpdatedPayload,
8+ type ServerProviderUpdateState,
9+} from "@t3tools/contracts";
10+import { Cause, Context, DateTime, Duration, Effect, Layer, Option, Ref, Schema } from "effect";
11+import { HttpClient } from "effect/unstable/http";
12+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13+
14+import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
15+import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
16+import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts";
17+import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
18+import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
19+
20+const UPDATE_TIMEOUT_MS = 5 * 60_000;
21+const UPDATE_OUTPUT_MAX_BYTES = 10_000;
22+
23+export interface ProviderMaintenanceCommandResult {
24+ readonly stdout: string;
25+ readonly stderr: string;
26+ readonly exitCode: number | null;
27+ readonly timedOut: boolean;
28+ readonly stdoutTruncated: boolean;
29+ readonly stderrTruncated: boolean;
30+}
31+
32+export interface ProviderMaintenanceRunnerShape {
33+ readonly updateProvider: (
34+ target:
35+ | ProviderDriverKind
36+ | {
37+ readonly provider: ProviderDriverKind;
38+ readonly instanceId?: ProviderInstanceId | undefined;
39+ },
40+ ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>;
41+}
42+
43+export class ProviderMaintenanceRunner extends Context.Service<
44+ ProviderMaintenanceRunner,
45+ ProviderMaintenanceRunnerShape
46+>()("t3/provider/ProviderMaintenanceRunner") {}
47+
48+interface VerifiedProviderRefresh {
49+ readonly providers: ReadonlyArray<ServerProvider>;
50+ readonly verifiedProviders: ReadonlyArray<ServerProvider>;
51+}
52+
53+const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
54+
55+const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceRunner.runCommand")(
56+ function* (input: {
57+ readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
58+ readonly command: string;
59+ readonly args: ReadonlyArray<string>;
60+ }) {
61+ const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(
62+ function* () {
63+ const child = yield* input.spawner
64+ .spawn(ChildProcess.make(input.command, [...input.args]))
65+ .pipe(
66+ Effect.mapError(
67+ (cause) =>
68+ new Error(`Failed to run update command ${input.command}: ${cause.message}`),
69+ ),
70+ );
71+ yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
72+
73+ const [stdout, stderr, exitCode] = yield* Effect.all(
74+ [
75+ collectUint8StreamText({
76+ stream: child.stdout,
77+ maxBytes: UPDATE_OUTPUT_MAX_BYTES,
78+ }),
79+ collectUint8StreamText({
80+ stream: child.stderr,
81+ maxBytes: UPDATE_OUTPUT_MAX_BYTES,
82+ }),
83+ child.exitCode,
84+ ],
85+ { concurrency: "unbounded" },
86+ ).pipe(
87+ Effect.mapError(
88+ (cause) =>
89+ new Error(cause instanceof Error ? cause.message : "Update command failed to run."),
90+ ),
91+ );
92+
93+ return {
94+ stdout: stdout.text,
95+ stderr: stderr.text,
96+ exitCode: Number(exitCode),
97+ timedOut: false,
98+ stdoutTruncated: stdout.truncated,
99+ stderrTruncated: stderr.truncated,
100+ } satisfies ProviderMaintenanceCommandResult;
101+ },
102+ );
103+
104+ return yield* collectCommandResult().pipe(
105+ Effect.scoped,
106+ Effect.timeoutOption(Duration.millis(UPDATE_TIMEOUT_MS)),
107+ Effect.map((result) =>
108+ Option.match(result, {
109+ onSome: (value) => value,
110+ onNone: () =>
111+ ({
112+ stdout: "",
113+ stderr: "",
114+ exitCode: null,
115+ timedOut: true,
116+ stdoutTruncated: false,
117+ stderrTruncated: false,
118+ }) satisfies ProviderMaintenanceCommandResult,
119+ }),
120+ ),
121+ );
122+ },
123+);
124+
125+function trimNullable(value: string): string | null {
126+ const trimmed = value.trim();
127+ return trimmed.length > 0 ? trimmed : null;
128+}
129+
130+function truncateText(value: string, maxLength: number): string {
131+ return value.length <= maxLength ? value : value.slice(0, maxLength);
132+}
133+
134+function commandOutput(result: ProviderMaintenanceCommandResult): string | null {
135+ const output = trimNullable([result.stderr, result.stdout].filter(Boolean).join("\n\n"));
136+ if (!output) {
137+ return null;
138+ }
139+ return truncateText(output, UPDATE_OUTPUT_MAX_BYTES);
140+}
141+
142+function failureMessage(result: ProviderMaintenanceCommandResult): string {
143+ if (result.timedOut) {
144+ return "Update timed out.";
145+ }
146+ if (result.exitCode !== null && result.exitCode !== 0) {
147+ return `Update command exited with code ${result.exitCode}.`;
148+ }
149+ return "Update command failed.";
150+}
151+
152+function isOutdatedProvider(provider: ServerProvider | undefined): boolean {
153+ return provider?.versionAdvisory?.status === "behind_latest";
154+}
155+
156+function makeUpdateState(input: {
157+ readonly status: ServerProviderUpdateState["status"];
158+ readonly startedAt: string | null;
159+ readonly finishedAt: string | null;
160+ readonly message: string | null;
161+ readonly output?: string | null;
162+}): ServerProviderUpdateState {
163+ return {
164+ status: input.status,
165+ startedAt: input.startedAt,
166+ finishedAt: input.finishedAt,
167+ message: input.message,
168+ output: input.output ?? null,
169+ };
170+}
171+
172+export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
173+ const providerRegistry = yield* ProviderRegistry;
174+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
175+ const httpClient = yield* HttpClient.HttpClient;
176+ const runMaintenanceCommand = (command: string, args: ReadonlyArray<string>) =>
177+ runProviderMaintenanceCommandWithSpawner({
178+ spawner,
179+ command,
180+ args,
181+ });
182+ const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({
183+ makeAlreadyRunningError: () =>
184+ new ServerProviderUpdateError({
185+ provider: ProviderDriverKind.make("unknown"),
186+ reason: "An update is already running for this provider.",
187+ }),
188+ });
189+
190+ const verifyRefreshedProvider = (
191+ provider: ProviderDriverKind,
192+ maintenanceCapabilities: ProviderMaintenanceCapabilities,
193+ instanceId: ProviderInstanceId,
194+ ): Effect.Effect<VerifiedProviderRefresh> =>
195+ providerRegistry.getProviders.pipe(
196+ Effect.map((providers) =>
197+ providers
198+ .filter(
199+ (candidate) => candidate.driver === provider && candidate.instanceId === instanceId,
200+ )
201+ .map((candidate) => candidate.instanceId),
202+ ),
203+ Effect.flatMap((instanceIds) =>
204+ instanceIds.length === 0
205+ ? providerRegistry.refreshInstance(instanceId)
206+ : Effect.forEach(
207+ instanceIds,
208+ (instanceId) => providerRegistry.refreshInstance(instanceId),
209+ {
210+ concurrency: "unbounded",
211+ discard: true,
212+ },
213+ ).pipe(Effect.andThen(providerRegistry.getProviders)),
214+ ),
215+ Effect.flatMap((providers) => {
216+ const refreshedProviders = providers.filter(
217+ (candidate) => candidate.driver === provider && candidate.instanceId === instanceId,
218+ );
219+ if (refreshedProviders.length === 0) {
220+ return Effect.succeed<VerifiedProviderRefresh>({
221+ providers,
222+ verifiedProviders: [],
223+ });
224+ }
225+ return Effect.forEach(
226+ refreshedProviders,
227+ (refreshedProvider) =>
228+ enrichProviderSnapshotWithVersionAdvisory(
229+ refreshedProvider,
230+ maintenanceCapabilities,
231+ ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
232+ {
233+ concurrency: "unbounded",
234+ },
235+ ).pipe(
236+ Effect.map(
237+ (verifiedProviders): VerifiedProviderRefresh => ({
238+ providers,
239+ verifiedProviders,
240+ }),
241+ ),
242+ Effect.catchCause((cause) =>
243+ Effect.logWarning("Provider post-update version verification failed", {
244+ provider,
245+ cause: Cause.pretty(cause),
246+ }).pipe(
247+ Effect.as<VerifiedProviderRefresh>({
248+ providers,
249+ verifiedProviders: refreshedProviders,
250+ }),
251+ ),
252+ ),
253+ );
254+ }),
255+ );
256+
257+ const updateProvider: ProviderMaintenanceRunnerShape["updateProvider"] = Effect.fn(
258+ "ProviderMaintenanceRunner.updateProvider",
259+ )(function* (target) {
260+ const provider = typeof target === "string" ? target : target.provider;
261+ const instanceId =
262+ typeof target === "string"
263+ ? defaultInstanceIdForDriver(provider)
264+ : (target.instanceId ?? defaultInstanceIdForDriver(provider));
265+ const targetKey = `instance:${instanceId}`;
266+ const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
267+ instanceId,
268+ provider,
269+ );
270+ const update = capabilities.update;
271+ if (!update) {
272+ return yield* new ServerProviderUpdateError({
273+ provider,
274+ reason: "This provider does not support one-click updates.",
275+ });
276+ }
277+
278+ const setUpdateState = (state: ServerProviderUpdateState | null) =>
279+ providerRegistry.setProviderMaintenanceActionState({
280+ instanceId,
281+ action: "update",
282+ state,
283+ });
284+ const setQueuedState = setUpdateState(
285+ makeUpdateState({
286+ status: "queued",
287+ startedAt: null,
288+ finishedAt: null,
289+ message: "Waiting for another provider update to finish.",
290+ }),
291+ ).pipe(Effect.asVoid);
292+
293+ const runProviderUpdate = Effect.fn("ProviderMaintenanceRunner.runProviderUpdate")(
294+ function* () {
295+ const finish = (state: ServerProviderUpdateState) =>
296+ setUpdateState(state).pipe(Effect.map((providers) => ({ providers })));
297+ const startedAtRef = yield* Ref.make<string | null>(null);
298+
299+ const runCommandAndVerify = Effect.fn("ProviderMaintenanceRunner.runCommandAndVerify")(
300+ function* () {
301+ const startedAt = yield* nowIso;
302+ yield* Ref.set(startedAtRef, startedAt);
303+ yield* setUpdateState(
304+ makeUpdateState({
305+ status: "running",
306+ startedAt,
307+ finishedAt: null,
308+ message: "Updating provider.",
309+ }),
310+ );
311+
312+ const result = yield* runMaintenanceCommand(update.executable, update.args);
313+ const finishedAt = yield* nowIso;
314+ if (result.timedOut || result.exitCode !== 0) {
315+ return yield* finish(
316+ makeUpdateState({
317+ status: "failed",
318+ startedAt,
319+ finishedAt,
320+ message: failureMessage(result),
321+ output: commandOutput(result),
322+ }),
323+ );
324+ }
325+
326+ const { verifiedProviders } = yield* verifyRefreshedProvider(
327+ provider,
328+ capabilities,
329+ instanceId,
330+ );
331+ const couldNotVerify = verifiedProviders.length === 0;
332+ const stillOutdated =
333+ couldNotVerify ||
334+ verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider));
335+ return yield* finish(
336+ makeUpdateState({
337+ status: stillOutdated ? "unchanged" : "succeeded",
338+ startedAt,
339+ finishedAt,
340+ message: couldNotVerify
341+ ? "Update command completed, but T3 Code could not verify the provider version."
342+ : stillOutdated
343+ ? "Update command completed, but T3 Code still detects an outdated provider version."
344+ : "Provider updated.",
345+ output: commandOutput(result),
346+ }),
347+ );
348+ },
349+ );
350+
351+ const recordFailedUpdate = Effect.fn("ProviderMaintenanceRunner.recordFailedUpdate")(
352+ function* (cause: Cause.Cause<unknown>) {
353+ const failure = Cause.squash(cause);
354+ const startedAt = yield* Ref.get(startedAtRef);
355+ return yield* finish(
356+ makeUpdateState({
357+ status: "failed",
358+ startedAt,
359+ finishedAt: yield* nowIso,
360+ message: failure instanceof Error ? failure.message : "Update command failed.",
361+ output: null,
362+ }),
363+ );
364+ },
365+ );
366+
367+ return yield* runCommandAndVerify().pipe(Effect.catchCause(recordFailedUpdate));
368+ },
369+ );
370+
371+ return yield* commandCoordinator
372+ .withCommandLock({
373+ targetKey,
374+ lockKey: update.lockKey,
375+ onQueued: setQueuedState,
376+ run: runProviderUpdate(),
377+ })
378+ .pipe(
379+ Effect.mapError((error) =>
380+ Schema.is(ServerProviderUpdateError)(error)
381+ ? new ServerProviderUpdateError({
382+ provider,
383+ reason: error.reason,
384+ })
385+ : error,
386+ ),
387+ );
388+ });
389+
390+ return ProviderMaintenanceRunner.of({
391+ updateProvider,
392+ });
393+});
394+
395+export const layer = Layer.effect(ProviderMaintenanceRunner, make());
apps/server/src/provider/providerSnapshot.ts+12−7
@@ -12,6 +12,8 @@ import { Effect, Stream } from "effect"; · 11 unmodified lines
1212 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1313 import { normalizeModelSlug } from "@t3tools/shared/model";
1414 import { isWindowsCommandNotFound } from "../processRunner.ts";
15+import { createProviderVersionAdvisory } from "./providerMaintenance.ts";
16+import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1517
1618 export const DEFAULT_TIMEOUT_MS = 4_000;
1719 // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
@@ -182,6 +184,7 @@ export function buildBooleanOptionDescriptor(input: { · 164 unmodified lines
182184 }
183185
184186 export function buildServerProvider(input: {
187+ driver?: ProviderDriverKind;
185188 presentation: ServerProviderPresentation;
186189 enabled: boolean;
187190 checkedAt: string;
@@ -190,6 +193,13 @@ export function buildServerProvider(input: { · 2 unmodified lines
190193 skills?: ReadonlyArray<ServerProviderSkill>;
191194 probe: ProviderProbeResult;
192195 }): ServerProviderDraft {
196+ const versionAdvisory = input.driver
197+ ? createProviderVersionAdvisory({
198+ driver: input.driver,
199+ currentVersion: input.probe.version,
200+ checkedAt: input.checkedAt,
201+ })
202+ : undefined;
193203 return {
194204 displayName: input.presentation.displayName,
195205 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),
@@ -206,16 +216,11 @@ export function buildServerProvider(input: { · 10 unmodified lines
206216 models: input.models,
207217 slashCommands: [...(input.slashCommands ?? [])],
208218 skills: [...(input.skills ?? [])],
219+ ...(versionAdvisory ? { versionAdvisory } : {}),
209220 };
210221 }
211222
212223 export const collectStreamAsString = <E>(
213224 stream: Stream.Stream<Uint8Array, E>,
214225 ): Effect.Effect<string, E> =>
215− stream.pipe(
216− Stream.decodeText(),
217− Stream.runFold(
218− () => "",
219− (acc, chunk) => acc + chunk,
220− ),
221− );
226+ collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text));
apps/server/src/provider/providerStatusCache.ts+5−3
@@ -140,8 +140,10 @@ export const readProviderStatusCache = (filePath: string) => · 139 unmodified lines
140140 export const writeProviderStatusCache = (input: {
141141 readonly filePath: string;
142142 readonly provider: ServerProvider;
143−}) =>
144− writeFileStringAtomically({
143+}) => {
144+ const { updateState: _updateState, ...cacheableProvider } = input.provider;
145+ return writeFileStringAtomically({
145146 filePath: input.filePath,
146− contents: `${JSON.stringify(input.provider, null, 2)}\n`,
147+ contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`,
147148 });
149+};
apps/server/src/server.test.ts+7−0
@@ -80,6 +80,7 @@ import { · 79 unmodified lines
8080 ProviderRegistry,
8181 type ProviderRegistryShape,
8282 } from "./provider/Services/ProviderRegistry.ts";
83+import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
8384 import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts";
8485 import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
8586 import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
@@ -517,6 +518,12 @@ const buildAppUnderTest = (options?: { · 431 unmodified lines
517518 Layer.mock(ProviderRegistry)({
518519 getProviders: Effect.succeed([]),
519520 refresh: () => Effect.succeed([]),
521+ refreshInstance: () => Effect.succeed([]),
522+ getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
523+ Effect.succeed(
524+ makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }),
525+ ),
526+ setProviderMaintenanceActionState: () => Effect.succeed([]),
520527 streamChanges: Stream.empty,
521528 ...options?.layers?.providerRegistry,
522529 }),
apps/server/src/stream/collectUint8StreamText.test.ts+38−0
@@ -0,0 +1,38 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import { Effect, Stream } from "effect";
3+
4+import { collectUint8StreamText } from "./collectUint8StreamText.ts";
5+
6+const encoder = new TextEncoder();
7+
8+describe("collectUint8StreamText", () => {
9+ it.effect("collects Uint8Array chunks into decoded text", () =>
10+ Effect.gen(function* () {
11+ const result = yield* collectUint8StreamText({
12+ stream: Stream.make(encoder.encode("hello "), encoder.encode("world")),
13+ });
14+
15+ assert.deepStrictEqual(result, {
16+ text: "hello world",
17+ bytes: 11,
18+ truncated: false,
19+ });
20+ }),
21+ );
22+
23+ it.effect("truncates by bytes and appends an optional marker once", () =>
24+ Effect.gen(function* () {
25+ const result = yield* collectUint8StreamText({
26+ stream: Stream.make(encoder.encode("abcdef"), encoder.encode("ghij")),
27+ maxBytes: 5,
28+ truncatedMarker: "[truncated]",
29+ });
30+
31+ assert.deepStrictEqual(result, {
32+ text: "abcde[truncated]",
33+ bytes: 5,
34+ truncated: true,
35+ });
36+ }),
37+ );
38+});
apps/server/src/stream/collectUint8StreamText.ts+66−0
@@ -0,0 +1,66 @@
1+import { Effect, Stream } from "effect";
2+
3+export interface CollectedUint8StreamText {
4+ readonly text: string;
5+ readonly truncated: boolean;
6+ readonly bytes: number;
7+}
8+
9+interface CollectState {
10+ readonly text: string;
11+ readonly bytes: number;
12+ readonly truncated: boolean;
13+}
14+
15+export const collectUint8StreamText = <E>(input: {
16+ readonly stream: Stream.Stream<Uint8Array, E>;
17+ readonly maxBytes?: number | undefined;
18+ readonly truncatedMarker?: string | null | undefined;
19+}): Effect.Effect<CollectedUint8StreamText, E> => {
20+ const decoder = new TextDecoder();
21+ const maxBytes = input.maxBytes ?? Number.POSITIVE_INFINITY;
22+ const truncatedMarker = input.truncatedMarker ?? "";
23+
24+ return input.stream.pipe(
25+ Stream.runFold(
26+ (): CollectState => ({
27+ text: "",
28+ bytes: 0,
29+ truncated: false,
30+ }),
31+ (state, chunk): CollectState => {
32+ if (state.truncated) {
33+ return state;
34+ }
35+
36+ const remainingBytes = maxBytes - state.bytes;
37+ if (remainingBytes <= 0) {
38+ return {
39+ ...state,
40+ text: `${state.text}${truncatedMarker}`,
41+ truncated: true,
42+ };
43+ }
44+
45+ const nextChunk =
46+ chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk;
47+ const text = `${state.text}${decoder.decode(nextChunk, { stream: true })}`;
48+ const bytes = state.bytes + nextChunk.byteLength;
49+ const truncated = chunk.byteLength > remainingBytes;
50+
51+ return {
52+ text: truncated ? `${text}${truncatedMarker}` : text,
53+ bytes,
54+ truncated,
55+ };
56+ },
57+ ),
58+ Effect.map(
59+ (state): CollectedUint8StreamText => ({
60+ text: state.truncated ? state.text : `${state.text}${decoder.decode()}`,
61+ bytes: state.bytes,
62+ truncated: state.truncated,
63+ }),
64+ ),
65+ );
66+};
apps/server/src/vcs/VcsProcess.ts+6−37
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 VcsProcessSpawnError,
99 VcsProcessTimeoutError,
1010 } from "@t3tools/contracts";
11+import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1112
1213 export interface VcsProcessInput {
1314 readonly operation: string;
@@ -86,44 +87,12 @@ export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: · 72 unmodified lines
8687 readonly maxOutputBytes?: number;
8788 readonly truncateOutputAtMaxBytes?: boolean;
8889 }) {
89− const decoder = new TextDecoder();
90− let text = "";
91− let bytes = 0;
92− let truncated = false;
9390 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
94− const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;
95−
96− yield* Stream.runForEach(input.stream, (chunk) =>
97− Effect.sync(() => {
98− if (truncated) return;
99−
100− const remainingBytes = maxOutputBytes - bytes;
101− if (remainingBytes <= 0) {
102− truncated = true;
103− if (truncateOutputAtMaxBytes) {
104− text += OUTPUT_TRUNCATED_MARKER;
105− }
106− return;
107− }
108−
109− const nextChunk = chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk;
110− text += decoder.decode(nextChunk, { stream: true });
111− bytes += nextChunk.byteLength;
112−
113− if (chunk.byteLength > remainingBytes) {
114− truncated = true;
115− if (truncateOutputAtMaxBytes) {
116− text += OUTPUT_TRUNCATED_MARKER;
117− }
118− }
119− }),
120− );
121−
122− if (!truncated) {
123− text += decoder.decode();
124− }
125−
126− return { text, truncated } satisfies VcsProcessCollectedText;
91+ return yield* collectUint8StreamText({
92+ stream: input.stream,
93+ maxBytes: maxOutputBytes,
94+ truncatedMarker: input.truncateOutputAtMaxBytes ? OUTPUT_TRUNCATED_MARKER : null,
95+ });
12796 });
12897
12998 export const make = Effect.fn("makeVcsProcess")(function* () {
apps/server/src/ws.ts+11−0
@@ -40,6 +40,7 @@ import { · 39 unmodified lines
4040 observeRpcStreamEffect,
4141 } from "./observability/RpcInstrumentation.ts";
4242 import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
43+import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
4344 import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
4445 import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
4546 import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
@@ -152,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 106 unmodified lines
152153 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
153154 const terminalManager = yield* TerminalManager;
154155 const providerRegistry = yield* ProviderRegistry;
156+ const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
155157 const config = yield* ServerConfig;
156158 const lifecycleEvents = yield* ServerLifecycleEvents;
157159 const serverSettings = yield* ServerSettingsService;
@@ -785,6 +787,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 627 unmodified lines
785787 ).pipe(Effect.map((providers) => ({ providers }))),
786788 { "rpc.aggregate": "server" },
787789 ),
790+ [WS_METHODS.serverUpdateProvider]: (input) =>
791+ observeRpcEffect(
792+ WS_METHODS.serverUpdateProvider,
793+ providerMaintenanceRunner.updateProvider(input),
794+ {
795+ "rpc.aggregate": "server",
796+ },
797+ ),
788798 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
789799 observeRpcEffect(
790800 WS_METHODS.serverUpsertKeybinding,
@@ -1152,6 +1162,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( · 361 unmodified lines
11521162 Effect.provide(
11531163 makeWsRpcLayer(session.sessionId).pipe(
11541164 Layer.provideMerge(RpcSerialization.layerJson),
1165+ Layer.provide(ProviderMaintenanceRunner.layer),
11551166 Layer.provide(
11561167 SourceControlDiscoveryLayer.layer.pipe(
11571168 Layer.provide(
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts+673−0
@@ -0,0 +1,673 @@
1+import { describe, expect, it } from "vitest";
2+import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
3+
4+import {
5+ canOneClickUpdateProviderCandidate,
6+ collectProviderUpdateCandidates,
7+ collectUpdatedProviderSnapshots,
8+ firstRejectedProviderUpdateMessage,
9+ getProviderUpdateInitialToastView,
10+ getProviderUpdateProgressToastView,
11+ getProviderUpdateRejectedToastView,
12+ getProviderUpdateSidebarPillView,
13+ getSingleProviderUpdateProgressToastView,
14+ hasOneClickUpdateProviderCandidate,
15+ isProviderUpdateCandidate,
16+ providerUpdateNotificationKey,
17+ type ProviderUpdateCandidate,
18+} from "./ProviderUpdateLaunchNotification.logic";
19+
20+const checkedAt = "2026-04-23T10:00:00.000Z";
21+const sessionStartedAt = "2026-04-23T09:59:00.000Z";
22+const laterCheckedAt = "2026-04-23T10:01:00.000Z";
23+
24+const driver = (value: string) => ProviderDriverKind.make(value);
25+const instanceId = (value: string) => ProviderInstanceId.make(value);
26+
27+function provider(input: {
28+ readonly driver: ReturnType<typeof ProviderDriverKind.make>;
29+ readonly instanceId?: ReturnType<typeof ProviderInstanceId.make>;
30+ readonly enabled?: boolean;
31+ readonly version?: string | null;
32+ readonly latestVersion?: string | null;
33+ readonly canUpdate?: boolean;
34+ readonly updateCommand?: string | null;
35+ readonly updateState?: ServerProvider["updateState"];
36+ readonly advisoryStatus?: NonNullable<ServerProvider["versionAdvisory"]>["status"];
37+}): ServerProvider {
38+ const result: ServerProvider = {
39+ instanceId: input.instanceId ?? instanceId(String(input.driver)),
40+ driver: input.driver,
41+ enabled: input.enabled ?? true,
42+ installed: true,
43+ version: input.version ?? "1.0.0",
44+ status: "ready",
45+ auth: { status: "authenticated" },
46+ checkedAt,
47+ models: [],
48+ slashCommands: [],
49+ skills: [],
50+ versionAdvisory: {
51+ status: input.advisoryStatus ?? "behind_latest",
52+ currentVersion: input.version ?? "1.0.0",
53+ latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0",
54+ updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider",
55+ canUpdate: input.canUpdate ?? true,
56+ checkedAt,
57+ message: "Update available.",
58+ },
59+ };
60+
61+ if (input.updateState) {
62+ return { ...result, updateState: input.updateState };
63+ }
64+
65+ return result;
66+}
67+
68+function updateCandidate(input: Parameters<typeof provider>[0]): ProviderUpdateCandidate {
69+ return provider(input) as ProviderUpdateCandidate;
70+}
71+
72+describe("provider update launch notification logic", () => {
73+ it("detects enabled providers with a latest-version advisory", () => {
74+ expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true);
75+ expect(isProviderUpdateCandidate(provider({ driver: driver("codex"), enabled: false }))).toBe(
76+ false,
77+ );
78+ expect(
79+ isProviderUpdateCandidate(
80+ provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }),
81+ ),
82+ ).toBe(false);
83+ expect(
84+ isProviderUpdateCandidate(provider({ driver: driver("codex"), latestVersion: null })),
85+ ).toBe(false);
86+ });
87+
88+ it("deduplicates multi-instance provider candidates by driver", () => {
89+ expect(
90+ collectProviderUpdateCandidates([
91+ provider({
92+ driver: driver("codex"),
93+ instanceId: instanceId("codex_personal"),
94+ latestVersion: "1.1.0",
95+ }),
96+ provider({
97+ driver: driver("codex"),
98+ instanceId: instanceId("codex"),
99+ latestVersion: "1.1.0",
100+ }),
101+ provider({ driver: driver("cursor"), latestVersion: "0.3.0" }),
102+ ]),
103+ ).toHaveLength(2);
104+ });
105+
106+ it("disables one-click updates when provider instances disagree on the update command", () => {
107+ const candidate = updateCandidate({
108+ driver: driver("claudeAgent"),
109+ instanceId: instanceId("claude_personal"),
110+ latestVersion: "2.1.123",
111+ });
112+
113+ expect(
114+ canOneClickUpdateProviderCandidate(candidate, [
115+ candidate,
116+ provider({
117+ driver: driver("claudeAgent"),
118+ instanceId: instanceId("claude_work"),
119+ latestVersion: "2.1.123",
120+ canUpdate: true,
121+ updateCommand: "bun add -g @anthropic-ai/claude-code@latest",
122+ }),
123+ ]),
124+ ).toBe(false);
125+ });
126+
127+ it("keeps one-click updates enabled when sibling instances are already current", () => {
128+ const candidate = updateCandidate({
129+ driver: driver("claudeAgent"),
130+ instanceId: instanceId("claude_personal"),
131+ latestVersion: "2.1.123",
132+ updateCommand: "npm install -g @anthropic-ai/claude-code@latest",
133+ });
134+
135+ expect(
136+ hasOneClickUpdateProviderCandidate(candidate, [
137+ candidate,
138+ provider({
139+ driver: driver("claudeAgent"),
140+ instanceId: instanceId("claude_work"),
141+ version: "2.1.123",
142+ latestVersion: "2.1.123",
143+ advisoryStatus: "current",
144+ canUpdate: false,
145+ updateCommand: null,
146+ }),
147+ ]),
148+ ).toBe(true);
149+ expect(
150+ canOneClickUpdateProviderCandidate(candidate, [
151+ candidate,
152+ provider({
153+ driver: driver("claudeAgent"),
154+ instanceId: instanceId("claude_work"),
155+ version: "2.1.123",
156+ latestVersion: "2.1.123",
157+ advisoryStatus: "current",
158+ canUpdate: false,
159+ updateCommand: null,
160+ }),
161+ ]),
162+ ).toBe(true);
163+ });
164+
165+ it("keeps the inline update action available while a provider update is already running", () => {
166+ const candidate = updateCandidate({
167+ driver: driver("codex"),
168+ updateState: {
169+ status: "running",
170+ startedAt: checkedAt,
171+ finishedAt: null,
172+ message: "Updating provider.",
173+ output: null,
174+ },
175+ });
176+
177+ expect(hasOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(true);
178+ expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false);
179+ });
180+
181+ it("builds a notification key from provider latest versions", () => {
182+ const codex = updateCandidate({
183+ driver: driver("codex"),
184+ version: "1.0.0",
185+ latestVersion: "1.1.0",
186+ });
187+ const cursor = updateCandidate({
188+ driver: driver("cursor"),
189+ version: "0.2.0",
190+ latestVersion: "0.3.0",
191+ });
192+
193+ expect(providerUpdateNotificationKey([codex, cursor])).toBe("codex:1.1.0|cursor:0.3.0");
194+ expect(providerUpdateNotificationKey([])).toBeNull();
195+ });
196+
197+ it("keeps the same notification key while the published update version is unchanged", () => {
198+ const first = updateCandidate({
199+ driver: driver("codex"),
200+ version: "1.0.0",
201+ latestVersion: "1.2.0",
202+ });
203+ const second = updateCandidate({
204+ driver: driver("codex"),
205+ version: "1.1.0",
206+ latestVersion: "1.2.0",
207+ });
208+ const nextPublishedVersion = updateCandidate({
209+ driver: driver("codex"),
210+ version: "1.1.0",
211+ latestVersion: "1.3.0",
212+ });
213+
214+ expect(providerUpdateNotificationKey([first])).toBe(providerUpdateNotificationKey([second]));
215+ expect(providerUpdateNotificationKey([nextPublishedVersion])).not.toBe(
216+ providerUpdateNotificationKey([first]),
217+ );
218+ });
219+
220+ it("tracks updated provider snapshots by instance instead of collapsing to a sibling driver", () => {
221+ const targetInstanceId = instanceId("codex_personal");
222+ const siblingInstanceId = instanceId("codex");
223+ const updatedPersonal = provider({
224+ driver: driver("codex"),
225+ instanceId: targetInstanceId,
226+ version: "1.1.0",
227+ latestVersion: "1.1.0",
228+ advisoryStatus: "current",
229+ updateState: {
230+ status: "succeeded",
231+ startedAt: checkedAt,
232+ finishedAt: checkedAt,
233+ message: "Provider updated.",
234+ output: null,
235+ },
236+ });
237+ const currentDefaultSibling = provider({
238+ driver: driver("codex"),
239+ instanceId: siblingInstanceId,
240+ version: "1.1.0",
241+ latestVersion: "1.1.0",
242+ advisoryStatus: "current",
243+ updateState: undefined,
244+ });
245+
246+ expect(
247+ collectUpdatedProviderSnapshots({
248+ results: [
249+ {
250+ status: "fulfilled",
251+ value: {
252+ providers: [updatedPersonal, currentDefaultSibling],
253+ },
254+ },
255+ ],
256+ providerInstanceIds: new Set([targetInstanceId]),
257+ }),
258+ ).toEqual([updatedPersonal]);
259+ });
260+
261+ it("describes a single one-click update", () => {
262+ const view = getProviderUpdateInitialToastView({
263+ updateProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })],
264+ oneClickProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })],
265+ });
266+
267+ expect(view).toMatchObject({
268+ phase: "initial",
269+ type: "warning",
270+ title: "Update Available: Codex v1.1.0",
271+ description: "Install the update now or review provider settings.",
272+ });
273+ });
274+
275+ it("describes settings-only updates without one-click support", () => {
276+ const view = getProviderUpdateInitialToastView({
277+ updateProviders: [
278+ updateCandidate({ driver: driver("codex"), canUpdate: false }),
279+ updateCandidate({ driver: driver("cursor"), canUpdate: false }),
280+ ],
281+ oneClickProviders: [],
282+ });
283+
284+ expect(view.description).toBe("Codex and Cursor can be updated from provider settings.");
285+ });
286+
287+ it("uses server update state for running progress", () => {
288+ const view = getProviderUpdateProgressToastView({
289+ providers: [
290+ provider({
291+ driver: driver("codex"),
292+ updateState: {
293+ status: "running",
294+ startedAt: checkedAt,
295+ finishedAt: null,
296+ message: "Updating provider.",
297+ output: null,
298+ },
299+ }),
300+ ],
301+ providerCount: 1,
302+ });
303+
304+ expect(view).toMatchObject({
305+ phase: "running",
306+ type: "loading",
307+ title: "Updating provider",
308+ });
309+ });
310+
311+ it("uses server failure state for failed progress", () => {
312+ const view = getProviderUpdateProgressToastView({
313+ providers: [
314+ provider({
315+ driver: driver("codex"),
316+ updateState: {
317+ status: "failed",
318+ startedAt: checkedAt,
319+ finishedAt: checkedAt,
320+ message: "command failed",
321+ output: "stderr",
322+ },
323+ }),
324+ ],
325+ providerCount: 1,
326+ });
327+
328+ expect(view).toMatchObject({
329+ phase: "failed",
330+ type: "error",
331+ title: "Provider update failed",
332+ description: "command failed",
333+ });
334+ });
335+
336+ it("resolves a single-provider completion view from the returned provider snapshot", () => {
337+ const view = getSingleProviderUpdateProgressToastView(
338+ provider({
339+ driver: driver("codex"),
340+ updateState: {
341+ status: "failed",
342+ startedAt: checkedAt,
343+ finishedAt: checkedAt,
344+ message: "command failed",
345+ output: "stderr",
346+ },
347+ }),
348+ );
349+
350+ expect(view).toMatchObject({
351+ phase: "failed",
352+ type: "error",
353+ title: "Codex v1.1.0 update failed",
354+ description: "command failed",
355+ });
356+ });
357+
358+ it("keeps unchanged providers actionable from settings", () => {
359+ const view = getProviderUpdateProgressToastView({
360+ providers: [
361+ provider({
362+ driver: driver("cursor"),
363+ updateState: {
364+ status: "unchanged",
365+ startedAt: checkedAt,
366+ finishedAt: checkedAt,
367+ message: "still old",
368+ output: null,
369+ },
370+ }),
371+ ],
372+ providerCount: 1,
373+ });
374+
375+ expect(view).toMatchObject({
376+ phase: "unchanged",
377+ type: "warning",
378+ title: "Provider still needs an update",
379+ description: "Cursor still appears outdated. Check provider settings for details.",
380+ });
381+ });
382+
383+ it("marks progress succeeded once every attempted provider is no longer outdated", () => {
384+ const view = getProviderUpdateProgressToastView({
385+ providers: [
386+ provider({
387+ driver: driver("codex"),
388+ version: "1.1.0",
389+ latestVersion: "1.1.0",
390+ advisoryStatus: "current",
391+ updateState: {
392+ status: "succeeded",
393+ startedAt: checkedAt,
394+ finishedAt: checkedAt,
395+ message: "Provider updated.",
396+ output: null,
397+ },
398+ }),
399+ ],
400+ providerCount: 1,
401+ });
402+
403+ expect(view).toMatchObject({
404+ phase: "succeeded",
405+ type: "success",
406+ title: "Provider updated",
407+ description: "New sessions will use the updated provider.",
408+ dismissAfterVisibleMs: 3_000,
409+ });
410+ });
411+
412+ it("uses the updated version in the single-provider success toast title", () => {
413+ const view = getSingleProviderUpdateProgressToastView(
414+ provider({
415+ driver: driver("codex"),
416+ version: "1.1.0",
417+ latestVersion: "1.1.0",
418+ advisoryStatus: "current",
419+ updateState: {
420+ status: "succeeded",
421+ startedAt: checkedAt,
422+ finishedAt: checkedAt,
423+ message: "Provider updated.",
424+ output: null,
425+ },
426+ }),
427+ );
428+
429+ expect(view).toMatchObject({
430+ phase: "succeeded",
431+ type: "success",
432+ title: "Codex updated: v1.1.0",
433+ description: "New sessions will use the updated provider.",
434+ });
435+ });
436+
437+ it("falls back to a rejected RPC message for transport-level failures", () => {
438+ const results: PromiseSettledResult<unknown>[] = [
439+ { status: "rejected", reason: new Error("WebSocket closed") },
440+ ];
441+
442+ expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed");
443+ expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({
444+ phase: "failed",
445+ title: "Provider updates failed",
446+ description: "WebSocket closed",
447+ });
448+ });
449+
450+ it("collects only attempted provider snapshots from update responses", () => {
451+ const codex = provider({ driver: driver("codex") });
452+ const cursor = provider({ driver: driver("cursor") });
453+ const results: PromiseSettledResult<{ readonly providers: ReadonlyArray<ServerProvider> }>[] = [
454+ { status: "fulfilled", value: { providers: [codex, cursor] } },
455+ ];
456+
457+ expect(
458+ collectUpdatedProviderSnapshots({
459+ results,
460+ providerInstanceIds: new Set([cursor.instanceId]),
461+ }),
462+ ).toEqual([cursor]);
463+ });
464+
465+ it("summarizes active provider updates for the sidebar pill", () => {
466+ const view = getProviderUpdateSidebarPillView([
467+ provider({
468+ driver: driver("codex"),
469+ updateState: {
470+ status: "running",
471+ startedAt: checkedAt,
472+ finishedAt: null,
473+ message: "Updating provider.",
474+ output: null,
475+ },
476+ }),
477+ provider({
478+ driver: driver("cursor"),
479+ updateState: {
480+ status: "queued",
481+ startedAt: null,
482+ finishedAt: null,
483+ message: "Waiting for another provider update to finish.",
484+ output: null,
485+ },
486+ }),
487+ ]);
488+
489+ expect(view).toMatchObject({
490+ tone: "loading",
491+ title: "Updating 2 providers",
492+ description: "Codex and Cursor updates are in progress.",
493+ });
494+ });
495+
496+ it("uses the provider name for single active sidebar pill updates", () => {
497+ const view = getProviderUpdateSidebarPillView([
498+ provider({
499+ driver: driver("codex"),
500+ updateState: {
501+ status: "running",
502+ startedAt: checkedAt,
503+ finishedAt: null,
504+ message: "Updating provider.",
505+ output: null,
506+ },
507+ }),
508+ ]);
509+
510+ expect(view).toMatchObject({
511+ key: "loading:codex:running",
512+ tone: "loading",
513+ title: "Updating Codex",
514+ description: "Codex update in progress.",
515+ });
516+ });
517+
518+ it("uses the provider name for single failed sidebar pill updates", () => {
519+ const view = getProviderUpdateSidebarPillView(
520+ [
521+ provider({
522+ driver: driver("claudeAgent"),
523+ updateState: {
524+ status: "failed",
525+ startedAt: checkedAt,
526+ finishedAt: checkedAt,
527+ message: "Update command exited with code 1.",
528+ output: null,
529+ },
530+ }),
531+ ],
532+ { visibleAfterIso: sessionStartedAt },
533+ );
534+
535+ expect(view).toMatchObject({
536+ key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.",
537+ tone: "error",
538+ title: "Claude v1.1.0 update failed",
539+ description: "Update command exited with code 1.",
540+ dismissible: true,
541+ });
542+ });
543+
544+ it("shows a short-lived success sidebar pill after a single provider update succeeds", () => {
545+ const view = getProviderUpdateSidebarPillView(
546+ [
547+ provider({
548+ driver: driver("codex"),
549+ version: "1.1.0",
550+ latestVersion: "1.1.0",
551+ advisoryStatus: "current",
552+ updateState: {
553+ status: "succeeded",
554+ startedAt: checkedAt,
555+ finishedAt: checkedAt,
556+ message: "Provider updated.",
557+ output: null,
558+ },
559+ }),
560+ ],
561+ { visibleAfterIso: sessionStartedAt },
562+ );
563+
564+ expect(view).toMatchObject({
565+ key: "succeeded:codex:2026-04-23T10:00:00.000Z:Provider updated.",
566+ tone: "success",
567+ title: "Codex updated: v1.1.0",
568+ description: "New sessions will use the updated provider.",
569+ dismissAfterVisibleMs: 3_000,
570+ });
571+ });
572+
573+ it("keeps unchanged sidebar pill states dismissible", () => {
574+ const view = getProviderUpdateSidebarPillView(
575+ [
576+ provider({
577+ driver: driver("cursor"),
578+ updateState: {
579+ status: "unchanged",
580+ startedAt: checkedAt,
581+ finishedAt: checkedAt,
582+ message: "still old",
583+ output: null,
584+ },
585+ }),
586+ ],
587+ { visibleAfterIso: sessionStartedAt },
588+ );
589+
590+ expect(view).toMatchObject({
591+ key: "unchanged:cursor:2026-04-23T10:00:00.000Z:still old",
592+ tone: "warning",
593+ title: "Cursor still needs an update",
594+ dismissible: true,
595+ });
596+ });
597+
598+ it("does not show sidebar terminal states from before the current app session", () => {
599+ expect(
600+ getProviderUpdateSidebarPillView(
601+ [
602+ provider({
603+ driver: driver("codex"),
604+ updateState: {
605+ status: "failed",
606+ startedAt: checkedAt,
607+ finishedAt: checkedAt,
608+ message: "command failed",
609+ output: "stderr",
610+ },
611+ }),
612+ ],
613+ { visibleAfterIso: "2026-04-23T10:00:01.000Z" },
614+ ),
615+ ).toBeNull();
616+ });
617+
618+ it("shows a newer success before falling back to an older failure", () => {
619+ const providers = [
620+ provider({
621+ driver: driver("claudeAgent"),
622+ updateState: {
623+ status: "failed",
624+ startedAt: checkedAt,
625+ finishedAt: checkedAt,
626+ message: "Update command exited with code 1.",
627+ output: null,
628+ },
629+ }),
630+ provider({
631+ driver: driver("codex"),
632+ version: "1.2.0",
633+ latestVersion: "1.2.0",
634+ advisoryStatus: "current",
635+ updateState: {
636+ status: "succeeded",
637+ startedAt: laterCheckedAt,
638+ finishedAt: laterCheckedAt,
639+ message: "Provider updated.",
640+ output: null,
641+ },
642+ }),
643+ ] satisfies ReadonlyArray<ServerProvider>;
644+
645+ const successView = getProviderUpdateSidebarPillView(providers, {
646+ visibleAfterIso: sessionStartedAt,
647+ });
648+ expect(successView).toMatchObject({
649+ key: "succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated.",
650+ tone: "success",
651+ title: "Codex updated: v1.2.0",
652+ });
653+
654+ const failureView = getProviderUpdateSidebarPillView(providers, {
655+ visibleAfterIso: sessionStartedAt,
656+ dismissedKeys: new Set(["succeeded:codex:2026-04-23T10:01:00.000Z:Provider updated."]),
657+ });
658+ expect(failureView).toMatchObject({
659+ key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.",
660+ tone: "error",
661+ title: "Claude v1.1.0 update failed",
662+ });
663+ });
664+
665+ it("does not show a sidebar pill for passive update availability", () => {
666+ expect(
667+ getProviderUpdateSidebarPillView([
668+ provider({ driver: driver("codex"), canUpdate: true }),
669+ provider({ driver: driver("cursor"), canUpdate: false }),
670+ ]),
671+ ).toBeNull();
672+ });
673+});
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+537−0
@@ -0,0 +1,537 @@
1+import {
2+ defaultInstanceIdForDriver,
3+ PROVIDER_DISPLAY_NAMES,
4+ type ProviderDriverKind,
5+ type ProviderInstanceId,
6+ type ServerProvider,
7+} from "@t3tools/contracts";
8+
9+export type ProviderUpdateCandidate = ServerProvider & {
10+ readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & {
11+ readonly status: "behind_latest";
12+ readonly latestVersion: string;
13+ };
14+};
15+
16+export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success";
17+export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded";
18+
19+export interface ProviderUpdateToastView {
20+ readonly phase: ProviderUpdateToastPhase;
21+ readonly type: ProviderUpdateToastType;
22+ readonly title: string;
23+ readonly description: string;
24+ readonly dismissAfterVisibleMs?: number;
25+}
26+
27+export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success";
28+
29+export interface ProviderUpdateSidebarPillView {
30+ readonly key: string;
31+ readonly tone: ProviderUpdateSidebarPillTone;
32+ readonly title: string;
33+ readonly description: string;
34+ readonly dismissible?: boolean;
35+ readonly dismissAfterVisibleMs?: number;
36+}
37+
38+interface ProviderUpdateSidebarPillOptions {
39+ readonly visibleAfterIso?: string;
40+ readonly dismissedKeys?: ReadonlySet<string>;
41+}
42+
43+const PROVIDER_UPDATE_SUCCESS_VISIBLE_MS = 3_000;
44+
45+function formatVersion(value: string): string {
46+ return value.startsWith("v") ? value : `v${value}`;
47+}
48+
49+function chooseRepresentativeProvider(
50+ current: ServerProvider | undefined,
51+ candidate: ServerProvider,
52+): ServerProvider {
53+ if (!current) {
54+ return candidate;
55+ }
56+ const defaultInstanceId = defaultInstanceIdForDriver(candidate.driver);
57+ if (candidate.instanceId === defaultInstanceId) {
58+ return candidate;
59+ }
60+ if (current.instanceId === defaultInstanceId) {
61+ return current;
62+ }
63+ return candidate.checkedAt.localeCompare(current.checkedAt) >= 0 ? candidate : current;
64+}
65+
66+function dedupeProvidersByDriver<T extends ServerProvider>(providers: ReadonlyArray<T>): T[] {
67+ const latestProviderByDriver = new Map<ProviderDriverKind, T>();
68+
69+ for (const provider of providers) {
70+ latestProviderByDriver.set(
71+ provider.driver,
72+ chooseRepresentativeProvider(latestProviderByDriver.get(provider.driver), provider) as T,
73+ );
74+ }
75+
76+ return [...latestProviderByDriver.values()];
77+}
78+
79+function dedupeProvidersByInstanceId<T extends ServerProvider>(providers: ReadonlyArray<T>): T[] {
80+ const latestProviderByInstanceId = new Map<ProviderInstanceId, T>();
81+
82+ for (const provider of providers) {
83+ const current = latestProviderByInstanceId.get(provider.instanceId);
84+ if (!current || provider.checkedAt.localeCompare(current.checkedAt) >= 0) {
85+ latestProviderByInstanceId.set(provider.instanceId, provider);
86+ }
87+ }
88+
89+ return [...latestProviderByInstanceId.values()];
90+}
91+
92+function getProviderUpdatedTitle(provider: Pick<ServerProvider, "driver" | "version">): string {
93+ const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
94+ return provider.version
95+ ? `${providerName} updated: ${formatVersion(provider.version)}`
96+ : `${providerName} updated`;
97+}
98+
99+function getProviderUpdatedDescription(providerCount: number): string {
100+ return providerCount === 1
101+ ? "New sessions will use the updated provider."
102+ : "New sessions will use the updated providers.";
103+}
104+
105+function getProviderFailedUpdateTitle(
106+ provider: Pick<ServerProvider, "driver" | "versionAdvisory">,
107+): string {
108+ const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
109+ const attemptedVersion = provider.versionAdvisory?.latestVersion;
110+ return attemptedVersion
111+ ? `${providerName} ${formatVersion(attemptedVersion)} update failed`
112+ : `${providerName} update failed`;
113+}
114+
115+export function isProviderUpdateCandidate(
116+ provider: ServerProvider,
117+): provider is ProviderUpdateCandidate {
118+ return (
119+ provider.enabled &&
120+ provider.versionAdvisory?.status === "behind_latest" &&
121+ provider.versionAdvisory.latestVersion !== null
122+ );
123+}
124+
125+export function isProviderUpdateActive(provider: Pick<ServerProvider, "updateState">): boolean {
126+ return provider.updateState?.status === "queued" || provider.updateState?.status === "running";
127+}
128+
129+export function collectProviderUpdateCandidates(
130+ providers: ReadonlyArray<ServerProvider>,
131+): ProviderUpdateCandidate[] {
132+ return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate));
133+}
134+
135+export function hasOneClickUpdateProviderCandidate(
136+ candidate: ProviderUpdateCandidate,
137+ providers: ReadonlyArray<ServerProvider>,
138+): boolean {
139+ if (
140+ candidate.versionAdvisory.canUpdate !== true ||
141+ candidate.versionAdvisory.updateCommand === null
142+ ) {
143+ return false;
144+ }
145+
146+ const driverProviders = providers.filter((provider) => provider.driver === candidate.driver);
147+ if (driverProviders.length === 0) {
148+ return false;
149+ }
150+
151+ const updateCommands = new Set<string>();
152+ for (const provider of driverProviders) {
153+ if (!isProviderUpdateCandidate(provider)) {
154+ continue;
155+ }
156+ const advisory = provider.versionAdvisory;
157+ if (!advisory || advisory.canUpdate !== true || advisory.updateCommand === null) {
158+ return false;
159+ }
160+ updateCommands.add(advisory.updateCommand);
161+ }
162+
163+ return updateCommands.size === 1;
164+}
165+
166+export function canOneClickUpdateProviderCandidate(
167+ candidate: ProviderUpdateCandidate,
168+ providers: ReadonlyArray<ServerProvider>,
169+): boolean {
170+ return (
171+ !isProviderUpdateActive(candidate) && hasOneClickUpdateProviderCandidate(candidate, providers)
172+ );
173+}
174+
175+export function providerUpdateNotificationKey(
176+ providers: ReadonlyArray<ProviderUpdateCandidate>,
177+): string | null {
178+ const parts = dedupeProvidersByDriver(providers)
179+ .map((provider) => {
180+ const advisory = provider.versionAdvisory;
181+ return [provider.driver, advisory.latestVersion].join(":");
182+ })
183+ .toSorted();
184+
185+ return parts.length > 0 ? parts.join("|") : null;
186+}
187+
188+export function providerUpdateCandidateKey(provider: ProviderUpdateCandidate): string {
189+ return providerUpdateNotificationKey([provider])!;
190+}
191+
192+export function formatProviderList(providers: ReadonlyArray<Pick<ServerProvider, "driver">>) {
193+ const names = providers.map(
194+ (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver,
195+ );
196+ if (names.length <= 2) {
197+ return names.join(" and ");
198+ }
199+ return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
200+}
201+
202+export function getProviderUpdateInitialToastView(input: {
203+ readonly updateProviders: ReadonlyArray<ProviderUpdateCandidate>;
204+ readonly oneClickProviders: ReadonlyArray<ProviderUpdateCandidate>;
205+}): ProviderUpdateToastView {
206+ return {
207+ phase: "initial",
208+ type: "warning",
209+ title: getProviderUpdateInitialToastTitle(input.updateProviders),
210+ description:
211+ input.oneClickProviders.length > 0
212+ ? "Install the update now or review provider settings."
213+ : `${formatProviderList(input.updateProviders)} can be updated from provider settings.`,
214+ };
215+}
216+
217+export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView {
218+ return {
219+ phase: "running",
220+ type: "loading",
221+ title: providerCount === 1 ? "Updating provider" : "Updating providers",
222+ description: "Running provider update command.",
223+ };
224+}
225+
226+export function getProviderUpdateRejectedToastView(
227+ providerCount: number,
228+ message: string,
229+): ProviderUpdateToastView {
230+ return {
231+ phase: "failed",
232+ type: "error",
233+ title: providerCount === 1 ? "Provider update failed" : "Provider updates failed",
234+ description: message,
235+ };
236+}
237+
238+export function getProviderUpdateProgressToastView(input: {
239+ readonly providers: ReadonlyArray<ServerProvider>;
240+ readonly providerCount: number;
241+}): ProviderUpdateToastView {
242+ const providers = dedupeProvidersByDriver(input.providers);
243+ const failedProviders = providers.filter((provider) => provider.updateState?.status === "failed");
244+ if (failedProviders.length > 0) {
245+ return {
246+ phase: "failed",
247+ type: "error",
248+ title: failedProviders.length === 1 ? "Provider update failed" : "Provider updates failed",
249+ description: getFailedProviderUpdateDescription(failedProviders),
250+ };
251+ }
252+
253+ const unchangedProviders = providers.filter(
254+ (provider) => provider.updateState?.status === "unchanged",
255+ );
256+ if (unchangedProviders.length > 0) {
257+ return {
258+ phase: "unchanged",
259+ type: "warning",
260+ title:
261+ unchangedProviders.length === 1
262+ ? "Provider still needs an update"
263+ : "Providers still need updates",
264+ description: `${formatProviderList(unchangedProviders)} ${
265+ unchangedProviders.length === 1 ? "still appears" : "still appear"
266+ } outdated. Check provider settings for details.`,
267+ };
268+ }
269+
270+ if (providers.some(isProviderUpdateActive)) {
271+ return getProviderUpdateRunningToastView(input.providerCount);
272+ }
273+
274+ const hasCompleteProviderSnapshots = providers.length >= input.providerCount;
275+ const allProvidersUpdated =
276+ hasCompleteProviderSnapshots &&
277+ providers.every(
278+ (provider) =>
279+ provider.updateState?.status === "succeeded" || !isProviderUpdateCandidate(provider),
280+ );
281+ if (allProvidersUpdated) {
282+ return {
283+ phase: "succeeded",
284+ type: "success",
285+ title: input.providerCount === 1 ? "Provider updated" : "Provider updates finished",
286+ description: getProviderUpdatedDescription(input.providerCount),
287+ dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS,
288+ };
289+ }
290+
291+ return getProviderUpdateRunningToastView(input.providerCount);
292+}
293+
294+export function getSingleProviderUpdateProgressToastView(
295+ provider: ServerProvider,
296+): ProviderUpdateToastView {
297+ const view = getProviderUpdateProgressToastView({
298+ providers: [provider],
299+ providerCount: 1,
300+ });
301+ const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
302+
303+ switch (view.phase) {
304+ case "running":
305+ return {
306+ ...view,
307+ title: `Updating ${providerName}`,
308+ };
309+ case "failed":
310+ return {
311+ ...view,
312+ title: getProviderFailedUpdateTitle(provider),
313+ };
314+ case "unchanged":
315+ return {
316+ ...view,
317+ title: `${providerName} still needs an update`,
318+ };
319+ case "succeeded":
320+ return {
321+ ...view,
322+ title: getProviderUpdatedTitle(provider),
323+ };
324+ default:
325+ return view;
326+ }
327+}
328+
329+export function collectUpdatedProviderSnapshots(input: {
330+ readonly results: ReadonlyArray<
331+ PromiseSettledResult<{ readonly providers: ReadonlyArray<ServerProvider> }>
332+ >;
333+ readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>;
334+}): ServerProvider[] {
335+ const matchedProviders: ServerProvider[] = [];
336+
337+ for (const result of input.results) {
338+ if (result.status !== "fulfilled") {
339+ continue;
340+ }
341+ for (const provider of result.value.providers) {
342+ if (input.providerInstanceIds.has(provider.instanceId)) {
343+ matchedProviders.push(provider);
344+ }
345+ }
346+ }
347+
348+ return dedupeProvidersByInstanceId(matchedProviders);
349+}
350+
351+export function firstRejectedProviderUpdateMessage(
352+ results: ReadonlyArray<PromiseSettledResult<unknown>>,
353+): string | null {
354+ const rejected = results.find((result) => result.status === "rejected");
355+ if (!rejected) {
356+ return null;
357+ }
358+ return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed.";
359+}
360+
361+function getUpdateFinishedAt(provider: ServerProvider): string | null {
362+ return provider.updateState?.finishedAt ?? null;
363+}
364+
365+function isRecentTerminalProvider(
366+ provider: ServerProvider,
367+ visibleAfterIso: string | undefined,
368+): boolean {
369+ const status = provider.updateState?.status;
370+ if (status !== "failed" && status !== "unchanged" && status !== "succeeded") {
371+ return false;
372+ }
373+ if (visibleAfterIso === undefined) {
374+ return true;
375+ }
376+ const finishedAt = getUpdateFinishedAt(provider);
377+ return finishedAt !== null && finishedAt >= visibleAfterIso;
378+}
379+
380+function latestFinishedAtForProviders(providers: ReadonlyArray<ServerProvider>): string | null {
381+ return providers.reduce<string | null>((latest, provider) => {
382+ const finishedAt = getUpdateFinishedAt(provider);
383+ if (finishedAt === null) {
384+ return latest;
385+ }
386+ return latest === null || finishedAt > latest ? finishedAt : latest;
387+ }, null);
388+}
389+
390+export function getProviderUpdateSidebarPillView(
391+ providers: ReadonlyArray<ServerProvider>,
392+ options?: ProviderUpdateSidebarPillOptions,
393+): ProviderUpdateSidebarPillView | null {
394+ const dedupedProviders = dedupeProvidersByDriver(providers);
395+ const activeProviders = dedupedProviders.filter(isProviderUpdateActive);
396+ if (activeProviders.length > 0) {
397+ const activeProvider = activeProviders[0]!;
398+ const activeProviderName =
399+ PROVIDER_DISPLAY_NAMES[activeProvider.driver] ?? activeProvider.driver;
400+ return {
401+ key: `loading:${activeProviders
402+ .map((provider) => `${provider.driver}:${provider.updateState?.status ?? "idle"}`)
403+ .toSorted()
404+ .join("|")}`,
405+ tone: "loading",
406+ title:
407+ activeProviders.length === 1
408+ ? `Updating ${activeProviderName}`
409+ : `Updating ${activeProviders.length} providers`,
410+ description:
411+ activeProviders.length === 1
412+ ? `${formatProviderList(activeProviders)} update in progress.`
413+ : `${formatProviderList(activeProviders)} updates are in progress.`,
414+ };
415+ }
416+
417+ const recentTerminalProviders = dedupedProviders.filter((provider) =>
418+ isRecentTerminalProvider(provider, options?.visibleAfterIso),
419+ );
420+ const terminalCandidates: ProviderUpdateSidebarPillView[] = [];
421+
422+ const failedProviders = recentTerminalProviders.filter(
423+ (provider) => provider.updateState?.status === "failed",
424+ );
425+ if (failedProviders.length > 0) {
426+ const failedProvider = failedProviders[0]!;
427+ terminalCandidates.push({
428+ key: `failed:${failedProviders
429+ .map(
430+ (provider) =>
431+ `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
432+ )
433+ .toSorted()
434+ .join("|")}`,
435+ tone: "error",
436+ title:
437+ failedProviders.length === 1
438+ ? getProviderFailedUpdateTitle(failedProvider)
439+ : `${failedProviders.length} provider updates failed`,
440+ description: getFailedProviderUpdateDescription(failedProviders),
441+ dismissible: true,
442+ });
443+ }
444+
445+ const unchangedProviders = recentTerminalProviders.filter(
446+ (provider) => provider.updateState?.status === "unchanged",
447+ );
448+ if (unchangedProviders.length > 0) {
449+ const unchangedProvider = unchangedProviders[0]!;
450+ const unchangedProviderName =
451+ PROVIDER_DISPLAY_NAMES[unchangedProvider.driver] ?? unchangedProvider.driver;
452+ terminalCandidates.push({
453+ key: `unchanged:${unchangedProviders
454+ .map(
455+ (provider) =>
456+ `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
457+ )
458+ .toSorted()
459+ .join("|")}`,
460+ tone: "warning",
461+ title:
462+ unchangedProviders.length === 1
463+ ? `${unchangedProviderName} still needs an update`
464+ : `${unchangedProviders.length} providers still need updates`,
465+ description: `${formatProviderList(unchangedProviders)} ${
466+ unchangedProviders.length === 1 ? "still appears" : "still appear"
467+ } outdated. Review provider settings for details.`,
468+ dismissible: true,
469+ });
470+ }
471+
472+ const succeededProviders = recentTerminalProviders.filter(
473+ (provider) => provider.updateState?.status === "succeeded",
474+ );
475+ if (succeededProviders.length > 0) {
476+ const succeededProvider = succeededProviders[0]!;
477+ terminalCandidates.push({
478+ key: `succeeded:${succeededProviders
479+ .map(
480+ (provider) =>
481+ `${provider.driver}:${provider.updateState?.finishedAt ?? "pending"}:${provider.updateState?.message ?? ""}`,
482+ )
483+ .toSorted()
484+ .join("|")}`,
485+ tone: "success",
486+ title:
487+ succeededProviders.length === 1
488+ ? getProviderUpdatedTitle(succeededProvider)
489+ : `${succeededProviders.length} providers updated`,
490+ description: getProviderUpdatedDescription(succeededProviders.length),
491+ dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS,
492+ });
493+ }
494+
495+ return (
496+ terminalCandidates
497+ .toSorted((left, right) => {
498+ const leftProviders =
499+ left.tone === "error"
500+ ? failedProviders
501+ : left.tone === "warning"
502+ ? unchangedProviders
503+ : succeededProviders;
504+ const rightProviders =
505+ right.tone === "error"
506+ ? failedProviders
507+ : right.tone === "warning"
508+ ? unchangedProviders
509+ : succeededProviders;
510+ const leftFinishedAt = latestFinishedAtForProviders(leftProviders) ?? "";
511+ const rightFinishedAt = latestFinishedAtForProviders(rightProviders) ?? "";
512+ return rightFinishedAt.localeCompare(leftFinishedAt);
513+ })
514+ .find((candidate) => !options?.dismissedKeys?.has(candidate.key)) ?? null
515+ );
516+}
517+
518+function getProviderUpdateInitialToastTitle(
519+ providers: ReadonlyArray<ProviderUpdateCandidate>,
520+): string {
521+ if (providers.length === 1) {
522+ const provider = providers[0]!;
523+ const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
524+ return `Update Available: ${providerName} ${formatVersion(provider.versionAdvisory.latestVersion)}`;
525+ }
526+ return `Updates Available: ${providers.length} providers`;
527+}
528+
529+function getFailedProviderUpdateDescription(providers: ReadonlyArray<ServerProvider>): string {
530+ if (providers.length === 1) {
531+ const provider = providers[0]!;
532+ if (provider.updateState?.message) {
533+ return provider.updateState.message;
534+ }
535+ }
536+ return `${formatProviderList(providers)} failed to update. Check provider settings for details.`;
537+}
apps/web/src/components/ProviderUpdateLaunchNotification.tsx+300−0
@@ -0,0 +1,300 @@
1+import { useNavigate } from "@tanstack/react-router";
2+import { DownloadIcon } from "lucide-react";
3+import { useCallback, useEffect, useMemo, useRef } from "react";
4+import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
5+
6+import { ensureLocalApi } from "../localApi";
7+import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
8+import { useServerProviders } from "../rpc/serverState";
9+import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";
10+import {
11+ canOneClickUpdateProviderCandidate,
12+ collectProviderUpdateCandidates,
13+ collectUpdatedProviderSnapshots,
14+ firstRejectedProviderUpdateMessage,
15+ getProviderUpdateInitialToastView,
16+ getProviderUpdateProgressToastView,
17+ getProviderUpdateRejectedToastView,
18+ getProviderUpdateRunningToastView,
19+ providerUpdateNotificationKey,
20+ type ProviderUpdateToastView,
21+} from "./ProviderUpdateLaunchNotification.logic";
22+import { stackedThreadToast, toastManager } from "./ui/toast";
23+
24+const seenProviderUpdateNotificationKeys = new Set<string>();
25+type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
26+
27+type ActiveProviderUpdateToast =
28+ | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId }
29+ | {
30+ readonly kind: "update";
31+ readonly key: string;
32+ readonly toastId: ProviderUpdateToastId;
33+ readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>;
34+ readonly providerCount: number;
35+ };
36+
37+function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) {
38+ const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[provider];
39+
40+ if (!ProviderIcon) {
41+ return (
42+ <span className="relative inline-flex size-4 shrink-0 items-center justify-center">
43+ <DownloadIcon aria-hidden="true" className="size-4 text-success" strokeWidth={2.5} />
44+ </span>
45+ );
46+ }
47+
48+ return (
49+ <span className="relative inline-flex size-4 shrink-0 items-center justify-center">
50+ <ProviderIcon aria-hidden="true" className="size-4" />
51+ <span className="absolute -right-1 -bottom-1 inline-flex size-3 items-center justify-center rounded-full bg-popover">
52+ <DownloadIcon aria-hidden="true" className="size-2.5 text-success" strokeWidth={2.5} />
53+ </span>
54+ </span>
55+ );
56+}
57+
58+function updateProviderUpdateToast(input: {
59+ readonly toastId: ProviderUpdateToastId;
60+ readonly view: ProviderUpdateToastView;
61+ readonly openSettings: () => void;
62+}) {
63+ if (input.view.type === "loading" || input.view.type === "success") {
64+ toastManager.update(input.toastId, {
65+ type: input.view.type,
66+ title: input.view.title,
67+ description: input.view.description,
68+ timeout: 0,
69+ data: {
70+ hideCopyButton: true,
71+ ...(input.view.dismissAfterVisibleMs !== undefined
72+ ? { dismissAfterVisibleMs: input.view.dismissAfterVisibleMs }
73+ : {}),
74+ },
75+ });
76+ return;
77+ }
78+
79+ toastManager.update(
80+ input.toastId,
81+ stackedThreadToast({
82+ type: input.view.type,
83+ title: input.view.title,
84+ description: input.view.description,
85+ timeout: 0,
86+ actionProps: {
87+ children: "Settings",
88+ onClick: input.openSettings,
89+ },
90+ actionVariant: "outline",
91+ data: {
92+ hideCopyButton: true,
93+ },
94+ }),
95+ );
96+}
97+
98+function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) {
99+ return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded";
100+}
101+
102+export function ProviderUpdateLaunchNotification() {
103+ const navigate = useNavigate();
104+ const providers = useServerProviders();
105+ const activeToastRef = useRef<ActiveProviderUpdateToast | null>(null);
106+ const { dismissedNotificationKeys, dismissNotificationKey } =
107+ useDismissedProviderUpdateNotificationKeys();
108+
109+ const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]);
110+ const notificationKey = useMemo(
111+ () => providerUpdateNotificationKey(updateProviders),
112+ [updateProviders],
113+ );
114+ const oneClickProviders = useMemo(
115+ () =>
116+ updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)),
117+ [providers, updateProviders],
118+ );
119+
120+ const openProviderSettings = useCallback(
121+ (toastId?: ProviderUpdateToastId) => {
122+ const activeToast = activeToastRef.current;
123+ if (toastId !== undefined) {
124+ toastManager.close(toastId);
125+ } else if (activeToast) {
126+ toastManager.close(activeToast.toastId);
127+ }
128+ if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) {
129+ activeToastRef.current = null;
130+ }
131+ void navigate({ to: "/settings/providers" });
132+ },
133+ [navigate],
134+ );
135+
136+ useEffect(() => {
137+ const activeToast = activeToastRef.current;
138+ if (activeToast?.kind !== "update") {
139+ return;
140+ }
141+
142+ const activeProviders = providers.filter((provider) =>
143+ activeToast.providerInstanceIds.has(provider.instanceId),
144+ );
145+ const view = getProviderUpdateProgressToastView({
146+ providers: activeProviders,
147+ providerCount: activeToast.providerCount,
148+ });
149+ updateProviderUpdateToast({
150+ toastId: activeToast.toastId,
151+ view,
152+ openSettings: () => openProviderSettings(activeToast.toastId),
153+ });
154+
155+ if (isTerminalProviderUpdateToastView(view)) {
156+ activeToastRef.current = null;
157+ }
158+ }, [providers, openProviderSettings]);
159+
160+ useEffect(() => {
161+ const activeToast = activeToastRef.current;
162+ if (activeToast?.kind === "prompt" && activeToast.key !== notificationKey) {
163+ toastManager.close(activeToast.toastId);
164+ activeToastRef.current = null;
165+ }
166+
167+ if (
168+ !notificationKey ||
169+ dismissedNotificationKeys.has(notificationKey) ||
170+ seenProviderUpdateNotificationKeys.has(notificationKey) ||
171+ activeToastRef.current
172+ ) {
173+ return;
174+ }
175+
176+ seenProviderUpdateNotificationKeys.add(notificationKey);
177+
178+ const initialView = getProviderUpdateInitialToastView({ updateProviders, oneClickProviders });
179+
180+ let toastId!: ProviderUpdateToastId;
181+ let updateStarted = false;
182+ const openSettings = () => openProviderSettings(toastId);
183+ const dismissPrompt = () => {
184+ dismissNotificationKey(notificationKey);
185+ };
186+
187+ const runUpdates = () => {
188+ if (updateStarted || oneClickProviders.length === 0) {
189+ return;
190+ }
191+ updateStarted = true;
192+
193+ const providerCount = oneClickProviders.length;
194+ const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId));
195+ activeToastRef.current = {
196+ kind: "update",
197+ key: notificationKey,
198+ toastId,
199+ providerInstanceIds,
200+ providerCount,
201+ };
202+
203+ updateProviderUpdateToast({
204+ toastId,
205+ view: getProviderUpdateRunningToastView(providerCount),
206+ openSettings,
207+ });
208+
209+ void Promise.allSettled(
210+ oneClickProviders.map(async (provider) =>
211+ ensureLocalApi().server.updateProvider({
212+ provider: provider.driver,
213+ instanceId: provider.instanceId,
214+ }),
215+ ),
216+ ).then((results) => {
217+ const activeUpdateToast = activeToastRef.current;
218+ if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) {
219+ return;
220+ }
221+
222+ const rejectedMessage = firstRejectedProviderUpdateMessage(results);
223+ if (rejectedMessage) {
224+ updateProviderUpdateToast({
225+ toastId,
226+ view: getProviderUpdateRejectedToastView(providerCount, rejectedMessage),
227+ openSettings,
228+ });
229+ activeToastRef.current = null;
230+ return;
231+ }
232+
233+ const updatedProviderSnapshots = collectUpdatedProviderSnapshots({
234+ results,
235+ providerInstanceIds,
236+ });
237+ const view = getProviderUpdateProgressToastView({
238+ providers: updatedProviderSnapshots,
239+ providerCount,
240+ });
241+ updateProviderUpdateToast({
242+ toastId,
243+ view,
244+ openSettings,
245+ });
246+
247+ if (isTerminalProviderUpdateToastView(view)) {
248+ activeToastRef.current = null;
249+ }
250+ });
251+ };
252+
253+ toastId = toastManager.add(
254+ stackedThreadToast({
255+ type: initialView.type,
256+ title: initialView.title,
257+ description: initialView.description,
258+ timeout: 0,
259+ actionProps:
260+ oneClickProviders.length > 0
261+ ? {
262+ children: "Update",
263+ onClick: runUpdates,
264+ }
265+ : {
266+ children: "Settings",
267+ onClick: openSettings,
268+ },
269+ actionVariant: oneClickProviders.length > 0 ? "default" : "outline",
270+ data: {
271+ leadingIcon:
272+ updateProviders.length === 1 ? (
273+ <ProviderUpdateToastIcon provider={updateProviders[0]!.driver} />
274+ ) : undefined,
275+ hideCopyButton: true,
276+ onClose: dismissPrompt,
277+ ...(oneClickProviders.length > 0
278+ ? {
279+ secondaryActionProps: {
280+ children: "Settings",
281+ onClick: openSettings,
282+ },
283+ secondaryActionVariant: "outline" as const,
284+ }
285+ : {}),
286+ },
287+ }),
288+ );
289+ activeToastRef.current = { kind: "prompt", key: notificationKey, toastId };
290+ }, [
291+ dismissNotificationKey,
292+ dismissedNotificationKeys,
293+ notificationKey,
294+ oneClickProviders,
295+ openProviderSettings,
296+ updateProviders,
297+ ]);
298+
299+ return null;
300+}
apps/web/src/components/Sidebar.tsx+2−0
@@ -185,6 +185,7 @@ import { · 184 unmodified lines
185185 type SidebarProjectGroupMember,
186186 type SidebarProjectSnapshot,
187187 } from "../sidebarProjectGrouping";
188+import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
188189 const THREAD_PREVIEW_LIMIT = 6;
189190 const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
190191 updated_at: "Last user message",
@@ -2434,6 +2435,7 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { · 2243 unmodified lines
24342435
24352436 return (
24362437 <SidebarFooter className="p-2">
2438+ <SidebarProviderUpdatePill />
24372439 <SidebarUpdatePill />
24382440 <SidebarMenu>
24392441 <SidebarMenuItem>
apps/web/src/components/settings/ProviderInstanceCard.tsx+226−83
@@ -1,6 +1,15 @@
11 "use client";
22
3−import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
3+import {
4+ ArrowUpCircleIcon,
5+ ChevronDownIcon,
6+ CopyIcon,
7+ DownloadIcon,
8+ LoaderIcon,
9+ PlusIcon,
10+ Trash2Icon,
11+ XIcon,
12+} from "lucide-react";
413 import { useEffect, useState, type ReactNode } from "react";
514 import {
615 isProviderDriverKind,
@@ -13,12 +22,15 @@ import { · 6 unmodified lines
1322 } from "@t3tools/contracts";
1423
1524 import { cn } from "../../lib/utils";
25+import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
1626 import { normalizeProviderAccentColor } from "../../providerInstances";
1727 import { Badge } from "../ui/badge";
1828 import { Button } from "../ui/button";
1929 import { Collapsible, CollapsibleContent } from "../ui/collapsible";
2030 import { DraftInput } from "../ui/draft-input";
31+import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
2132 import { Switch } from "../ui/switch";
33+import { stackedThreadToast, toastManager } from "../ui/toast";
2234 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2335 import type { DriverOption } from "./providerDriverMeta";
2436 import { ProviderSettingsForm } from "./ProviderSettingsForm";
@@ -26,6 +38,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; · 1 unmodified line
2638 import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
2739 import { RedactedSensitiveText } from "./RedactedSensitiveText";
2840 import {
41+ getProviderVersionAdvisoryPresentation,
2942 PROVIDER_STATUS_STYLES,
3043 getProviderSummary,
3144 getProviderVersionLabel,
@@ -405,6 +418,8 @@ interface ProviderInstanceCardProps { · 373 unmodified lines
405418 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;
406419 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
407420 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
421+ readonly onRunUpdate?: (() => void) | undefined;
422+ readonly isUpdating?: boolean | undefined;
408423 }
409424
410425 /**
@@ -447,6 +462,8 @@ export function ProviderInstanceCard({ · 36 unmodified lines
447462 onHiddenModelsChange,
448463 onFavoriteModelsChange,
449464 onModelOrderChange,
465+ onRunUpdate,
466+ isUpdating = false,
450467 }: ProviderInstanceCardProps) {
451468 const enabled = instance.enabled ?? true;
452469 // The server-reported status wins when present; otherwise fall back to
@@ -464,10 +481,30 @@ export function ProviderInstanceCard({ · 11 unmodified lines
464481 : null;
465482 const summary = rawSummary;
466483 const versionLabel = getProviderVersionLabel(liveProvider?.version);
484+ const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
485+ const updateCommand = versionAdvisory?.updateCommand ?? null;
467486 const FallbackIconComponent = driverOption?.icon;
468487 const displayName =
469488 instance.displayName?.trim() || driverOption?.label || String(instance.driver);
470489 const accentColor = normalizeProviderAccentColor(instance.accentColor);
490+ const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({
491+ onCopy: ({ providerName }) => {
492+ toastManager.add({
493+ type: "success",
494+ title: `${providerName} update command copied`,
495+ description: "Run it in a terminal when you are ready to update.",
496+ });
497+ },
498+ onError: (error, { providerName }) => {
499+ toastManager.add(
500+ stackedThreadToast({
501+ type: "error",
502+ title: `Could not copy ${providerName} update command`,
503+ description: error.message,
504+ }),
505+ );
506+ },
507+ });
471508
472509 // Narrow `instance.driver` for callers that key on the closed
473510 // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom
@@ -535,97 +572,203 @@ export function ProviderInstanceCard({ · 61 unmodified lines
535572 );
536573 };
537574
575+ const titleIconNode = driverKind ? (
576+ <ProviderInstanceIcon
577+ driverKind={driverKind}
578+ displayName={displayName}
579+ accentColor={accentColor}
580+ showBadge={Boolean(accentColor)}
581+ statusDotClassName={statusStyle.dot}
582+ className="size-5"
583+ iconClassName="size-4 text-foreground/80"
584+ badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]"
585+ />
586+ ) : FallbackIconComponent ? (
587+ <span className="relative inline-flex size-5 shrink-0 items-center justify-center">
588+ <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden />
589+ <span
590+ className={cn(
591+ "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background",
592+ statusStyle.dot,
593+ )}
594+ aria-hidden
595+ />
596+ </span>
597+ ) : (
598+ <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} />
599+ );
600+
601+ const titleHeadNode = (
602+ <>
603+ {titleIconNode}
604+ <h3 className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground">
605+ {displayName}
606+ </h3>
607+ {String(instanceId) !== String(instance.driver) ? (
608+ <code className="truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground">
609+ {instanceId}
610+ </code>
611+ ) : null}
612+ {driverOption?.badgeLabel ? (
613+ <Badge variant="warning" size="sm" className="shrink-0">
614+ {driverOption.badgeLabel}
615+ </Badge>
616+ ) : null}
617+ </>
618+ );
619+
620+ const titleTailNode = (
621+ <>
622+ {headerAction ? (
623+ <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
624+ {headerAction}
625+ </span>
626+ ) : null}
627+ {onDelete ? (
628+ <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
629+ <Tooltip>
630+ <TooltipTrigger
631+ render={
632+ <Button
633+ size="icon-xs"
634+ variant="ghost"
635+ className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive"
636+ onClick={onDelete}
637+ aria-label={`Delete provider instance ${instanceId}`}
638+ >
639+ <Trash2Icon className="size-3" />
640+ </Button>
641+ }
642+ />
643+ <TooltipPopup side="top">Delete instance</TooltipPopup>
644+ </Tooltip>
645+ </span>
646+ ) : null}
647+ </>
648+ );
649+
650+ const authRowNode = (
651+ <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
652+ {hasAuthenticatedEmail ? (
653+ <>
654+ <span>Authenticated as</span>
655+ <ProviderAuthEmail email={authEmail} />
656+ {authenticatedDetail ? <span>· {authenticatedDetail}</span> : null}
657+ </>
658+ ) : (
659+ <>
660+ <span>{summary.headline}</span>
661+ <ProviderAuthEmail email={authEmail} separator prefix="Email" />
662+ </>
663+ )}
664+ {summary.detail ? <span>- {summary.detail}</span> : null}
665+ </p>
666+ );
667+
668+ const versionCodeNode = versionLabel ? (
669+ <code className="text-xs text-muted-foreground">{versionLabel}</code>
670+ ) : null;
671+
538672 return (
539− <div className="border-t border-border first:border-t-0">
540− <div className="px-4 py-4 sm:px-5">
673+ <div className="border-t border-border/60 first:border-t-0">
674+ <div className="px-4 py-3.5 sm:px-5">
541675 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
542676 <div className="min-w-0 flex-1 space-y-1">
543− <div className="flex min-h-5 items-center gap-1.5">
544− {driverKind ? (
545− <ProviderInstanceIcon
546− driverKind={driverKind}
547− displayName={displayName}
548− accentColor={accentColor}
549− showBadge={Boolean(accentColor)}
550− statusDotClassName={statusStyle.dot}
551− className="size-5"
552− iconClassName="size-4 text-foreground/80"
553− badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]"
554− />
555− ) : FallbackIconComponent ? (
556− <span className="relative inline-flex size-5 shrink-0 items-center justify-center">
557− <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden />
558− <span
559− className={cn(
560− "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background",
561− statusStyle.dot,
562− )}
563− aria-hidden
677+ <div className="flex min-w-0 flex-wrap items-center gap-2">
678+ {titleHeadNode}
679+ {versionCodeNode}
680+ {versionAdvisory ? (
681+ <Popover>
682+ <PopoverTrigger
683+ render={
684+ <Button
685+ type="button"
686+ size="icon-xs"
687+ variant="ghost"
688+ className={cn(
689+ "size-5 rounded-sm p-0",
690+ versionAdvisory.emphasis === "strong"
691+ ? "text-warning hover:text-warning"
692+ : "text-primary hover:text-primary",
693+ )}
694+ aria-label="Update available — view details"
695+ >
696+ <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" />
697+ </Button>
698+ }
564699 />
565− </span>
566− ) : (
567− <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} />
568− )}
569− <h3 className="truncate text-sm font-medium text-foreground">{displayName}</h3>
570− {String(instanceId) !== String(instance.driver) ? (
571− // Hide the id chip on a default slot whose id === the
572− // driver slug — it's redundant with the driver icon +
573− // label. Custom instances (and any instance the user has
574− // since renamed) keep the chip so their slug stays
575− // visible for copy/paste + disambiguation.
576− <code className="truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground">
577− {instanceId}
578− </code>
579− ) : null}
580− {driverOption?.badgeLabel ? (
581− <Badge variant="warning" size="sm" className="shrink-0">
582− {driverOption.badgeLabel}
583− </Badge>
584− ) : null}
585− {versionLabel ? (
586− <code className="text-xs text-muted-foreground">{versionLabel}</code>
587− ) : null}
588− {headerAction ? (
589− <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
590− {headerAction}
591− </span>
592− ) : null}
593− {onDelete ? (
594− <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
595− <Tooltip>
596− <TooltipTrigger
597− render={
700+ <PopoverPopup side="bottom" align="start" className="w-84">
701+ <div className="grid gap-3">
702+ <div className="grid gap-0.5">
703+ <p className="text-[13px] font-semibold leading-tight text-foreground">
704+ Update available
705+ </p>
706+ <p
707+ className={cn(
708+ "text-xs leading-snug",
709+ versionAdvisory.emphasis === "strong"
710+ ? "text-warning"
711+ : "text-muted-foreground",
712+ )}
713+ >
714+ {versionAdvisory.detail}
715+ </p>
716+ </div>
717+ {onRunUpdate ? (
598718 <Button
599− size="icon-xs"
600− variant="ghost"
601− className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive"
602− onClick={onDelete}
603− aria-label={`Delete provider instance ${instanceId}`}
719+ type="button"
720+ size="xs"
721+ variant="default"
722+ className="w-full"
723+ disabled={isUpdating}
724+ onClick={onRunUpdate}
604725 >
605− <Trash2Icon className="size-3" />
726+ {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
727+ {isUpdating ? "Updating" : "Update now"}
606728 </Button>
607− }
608− />
609− <TooltipPopup side="top">Delete instance</TooltipPopup>
610− </Tooltip>
611− </span>
729+ ) : null}
730+ {onRunUpdate && updateCommand ? (
731+ <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
732+ <span aria-hidden className="h-px flex-1 bg-border" />
733+ or, update manually using
734+ <span aria-hidden className="h-px flex-1 bg-border" />
735+ </div>
736+ ) : null}
737+ {updateCommand ? (
738+ <div className="flex items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">
739+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
740+ {updateCommand}
741+ </code>
742+ <Tooltip>
743+ <TooltipTrigger
744+ render={
745+ <Button
746+ type="button"
747+ size="icon-xs"
748+ variant="ghost"
749+ className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
750+ onClick={() =>
751+ copyToClipboard(updateCommand, {
752+ providerName: displayName,
753+ })
754+ }
755+ aria-label="Copy update command"
756+ >
757+ <CopyIcon className="size-3" />
758+ </Button>
759+ }
760+ />
761+ <TooltipPopup side="top">Copy command</TooltipPopup>
762+ </Tooltip>
763+ </div>
764+ ) : null}
765+ </div>
766+ </PopoverPopup>
767+ </Popover>
612768 ) : null}
769+ {titleTailNode}
613770 </div>
614− <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
615− {hasAuthenticatedEmail ? (
616− <>
617− <span>Authenticated as</span>
618− <ProviderAuthEmail email={authEmail} />
619− {authenticatedDetail ? <span>· {authenticatedDetail}</span> : null}
620− </>
621− ) : (
622− <>
623− <span>{summary.headline}</span>
624− <ProviderAuthEmail email={authEmail} separator prefix="Email" />
625− </>
626− )}
627− {summary.detail ? <span>- {summary.detail}</span> : null}
628− </p>
771+ {authRowNode}
629772 </div>
630773 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
631774 <Button
apps/web/src/components/settings/SettingsPanels.browser.tsx+65−2
@@ -10,7 +10,10 @@ import { · 9 unmodified lines
1010 type DesktopUpdateChannel,
1111 type DesktopUpdateState,
1212 type LocalApi,
13+ ProviderDriverKind,
14+ ProviderInstanceId,
1315 type ServerConfig,
16+ type ServerProvider,
1417 type SourceControlDiscoveryResult,
1518 } from "@t3tools/contracts";
1619 import { DateTime, Option } from "effect";
@@ -23,7 +26,7 @@ import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc · 6 unmodified lines
2326 import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
2427 import { useUiStateStore } from "../../uiStateStore";
2528 import { ConnectionsSettings } from "./ConnectionsSettings";
26−import { GeneralSettingsPanel } from "./SettingsPanels";
29+import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels";
2730 import { SourceControlSettingsPanel } from "./SourceControlSettings";
2831
2932 const authAccessHarness = vi.hoisted(() => {
@@ -207,6 +210,31 @@ function createBaseServerConfig(): ServerConfig { · 177 unmodified lines
207210 };
208211 }
209212
213+function createOutdatedProvider(driver: string): ServerProvider {
214+ return {
215+ instanceId: ProviderInstanceId.make(driver),
216+ driver: ProviderDriverKind.make(driver),
217+ enabled: true,
218+ installed: true,
219+ version: "1.0.0",
220+ status: "ready",
221+ auth: { status: "authenticated" },
222+ checkedAt: "2026-05-04T10:00:00.000Z",
223+ models: [],
224+ slashCommands: [],
225+ skills: [],
226+ versionAdvisory: {
227+ status: "behind_latest",
228+ currentVersion: "1.0.0",
229+ latestVersion: "1.1.0",
230+ message: "Update available.",
231+ checkedAt: "2026-05-04T10:00:00.000Z",
232+ updateCommand: "npm install -g openai/codex@latest",
233+ canUpdate: true,
234+ },
235+ };
236+}
237+
210238 function makeUtc(value: string) {
211239 return DateTime.makeUnsafe(Date.parse(value));
212240 }
@@ -1016,7 +1044,7 @@ describe("GeneralSettingsPanel observability", () => { · 803 unmodified lines
10161044
10171045 mounted = await render(
10181046 <AppAtomRegistryProvider>
1019− <GeneralSettingsPanel />
1047+ <ProviderSettingsPanel />
10201048 </AppAtomRegistryProvider>,
10211049 );
10221050
@@ -1031,6 +1059,41 @@ describe("GeneralSettingsPanel observability", () => { · 8 unmodified lines
10311059 await expect.element(page.getByText("Server password")).toBeInTheDocument();
10321060 await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument();
10331061 });
1062+
1063+ it("runs one-click provider updates from the provider card", async () => {
1064+ const updateProvider = vi.fn<LocalApi["server"]["updateProvider"]>().mockResolvedValue({
1065+ providers: [createOutdatedProvider("codex")],
1066+ });
1067+ window.nativeApi = {
1068+ persistence: {
1069+ getClientSettings: vi.fn().mockResolvedValue(null),
1070+ setClientSettings: vi.fn().mockResolvedValue(undefined),
1071+ },
1072+ server: {
1073+ updateProvider,
1074+ },
1075+ } as unknown as LocalApi;
1076+
1077+ setServerConfigSnapshot({
1078+ ...createBaseServerConfig(),
1079+ providers: [createOutdatedProvider("codex")],
1080+ });
1081+
1082+ mounted = await render(
1083+ <AppAtomRegistryProvider>
1084+ <ProviderSettingsPanel />
1085+ </AppAtomRegistryProvider>,
1086+ );
1087+
1088+ await page.getByRole("button", { name: "Update available — view details" }).click();
1089+ await expect.element(page.getByRole("button", { name: "Update now" })).toBeInTheDocument();
1090+ await page.getByRole("button", { name: "Update now" }).click();
1091+
1092+ expect(updateProvider).toHaveBeenCalledWith({
1093+ provider: ProviderDriverKind.make("codex"),
1094+ instanceId: ProviderInstanceId.make("codex"),
1095+ });
1096+ });
10341097 });
10351098
10361099 describe("SourceControlSettingsPanel discovery states", () => {
apps/web/src/components/settings/SettingsPanels.tsx+433−402
@@ -4,6 +4,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; · 3 unmodified lines
44 import {
55 defaultInstanceIdForDriver,
66 type DesktopUpdateChannel,
7+ PROVIDER_DISPLAY_NAMES,
78 ProviderDriverKind,
89 type ProviderInstanceConfig,
910 type ProviderInstanceId,
@@ -56,6 +57,13 @@ import { Switch } from "../ui/switch"; · 46 unmodified lines
5657 import { stackedThreadToast, toastManager } from "../ui/toast";
5758 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5859 import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
60+import {
61+ canOneClickUpdateProviderCandidate,
62+ collectProviderUpdateCandidates,
63+ hasOneClickUpdateProviderCandidate,
64+ isProviderUpdateActive,
65+ type ProviderUpdateCandidate,
66+} from "../ProviderUpdateLaunchNotification.logic";
5967 import { ProviderInstanceCard } from "./ProviderInstanceCard";
6068 import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
6169 import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic";
@@ -345,37 +353,12 @@ function AboutVersionSection() { · 283 unmodified lines
345353 export function useSettingsRestore(onRestored?: () => void) {
346354 const { theme, setTheme } = useTheme();
347355 const settings = useSettings();
348− const { resetSettings } = useUpdateSettings();
356+ const { updateSettings } = useUpdateSettings();
349357
350358 const isGitWritingModelDirty = !Equal.equals(
351359 settings.textGenerationModelSelection ?? null,
352360 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
353361 );
354− // A provider surface is "dirty" if either the legacy per-kind
355− // `settings.providers[kind]` struct differs from defaults (for users
356− // on pre-migration data) or the new `settings.providerInstances` map
357− // has any entries (every edit to a default slot promotes it into an
358− // explicit entry, so any key in that map represents user intent to
359− // diverge from factory defaults). Checking both keeps the Restore
360− // Defaults chip accurate throughout the legacy→instance migration.
361− const areProviderSettingsDirty =
362− PROVIDER_SETTINGS.some((providerSettings) => {
363− type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
364− const currentProviders = settings.providers as Record<
365− string,
366− LegacyProviderSettings | undefined
367− >;
368− const defaultProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
369− string,
370− LegacyProviderSettings | undefined
371− >;
372− const currentSettings = currentProviders[providerSettings.provider];
373− const defaultSettings = defaultProviders[providerSettings.provider];
374− return !Equal.equals(currentSettings, defaultSettings);
375− }) ||
376− Object.keys(settings.providerInstances ?? {}).length > 0 ||
377− Object.keys(settings.providerModelPreferences ?? {}).length > 0 ||
378− (settings.favorites ?? []).length > 0;
379362
380363 const changedSettingLabels = useMemo(
381364 () => [
@@ -408,10 +391,8 @@ export function useSettingsRestore(onRestored?: () => void) { · 26 unmodified lines
408391 ? ["Delete confirmation"]
409392 : []),
410393 ...(isGitWritingModelDirty ? ["Git writing model"] : []),
411− ...(areProviderSettingsDirty ? ["Providers"] : []),
412394 ],
413395 [
414− areProviderSettingsDirty,
415396 isGitWritingModelDirty,
416397 settings.autoOpenPlanSidebar,
417398 settings.confirmThreadArchive,
@@ -437,9 +418,20 @@ export function useSettingsRestore(onRestored?: () => void) { · 19 unmodified lines
437418 if (!confirmed) return;
438419
439420 setTheme("system");
440− resetSettings();
421+ updateSettings({
422+ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
423+ diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
424+ diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
425+ autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
426+ enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
427+ defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
428+ addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
429+ confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
430+ confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
431+ textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
432+ });
441433 onRestored?.();
442− }, [changedSettingLabels, onRestored, resetSettings, setTheme]);
434+ }, [changedSettingLabels, onRestored, setTheme, updateSettings]);
443435
444436 return {
445437 changedSettingLabels,
@@ -458,44 +450,10 @@ export function GeneralSettingsPanel() { · 12 unmodified lines
458450 const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState<
459451 Partial<Record<"keybindings" | "logsDirectory", string | null>>
460452 >({});
461− const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
462− const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
463− // Collapsible state per provider-instance card, keyed by the instance id.
464− // `Record<string, boolean>` so we don't need to preseed an entry for every
465− // configured instance — an absent key reads as collapsed. Default-slot
466− // rows share this state: their id is the driver slug
467− // (`defaultInstanceIdForDriver(driver)`), which is also `ProviderDriverKind` at
468− // runtime, so a pre-existing open key for e.g. "codex" persists across
469− // the legacy/unified render swap.
470− const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({});
471− const refreshingRef = useRef(false);
472− const refreshProviders = useCallback(() => {
473− if (refreshingRef.current) return;
474− refreshingRef.current = true;
475− setIsRefreshingProviders(true);
476− void ensureLocalApi()
477− .server.refreshProviders()
478− .catch((error: unknown) => {
479− console.warn("Failed to refresh providers", error);
480− })
481− .finally(() => {
482− refreshingRef.current = false;
483− setIsRefreshingProviders(false);
484− });
485− }, []);
486−
487453 const keybindingsConfigPath = useServerKeybindingsConfigPath();
488454 const availableEditors = useServerAvailableEditors();
489455 const observability = useServerObservability();
490456 const serverProviders = useServerProviders();
491− const visibleProviderSettings = PROVIDER_SETTINGS.filter(
492− (providerSettings) =>
493− providerSettings.provider !== "cursor" ||
494− serverProviders.some(
495− (provider) =>
496− provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")),
497− ),
498− );
499457 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
500458 const diagnosticsDescription = (() => {
501459 const exports: string[] = [];
@@ -576,221 +534,6 @@ export function GeneralSettingsPanel() { · 74 unmodified lines
576534 const isOpeningKeybindings = openingPathByTarget.keybindings;
577535 const isOpeningLogsDirectory = openingPathByTarget.logsDirectory;
578536
579− const lastCheckedAt =
580− serverProviders.length > 0
581− ? serverProviders.reduce(
582− (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest),
583− serverProviders[0]!.checkedAt,
584− )
585− : null;
586−
587− /**
588− * Build the list of rows to render, one per configured instance. Each
589− * row carries enough context to drive `ProviderInstanceCard` without
590− * threading storage concerns: whether it's a built-in default slot (in
591− * which case `isDefault` is true, deletion is gated off, and the
592− * effective envelope may have been synthesized from legacy just for
593− * this render), the driver kind narrow for the in-card model-slug
594− * normalization, and whether a reset-to-factory action is warranted.
595− *
596− * Ordering mirrors the prior split: visible built-in default slots
597− * first (one per visible kind), then user-authored custom instances
598− * grouped by driver after their default sibling, then orphan instances
599− * whose driver isn't in the visible-defaults set.
600− */
601− interface InstanceRow {
602− readonly instanceId: ProviderInstanceId;
603− readonly instance: ProviderInstanceConfig;
604− readonly driver: ProviderDriverKind;
605− /** True for the slot whose id is `defaultInstanceIdForDriver(driver)`. */
606− readonly isDefault: boolean;
607− /**
608− * True when this default slot differs from the factory defaults —
609− * either through an explicit `providerInstances[defaultId]` entry,
610− * or through a non-default legacy `settings.providers[kind]` struct
611− * that we're still bridging. Used to show the reset-to-factory
612− * affordance. Undefined for custom rows (they have a delete button
613− * instead; "factory defaults" isn't meaningful).
614− */
615− readonly isDirty?: boolean;
616− }
617−
618− const instancesByDriver = new Map<
619− ProviderDriverKind,
620− Array<[ProviderInstanceId, ProviderInstanceConfig]>
621− >();
622− for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) {
623− const driver = instance.driver;
624− const list = instancesByDriver.get(driver) ?? [];
625− list.push([rawId as ProviderInstanceId, instance]);
626− instancesByDriver.set(driver, list);
627− }
628−
629− const defaultSlotIdsBySource = new Set<string>(
630− visibleProviderSettings.map((providerSettings) =>
631− String(defaultInstanceIdForDriver(providerSettings.provider)),
632− ),
633− );
634−
635− const rows: InstanceRow[] = [];
636− const visibleDriverKinds = new Set<ProviderDriverKind>(
637− visibleProviderSettings.map((providerSettings) => providerSettings.provider),
638− );
639−
640− for (const providerSettings of visibleProviderSettings) {
641− type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
642− const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>;
643− const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
644− string,
645− LegacyProviderSettings
646− >;
647− const driver = providerSettings.provider;
648− const defaultInstanceId = defaultInstanceIdForDriver(driver);
649− // Prefer an explicit `providerInstances[defaultId]` entry when one
650− // exists (every edit via this UI promotes the default slot into
651− // that map); fall back to synthesizing one from the legacy
652− // `settings.providers[kind]` struct so first-time viewers still see
653− // their persisted config.
654− const explicitInstance = settings.providerInstances?.[defaultInstanceId];
655− const legacyConfig = legacyProviders[providerSettings.provider]!;
656− const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!;
657− const effectiveInstance: ProviderInstanceConfig =
658− explicitInstance ??
659− ({
660− driver,
661− enabled: legacyConfig.enabled,
662− config: legacyConfig,
663− } satisfies ProviderInstanceConfig);
664− const isDirty =
665− explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig);
666− rows.push({
667− instanceId: defaultInstanceId,
668− instance: effectiveInstance,
669− driver,
670− isDefault: true,
671− isDirty,
672− });
673− // Non-default customs for this driver kind follow their default.
674− for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) {
675− if (id === defaultInstanceId) continue;
676− rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false });
677− }
678− }
679− // Orphan instances: drivers the visible-defaults list doesn't cover
680− // (e.g. Cursor when the server hasn't reported it but the user has
681− // authored a Cursor instance anyway, or fork drivers not shipped by
682− // this build). Preserve insertion order within each driver.
683− for (const [driver, list] of instancesByDriver) {
684− if (visibleDriverKinds.has(driver)) continue;
685− for (const [id, instance] of list) {
686− const isDefaultSlot = defaultSlotIdsBySource.has(String(id));
687− rows.push({
688− instanceId: id,
689− instance,
690− driver: instance.driver,
691− isDefault: isDefaultSlot,
692− });
693− }
694− }
695−
696− const updateProviderInstance = (
697− row: InstanceRow,
698− next: ProviderInstanceConfig,
699− options?: {
700− readonly textGenerationModelSelection?: Parameters<
701− typeof buildProviderInstanceUpdatePatch
702− >[0]["textGenerationModelSelection"];
703− },
704− ) => {
705− updateSettings(
706− buildProviderInstanceUpdatePatch({
707− settings,
708− instanceId: row.instanceId,
709− instance: next,
710− driver: row.driver,
711− isDefault: row.isDefault,
712− textGenerationModelSelection: options?.textGenerationModelSelection,
713− }),
714− );
715− };
716−
717− const deleteProviderInstance = (id: ProviderInstanceId) => {
718− updateSettings({
719− providerInstances: withoutProviderInstanceKey(settings.providerInstances, id),
720− providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id),
721− favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id),
722− });
723− };
724−
725− const updateProviderModelPreferences = (
726− instanceId: ProviderInstanceId,
727− next: {
728− readonly hiddenModels: ReadonlyArray<string>;
729− readonly modelOrder: ReadonlyArray<string>;
730− },
731− ) => {
732− const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];
733− const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];
734− const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);
735− updateSettings({
736− providerModelPreferences:
737− hiddenModels.length === 0 && modelOrder.length === 0
738− ? rest
739− : {
740− ...rest,
741− [instanceId]: {
742− hiddenModels,
743− modelOrder,
744− },
745− },
746− });
747− };
748−
749− const updateProviderFavoriteModels = (
750− instanceId: ProviderInstanceId,
751− nextFavoriteModels: ReadonlyArray<string>,
752− ) => {
753− const favoriteModels = [
754− ...new Set(nextFavoriteModels.map((slug) => slug.trim()).filter((slug) => slug.length > 0)),
755− ];
756− updateSettings({
757− favorites: [
758− ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),
759− ...favoriteModels.map((model) => ({ provider: instanceId, model })),
760− ],
761− });
762− };
763−
764− /**
765− * Reset a built-in default slot back to factory defaults. Clears both
766− * the legacy `settings.providers[kind]` struct and any explicit
767− * `providerInstances[defaultId]` entry that has promoted legacy into
768− * the new map, so hydration re-synthesizes a clean envelope on next
769− * load. Safe to call on drivers that have never been edited.
770− */
771− const resetDefaultInstance = (driverKind: ProviderDriverKind) => {
772− type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
773− const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
774− string,
775− LegacyProviderSettings | undefined
776− >;
777− const defaultInstanceId = defaultInstanceIdForDriver(driverKind);
778− const defaultLegacyProvider = defaultLegacyProviders[driverKind];
779− if (defaultLegacyProvider === undefined) return;
780− updateSettings({
781− providers: {
782− ...settings.providers,
783− [driverKind]: defaultLegacyProvider,
784− } as typeof settings.providers,
785− providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId),
786− providerModelPreferences: withoutProviderInstanceKey(
787− settings.providerModelPreferences,
788− defaultInstanceId,
789− ),
790− favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId),
791− });
792− };
793−
794537 return (
795538 <SettingsPageContainer>
796539 <SettingsSection title="General">
@@ -1168,58 +911,406 @@ export function GeneralSettingsPanel() { · 371 unmodified lines
1168911 />
1169912 </SettingsSection>
1170913
1171− <SettingsSection
1172− title="Providers"
1173− headerAction={
1174− <div className="flex items-center gap-1.5">
1175− <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
1176− <Tooltip>
1177− <TooltipTrigger
1178− render={
1179− <Button
1180− size="icon-xs"
1181− variant="ghost"
1182− className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
1183− onClick={() => setIsAddInstanceDialogOpen(true)}
1184− aria-label="Add provider instance"
1185− >
1186− <PlusIcon className="size-3" />
1187− </Button>
1188− }
1189− />
1190− <TooltipPopup side="top">Add provider instance</TooltipPopup>
1191− </Tooltip>
1192− <Tooltip>
1193− <TooltipTrigger
1194− render={
1195− <Button
1196− size="icon-xs"
1197− variant="ghost"
1198− className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
1199− disabled={isRefreshingProviders}
1200− onClick={() => void refreshProviders()}
1201− aria-label="Refresh provider status"
1202− >
1203− {isRefreshingProviders ? (
1204− <LoaderIcon className="size-3 animate-spin" />
1205− ) : (
1206− <RefreshCwIcon className="size-3" />
1207− )}
1208− </Button>
1209− }
1210− />
1211− <TooltipPopup side="top">Refresh provider status</TooltipPopup>
1212− </Tooltip>
1213− </div>
1214− }
1215− >
1216− {rows.map((row) => {
1217− const driverOption = getDriverOption(row.driver);
1218− const liveProvider = serverProviders.find(
1219− (candidate) => candidate.instanceId === row.instanceId,
1220− );
1221− const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
1222− hiddenModels: [],
914+ <SettingsSection title="Advanced">
915+ <SettingsRow
916+ title="Keybindings"
917+ description="Open the persisted `keybindings.json` file to edit advanced bindings directly."
918+ status={
919+ <>
920+ <span className="block break-all font-mono text-[11px] text-foreground">
921+ {keybindingsConfigPath ?? "Resolving keybindings path..."}
922+ </span>
923+ {openKeybindingsError ? (
924+ <span className="mt-1 block text-destructive">{openKeybindingsError}</span>
925+ ) : (
926+ <span className="mt-1 block">Opens in your preferred editor.</span>
927+ )}
928+ </>
929+ }
930+ control={
931+ <Button
932+ size="xs"
933+ variant="outline"
934+ disabled={!keybindingsConfigPath || isOpeningKeybindings}
935+ onClick={openKeybindingsFile}
936+ >
937+ {isOpeningKeybindings ? "Opening..." : "Open file"}
938+ </Button>
939+ }
940+ />
941+ </SettingsSection>
942+
943+ <SettingsSection title="About">
944+ {isElectron ? (
945+ <AboutVersionSection />
946+ ) : (
947+ <SettingsRow
948+ title={<AboutVersionTitle />}
949+ description="Current version of the application."
950+ />
951+ )}
952+ <SettingsRow
953+ title="Diagnostics"
954+ description={diagnosticsDescription}
955+ status={
956+ <>
957+ <span className="block break-all font-mono text-[11px] text-foreground">
958+ {logsDirectoryPath ?? "Resolving logs directory..."}
959+ </span>
960+ {openDiagnosticsError ? (
961+ <span className="mt-1 block text-destructive">{openDiagnosticsError}</span>
962+ ) : null}
963+ </>
964+ }
965+ control={
966+ <Button
967+ size="xs"
968+ variant="outline"
969+ disabled={!logsDirectoryPath || isOpeningLogsDirectory}
970+ onClick={openLogsDirectory}
971+ >
972+ {isOpeningLogsDirectory ? "Opening..." : "Open logs folder"}
973+ </Button>
974+ }
975+ />
976+ </SettingsSection>
977+ </SettingsPageContainer>
978+ );
979+}
980+
981+export function ProviderSettingsPanel() {
982+ const settings = useSettings();
983+ const { updateSettings } = useUpdateSettings();
984+ const serverProviders = useServerProviders();
985+ const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
986+ const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
987+ const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
988+ ReadonlySet<ProviderDriverKind>
989+ >(() => new Set());
990+ const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({});
991+ const refreshingRef = useRef(false);
992+
993+ const providerUpdateCandidates = useMemo(
994+ () => collectProviderUpdateCandidates(serverProviders),
995+ [serverProviders],
996+ );
997+ const providerUpdateCandidateByInstanceId = useMemo(
998+ () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])),
999+ [providerUpdateCandidates],
1000+ );
1001+ const visibleProviderSettings = PROVIDER_SETTINGS.filter(
1002+ (providerSettings) =>
1003+ providerSettings.provider !== "cursor" ||
1004+ serverProviders.some(
1005+ (provider) =>
1006+ provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")),
1007+ ),
1008+ );
1009+ const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
1010+ const textGenInstanceId = textGenerationModelSelection.instanceId;
1011+ const lastCheckedAt =
1012+ serverProviders.length > 0
1013+ ? serverProviders.reduce(
1014+ (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest),
1015+ serverProviders[0]!.checkedAt,
1016+ )
1017+ : null;
1018+
1019+ const refreshProviders = useCallback(() => {
1020+ if (refreshingRef.current) return;
1021+ refreshingRef.current = true;
1022+ setIsRefreshingProviders(true);
1023+ void ensureLocalApi()
1024+ .server.refreshProviders()
1025+ .catch((error: unknown) => {
1026+ console.warn("Failed to refresh providers", error);
1027+ })
1028+ .finally(() => {
1029+ refreshingRef.current = false;
1030+ setIsRefreshingProviders(false);
1031+ });
1032+ }, []);
1033+
1034+ const runProviderUpdate = useCallback(async (candidate: ProviderUpdateCandidate) => {
1035+ let started = false;
1036+ setUpdatingProviderDrivers((previous) => {
1037+ if (previous.has(candidate.driver)) {
1038+ return previous;
1039+ }
1040+ started = true;
1041+ const next = new Set(previous);
1042+ next.add(candidate.driver);
1043+ return next;
1044+ });
1045+ if (!started) {
1046+ return;
1047+ }
1048+
1049+ try {
1050+ await ensureLocalApi().server.updateProvider({
1051+ provider: candidate.driver,
1052+ instanceId: candidate.instanceId,
1053+ });
1054+ } catch (error) {
1055+ toastManager.add(
1056+ stackedThreadToast({
1057+ type: "error",
1058+ title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`,
1059+ description:
1060+ error instanceof Error
1061+ ? error.message
1062+ : "The provider update command could not be started.",
1063+ }),
1064+ );
1065+ } finally {
1066+ setUpdatingProviderDrivers((previous) => {
1067+ if (!previous.has(candidate.driver)) {
1068+ return previous;
1069+ }
1070+ const next = new Set(previous);
1071+ next.delete(candidate.driver);
1072+ return next;
1073+ });
1074+ }
1075+ }, []);
1076+
1077+ interface InstanceRow {
1078+ readonly instanceId: ProviderInstanceId;
1079+ readonly instance: ProviderInstanceConfig;
1080+ readonly driver: ProviderDriverKind;
1081+ readonly isDefault: boolean;
1082+ readonly isDirty?: boolean;
1083+ }
1084+
1085+ const instancesByDriver = new Map<
1086+ ProviderDriverKind,
1087+ Array<[ProviderInstanceId, ProviderInstanceConfig]>
1088+ >();
1089+ for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) {
1090+ const driver = instance.driver;
1091+ const list = instancesByDriver.get(driver) ?? [];
1092+ list.push([rawId as ProviderInstanceId, instance]);
1093+ instancesByDriver.set(driver, list);
1094+ }
1095+
1096+ const defaultSlotIdsBySource = new Set<string>(
1097+ visibleProviderSettings.map((providerSettings) =>
1098+ String(defaultInstanceIdForDriver(providerSettings.provider)),
1099+ ),
1100+ );
1101+
1102+ const rows: InstanceRow[] = [];
1103+ const visibleDriverKinds = new Set<ProviderDriverKind>(
1104+ visibleProviderSettings.map((providerSettings) => providerSettings.provider),
1105+ );
1106+
1107+ for (const providerSettings of visibleProviderSettings) {
1108+ type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
1109+ const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>;
1110+ const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
1111+ string,
1112+ LegacyProviderSettings
1113+ >;
1114+ const driver = providerSettings.provider;
1115+ const defaultInstanceId = defaultInstanceIdForDriver(driver);
1116+ const explicitInstance = settings.providerInstances?.[defaultInstanceId];
1117+ const legacyConfig = legacyProviders[providerSettings.provider]!;
1118+ const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!;
1119+ const effectiveInstance: ProviderInstanceConfig =
1120+ explicitInstance ??
1121+ ({
1122+ driver,
1123+ enabled: legacyConfig.enabled,
1124+ config: legacyConfig,
1125+ } satisfies ProviderInstanceConfig);
1126+ const isDirty =
1127+ explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig);
1128+ rows.push({
1129+ instanceId: defaultInstanceId,
1130+ instance: effectiveInstance,
1131+ driver,
1132+ isDefault: true,
1133+ isDirty,
1134+ });
1135+ for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) {
1136+ if (id === defaultInstanceId) continue;
1137+ rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false });
1138+ }
1139+ }
1140+ for (const [driver, list] of instancesByDriver) {
1141+ if (visibleDriverKinds.has(driver)) continue;
1142+ for (const [id, instance] of list) {
1143+ rows.push({
1144+ instanceId: id,
1145+ instance,
1146+ driver: instance.driver,
1147+ isDefault: defaultSlotIdsBySource.has(String(id)),
1148+ });
1149+ }
1150+ }
1151+
1152+ const updateProviderInstance = (
1153+ row: InstanceRow,
1154+ next: ProviderInstanceConfig,
1155+ options?: {
1156+ readonly textGenerationModelSelection?: Parameters<
1157+ typeof buildProviderInstanceUpdatePatch
1158+ >[0]["textGenerationModelSelection"];
1159+ },
1160+ ) => {
1161+ updateSettings(
1162+ buildProviderInstanceUpdatePatch({
1163+ settings,
1164+ instanceId: row.instanceId,
1165+ instance: next,
1166+ driver: row.driver,
1167+ isDefault: row.isDefault,
1168+ textGenerationModelSelection: options?.textGenerationModelSelection,
1169+ }),
1170+ );
1171+ };
1172+
1173+ const deleteProviderInstance = (id: ProviderInstanceId) => {
1174+ updateSettings({
1175+ providerInstances: withoutProviderInstanceKey(settings.providerInstances, id),
1176+ providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id),
1177+ favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id),
1178+ });
1179+ };
1180+
1181+ const updateProviderModelPreferences = (
1182+ instanceId: ProviderInstanceId,
1183+ next: {
1184+ readonly hiddenModels: ReadonlyArray<string>;
1185+ readonly modelOrder: ReadonlyArray<string>;
1186+ },
1187+ ) => {
1188+ const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];
1189+ const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];
1190+ const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);
1191+ updateSettings({
1192+ providerModelPreferences:
1193+ hiddenModels.length === 0 && modelOrder.length === 0
1194+ ? rest
1195+ : {
1196+ ...rest,
1197+ [instanceId]: {
1198+ hiddenModels,
1199+ modelOrder,
1200+ },
1201+ },
1202+ });
1203+ };
1204+
1205+ const updateProviderFavoriteModels = (
1206+ instanceId: ProviderInstanceId,
1207+ nextFavoriteModels: ReadonlyArray<string>,
1208+ ) => {
1209+ const favoriteModels = [
1210+ ...new Set(nextFavoriteModels.map((slug) => slug.trim()).filter((slug) => slug.length > 0)),
1211+ ];
1212+ updateSettings({
1213+ favorites: [
1214+ ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),
1215+ ...favoriteModels.map((model) => ({ provider: instanceId, model })),
1216+ ],
1217+ });
1218+ };
1219+
1220+ const resetDefaultInstance = (driverKind: ProviderDriverKind) => {
1221+ type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
1222+ const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
1223+ string,
1224+ LegacyProviderSettings | undefined
1225+ >;
1226+ const defaultInstanceId = defaultInstanceIdForDriver(driverKind);
1227+ const defaultLegacyProvider = defaultLegacyProviders[driverKind];
1228+ if (defaultLegacyProvider === undefined) return;
1229+ updateSettings({
1230+ providers: {
1231+ ...settings.providers,
1232+ [driverKind]: defaultLegacyProvider,
1233+ } as typeof settings.providers,
1234+ providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId),
1235+ providerModelPreferences: withoutProviderInstanceKey(
1236+ settings.providerModelPreferences,
1237+ defaultInstanceId,
1238+ ),
1239+ favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId),
1240+ });
1241+ };
1242+
1243+ return (
1244+ <SettingsPageContainer>
1245+ <SettingsSection
1246+ title="Providers"
1247+ headerAction={
1248+ <div className="flex items-center gap-1.5">
1249+ <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
1250+ <Tooltip>
1251+ <TooltipTrigger
1252+ render={
1253+ <Button
1254+ size="icon-xs"
1255+ variant="ghost"
1256+ className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
1257+ onClick={() => setIsAddInstanceDialogOpen(true)}
1258+ aria-label="Add provider instance"
1259+ >
1260+ <PlusIcon className="size-3" />
1261+ </Button>
1262+ }
1263+ />
1264+ <TooltipPopup side="top">Add provider instance</TooltipPopup>
1265+ </Tooltip>
1266+ <Tooltip>
1267+ <TooltipTrigger
1268+ render={
1269+ <Button
1270+ size="icon-xs"
1271+ variant="ghost"
1272+ className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
1273+ disabled={isRefreshingProviders}
1274+ onClick={() => void refreshProviders()}
1275+ aria-label="Refresh provider status"
1276+ >
1277+ {isRefreshingProviders ? (
1278+ <LoaderIcon className="size-3 animate-spin" />
1279+ ) : (
1280+ <RefreshCwIcon className="size-3" />
1281+ )}
1282+ </Button>
1283+ }
1284+ />
1285+ <TooltipPopup side="top">Refresh provider status</TooltipPopup>
1286+ </Tooltip>
1287+ </div>
1288+ }
1289+ >
1290+ {rows.map((row) => {
1291+ const driverOption = getDriverOption(row.driver);
1292+ const liveProvider = serverProviders.find(
1293+ (candidate) => candidate.instanceId === row.instanceId,
1294+ );
1295+ const updateCandidate = liveProvider
1296+ ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId)
1297+ : undefined;
1298+ const isDriverUpdateRunning =
1299+ updateCandidate !== undefined &&
1300+ (updatingProviderDrivers.has(updateCandidate.driver) ||
1301+ serverProviders.some(
1302+ (provider) =>
1303+ provider.driver === updateCandidate.driver && isProviderUpdateActive(provider),
1304+ ));
1305+ const showInlineUpdateButton =
1306+ updateCandidate !== undefined &&
1307+ hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders);
1308+ const canRunInlineUpdate =
1309+ updateCandidate !== undefined &&
1310+ canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&
1311+ !updatingProviderDrivers.has(updateCandidate.driver);
1312+ const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
1313+ hiddenModels: [],
12231314 modelOrder: [],
12241315 };
12251316 const favoriteModels = (settings.favorites ?? [])
@@ -1248,13 +1339,6 @@ export function GeneralSettingsPanel() { · 22 unmodified lines
12481339 }))
12491340 }
12501341 onUpdate={(next) => {
1251− // When the user disables the exact instance the text-gen
1252− // selection points at, fall back to the global default so we
1253− // don't leave the selection dangling on a disabled instance.
1254− // Prior kind-level behavior cleared on any kind-matching
1255− // disable; instance-level addressing makes this narrower and
1256− // more accurate (other instances of the same kind stay
1257− // untouched).
12581342 const wasEnabled = row.instance.enabled ?? true;
12591343 const isDisabling = next.enabled === false && wasEnabled;
12601344 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
@@ -1287,6 +1371,17 @@ export function GeneralSettingsPanel() { · 26 unmodified lines
12871371 modelOrder,
12881372 })
12891373 }
1374+ onRunUpdate={
1375+ showInlineUpdateButton && updateCandidate
1376+ ? () => {
1377+ if (!canRunInlineUpdate) {
1378+ return;
1379+ }
1380+ void runProviderUpdate(updateCandidate);
1381+ }
1382+ : undefined
1383+ }
1384+ isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined}
12901385 />
12911386 );
12921387 })}
@@ -1296,70 +1391,6 @@ export function GeneralSettingsPanel() { · 3 unmodified lines
12961391 open={isAddInstanceDialogOpen}
12971392 onOpenChange={setIsAddInstanceDialogOpen}
12981393 />
1299−
1300− <SettingsSection title="Advanced">
1301− <SettingsRow
1302− title="Keybindings"
1303− description="Open the persisted `keybindings.json` file to edit advanced bindings directly."
1304− status={
1305− <>
1306− <span className="block break-all font-mono text-[11px] text-foreground">
1307− {keybindingsConfigPath ?? "Resolving keybindings path..."}
1308− </span>
1309− {openKeybindingsError ? (
1310− <span className="mt-1 block text-destructive">{openKeybindingsError}</span>
1311− ) : (
1312− <span className="mt-1 block">Opens in your preferred editor.</span>
1313− )}
1314− </>
1315− }
1316− control={
1317− <Button
1318− size="xs"
1319− variant="outline"
1320− disabled={!keybindingsConfigPath || isOpeningKeybindings}
1321− onClick={openKeybindingsFile}
1322− >
1323− {isOpeningKeybindings ? "Opening..." : "Open file"}
1324− </Button>
1325− }
1326− />
1327− </SettingsSection>
1328−
1329− <SettingsSection title="About">
1330− {isElectron ? (
1331− <AboutVersionSection />
1332− ) : (
1333− <SettingsRow
1334− title={<AboutVersionTitle />}
1335− description="Current version of the application."
1336− />
1337− )}
1338− <SettingsRow
1339− title="Diagnostics"
1340− description={diagnosticsDescription}
1341− status={
1342− <>
1343− <span className="block break-all font-mono text-[11px] text-foreground">
1344− {logsDirectoryPath ?? "Resolving logs directory..."}
1345− </span>
1346− {openDiagnosticsError ? (
1347− <span className="mt-1 block text-destructive">{openDiagnosticsError}</span>
1348− ) : null}
1349− </>
1350− }
1351− control={
1352− <Button
1353− size="xs"
1354− variant="outline"
1355− disabled={!logsDirectoryPath || isOpeningLogsDirectory}
1356− onClick={openLogsDirectory}
1357− >
1358− {isOpeningLogsDirectory ? "Opening..." : "Open logs folder"}
1359− </Button>
1360− }
1361− />
1362− </SettingsSection>
13631394 </SettingsPageContainer>
13641395 );
13651396 }
apps/web/src/components/settings/SettingsSidebarNav.tsx+10−1
@@ -1,5 +1,12 @@
11 import { useCallback, type ComponentType } from "react";
2−import { ArchiveIcon, ArrowLeftIcon, GitBranchIcon, Link2Icon, Settings2Icon } from "lucide-react";
2+import {
3+ ArchiveIcon,
4+ ArrowLeftIcon,
5+ BotIcon,
6+ GitBranchIcon,
7+ Link2Icon,
8+ Settings2Icon,
9+} from "lucide-react";
310 import { useCanGoBack, useNavigate } from "@tanstack/react-router";
411
512 import {
@@ -15,6 +22,7 @@ import { · 9 unmodified lines
1522
1623 export type SettingsSectionPath =
1724 | "/settings/general"
25+ | "/settings/providers"
1826 | "/settings/source-control"
1927 | "/settings/connections"
2028 | "/settings/archived";
@@ -25,6 +33,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ · 4 unmodified lines
2533 icon: ComponentType<{ className?: string }>;
2634 }> = [
2735 { label: "General", to: "/settings/general", icon: Settings2Icon },
36+ { label: "Providers", to: "/settings/providers", icon: BotIcon },
2837 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
2938 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
3039 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
apps/web/src/components/settings/providerStatus.ts+27−1
@@ -1,4 +1,4 @@
1−import type { ServerProvider } from "@t3tools/contracts";
1+import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts";
22
33 /**
44 * Visual treatment for each server-reported provider status. Centralized so
@@ -89,3 +89,29 @@ export function getProviderVersionLabel(version: string | null | undefined) { · 84 unmodified lines
8989 if (!version) return null;
9090 return version.startsWith("v") ? version : `v${version}`;
9191 }
92+
93+export function getProviderVersionAdvisoryPresentation(
94+ advisory: ServerProviderVersionAdvisory | undefined,
95+): {
96+ readonly detail: string;
97+ readonly updateCommand: string | null;
98+ readonly emphasis: "normal" | "strong";
99+} | null {
100+ if (!advisory || advisory.status === "current" || advisory.status === "unknown") {
101+ return null;
102+ }
103+
104+ const label = "Update available";
105+ const version = advisory.latestVersion;
106+ const versionLabel = getProviderVersionLabel(version);
107+
108+ return {
109+ detail:
110+ advisory.message ??
111+ (versionLabel
112+ ? `${label}: install ${versionLabel}.`
113+ : `${label}: install the latest provider version.`),
114+ updateCommand: advisory.updateCommand,
115+ emphasis: "normal" as const,
116+ };
117+}
apps/web/src/components/settings/settingsLayout.tsx+8−6
@@ -1,5 +1,5 @@
11 import { Undo2Icon } from "lucide-react";
2−import { type ReactNode, useEffect, useState } from "react";
2+import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useState } from "react";
33
44 import { cn } from "../../lib/utils";
55 import { Button } from "../ui/button";
@@ -20,15 +20,17 @@ export function SettingsSection({ · 14 unmodified lines
2020 icon,
2121 headerAction,
2222 children,
23−}: {
23+ className,
24+ ...sectionProps
25+}: ComponentPropsWithoutRef<"section"> & {
2426 title: string;
2527 icon?: ReactNode;
2628 headerAction?: ReactNode;
2729 children: ReactNode;
2830 }) {
2931 return (
30− <section className="space-y-2.5">
31− <div className="flex h-5 items-center justify-between px-1">
32+ <section {...sectionProps} className={cn("space-y-2.5", className)}>
33+ <div className="flex items-center justify-between px-1">
3234 <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50">
3335 <span className="inline-block h-px w-3 bg-border" aria-hidden />
3436 {icon}
@@ -62,7 +64,7 @@ export function SettingsRow({ · 27 unmodified lines
6264 <div
6365 className={cn(
6466 "border-t border-border/60 px-4 first:border-t-0 sm:px-5",
65− children ? "pt-4 pb-0" : "py-4",
67+ children ? "pt-3.5 pb-0" : "py-3.5",
6668 )}
6769 >
6870 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -75,7 +77,7 @@ export function SettingsRow({ · 6 unmodified lines
7577 {resetAction}
7678 </span>
7779 </div>
78− <p className="text-xs leading-relaxed text-muted-foreground/80">{description}</p>
80+ <p className="text-xs text-muted-foreground/80">{description}</p>
7981 {status ? <div className="pt-0.5 text-[11px] text-muted-foreground">{status}</div> : null}
8082 </div>
8183 {control ? (
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx+207−0
@@ -0,0 +1,207 @@
1+import { useNavigate } from "@tanstack/react-router";
2+import type { ServerProvider } from "@t3tools/contracts";
3+import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react";
4+import { useCallback, useEffect, useState, type CSSProperties } from "react";
5+
6+import { useServerProviders } from "../../rpc/serverState";
7+import {
8+ getProviderUpdateSidebarPillView,
9+ type ProviderUpdateSidebarPillView,
10+} from "../ProviderUpdateLaunchNotification.logic";
11+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12+
13+const PROVIDER_UPDATE_PILL_STYLES = {
14+ loading:
15+ "bg-primary/15 text-primary group-has-[button.provider-update-main:hover]/provider-update:bg-primary/22",
16+ success:
17+ "bg-success/12 text-success group-has-[button.provider-update-main:hover]/provider-update:bg-success/18",
18+ warning:
19+ "bg-warning/12 text-warning group-has-[button.provider-update-main:hover]/provider-update:bg-warning/18",
20+ error:
21+ "bg-destructive/12 text-destructive group-has-[button.provider-update-main:hover]/provider-update:bg-destructive/18",
22+} as const;
23+
24+const PROVIDER_UPDATE_PILL_PROGRESS_STYLES = {
25+ success: "bg-success/18",
26+ warning: "bg-warning/14",
27+ error: "bg-destructive/14",
28+} as const;
29+
30+function latestProviderCheckedAt(
31+ providers: ReadonlyArray<Pick<ServerProvider, "checkedAt">>,
32+): string | undefined {
33+ return providers.reduce<string | undefined>(
34+ (latest, provider) =>
35+ latest === undefined || provider.checkedAt > latest ? provider.checkedAt : latest,
36+ undefined,
37+ );
38+}
39+
40+export function SidebarProviderUpdatePill() {
41+ const navigate = useNavigate();
42+ const providers = useServerProviders();
43+ const [dismissedKeys, setDismissedKeys] = useState<ReadonlySet<string>>(() => new Set());
44+ const [renderedView, setRenderedView] = useState<ProviderUpdateSidebarPillView | null>(null);
45+ const [pendingView, setPendingView] = useState<ProviderUpdateSidebarPillView | null>(null);
46+ const [exitingKey, setExitingKey] = useState<string | null>(null);
47+ const [dismissAfterExitKey, setDismissAfterExitKey] = useState<string | null>(null);
48+ const [visibleAfterIso, setVisibleAfterIso] = useState<string | undefined>();
49+ const effectiveVisibleAfterIso = visibleAfterIso ?? latestProviderCheckedAt(providers);
50+ const view = getProviderUpdateSidebarPillView(providers, {
51+ ...(effectiveVisibleAfterIso !== undefined
52+ ? { visibleAfterIso: effectiveVisibleAfterIso }
53+ : {}),
54+ dismissedKeys,
55+ });
56+
57+ useEffect(() => {
58+ if (visibleAfterIso === undefined && effectiveVisibleAfterIso !== undefined) {
59+ setVisibleAfterIso(effectiveVisibleAfterIso);
60+ }
61+ }, [effectiveVisibleAfterIso, visibleAfterIso]);
62+
63+ const openProviderSettings = useCallback(() => {
64+ void navigate({ to: "/settings/providers" });
65+ }, [navigate]);
66+ const displayedView = renderedView ?? view;
67+ const dismissAfterVisibleMs = displayedView?.dismissAfterVisibleMs;
68+ const viewKey = displayedView?.key ?? null;
69+ const showDismissProgress =
70+ dismissAfterVisibleMs !== undefined &&
71+ displayedView?.tone !== "loading" &&
72+ exitingKey !== viewKey;
73+
74+ const startExit = useCallback(
75+ (key: string, nextView: ProviderUpdateSidebarPillView | null, dismissKey?: string) => {
76+ if (exitingKey === key) {
77+ return;
78+ }
79+ setPendingView(nextView);
80+ setExitingKey(key);
81+ setDismissAfterExitKey(dismissKey ?? null);
82+ },
83+ [exitingKey],
84+ );
85+
86+ useEffect(() => {
87+ if (exitingKey !== null) {
88+ return;
89+ }
90+ if (!renderedView) {
91+ if (view) {
92+ setRenderedView(view);
93+ }
94+ return;
95+ }
96+ if (!view) {
97+ startExit(renderedView.key, null);
98+ return;
99+ }
100+ if (view.key !== renderedView.key) {
101+ startExit(renderedView.key, view);
102+ return;
103+ }
104+ }, [exitingKey, renderedView, startExit, view]);
105+
106+ useEffect(() => {
107+ if (!dismissAfterVisibleMs || !viewKey) {
108+ return;
109+ }
110+ if (exitingKey === viewKey) {
111+ return;
112+ }
113+ const timeoutId = window.setTimeout(() => {
114+ startExit(viewKey, null, viewKey);
115+ }, dismissAfterVisibleMs);
116+
117+ return () => window.clearTimeout(timeoutId);
118+ }, [dismissAfterVisibleMs, exitingKey, startExit, viewKey]);
119+
120+ if (!displayedView) {
121+ return null;
122+ }
123+
124+ return (
125+ <div
126+ className={`group/provider-update relative flex h-7 w-full items-center overflow-hidden rounded-lg text-xs font-medium transform-gpu transition-all duration-180 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform ${
127+ PROVIDER_UPDATE_PILL_STYLES[displayedView.tone]
128+ } ${
129+ exitingKey === displayedView.key
130+ ? "pointer-events-none translate-y-1.5 opacity-0"
131+ : "translate-y-0 opacity-100"
132+ }`}
133+ onTransitionEnd={(event) => {
134+ if (event.target !== event.currentTarget) {
135+ return;
136+ }
137+ if (!displayedView || exitingKey !== displayedView.key) {
138+ return;
139+ }
140+ if (dismissAfterExitKey === displayedView.key) {
141+ setDismissedKeys((previous) => new Set(previous).add(displayedView.key));
142+ }
143+ setRenderedView(pendingView);
144+ setPendingView(null);
145+ setExitingKey(null);
146+ setDismissAfterExitKey(null);
147+ }}
148+ >
149+ {showDismissProgress ? (
150+ <div
151+ key={displayedView.key}
152+ aria-hidden="true"
153+ className={`provider-update-pill-progress pointer-events-none absolute inset-y-0 left-0 w-full origin-left border-r border-current/15 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.08)] ${
154+ PROVIDER_UPDATE_PILL_PROGRESS_STYLES[displayedView.tone]
155+ }`}
156+ style={
157+ {
158+ "--provider-update-pill-dismiss-ms": `${dismissAfterVisibleMs}ms`,
159+ } as CSSProperties
160+ }
161+ />
162+ ) : null}
163+ <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors" />
164+ <Tooltip>
165+ <TooltipTrigger
166+ render={
167+ <button
168+ type="button"
169+ aria-label={displayedView.description}
170+ className="provider-update-main relative z-[1] flex h-full flex-1 items-center gap-2 px-2 text-left"
171+ onClick={openProviderSettings}
172+ >
173+ {displayedView.tone === "loading" ? (
174+ <LoaderIcon className="size-3.5 animate-spin" />
175+ ) : displayedView.tone === "success" ? (
176+ <CircleCheckIcon className="size-3.5" />
177+ ) : displayedView.tone === "error" ? (
178+ <TriangleAlertIcon className="size-3.5" />
179+ ) : (
180+ <DownloadIcon className="size-3.5" />
181+ )}
182+ <span>{displayedView.title}</span>
183+ </button>
184+ }
185+ />
186+ <TooltipPopup side="top">{displayedView.description}</TooltipPopup>
187+ </Tooltip>
188+ {displayedView.dismissible && (
189+ <Tooltip>
190+ <TooltipTrigger
191+ render={
192+ <button
193+ type="button"
194+ aria-label="Dismiss provider update notice"
195+ className="relative z-[1] mr-1 inline-flex size-5 items-center justify-center rounded-md opacity-70 transition-opacity hover:opacity-100"
196+ onClick={() => startExit(displayedView.key, null, displayedView.key)}
197+ >
198+ <XIcon className="size-3.5" />
199+ </button>
200+ }
201+ />
202+ <TooltipPopup side="top">Dismiss until provider status changes</TooltipPopup>
203+ </Tooltip>
204+ )}
205+ </div>
206+ );
207+}
apps/web/src/components/ui/toast.tsx+63−5
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 useMemo,
77 useState,
88 type CSSProperties,
9+ type ComponentPropsWithoutRef,
910 type KeyboardEvent,
1011 type ReactNode,
1112 } from "react";
@@ -38,9 +39,20 @@ import { · 26 unmodified lines
3839 export type ThreadToastData = {
3940 threadRef?: ScopedThreadRef | null;
4041 threadId?: ThreadId | null;
42+ leadingIcon?: ReactNode;
4143 tooltipStyle?: boolean;
44+ onClose?: (() => void) | undefined;
4245 dismissAfterVisibleMs?: number;
4346 hideCopyButton?: boolean;
47+ secondaryActionProps?: ComponentPropsWithoutRef<"button">;
48+ secondaryActionVariant?:
49+ | "default"
50+ | "destructive"
51+ | "destructive-outline"
52+ | "ghost"
53+ | "link"
54+ | "outline"
55+ | "secondary";
4456 /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */
4557 expandableContent?: ReactNode;
4658 expandableLabels?: { expand?: string; collapse?: string };
@@ -90,6 +102,15 @@ const toastCornerOrbClass = cn( · 43 unmodified lines
90102 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
91103 );
92104
105+function handleToastDismissClick(
106+ manager: typeof toastManager | typeof anchoredToastManager,
107+ toastId: ToastId,
108+ onClose: (() => void) | undefined,
109+) {
110+ onClose?.();
111+ manager.close(toastId);
112+}
113+
93114 function CopyErrorButton({ text }: { text: string }) {
94115 const { copyToClipboard, isCopied } = useCopyToClipboard();
95116
@@ -232,6 +253,7 @@ interface ToastBodyDescriptor { · 136 unmodified lines
232253 readonly Icon: ToastIconComponent | null | undefined;
233254 readonly stackedActionLayout: boolean;
234255 readonly actionVariant: NonNullable<ThreadToastData["actionVariant"]>;
256+ readonly secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]>;
235257 readonly copyErrorText: string | null;
236258 readonly hasTrailingControls: boolean;
237259 readonly inlineContentEndPad: string;
@@ -248,16 +270,21 @@ function deriveToastBodyDescriptor(toast: { · 10 unmodified lines
248270 toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end";
249271 const actionVariant: NonNullable<ThreadToastData["actionVariant"]> =
250272 toast.data?.actionVariant ?? "default";
273+ const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> =
274+ toast.data?.secondaryActionVariant ?? "outline";
251275 const copyErrorText =
252276 toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton
253277 ? toast.description
254278 : null;
255− const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined;
279+ const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined;
280+ const hasTrailingControls =
281+ copyErrorText !== null || toast.actionProps !== undefined || hasSecondaryAction;
256282 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";
257283 return {
258284 Icon,
259285 stackedActionLayout,
260286 actionVariant,
287+ secondaryActionVariant,
261288 copyErrorText,
262289 hasTrailingControls,
263290 inlineContentEndPad,
@@ -277,22 +304,35 @@ function ToastBodyContent({ · 13 unmodified lines
277304 copyErrorText,
278305 actionProps,
279306 actionVariant,
307+ secondaryActionVariant,
280308 hasTrailingControls,
281309 toastData,
282310 toastDescription,
283311 toastType,
284312 }: ToastBodyContentProps) {
313+ const secondaryActionProps = toastData?.secondaryActionProps;
314+ const leadingIcon = toastData?.leadingIcon;
315+ const { className: secondaryActionClassName, ...secondaryActionRest } =
316+ secondaryActionProps ?? {};
317+
285318 return (
286319 <>
287320 <div className={cn("flex min-w-0 gap-2", !stackedActionLayout && "flex-1")}>
288− {Icon && (
321+ {leadingIcon ? (
322+ <div
323+ className="flex h-lh w-4 shrink-0 items-center justify-center"
324+ data-slot="toast-icon"
325+ >
326+ {leadingIcon}
327+ </div>
328+ ) : Icon ? (
289329 <div
290330 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
291331 data-slot="toast-icon"
292332 >
293333 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
294334 </div>
295− )}
335+ ) : null}
296336 <div
297337 className={cn(
298338 "flex min-h-0 min-w-0 flex-1 flex-col gap-0.5",
@@ -315,6 +355,16 @@ function ToastBodyContent({ · 16 unmodified lines
315355 )}
316356 >
317357 {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null}
358+ {secondaryActionProps ? (
359+ <button
360+ {...secondaryActionRest}
361+ className={cn(
362+ buttonVariants({ size: "xs", variant: secondaryActionVariant }),
363+ secondaryActionClassName,
364+ )}
365+ type="button"
366+ />
367+ ) : null}
318368 {actionProps ? (
319369 <Toast.Action
320370 className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")}
@@ -579,7 +629,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 258 unmodified lines
579629 aria-label="Dismiss notification"
580630 className={toastCornerOrbClass}
581631 data-slot="toast-close"
582− onClick={() => toastManager.close(toast.id)}
632+ onClick={() =>
633+ handleToastDismissClick(toastManager, toast.id, toast.data?.onClose)
634+ }
583635 type="button"
584636 >
585637 <XIcon className="size-3" strokeWidth={2.25} />
@@ -670,7 +722,13 @@ function AnchoredToasts() { · 84 unmodified lines
670722 aria-label="Dismiss notification"
671723 className={toastCornerOrbClass}
672724 data-slot="toast-close"
673− onClick={() => anchoredToastManager.close(toast.id)}
725+ onClick={() =>
726+ handleToastDismissClick(
727+ anchoredToastManager,
728+ toast.id,
729+ toast.data?.onClose,
730+ )
731+ }
674732 type="button"
675733 >
676734 <XIcon className="size-3" strokeWidth={2.25} />
apps/web/src/hooks/useSettings.ts+37−1
@@ -26,6 +26,7 @@ import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/ · 25 unmodified lines
2626 const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
2727
2828 const clientSettingsListeners = new Set<() => void>();
29+const clientSettingsHydrationListeners = new Set<() => void>();
2930 let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
3031 let clientSettingsHydrated = false;
3132 let clientSettingsHydrationPromise: Promise<void> | null = null;
@@ -36,6 +37,12 @@ function emitClientSettingsChange() { · 4 unmodified lines
3637 }
3738 }
3839
40+function emitClientSettingsHydrationChange() {
41+ for (const listener of clientSettingsHydrationListeners) {
42+ listener();
43+ }
44+}
45+
3946 function getClientSettingsSnapshot(): ClientSettings {
4047 return clientSettingsSnapshot;
4148 }
@@ -45,6 +52,14 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { · 3 unmodified lines
4552 emitClientSettingsChange();
4653 }
4754
55+function setClientSettingsHydrated(nextHydrated: boolean): void {
56+ if (clientSettingsHydrated === nextHydrated) {
57+ return;
58+ }
59+ clientSettingsHydrated = nextHydrated;
60+ emitClientSettingsHydrationChange();
61+}
62+
4863 function subscribeClientSettings(listener: () => void): () => void {
4964 clientSettingsListeners.add(listener);
5065 void hydrateClientSettings();
@@ -53,6 +68,18 @@ function subscribeClientSettings(listener: () => void): () => void { · 2 unmodified lines
5368 };
5469 }
5570
71+function getClientSettingsHydratedSnapshot(): boolean {
72+ return clientSettingsHydrated;
73+}
74+
75+function subscribeClientSettingsHydration(listener: () => void): () => void {
76+ clientSettingsHydrationListeners.add(listener);
77+ void hydrateClientSettings();
78+ return () => {
79+ clientSettingsHydrationListeners.delete(listener);
80+ };
81+}
82+
5683 async function hydrateClientSettings(): Promise<void> {
5784 if (clientSettingsHydrated) {
5885 return;
@@ -70,7 +97,7 @@ async function hydrateClientSettings(): Promise<void> { · 11 unmodified lines
7097 } catch (error) {
7198 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
7299 } finally {
73− clientSettingsHydrated = true;
100+ setClientSettingsHydrated(true);
74101 }
75102 })();
76103
@@ -132,6 +159,14 @@ export function getClientSettings(): ClientSettings { · 55 unmodified lines
132159 return getClientSettingsSnapshot();
133160 }
134161
162+export function useClientSettingsHydrated(): boolean {
163+ return useSyncExternalStore(
164+ subscribeClientSettingsHydration,
165+ getClientSettingsHydratedSnapshot,
166+ () => false,
167+ );
168+}
169+
135170 export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
136171 const serverSettings = useServerSettings();
137172 const clientSettings = useSyncExternalStore(
@@ -193,4 +228,5 @@ export function __resetClientSettingsPersistenceForTests(): void { · 55 unmodified lines
193228 clientSettingsHydrated = false;
194229 clientSettingsHydrationPromise = null;
195230 clientSettingsListeners.clear();
231+ clientSettingsHydrationListeners.clear();
196232 }
apps/web/src/index.css+13−0
@@ -485,6 +485,19 @@ label:has(> select#reasoning-effort) select { · 484 unmodified lines
485485 text-align: left;
486486 }
487487
488+@keyframes provider-update-pill-countdown {
489+ from {
490+ transform: scaleX(1);
491+ }
492+ to {
493+ transform: scaleX(0);
494+ }
495+}
496+
497+.provider-update-pill-progress {
498+ animation: provider-update-pill-countdown var(--provider-update-pill-dismiss-ms) linear forwards;
499+}
500+
488501 /* Diffs theme bridge (match diff surfaces to app palette) */
489502 .diff-panel-viewport {
490503 background: color-mix(in srgb, var(--background) 94%, var(--card));
apps/web/src/localApi.test.ts+31−0
@@ -85,6 +85,7 @@ const rpcClientMock = { · 84 unmodified lines
8585 server: {
8686 getConfig: vi.fn(),
8787 refreshProviders: vi.fn(),
88+ updateProvider: vi.fn(),
8889 upsertKeybinding: vi.fn(),
8990 getSettings: vi.fn(),
9091 updateSettings: vi.fn(),
@@ -514,6 +515,34 @@ describe("wsApi", () => { · 423 unmodified lines
514515 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
515516 });
516517
518+ it("forwards provider updates directly to the RPC client", async () => {
519+ const nextProviders: ReadonlyArray<ServerProvider> = [
520+ {
521+ ...defaultProviders[0]!,
522+ updateState: {
523+ status: "succeeded",
524+ startedAt: "2026-01-03T00:00:00.000Z",
525+ finishedAt: "2026-01-03T00:00:01.000Z",
526+ message: "Provider updated.",
527+ output: null,
528+ },
529+ },
530+ ];
531+ rpcClientMock.server.updateProvider.mockResolvedValue({ providers: nextProviders });
532+ const { createLocalApi } = await import("./localApi");
533+
534+ const api = createLocalApi(rpcClientMock as never);
535+
536+ await expect(
537+ api.server.updateProvider({ provider: ProviderDriverKind.make("codex") }),
538+ ).resolves.toEqual({
539+ providers: nextProviders,
540+ });
541+ expect(rpcClientMock.server.updateProvider).toHaveBeenCalledWith({
542+ provider: ProviderDriverKind.make("codex"),
543+ });
544+ });
545+
517546 it("forwards server settings updates directly to the RPC client", async () => {
518547 const nextSettings = {
519548 ...DEFAULT_SERVER_SETTINGS,
@@ -573,6 +602,7 @@ describe("wsApi", () => { · 53 unmodified lines
573602 autoOpenPlanSidebar: false,
574603 confirmThreadArchive: true,
575604 confirmThreadDelete: false,
605+ dismissedProviderUpdateNotificationKeys: [],
576606 diffIgnoreWhitespace: true,
577607 diffWordWrap: true,
578608 favorites: [],
@@ -634,6 +664,7 @@ describe("wsApi", () => { · 55 unmodified lines
634664 autoOpenPlanSidebar: false,
635665 confirmThreadArchive: true,
636666 confirmThreadDelete: false,
667+ dismissedProviderUpdateNotificationKeys: [],
637668 diffIgnoreWhitespace: true,
638669 diffWordWrap: true,
639670 favorites: [],
apps/web/src/localApi.ts+4−0
@@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { · 124 unmodified lines
125125 rpcClient
126126 ? rpcClient.server.refreshProviders()
127127 : Promise.reject(unavailableLocalBackendError()),
128+ updateProvider: (input) =>
129+ rpcClient
130+ ? rpcClient.server.updateProvider(input)
131+ : Promise.reject(unavailableLocalBackendError()),
128132 upsertKeybinding: (input) =>
129133 rpcClient
130134 ? rpcClient.server.upsertKeybinding(input)
apps/web/src/providerUpdateDismissal.test.ts+23−0
@@ -0,0 +1,23 @@
1+import { beforeEach, describe, expect, it } from "vitest";
2+
3+import { removeLocalStorageItem } from "./hooks/useLocalStorage";
4+import {
5+ dismissProviderUpdateNotification,
6+ isProviderUpdateNotificationDismissed,
7+ PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY,
8+} from "./providerUpdateDismissal";
9+
10+describe("providerUpdateDismissal", () => {
11+ beforeEach(() => {
12+ removeLocalStorageItem(PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY);
13+ });
14+
15+ it("persists provider update dismissals by notification key", () => {
16+ expect(isProviderUpdateNotificationDismissed("opencode:1.14.33")).toBe(false);
17+
18+ dismissProviderUpdateNotification("opencode:1.14.33");
19+
20+ expect(isProviderUpdateNotificationDismissed("opencode:1.14.33")).toBe(true);
21+ expect(isProviderUpdateNotificationDismissed("opencode:1.14.34")).toBe(false);
22+ });
23+});
apps/web/src/providerUpdateDismissal.ts+92−0
@@ -0,0 +1,92 @@
1+import { useCallback, useMemo } from "react";
2+import * as Schema from "effect/Schema";
3+
4+import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage";
5+
6+export const PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY = "t3code:provider-update-dismissals:v1";
7+
8+const ProviderUpdateDismissalsSchema = Schema.Struct({
9+ keys: Schema.Array(Schema.String),
10+});
11+
12+type ProviderUpdateDismissals = typeof ProviderUpdateDismissalsSchema.Type;
13+
14+function readProviderUpdateDismissals(): ProviderUpdateDismissals {
15+ try {
16+ return (
17+ getLocalStorageItem(
18+ PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY,
19+ ProviderUpdateDismissalsSchema,
20+ ) ?? {
21+ keys: [],
22+ }
23+ );
24+ } catch {
25+ return { keys: [] };
26+ }
27+}
28+
29+function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void {
30+ try {
31+ setLocalStorageItem(
32+ PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY,
33+ document,
34+ ProviderUpdateDismissalsSchema,
35+ );
36+ } catch {
37+ // Dismissal state is best-effort UI state; a storage failure should not block the toast.
38+ }
39+}
40+
41+export function isProviderUpdateNotificationDismissed(
42+ dismissalKey: string | null | undefined,
43+): boolean {
44+ if (!dismissalKey) {
45+ return false;
46+ }
47+ return readProviderUpdateDismissals().keys.includes(dismissalKey);
48+}
49+
50+export function dismissProviderUpdateNotification(dismissalKey: string | null | undefined): void {
51+ const trimmedKey = dismissalKey?.trim();
52+ if (!trimmedKey) {
53+ return;
54+ }
55+ const document = readProviderUpdateDismissals();
56+ if (document.keys.includes(trimmedKey)) {
57+ return;
58+ }
59+ writeProviderUpdateDismissals({
60+ keys: [...document.keys, trimmedKey],
61+ });
62+}
63+
64+export function useDismissedProviderUpdateNotificationKeys() {
65+ const [dismissals, setDismissals] = useLocalStorage(
66+ PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY,
67+ { keys: [] },
68+ ProviderUpdateDismissalsSchema,
69+ );
70+ const dismissedKeys = dismissals.keys;
71+
72+ const dismissedKeySet = useMemo(() => new Set(dismissedKeys), [dismissedKeys]);
73+
74+ const dismissNotificationKey = useCallback(
75+ (key: string) => {
76+ const trimmedKey = key.trim();
77+ if (trimmedKey.length === 0 || dismissedKeySet.has(trimmedKey)) {
78+ return;
79+ }
80+
81+ setDismissals({
82+ keys: [...dismissedKeys, trimmedKey],
83+ });
84+ },
85+ [dismissedKeySet, dismissedKeys, setDismissals],
86+ );
87+
88+ return {
89+ dismissedNotificationKeys: dismissedKeySet,
90+ dismissNotificationKey,
91+ };
92+}
apps/web/src/routeTree.gen.ts+21−0
@@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair' · 13 unmodified lines
1414 import { Route as ChatRouteImport } from './routes/_chat'
1515 import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1616 import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
17+import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
1718 import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
1819 import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
1920 import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
@@ -44,6 +45,11 @@ const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ · 24 unmodified lines
4445 path: '/source-control',
4546 getParentRoute: () => SettingsRoute,
4647 } as any)
48+const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
49+ id: '/providers',
50+ path: '/providers',
51+ getParentRoute: () => SettingsRoute,
52+} as any)
4753 const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
4854 id: '/general',
4955 path: '/general',
@@ -78,6 +84,7 @@ export interface FileRoutesByFullPath { · 28 unmodified lines
7884 '/settings/archived': typeof SettingsArchivedRoute
7985 '/settings/connections': typeof SettingsConnectionsRoute
8086 '/settings/general': typeof SettingsGeneralRoute
87+ '/settings/providers': typeof SettingsProvidersRoute
8188 '/settings/source-control': typeof SettingsSourceControlRoute
8289 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
8390 '/draft/$draftId': typeof ChatDraftDraftIdRoute
@@ -88,6 +95,7 @@ export interface FileRoutesByTo { · 4 unmodified lines
8895 '/settings/archived': typeof SettingsArchivedRoute
8996 '/settings/connections': typeof SettingsConnectionsRoute
9097 '/settings/general': typeof SettingsGeneralRoute
98+ '/settings/providers': typeof SettingsProvidersRoute
9199 '/settings/source-control': typeof SettingsSourceControlRoute
92100 '/': typeof ChatIndexRoute
93101 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
@@ -101,6 +109,7 @@ export interface FileRoutesById { · 7 unmodified lines
101109 '/settings/archived': typeof SettingsArchivedRoute
102110 '/settings/connections': typeof SettingsConnectionsRoute
103111 '/settings/general': typeof SettingsGeneralRoute
112+ '/settings/providers': typeof SettingsProvidersRoute
104113 '/settings/source-control': typeof SettingsSourceControlRoute
105114 '/_chat/': typeof ChatIndexRoute
106115 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
@@ -115,6 +124,7 @@ export interface FileRouteTypes { · 8 unmodified lines
115124 | '/settings/archived'
116125 | '/settings/connections'
117126 | '/settings/general'
127+ | '/settings/providers'
118128 | '/settings/source-control'
119129 | '/$environmentId/$threadId'
120130 | '/draft/$draftId'
@@ -125,6 +135,7 @@ export interface FileRouteTypes { · 4 unmodified lines
125135 | '/settings/archived'
126136 | '/settings/connections'
127137 | '/settings/general'
138+ | '/settings/providers'
128139 | '/settings/source-control'
129140 | '/'
130141 | '/$environmentId/$threadId'
@@ -137,6 +148,7 @@ export interface FileRouteTypes { · 6 unmodified lines
137148 | '/settings/archived'
138149 | '/settings/connections'
139150 | '/settings/general'
151+ | '/settings/providers'
140152 | '/settings/source-control'
141153 | '/_chat/'
142154 | '/_chat/$environmentId/$threadId'
@@ -186,6 +198,13 @@ declare module '@tanstack/react-router' { · 43 unmodified lines
186198 preLoaderRoute: typeof SettingsSourceControlRouteImport
187199 parentRoute: typeof SettingsRoute
188200 }
201+ '/settings/providers': {
202+ id: '/settings/providers'
203+ path: '/providers'
204+ fullPath: '/settings/providers'
205+ preLoaderRoute: typeof SettingsProvidersRouteImport
206+ parentRoute: typeof SettingsRoute
207+ }
189208 '/settings/general': {
190209 id: '/settings/general'
191210 path: '/general'
@@ -242,6 +261,7 @@ interface SettingsRouteChildren { · 50 unmodified lines
242261 SettingsArchivedRoute: typeof SettingsArchivedRoute
243262 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
244263 SettingsGeneralRoute: typeof SettingsGeneralRoute
264+ SettingsProvidersRoute: typeof SettingsProvidersRoute
245265 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
246266 }
247267
@@ -249,6 +269,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { · 1 unmodified line
249269 SettingsArchivedRoute: SettingsArchivedRoute,
250270 SettingsConnectionsRoute: SettingsConnectionsRoute,
251271 SettingsGeneralRoute: SettingsGeneralRoute,
272+ SettingsProvidersRoute: SettingsProvidersRoute,
252273 SettingsSourceControlRoute: SettingsSourceControlRoute,
253274 }
254275
apps/web/src/routes/__root.tsx+2−0
@@ -14,6 +14,7 @@ import { APP_DISPLAY_NAME } from "../branding"; · 13 unmodified lines
1414 import { AppSidebarLayout } from "../components/AppSidebarLayout";
1515 import { CommandPalette } from "../components/CommandPalette";
1616 import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
17+import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
1718 import {
1819 SlowRpcAckToastCoordinator,
1920 WebSocketConnectionCoordinator,
@@ -136,6 +137,7 @@ function RootRouteView() { · 116 unmodified lines
136137 <SshPasswordPromptDialog />
137138 <HostedStaticEnvironmentBootstrap />
138139 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
140+ {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
139141 {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null}
140142 {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null}
141143 {primaryEnvironmentAuthenticated ? (
apps/web/src/routes/settings.general.tsx+5−1
@@ -2,6 +2,10 @@ import { createFileRoute } from "@tanstack/react-router"; · 1 unmodified line
22
33 import { GeneralSettingsPanel } from "../components/settings/SettingsPanels";
44
5+function SettingsGeneralRoute() {
6+ return <GeneralSettingsPanel />;
7+}
8+
59 export const Route = createFileRoute("/settings/general")({
6− component: GeneralSettingsPanel,
10+ component: SettingsGeneralRoute,
711 });
apps/web/src/routes/settings.providers.tsx+11−0
@@ -0,0 +1,11 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { ProviderSettingsPanel } from "../components/settings/SettingsPanels";
4+
5+function SettingsProvidersRoute() {
6+ return <ProviderSettingsPanel />;
7+}
8+
9+export const Route = createFileRoute("/settings/providers")({
10+ component: SettingsProvidersRoute,
11+});
apps/web/src/rpc/wsRpcClient.ts+3−0
@@ -119,6 +119,7 @@ export interface WsRpcClient { · 118 unmodified lines
119119 readonly refreshProviders: (
120120 input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>,
121121 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>;
122+ readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>;
122123 readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>;
123124 readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>;
124125 readonly updateSettings: (
@@ -234,6 +235,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { · 109 unmodified lines
234235 getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})),
235236 refreshProviders: (input) =>
236237 transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})),
238+ updateProvider: (input) =>
239+ transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)),
237240 upsertKeybinding: (input) =>
238241 transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)),
239242 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
packages/contracts/src/ipc.ts+2−0
@@ -28,6 +28,7 @@ import type { · 27 unmodified lines
2828 import type { ProviderInstanceId } from "./providerInstance.ts";
2929 import type {
3030 ServerConfig,
31+ ServerProviderUpdateInput,
3132 ServerProviderUpdatedPayload,
3233 ServerUpsertKeybindingResult,
3334 } from "./server.ts";
@@ -296,6 +297,7 @@ export interface LocalApi { · 262 unmodified lines
296297 refreshProviders: (input?: {
297298 readonly instanceId?: ProviderInstanceId;
298299 }) => Promise<ServerProviderUpdatedPayload>;
300+ updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>;
299301 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
300302 getSettings: () => Promise<ServerSettings>;
301303 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
packages/contracts/src/rpc.ts+10−0
@@ -71,6 +71,8 @@ import { · 70 unmodified lines
7171 import {
7272 ServerConfigStreamEvent,
7373 ServerConfig,
74+ ServerProviderUpdateError,
75+ ServerProviderUpdateInput,
7476 ServerLifecycleStreamEvent,
7577 ServerProviderUpdatedPayload,
7678 ServerUpsertKeybindingInput,
@@ -129,6 +131,7 @@ export const WS_METHODS = { · 52 unmodified lines
129131 // Server meta
130132 serverGetConfig: "server.getConfig",
131133 serverRefreshProviders: "server.refreshProviders",
134+ serverUpdateProvider: "server.updateProvider",
132135 serverUpsertKeybinding: "server.upsertKeybinding",
133136 serverGetSettings: "server.getSettings",
134137 serverUpdateSettings: "server.updateSettings",
@@ -172,6 +175,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv · 37 unmodified lines
172175 success: ServerProviderUpdatedPayload,
173176 });
174177
178+export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, {
179+ payload: ServerProviderUpdateInput,
180+ success: ServerProviderUpdatedPayload,
181+ error: ServerProviderUpdateError,
182+});
183+
175184 export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
176185 payload: Schema.Struct({}),
177186 success: ServerSettings,
@@ -415,6 +424,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, · 237 unmodified lines
415424 export const WsRpcGroup = RpcGroup.make(
416425 WsServerGetConfigRpc,
417426 WsServerRefreshProvidersRpc,
427+ WsServerUpdateProviderRpc,
418428 WsServerUpsertKeybindingRpc,
419429 WsServerGetSettingsRpc,
420430 WsServerUpdateSettingsRpc,
packages/contracts/src/server.test.ts+28−0
@@ -23,6 +23,34 @@ describe("ServerProvider", () => { · 22 unmodified lines
2323
2424 expect(parsed.slashCommands).toEqual([]);
2525 expect(parsed.skills).toEqual([]);
26+ expect(parsed.versionAdvisory).toBeUndefined();
27+ expect(parsed.updateState).toBeUndefined();
28+ });
29+
30+ it("defaults one-click update support when decoding older advisory snapshots", () => {
31+ const parsed = decodeServerProvider({
32+ instanceId: "codex",
33+ driver: "codex",
34+ enabled: true,
35+ installed: true,
36+ version: "1.0.0",
37+ status: "ready",
38+ auth: {
39+ status: "authenticated",
40+ },
41+ checkedAt: "2026-04-10T00:00:00.000Z",
42+ models: [],
43+ versionAdvisory: {
44+ status: "behind_latest",
45+ currentVersion: "1.0.0",
46+ latestVersion: "1.0.1",
47+ updateCommand: "npm install -g @openai/codex@latest",
48+ checkedAt: "2026-04-10T00:00:00.000Z",
49+ message: "Update available.",
50+ },
51+ });
52+
53+ expect(parsed.versionAdvisory?.canUpdate).toBe(false);
2654 });
2755
2856 it("decodes continuation group metadata", () => {
packages/contracts/src/server.ts+58−0
@@ -109,6 +109,43 @@ export const ServerProviderContinuation = Schema.Struct({ · 108 unmodified lines
109109 });
110110 export type ServerProviderContinuation = typeof ServerProviderContinuation.Type;
111111
112+export const ServerProviderVersionAdvisoryStatus = Schema.Literals([
113+ "unknown",
114+ "current",
115+ "behind_latest",
116+]);
117+export type ServerProviderVersionAdvisoryStatus = typeof ServerProviderVersionAdvisoryStatus.Type;
118+
119+export const ServerProviderVersionAdvisory = Schema.Struct({
120+ status: ServerProviderVersionAdvisoryStatus,
121+ currentVersion: Schema.NullOr(TrimmedNonEmptyString),
122+ latestVersion: Schema.NullOr(TrimmedNonEmptyString),
123+ updateCommand: Schema.NullOr(TrimmedNonEmptyString),
124+ canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
125+ checkedAt: Schema.NullOr(IsoDateTime),
126+ message: Schema.NullOr(TrimmedNonEmptyString),
127+});
128+export type ServerProviderVersionAdvisory = typeof ServerProviderVersionAdvisory.Type;
129+
130+export const ServerProviderUpdateStatus = Schema.Literals([
131+ "idle",
132+ "queued",
133+ "running",
134+ "succeeded",
135+ "failed",
136+ "unchanged",
137+]);
138+export type ServerProviderUpdateStatus = typeof ServerProviderUpdateStatus.Type;
139+
140+export const ServerProviderUpdateState = Schema.Struct({
141+ status: ServerProviderUpdateStatus,
142+ startedAt: Schema.NullOr(IsoDateTime),
143+ finishedAt: Schema.NullOr(IsoDateTime),
144+ message: Schema.NullOr(TrimmedNonEmptyString),
145+ output: Schema.NullOr(Schema.String.check(Schema.isMaxLength(10_000))),
146+});
147+export type ServerProviderUpdateState = typeof ServerProviderUpdateState.Type;
148+
112149 export const ServerProvider = Schema.Struct({
113150 // Routing key for the configured instance this snapshot represents. This
114151 // is the only stable identity consumers may use for provider routing.
@@ -142,6 +179,8 @@ export const ServerProvider = Schema.Struct({ · 27 unmodified lines
142179 Schema.withDecodingDefault(Effect.succeed([])),
143180 ),
144181 skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
182+ versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
183+ updateState: Schema.optionalKey(ServerProviderUpdateState),
145184 });
146185 export type ServerProvider = typeof ServerProvider.Type;
147186
@@ -294,3 +333,22 @@ export const ServerProviderUpdatedPayload = Schema.Struct({ · 146 unmodified lines
294333 providers: ServerProviders,
295334 });
296335 export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type;
336+
337+export const ServerProviderUpdateInput = Schema.Struct({
338+ provider: ProviderDriverKind,
339+ instanceId: Schema.optionalKey(ProviderInstanceId),
340+});
341+export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type;
342+
343+export class ServerProviderUpdateError extends Schema.TaggedErrorClass<ServerProviderUpdateError>()(
344+ "ServerProviderUpdateError",
345+ {
346+ provider: ProviderDriverKind,
347+ reason: TrimmedNonEmptyString,
348+ cause: Schema.optional(Schema.Defect),
349+ },
350+) {
351+ override get message(): string {
352+ return `Provider update failed for ${this.provider}: ${this.reason}`;
353+ }
354+}
packages/contracts/src/settings.ts+3−0
@@ -32,6 +32,9 @@ export const ClientSettingsSchema = Schema.Struct({ · 31 unmodified lines
3232 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
3333 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
3434 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
35+ dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
36+ Schema.withDecodingDefault(Effect.succeed([])),
37+ ),
3538 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
3639 diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
3740 // Model favorites. Historically keyed by provider kind, now
packages/effect-acp/src/client.test.ts+0−1
@@ -24,7 +24,6 @@ const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeReque · 23 unmodified lines
2424 const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);
2525 const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
2626 const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
27−
2827 const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
2928 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
3029 );
packages/effect-acp/src/protocol.test.ts+0−1
@@ -41,7 +41,6 @@ const RequestPermissionRequest = jsonRpcRequest( · 40 unmodified lines
4141 const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse);
4242 const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
4343 const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
44−
4544 const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
4645 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
4746 );
packages/shared/src/shell.test.ts+12−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 readEnvironmentFromWindowsShell,
1111 readPathFromLaunchctl,
1212 readPathFromLoginShell,
13+ resolveCommandPath,
1314 resolveKnownWindowsCliDirs,
1415 resolveWindowsEnvironment,
1516 } from "./shell.ts";
@@ -332,6 +333,17 @@ describe("isCommandAvailable", () => { · 316 unmodified lines
332333 });
333334 });
334335
336+describe("resolveCommandPath", () => {
337+ it("returns the first executable resolved from PATH", () => {
338+ expect(
339+ resolveCommandPath("definitely-not-installed", {
340+ platform: "win32",
341+ env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
342+ }),
343+ ).toBeNull();
344+ });
345+});
346+
335347 describe("resolveWindowsEnvironment", () => {
336348 it("returns the baseline no-profile PATH patch when node is already available", () => {
337349 const readEnvironment = vi.fn(
packages/shared/src/shell.ts+20−9
@@ -376,23 +376,26 @@ function isExecutableFile( · 375 unmodified lines
376376 }
377377 }
378378
379−export function isCommandAvailable(
379+export function resolveCommandPath(
380380 command: string,
381381 options: CommandAvailabilityOptions = {},
382−): boolean {
382+): string | null {
383383 const platform = options.platform ?? process.platform;
384384 const env = options.env ?? process.env;
385385 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
386386 const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
387387
388388 if (command.includes("/") || command.includes("\\")) {
389− return commandCandidates.some((candidate) =>
390− isExecutableFile(candidate, platform, windowsPathExtensions),
391− );
389+ for (const candidate of commandCandidates) {
390+ if (isExecutableFile(candidate, platform, windowsPathExtensions)) {
391+ return candidate;
392+ }
393+ }
394+ return null;
392395 }
393396
394397 const pathValue = resolvePathEnvironmentVariable(env);
395− if (pathValue.length === 0) return false;
398+ if (pathValue.length === 0) return null;
396399 const pathEntries = pathValue
397400 .split(pathDelimiterForPlatform(platform))
398401 .map((entry) => stripWrappingQuotes(entry.trim()))
@@ -400,12 +403,20 @@ export function isCommandAvailable( · 1 unmodified line
400403
401404 for (const pathEntry of pathEntries) {
402405 for (const candidate of commandCandidates) {
403− if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
404− return true;
406+ const candidatePath = join(pathEntry, candidate);
407+ if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) {
408+ return candidatePath;
405409 }
406410 }
407411 }
408− return false;
412+ return null;
413+}
414+
415+export function isCommandAvailable(
416+ command: string,
417+ options: CommandAvailabilityOptions = {},
418+): boolean {
419+ return resolveCommandPath(command, options) !== null;
409420 }
410421
411422 export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t