From 6f3bd895e72b737b8dcf1898a982aad661593b11 Mon Sep 17 00:00:00 2001
From: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Date: Wed, 22 Apr 2026 16:15:13 +0100
Subject: [PATCH 01/30] Add provider version advisories
---
apps/server/package.json | 2 +-
.../generate-provider-tested-versions.ts | 151 ++++++++++++
.../src/provider/Drivers/ClaudeDriver.ts | 9 +
.../src/provider/Drivers/CodexDriver.ts | 9 +
.../src/provider/Drivers/CursorDriver.ts | 2 +
.../src/provider/Drivers/OpenCodeDriver.ts | 9 +
.../src/provider/Layers/CodexProvider.ts | 1 -
.../src/provider/Layers/CursorProvider.ts | 72 ++++--
.../src/provider/Layers/OpenCodeProvider.ts | 1 -
.../src/provider/Services/ServerProvider.ts | 2 +
.../makeManagedServerProvider.test.ts | 10 +
.../src/provider/makeManagedServerProvider.ts | 2 +
apps/server/src/provider/providerSnapshot.ts | 10 +
.../providerTestedVersions.generated.json | 17 ++
.../provider/providerVersionLifecycle.test.ts | 45 ++++
.../src/provider/providerVersionLifecycle.ts | 224 ++++++++++++++++++
.../settings/ProviderInstanceCard.tsx | 60 ++++-
.../src/components/settings/providerStatus.ts | 27 ++-
packages/contracts/src/server.test.ts | 1 +
packages/contracts/src/server.ts | 20 ++
20 files changed, 644 insertions(+), 30 deletions(-)
create mode 100644 apps/server/scripts/generate-provider-tested-versions.ts
create mode 100644 apps/server/src/provider/providerTestedVersions.generated.json
create mode 100644 apps/server/src/provider/providerVersionLifecycle.test.ts
create mode 100644 apps/server/src/provider/providerVersionLifecycle.ts
diff --git a/apps/server/package.json b/apps/server/package.json
index c562e0401e..26f4368e42 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -16,7 +16,7 @@
"type": "module",
"scripts": {
"dev": "node --watch src/bin.ts",
- "build": "node scripts/cli.ts build",
+ "build": "node scripts/generate-provider-tested-versions.ts && node scripts/cli.ts build",
"build:bundle": "tsdown",
"start": "node dist/bin.mjs",
"prepare": "effect-language-service patch",
diff --git a/apps/server/scripts/generate-provider-tested-versions.ts b/apps/server/scripts/generate-provider-tested-versions.ts
new file mode 100644
index 0000000000..1bb2fa282c
--- /dev/null
+++ b/apps/server/scripts/generate-provider-tested-versions.ts
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+
+import { exec, execFile } from "node:child_process";
+import { mkdir, rename, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+const execAsync = promisify(exec);
+
+const OUTPUT_PATH = path.join(
+ import.meta.dirname,
+ "..",
+ "src",
+ "provider",
+ "providerTestedVersions.generated.json",
+);
+
+const PROVIDERS = {
+ codex: {
+ binary: process.env.T3CODE_TESTED_CODEX_BINARY || "codex",
+ args: ["--version"],
+ parse: parseSemver,
+ },
+ claudeAgent: {
+ binary: process.env.T3CODE_TESTED_CLAUDE_BINARY || "claude",
+ args: ["--version"],
+ parse: parseSemver,
+ },
+ cursor: {
+ binary: process.env.T3CODE_TESTED_CURSOR_BINARY || "agent",
+ args: ["about", "--format", "json"],
+ parse: parseCursorAboutVersion,
+ },
+ opencode: {
+ binary: process.env.T3CODE_TESTED_OPENCODE_BINARY || "opencode",
+ args: ["--version"],
+ parse: parseSemver,
+ },
+} as const;
+
+function quoteWindowsCommandArg(value: string): string {
+ return `"${value.replace(/"/g, '\\"')}"`;
+}
+
+async function resolveWindowsBinary(binary: string): Promise {
+ if (binary.includes("\\") || binary.includes("/") || path.extname(binary)) {
+ return binary;
+ }
+
+ try {
+ const result = await execFileAsync("where.exe", [binary], {
+ timeout: 2_000,
+ windowsHide: true,
+ });
+ const candidates = result.stdout
+ .split(/\r?\n/g)
+ .map((line) => line.trim())
+ .filter(Boolean);
+ return (
+ candidates.find((candidate) => candidate.toLowerCase().endsWith(".exe")) ??
+ candidates.find((candidate) => candidate.toLowerCase().endsWith(".cmd")) ??
+ candidates[0] ??
+ binary
+ );
+ } catch {
+ return binary;
+ }
+}
+
+async function execProviderCommand(binary: string, args: ReadonlyArray) {
+ if (process.platform !== "win32") {
+ return execFileAsync(binary, [...args], {
+ timeout: 5_000,
+ windowsHide: true,
+ });
+ }
+
+ const resolvedBinary = await resolveWindowsBinary(binary);
+ if (resolvedBinary.toLowerCase().endsWith(".exe")) {
+ return execFileAsync(resolvedBinary, [...args], {
+ timeout: 5_000,
+ windowsHide: true,
+ });
+ }
+
+ const commandLine = [resolvedBinary, ...args].map(quoteWindowsCommandArg).join(" ");
+ return execAsync(commandLine, {
+ timeout: 5_000,
+ windowsHide: true,
+ });
+}
+
+function parseSemver(output: string): string | null {
+ return output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)?.[1] ?? null;
+}
+
+function parseCursorAboutVersion(output: string): string | null {
+ try {
+ const parsed = JSON.parse(output) as { cliVersion?: unknown };
+ return typeof parsed.cliVersion === "string" && parsed.cliVersion.trim()
+ ? parsed.cliVersion.trim()
+ : null;
+ } catch {
+ return output.match(/^CLI Version\s{2,}(.+)$/im)?.[1]?.trim() ?? null;
+ }
+}
+
+async function probeProviderVersion(provider: keyof typeof PROVIDERS): Promise {
+ const config = PROVIDERS[provider];
+ const envKey = `T3CODE_TESTED_${provider.toUpperCase()}_VERSION`;
+ const override = process.env[envKey]?.trim();
+ if (override) {
+ return override;
+ }
+
+ try {
+ const result = await execProviderCommand(config.binary, config.args);
+ return config.parse(`${result.stdout}\n${result.stderr}`);
+ } catch {
+ if (provider === "cursor") {
+ try {
+ const result = await execProviderCommand(config.binary, ["about"]);
+ return config.parse(`${result.stdout}\n${result.stderr}`);
+ } catch {
+ return null;
+ }
+ }
+ return null;
+ }
+}
+
+async function main(): Promise {
+ const entries = await Promise.all(
+ Object.keys(PROVIDERS).map(async (provider) => [
+ provider,
+ { testedVersion: await probeProviderVersion(provider as keyof typeof PROVIDERS) },
+ ]),
+ );
+ const manifest = {
+ generatedAt: new Date().toISOString(),
+ providers: Object.fromEntries(entries),
+ };
+
+ await mkdir(path.dirname(OUTPUT_PATH), { recursive: true });
+ const tmpPath = `${OUTPUT_PATH}.tmp`;
+ await writeFile(tmpPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
+ await rename(tmpPath, OUTPUT_PATH);
+}
+
+await main();
diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts
index 311f495865..4f3bd9d94f 100644
--- a/apps/server/src/provider/Drivers/ClaudeDriver.ts
+++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts
@@ -34,6 +34,10 @@ import {
} from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
+import {
+ enrichProviderSnapshotWithVersionAdvisory,
+ getProviderVersionLifecycle,
+} from "../providerVersionLifecycle.ts";
import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
@@ -121,11 +125,16 @@ export const ClaudeDriver: ProviderDriver = {
);
const snapshot = yield* makeManagedServerProvider({
+ versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND),
getSettings: Effect.succeed(effectiveConfig),
streamSettings: Stream.never,
haveSettingsChanged: () => false,
initialSnapshot: (settings) => stampIdentity(makePendingClaudeProvider(settings)),
checkProvider,
+ enrichSnapshot: ({ snapshot, publishSnapshot }) =>
+ Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe(
+ Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
+ ),
refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
}).pipe(
Effect.mapError(
diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts
index 26fffd5e21..d180e74847 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.ts
@@ -35,6 +35,10 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
+import {
+ enrichProviderSnapshotWithVersionAdvisory,
+ getProviderVersionLifecycle,
+} from "../providerVersionLifecycle.ts";
import {
codexContinuationIdentity,
materializeCodexShadowHome,
@@ -138,11 +142,16 @@ export const CodexDriver: ProviderDriver = {
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const snapshot = yield* makeManagedServerProvider({
+ versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND),
getSettings: Effect.succeed(effectiveConfig),
streamSettings: Stream.never,
haveSettingsChanged: () => false,
initialSnapshot: (settings) => stampIdentity(makePendingCodexProvider(settings)),
checkProvider,
+ enrichSnapshot: ({ snapshot, publishSnapshot }) =>
+ Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe(
+ Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
+ ),
refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
}).pipe(
Effect.mapError(
diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts
index cd058800f2..36f8dea190 100644
--- a/apps/server/src/provider/Drivers/CursorDriver.ts
+++ b/apps/server/src/provider/Drivers/CursorDriver.ts
@@ -34,6 +34,7 @@ import {
} from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
+import { getProviderVersionLifecycle } from "../providerVersionLifecycle.ts";
const DRIVER_KIND = ProviderDriverKind.make("cursor");
const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
@@ -103,6 +104,7 @@ export const CursorDriver: ProviderDriver = {
);
const snapshot = yield* makeManagedServerProvider({
+ versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND),
getSettings: Effect.succeed(effectiveConfig),
streamSettings: Stream.never,
haveSettingsChanged: () => false,
diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts
index 27f98a9830..ee304b3338 100644
--- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts
+++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts
@@ -34,6 +34,10 @@ import {
} from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
+import {
+ enrichProviderSnapshotWithVersionAdvisory,
+ getProviderVersionLifecycle,
+} from "../providerVersionLifecycle.ts";
const DRIVER_KIND = ProviderDriverKind.make("opencode");
const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
@@ -102,11 +106,16 @@ export const OpenCodeDriver: ProviderDriver
).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime));
const snapshot = yield* makeManagedServerProvider({
+ versionLifecycle: getProviderVersionLifecycle(DRIVER_KIND),
getSettings: Effect.succeed(effectiveConfig),
streamSettings: Stream.never,
haveSettingsChanged: () => false,
initialSnapshot: (settings) => stampIdentity(makePendingOpenCodeProvider(settings)),
checkProvider,
+ enrichSnapshot: ({ snapshot, publishSnapshot }) =>
+ Effect.promise(() => enrichProviderSnapshotWithVersionAdvisory(snapshot)).pipe(
+ Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
+ ),
refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
}).pipe(
Effect.mapError(
diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts
index 0917d842a6..618103883a 100644
--- a/apps/server/src/provider/Layers/CodexProvider.ts
+++ b/apps/server/src/provider/Layers/CodexProvider.ts
@@ -15,7 +15,6 @@ import type {
import { ServerSettingsError } from "@t3tools/contracts";
import { createModelCapabilities } from "@t3tools/shared/model";
-
import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts";
import { expandHomePath } from "../../pathExpansion.ts";
import { scopedSafeTeardown } from "./scopedSafeTeardown.ts";
diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts
index ad52f63fbb..f290477c6f 100644
--- a/apps/server/src/provider/Layers/CursorProvider.ts
+++ b/apps/server/src/provider/Layers/CursorProvider.ts
@@ -29,6 +29,7 @@ import {
type CommandResult,
type ServerProviderDraft,
} from "../providerSnapshot.ts";
+import { enrichProviderSnapshotWithVersionAdvisory } from "../providerVersionLifecycle.ts";
import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
const PROVIDER = ProviderDriverKind.make("cursor");
@@ -1222,36 +1223,57 @@ export const enrichCursorSnapshot = (input: {
const { settings, snapshot, publishSnapshot } = input;
const stampIdentity = input.stampIdentity ?? ((value) => value);
- if (
- !settings.enabled ||
- snapshot.auth.status === "unauthenticated" ||
- !hasUncapturedCursorModels(snapshot)
- ) {
- return Effect.void;
- }
+ const enrichVersionAdvisory = Effect.promise(() =>
+ enrichProviderSnapshotWithVersionAdvisory(snapshot),
+ ).pipe(
+ Effect.flatMap((enrichedSnapshot) =>
+ publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)),
+ ),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Cursor version advisory enrichment failed", {
+ cause: Cause.pretty(cause),
+ }).pipe(Effect.as(snapshot)),
+ ),
+ );
- return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models, input.environment).pipe(
- Effect.flatMap((discoveredModels) => {
- if (discoveredModels.length === 0) {
+ return enrichVersionAdvisory.pipe(
+ Effect.flatMap((baseSnapshot) => {
+ if (
+ !settings.enabled ||
+ baseSnapshot.auth.status === "unauthenticated" ||
+ !hasUncapturedCursorModels(baseSnapshot)
+ ) {
return Effect.void;
}
- return publishSnapshot(
- stampIdentity({
- ...snapshot,
- models: providerModelsFromSettings(
- discoveredModels,
- PROVIDER,
- settings.customModels,
- EMPTY_CAPABILITIES,
- ),
+
+ return discoverCursorModelCapabilitiesViaAcp(
+ settings,
+ baseSnapshot.models,
+ input.environment,
+ ).pipe(
+ Effect.flatMap((discoveredModels) => {
+ if (discoveredModels.length === 0) {
+ return Effect.void;
+ }
+ return publishSnapshot(
+ stampIdentity({
+ ...baseSnapshot,
+ models: providerModelsFromSettings(
+ discoveredModels,
+ PROVIDER,
+ settings.customModels,
+ EMPTY_CAPABILITIES,
+ ),
+ }),
+ );
}),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Cursor ACP background capability enrichment failed", {
+ models: baseSnapshot.models.map((model) => model.slug),
+ cause: Cause.pretty(cause),
+ }).pipe(Effect.asVoid),
+ ),
);
}),
- Effect.catchCause((cause) =>
- Effect.logWarning("Cursor ACP background capability enrichment failed", {
- models: snapshot.models.map((model) => model.slug),
- cause: Cause.pretty(cause),
- }).pipe(Effect.asVoid),
- ),
);
};
diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts
index c7487d7d52..6431282d63 100644
--- a/apps/server/src/provider/Layers/OpenCodeProvider.ts
+++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts
@@ -7,7 +7,6 @@ import {
import { Cause, Data, Effect } from "effect";
import { createModelCapabilities } from "@t3tools/shared/model";
-
import {
buildServerProvider,
nonEmptyTrimmed,
diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts
index 4df0bc8fc2..fabd2d003f 100644
--- a/apps/server/src/provider/Services/ServerProvider.ts
+++ b/apps/server/src/provider/Services/ServerProvider.ts
@@ -1,7 +1,9 @@
import type { ServerProvider } from "@t3tools/contracts";
import type { Effect, Stream } from "effect";
+import type { ProviderVersionLifecycle } from "../providerVersionLifecycle.ts";
export interface ServerProviderShape {
+ readonly versionLifecycle: ProviderVersionLifecycle;
readonly getSnapshot: Effect.Effect;
readonly refresh: Effect.Effect;
readonly streamChanges: Stream.Stream;
diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts
index ff66476380..f440ea1c64 100644
--- a/apps/server/src/provider/makeManagedServerProvider.test.ts
+++ b/apps/server/src/provider/makeManagedServerProvider.test.ts
@@ -20,6 +20,12 @@ interface TestSettings {
readonly enabled: boolean;
}
+const versionLifecycle = {
+ provider: "codex",
+ packageName: "@openai/codex",
+ updateCommand: "npm install -g @openai/codex@latest",
+} as const;
+
const initialSnapshot: ServerProvider = {
instanceId: ProviderInstanceId.make("codex"),
driver: ProviderDriverKind.make("codex"),
@@ -90,6 +96,7 @@ describe("makeManagedServerProvider", () => {
const checkCalls = yield* Ref.make(0);
const releaseCheck = yield* Deferred.make();
const provider = yield* makeManagedServerProvider({
+ versionLifecycle,
getSettings: Effect.succeed({ enabled: true }),
streamSettings: Stream.empty,
haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
@@ -131,6 +138,7 @@ describe("makeManagedServerProvider", () => {
const releaseInitialCheck = yield* Deferred.make();
const releaseSettingsCheck = yield* Deferred.make();
const provider = yield* makeManagedServerProvider({
+ versionLifecycle,
getSettings: Ref.get(settingsRef),
streamSettings: Stream.fromPubSub(settingsChanges),
haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
@@ -172,6 +180,7 @@ describe("makeManagedServerProvider", () => {
const releaseEnrichment = yield* Deferred.make();
const releaseCheck = yield* Deferred.make();
const provider = yield* makeManagedServerProvider({
+ versionLifecycle,
getSettings: Effect.succeed({ enabled: true }),
streamSettings: Stream.empty,
haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
@@ -212,6 +221,7 @@ describe("makeManagedServerProvider", () => {
const secondCallbackReady = yield* Deferred.make();
const allowFirstRefresh = yield* Deferred.make();
const provider = yield* makeManagedServerProvider({
+ versionLifecycle,
getSettings: Effect.succeed({ enabled: true }),
streamSettings: Stream.empty,
haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts
index 4787a9f9cb..115ec04b14 100644
--- a/apps/server/src/provider/makeManagedServerProvider.ts
+++ b/apps/server/src/provider/makeManagedServerProvider.ts
@@ -13,6 +13,7 @@ interface ProviderSnapshotState {
export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
Settings,
>(input: {
+ readonly versionLifecycle: ServerProviderShape["versionLifecycle"];
readonly getSettings: Effect.Effect;
readonly streamSettings: Stream.Stream;
readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;
@@ -143,6 +144,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
);
return {
+ versionLifecycle: input.versionLifecycle,
getSnapshot: input.getSettings.pipe(
Effect.flatMap(applySnapshot),
Effect.tapError(Effect.logError),
diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts
index af0c91274c..1db7f219a2 100644
--- a/apps/server/src/provider/providerSnapshot.ts
+++ b/apps/server/src/provider/providerSnapshot.ts
@@ -12,6 +12,7 @@ import { Effect, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { normalizeModelSlug } from "@t3tools/shared/model";
import { isWindowsCommandNotFound } from "../processRunner.ts";
+import { createProviderVersionAdvisory } from "./providerVersionLifecycle.ts";
export const DEFAULT_TIMEOUT_MS = 4_000;
// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
@@ -182,6 +183,7 @@ export function buildBooleanOptionDescriptor(input: {
}
export function buildServerProvider(input: {
+ driver?: ProviderDriverKind;
presentation: ServerProviderPresentation;
enabled: boolean;
checkedAt: string;
@@ -190,6 +192,13 @@ export function buildServerProvider(input: {
skills?: ReadonlyArray;
probe: ProviderProbeResult;
}): ServerProviderDraft {
+ const versionAdvisory = input.driver
+ ? createProviderVersionAdvisory({
+ driver: input.driver,
+ currentVersion: input.probe.version,
+ checkedAt: input.checkedAt,
+ })
+ : undefined;
return {
displayName: input.presentation.displayName,
...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),
@@ -206,6 +215,7 @@ export function buildServerProvider(input: {
models: input.models,
slashCommands: [...(input.slashCommands ?? [])],
skills: [...(input.skills ?? [])],
+ ...(versionAdvisory ? { versionAdvisory } : {}),
};
}
diff --git a/apps/server/src/provider/providerTestedVersions.generated.json b/apps/server/src/provider/providerTestedVersions.generated.json
new file mode 100644
index 0000000000..4fc87be0aa
--- /dev/null
+++ b/apps/server/src/provider/providerTestedVersions.generated.json
@@ -0,0 +1,17 @@
+{
+ "generatedAt": "2026-04-22T15:07:09.413Z",
+ "providers": {
+ "codex": {
+ "testedVersion": "0.121.0"
+ },
+ "claudeAgent": {
+ "testedVersion": "2.0.14"
+ },
+ "cursor": {
+ "testedVersion": null
+ },
+ "opencode": {
+ "testedVersion": null
+ }
+ }
+}
diff --git a/apps/server/src/provider/providerVersionLifecycle.test.ts b/apps/server/src/provider/providerVersionLifecycle.test.ts
new file mode 100644
index 0000000000..9c751395b8
--- /dev/null
+++ b/apps/server/src/provider/providerVersionLifecycle.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createProviderVersionAdvisory,
+ getProviderVersionLifecycle,
+} from "./providerVersionLifecycle.ts";
+
+describe("providerVersionLifecycle", () => {
+ it("marks providers with unknown current versions as unknown", () => {
+ expect(
+ createProviderVersionAdvisory({
+ driver: "codex",
+ currentVersion: null,
+ latestVersion: "9.9.9",
+ }),
+ ).toMatchObject({
+ status: "unknown",
+ currentVersion: null,
+ latestVersion: "9.9.9",
+ });
+ });
+
+ it("marks installed providers behind latest when no stronger tested-version advisory applies", () => {
+ expect(
+ createProviderVersionAdvisory({
+ driver: "claudeAgent",
+ currentVersion: "2.1.110",
+ latestVersion: "2.1.117",
+ }),
+ ).toMatchObject({
+ status: "behind_latest",
+ currentVersion: "2.1.110",
+ latestVersion: "2.1.117",
+ updateCommand: "npm install -g @anthropic-ai/claude-code@latest",
+ });
+ });
+
+ it("keeps update commands owned by provider lifecycle metadata", () => {
+ expect(getProviderVersionLifecycle("cursor")).toEqual({
+ provider: "cursor",
+ packageName: null,
+ updateCommand: "agent update",
+ });
+ });
+});
diff --git a/apps/server/src/provider/providerVersionLifecycle.ts b/apps/server/src/provider/providerVersionLifecycle.ts
new file mode 100644
index 0000000000..d0d3c90d3f
--- /dev/null
+++ b/apps/server/src/provider/providerVersionLifecycle.ts
@@ -0,0 +1,224 @@
+import type {
+ ProviderDriverKind,
+ ServerProvider,
+ ServerProviderVersionAdvisory,
+ ServerProviderVersionAdvisoryStatus,
+} from "@t3tools/contracts";
+
+import testedVersions from "./providerTestedVersions.generated.json" with { type: "json" };
+import { compareCliVersions } from "./cliVersion.ts";
+
+const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
+const LATEST_VERSION_TIMEOUT_MS = 4_000;
+
+type VersionLifecycleProvider = "codex" | "claudeAgent" | "cursor" | "opencode";
+
+type TestedVersionsManifest = {
+ readonly providers?: Partial<
+ Record
+ >;
+};
+
+export interface ProviderVersionLifecycle {
+ readonly provider: ProviderDriverKind;
+ readonly packageName: string | null;
+ readonly updateCommand: string | null;
+}
+
+const PROVIDER_VERSION_LIFECYCLES = {
+ codex: {
+ provider: "codex",
+ packageName: "@openai/codex",
+ updateCommand: "npm install -g @openai/codex@latest",
+ },
+ claudeAgent: {
+ provider: "claudeAgent",
+ packageName: "@anthropic-ai/claude-code",
+ updateCommand: "npm install -g @anthropic-ai/claude-code@latest",
+ },
+ cursor: {
+ provider: "cursor",
+ packageName: null,
+ updateCommand: "agent update",
+ },
+ opencode: {
+ provider: "opencode",
+ packageName: "opencode-ai",
+ updateCommand: "npm install -g opencode-ai@latest",
+ },
+} as const satisfies Record;
+
+interface LatestVersionCacheEntry {
+ readonly expiresAt: number;
+ readonly version: string | null;
+}
+
+const latestVersionCache = new Map();
+
+function nonEmptyString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+}
+
+function isVersionLifecycleProvider(
+ provider: ProviderDriverKind | string,
+): provider is VersionLifecycleProvider {
+ return provider in PROVIDER_VERSION_LIFECYCLES;
+}
+
+export function getProviderVersionLifecycle(provider: ProviderDriverKind): ProviderVersionLifecycle {
+ if (isVersionLifecycleProvider(provider)) {
+ return PROVIDER_VERSION_LIFECYCLES[provider];
+ }
+ return {
+ provider,
+ packageName: null,
+ updateCommand: null,
+ };
+}
+
+export function getProviderTestedVersion(provider: ProviderDriverKind): string | null {
+ if (!isVersionLifecycleProvider(provider)) {
+ return null;
+ }
+ const manifest = testedVersions as TestedVersionsManifest;
+ return nonEmptyString(manifest.providers?.[provider]?.testedVersion);
+}
+
+function formatVersion(value: string): string {
+ return value.startsWith("v") ? value : `v${value}`;
+}
+
+function deriveVersionAdvisoryStatus(input: {
+ readonly currentVersion: string | null;
+ readonly testedVersion: string | null;
+ readonly latestVersion: string | null;
+}): ServerProviderVersionAdvisoryStatus {
+ if (!input.currentVersion) {
+ return "unknown";
+ }
+ if (input.testedVersion && compareCliVersions(input.currentVersion, input.testedVersion) < 0) {
+ return "behind_tested";
+ }
+ if (input.latestVersion && compareCliVersions(input.currentVersion, input.latestVersion) < 0) {
+ return "behind_latest";
+ }
+ return "current";
+}
+
+function advisoryMessage(input: {
+ readonly status: ServerProviderVersionAdvisoryStatus;
+ readonly testedVersion: string | null;
+ readonly latestVersion: string | null;
+}): string | null {
+ switch (input.status) {
+ case "behind_tested":
+ return input.testedVersion
+ ? `Recommended update: this T3 Code build was tested with ${formatVersion(input.testedVersion)}.`
+ : "Recommended update: this provider is behind the version tested with this T3 Code build.";
+ case "behind_latest":
+ return input.latestVersion
+ ? `Update available: latest is ${formatVersion(input.latestVersion)}.`
+ : "Update available.";
+ case "unknown":
+ return null;
+ case "current":
+ return null;
+ }
+}
+
+export function createProviderVersionAdvisory(input: {
+ readonly driver: ProviderDriverKind;
+ readonly currentVersion: string | null;
+ readonly latestVersion?: string | null;
+ readonly checkedAt?: string | null;
+}): ServerProviderVersionAdvisory {
+ const lifecycle = getProviderVersionLifecycle(input.driver);
+ const testedVersion = getProviderTestedVersion(input.driver);
+ const latestVersion = input.latestVersion ?? null;
+ const status = deriveVersionAdvisoryStatus({
+ currentVersion: input.currentVersion,
+ testedVersion,
+ latestVersion,
+ });
+
+ return {
+ status,
+ currentVersion: input.currentVersion,
+ testedVersion,
+ latestVersion,
+ updateCommand: lifecycle.updateCommand,
+ checkedAt: input.checkedAt ?? null,
+ message: advisoryMessage({ status, testedVersion, latestVersion }),
+ };
+}
+
+async function fetchNpmLatestVersion(packageName: string): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), LATEST_VERSION_TIMEOUT_MS);
+ try {
+ const response = await fetch(
+ `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`,
+ {
+ signal: controller.signal,
+ headers: { accept: "application/json" },
+ },
+ );
+ if (!response.ok) {
+ return null;
+ }
+ const payload = (await response.json()) as { version?: unknown };
+ return nonEmptyString(payload.version);
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+export async function resolveLatestProviderVersion(
+ provider: ProviderDriverKind,
+): Promise {
+ const lifecycle = getProviderVersionLifecycle(provider);
+ if (!lifecycle.packageName) {
+ return null;
+ }
+
+ const cached = latestVersionCache.get(lifecycle.packageName);
+ const now = Date.now();
+ if (cached && cached.expiresAt > now) {
+ return cached.version;
+ }
+
+ const version = await fetchNpmLatestVersion(lifecycle.packageName);
+ latestVersionCache.set(lifecycle.packageName, {
+ expiresAt: now + LATEST_VERSION_CACHE_TTL_MS,
+ version,
+ });
+ return version;
+}
+
+export async function enrichProviderSnapshotWithVersionAdvisory(
+ snapshot: ServerProvider,
+): Promise {
+ if (!snapshot.enabled || !snapshot.installed || !snapshot.version) {
+ return {
+ ...snapshot,
+ versionAdvisory: createProviderVersionAdvisory({
+ driver: snapshot.driver,
+ currentVersion: snapshot.version,
+ checkedAt: snapshot.checkedAt,
+ }),
+ };
+ }
+
+ const latestVersion = await resolveLatestProviderVersion(snapshot.driver);
+ return {
+ ...snapshot,
+ versionAdvisory: createProviderVersionAdvisory({
+ driver: snapshot.driver,
+ currentVersion: snapshot.version,
+ latestVersion,
+ checkedAt: new Date().toISOString(),
+ }),
+ };
+}
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx
index 236e1db565..f09907d768 100644
--- a/apps/web/src/components/settings/ProviderInstanceCard.tsx
+++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
+import { ChevronDownIcon, CopyIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import {
isProviderDriverKind,
@@ -13,12 +13,14 @@ import {
} from "@t3tools/contracts";
import { cn } from "../../lib/utils";
+import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { normalizeProviderAccentColor } from "../../providerInstances";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import { DraftInput } from "../ui/draft-input";
import { Switch } from "../ui/switch";
+import { stackedThreadToast, toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { DriverOption } from "./providerDriverMeta";
import { ProviderSettingsForm } from "./ProviderSettingsForm";
@@ -26,6 +28,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection";
import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
import { RedactedSensitiveText } from "./RedactedSensitiveText";
import {
+ getProviderVersionAdvisoryPresentation,
PROVIDER_STATUS_STYLES,
getProviderSummary,
getProviderVersionLabel,
@@ -464,10 +467,29 @@ export function ProviderInstanceCard({
: null;
const summary = rawSummary;
const versionLabel = getProviderVersionLabel(liveProvider?.version);
+ const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
const FallbackIconComponent = driverOption?.icon;
const displayName =
instance.displayName?.trim() || driverOption?.label || String(instance.driver);
const accentColor = normalizeProviderAccentColor(instance.accentColor);
+ const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({
+ onCopy: ({ providerName }) => {
+ toastManager.add({
+ type: "success",
+ title: `${providerName} update command copied`,
+ description: "Run it in a terminal when you are ready to update.",
+ });
+ },
+ onError: (error, { providerName }) => {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: `Could not copy ${providerName} update command`,
+ description: error.message,
+ }),
+ );
+ },
+ });
// Narrow `instance.driver` for callers that key on the closed
// `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom
@@ -626,6 +648,42 @@ export function ProviderInstanceCard({
)}
{summary.detail ? - {summary.detail} : null}
+ {versionAdvisory ? (
+
+
+ {versionAdvisory.detail}
+
+ {versionAdvisory.updateCommand ? (
+
+
+ copyToClipboard(versionAdvisory.updateCommand, {
+ providerName: displayName,
+ })
+ }
+ >
+
+ Copy command
+
+ }
+ />
+ {versionAdvisory.updateCommand}
+
+ ) : null}
+
+ ) : null}