{/* Sidebar */}
@@ -887,7 +953,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
model={model}
instanceId={model.instanceId}
driverKind={model.driverKind}
- providerDisplayName={model.instanceDisplayName}
+ providerDisplayName={
+ model.isFusionGroup && model.fusion
+ ? `${model.instanceDisplayName} · ${model.fusion.lead.name} + ${model.fusion.sidekick.name}`
+ : model.instanceDisplayName
+ }
providerAccentColor={model.instanceAccentColor}
isFavorite={favoritesSet.has(
providerModelKey(model.instanceId, model.slug),
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index 6f777399451d..a0034bf4f0e8 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -1,3 +1,4 @@
+import { resolveProviderModelPolicy } from "@t3tools/contracts";
import {
ANTIGRAVITY_DEFAULT_MODEL,
type ProviderInstanceId,
@@ -78,14 +79,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
model: props.model,
options: selectedInstanceOptions,
}) ??
- (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity"
+ (resolveProviderModelPolicy(activeEntry?.snapshot).preserveUnavailableModels
? undefined
: selectedInstanceOptions[0]);
- const triggerTitle = selectedModel
- ? getTriggerDisplayModelName(selectedModel)
- : props.model === ANTIGRAVITY_DEFAULT_MODEL
- ? "Choose model"
- : props.model || "Choose model";
+ const triggerTitle = selectedModel?.fusion
+ ? "Fusion"
+ : selectedModel
+ ? getTriggerDisplayModelName(selectedModel)
+ : props.model === ANTIGRAVITY_DEFAULT_MODEL
+ ? "Choose model"
+ : props.model || "Choose model";
const triggerLabel = selectedModel
? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}`
: triggerTitle;
@@ -224,8 +227,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
=>
@@ -217,6 +222,7 @@ function getSelectedTraits(
function getTraitsSectionVisibility(input: {
provider: ProviderDriverKind;
+ modelPolicy?: ServerProvider["modelPolicy"];
models: ReadonlyArray;
model: string | null | undefined;
prompt: string;
@@ -232,6 +238,7 @@ function getTraitsSectionVisibility(input: {
input.modelOptions,
input.allowPromptInjectedEffort ?? true,
input.planModeEnabled,
+ input.modelPolicy,
);
const showEffort = selected.primarySelectDescriptor !== null;
@@ -259,6 +266,7 @@ function getTraitsSectionVisibility(input: {
export function shouldRenderTraitsControls(input: {
provider: ProviderDriverKind;
+ modelPolicy?: ServerProvider["modelPolicy"];
models: ReadonlyArray;
model: string | null | undefined;
prompt: string;
@@ -271,6 +279,7 @@ export function shouldRenderTraitsControls(input: {
export interface TraitsMenuContentProps {
provider: ProviderDriverKind;
+ modelPolicy?: ServerProvider["modelPolicy"];
instanceId?: ProviderInstanceId;
models: ReadonlyArray;
model: string | null | undefined;
@@ -286,6 +295,7 @@ export interface TraitsMenuContentProps {
export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
provider,
+ modelPolicy,
instanceId,
models,
model,
@@ -326,6 +336,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
modelIsUnavailable,
} = getTraitsSectionVisibility({
provider,
+ modelPolicy,
models,
model,
prompt,
@@ -538,6 +549,7 @@ export function buildTraitsTriggerDisplay(input: {
export const TraitsPicker = memo(function TraitsPicker({
provider,
+ modelPolicy,
instanceId,
models,
model,
@@ -561,6 +573,7 @@ export const TraitsPicker = memo(function TraitsPicker({
const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
getTraitsSectionVisibility({
provider,
+ modelPolicy,
models,
model,
prompt,
@@ -571,6 +584,7 @@ export const TraitsPicker = memo(function TraitsPicker({
if (
!shouldRenderTraitsControls({
provider,
+ modelPolicy,
models,
model,
prompt,
@@ -650,6 +664,7 @@ export const TraitsPicker = memo(function TraitsPicker({
{
expect(renderProviderTraitsMenuContent(args)).toBeNull();
});
});
+
+it("preserves exact catalog options for an unknown driver", () => {
+ const modelOptions = selections(["reasoningEffort", "max"], ["fastMode", true]);
+ const models = modelWith([
+ selectDescriptor("reasoningEffort", [{ id: "high", label: "High", isDefault: true }]),
+ ]);
+ const state = getComposerProviderState({
+ provider: ProviderDriverKind.make("test-account-provider"),
+ modelPolicy: { optionSelection: "exact" },
+ model: MODEL,
+ models,
+ modelOptions,
+ planModeEnabled: false,
+ });
+ expect(state.modelOptionsForDispatch).toEqual(modelOptions);
+ const defaultState = getComposerProviderState({
+ provider: ProviderDriverKind.make("test-account-provider"),
+ modelPolicy: { optionSelection: "exact" },
+ model: MODEL,
+ models: modelWith([{ id: "fastMode", label: "Fast", type: "boolean", currentValue: true }]),
+ modelOptions: undefined,
+ planModeEnabled: false,
+ });
+ expect(defaultState.modelOptionsForDispatch).toBeUndefined();
+ const descriptors = getProviderOptionDescriptors({
+ caps: models[0]!.capabilities!,
+ selections: modelOptions,
+ preserveUnavailableSelections: true,
+ });
+ expect(descriptors[0]?.currentValue).toBe("max");
+ expect(descriptors[1]?.currentValue).toBe(true);
+ expect(descriptors[0]?.type === "select" && descriptors[0].options.at(-1)?.label).toContain(
+ "Unavailable",
+ );
+});
diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx
index c6837700cdfa..828c6dfe9f75 100644
--- a/apps/web/src/components/chat/composerProviderState.tsx
+++ b/apps/web/src/components/chat/composerProviderState.tsx
@@ -1,10 +1,12 @@
import {
+ resolveProviderModelPolicy,
type ModelCapabilities,
type ProviderDriverKind,
type ProviderInstanceId,
type ProviderOptionSelection,
type ScopedThreadRef,
type ServerProviderModel,
+ type ServerProvider,
} from "@t3tools/contracts";
import {
buildExplicitProviderOptionSelectionsFromDescriptors,
@@ -24,6 +26,7 @@ import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./T
export type ComposerProviderStateInput = {
provider: ProviderDriverKind;
+ modelPolicy?: ServerProvider["modelPolicy"];
model: string;
models: ReadonlyArray;
promptInjectionState?: ComposerPromptInjectionState;
@@ -44,6 +47,7 @@ export type ComposerProviderState = {
type TraitsRenderInput = {
provider: ProviderDriverKind;
+ modelPolicy?: ServerProvider["modelPolicy"];
instanceId?: ProviderInstanceId;
threadRef?: ScopedThreadRef;
draftId?: DraftId;
@@ -94,12 +98,19 @@ function resolveComposerOptionSelections(
provider: ProviderDriverKind,
modelOptions: ReadonlyArray | null | undefined,
planModeEnabled: boolean,
+ modelPolicy: ServerProvider["modelPolicy"],
): {
caps: ModelCapabilities;
selections: ReadonlyArray | undefined;
} {
const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
- return { caps, selections: withImplicitFastModeDefault(caps, modelOptions) };
+ return {
+ caps,
+ selections:
+ resolveProviderModelPolicy({ driver: provider, modelPolicy }).optionSelection === "exact"
+ ? (modelOptions ?? undefined)
+ : withImplicitFastModeDefault(caps, modelOptions),
+ };
}
export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
@@ -110,6 +121,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
modelOptions,
promptInjectionState = "none",
planModeEnabled,
+ modelPolicy,
} = input;
if (provider === "opencode") {
const normalizedModel = normalizeModelSlug(model, provider);
@@ -132,8 +144,14 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
provider,
modelOptions,
planModeEnabled,
+ modelPolicy,
);
- const descriptors = getProviderOptionDescriptors({ caps, selections });
+ const descriptors = getProviderOptionDescriptors({
+ caps,
+ selections,
+ preserveUnavailableSelections:
+ resolveProviderModelPolicy({ driver: provider, modelPolicy }).optionSelection === "exact",
+ });
const primarySelectDescriptor = descriptors.find(
(descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> =>
descriptor.type === "select",
@@ -178,6 +196,7 @@ function renderTraitsControl(
planModeEnabled,
size,
hidden,
+ modelPolicy,
triggerVariant,
triggerClassName,
isComposerOwned,
@@ -189,11 +208,13 @@ function renderTraitsControl(
provider,
modelOptions,
planModeEnabled,
+ modelPolicy,
);
if (
!hasTarget ||
!shouldRenderTraitsControls({
provider,
+ modelPolicy,
models,
model,
modelOptions: resolvedModelOptions,
@@ -206,6 +227,7 @@ function renderTraitsControl(
return (
{
+ it("shows one Fusion entry per account and preserves the selected pairing", () => {
+ const entries = [...models, { ...models[1]!, instanceId: otherInstanceId }];
+ const result = collapseFusionModels(entries, instanceId, "opus-glm");
+ expect(result.map((model) => [model.instanceId, model.slug, model.name])).toEqual([
+ [instanceId, "swe", "SWE-2"],
+ [instanceId, "opus-glm", "Fusion"],
+ [otherInstanceId, "fable-swe", "Fusion"],
+ ]);
+ });
+
+ it("keeps a removed saved pairing visible alongside the available Fusion entry", () => {
+ const unavailable = { ...models[1]!, slug: "old-fusion", isUnavailable: true };
+ expect(
+ collapseFusionModels([...models, unavailable], instanceId, "old-fusion").map(
+ (model) => model.slug,
+ ),
+ ).toEqual(["swe", "fable-swe", "old-fusion"]);
+ });
+
+ it("opens a matching pairing when a search excludes the selected one", () => {
+ const matches = models.filter((model) => model.fusion?.lead.id === "fable");
+ expect(collapseFusionModels(matches, instanceId, "opus-glm")[0]?.slug).toBe("fable-swe");
+ });
+
+ it("keeps the sidekick across leads and falls back only to an offered pairing", () => {
+ expect(findFusionLeadPairing(models, "opus", "swe")?.slug).toBe("opus-swe");
+ expect(findFusionLeadPairing(models, "fable", "glm")?.slug).toBe("fable-swe");
+ expect(findFusionLeadPairing(models, "missing", "swe")).toBeUndefined();
+ expect(
+ findFusionLeadPairing(
+ models.map((model) => ({ ...model, isUnavailable: true })),
+ "opus",
+ "swe",
+ ),
+ ).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/components/chat/fusionModelPicker.ts b/apps/web/src/components/chat/fusionModelPicker.ts
new file mode 100644
index 000000000000..8a62bc8b7fe1
--- /dev/null
+++ b/apps/web/src/components/chat/fusionModelPicker.ts
@@ -0,0 +1,39 @@
+import type { ProviderInstanceId } from "@t3tools/contracts";
+import type { ModelEsque } from "./providerIconUtils";
+
+/** One entry per account opens the pairing editor; saved model IDs remain unchanged. */
+export function collapseFusionModels(
+ models: ReadonlyArray,
+ activeInstanceId: ProviderInstanceId,
+ activeModel: string,
+): T[] {
+ const seen = new Set();
+ return models.flatMap((model) => {
+ if (!model.fusion || model.isUnavailable) return [model];
+ if (seen.has(model.instanceId)) return [];
+ seen.add(model.instanceId);
+ const active =
+ model.instanceId === activeInstanceId
+ ? models.find(
+ (entry) =>
+ entry.instanceId === activeInstanceId &&
+ entry.slug === activeModel &&
+ entry.fusion &&
+ !entry.isUnavailable,
+ )
+ : undefined;
+ return [{ ...(active ?? model), name: "Fusion", shortName: "Fusion", isFusionGroup: true }];
+ });
+}
+
+/** Preserve the sidekick when changing leads, falling back only to an offered pairing. */
+export function findFusionLeadPairing(
+ models: ReadonlyArray,
+ leadId: string,
+ sidekickId: string,
+) {
+ const candidates = models.filter(
+ (model) => model.fusion?.lead.id === leadId && !model.isUnavailable,
+ );
+ return candidates.find((model) => model.fusion?.sidekick.id === sidekickId) ?? candidates[0];
+}
diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts
index db0e5ca222f3..1c284f6305d6 100644
--- a/apps/web/src/components/chat/providerIconUtils.ts
+++ b/apps/web/src/components/chat/providerIconUtils.ts
@@ -1,9 +1,10 @@
-import { ProviderDriverKind } from "@t3tools/contracts";
+import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";
import {
AntigravityIcon,
ClaudeAI,
CursorIcon,
GrokIcon,
+ DevinIcon,
Icon,
OpenAI,
OpenCodeIcon,
@@ -15,10 +16,13 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial
[ProviderDriverKind.make("opencode")]: OpenCodeIcon,
[ProviderDriverKind.make("cursor")]: CursorIcon,
[ProviderDriverKind.make("grok")]: GrokIcon,
+ [ProviderDriverKind.make("devin")]: DevinIcon,
[ProviderDriverKind.make("antigravity")]: AntigravityIcon,
};
export type ModelEsque = {
+ isFusionGroup?: boolean;
+ fusion?: ServerProviderModel["fusion"];
slug: string;
name: string;
shortName?: string | undefined;
diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
index a08b918be4c5..aa84917eab20 100644
--- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
+++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
@@ -201,6 +201,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
{!mixedModel ? (
provider.instanceId === input.selectedInstanceId,
+ );
+ const isInstanceCatalog =
+ resolveProviderModelPolicy(selectedSnapshot ?? { driver: input.selectedProvider })
+ .catalogScope === "instance" ||
+ (input.selectedInstanceId != null && !selectedSnapshot);
const baseModelCandidate =
input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null;
const preserveThreadModel =
@@ -1200,8 +1208,8 @@ export function deriveEffectiveComposerModelState(input: {
{ preserveUnavailableSelection: preserveThreadModel },
)
: null) ??
- // Antigravity has no static model or cross-account catalog fallback.
- (input.selectedProvider === "antigravity" && input.selectedInstanceId ? "" : null) ??
+ // Account catalogs have no static model or cross-account fallback.
+ (isInstanceCatalog && input.selectedInstanceId ? "" : null) ??
resolveAppModelSelection(
input.selectedProvider,
input.settings,
@@ -1218,7 +1226,7 @@ export function deriveEffectiveComposerModelState(input: {
? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId]
: undefined;
const legacySelection =
- input.selectedProvider === "antigravity" &&
+ isInstanceCatalog &&
input.selectedInstanceId &&
input.selectedInstanceId !== defaultInstanceIdForDriver(input.selectedProvider)
? undefined
@@ -1235,7 +1243,7 @@ export function deriveEffectiveComposerModelState(input: {
activeSelection.model,
{ preserveUnavailableSelection: true },
) ??
- (input.selectedProvider === "antigravity" ? "" : null) ??
+ (isInstanceCatalog ? "" : null) ??
resolveAppModelSelection(
input.selectedProvider,
input.settings,
diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts
index 959a5eb8c481..313c4fdee424 100644
--- a/apps/web/src/modelSelection.test.ts
+++ b/apps/web/src/modelSelection.test.ts
@@ -66,6 +66,35 @@ function settingsWithProviderInstances(): UnifiedSettings {
}
describe("instance-scoped model selection", () => {
+ it("uses advertised catalog policy for an unknown driver", () => {
+ const instanceId = ProviderInstanceId.make("test-account");
+ const snapshot: ServerProvider = {
+ ...provider({ instanceId, provider: ProviderDriverKind.make("test-driver") }),
+ modelPolicy: { catalogScope: "instance", preserveUnavailableModels: true },
+ };
+ const entry = deriveProviderInstanceEntries([snapshot])[0]!;
+ const settings = settingsWithProviderInstances();
+ expect(getAppModelOptionsForInstance(settings, entry, "retired-model")).toEqual([
+ { slug: "retired-model", name: "retired-model", isCustom: false, isUnavailable: true },
+ ]);
+ expect(
+ resolveAppModelSelectionForInstance(instanceId, settings, [snapshot], "retired-model", {
+ preserveUnavailableSelection: true,
+ }),
+ ).toBe("retired-model");
+ expect(
+ deriveEffectiveComposerModelState({
+ draft: null,
+ providers: [snapshot],
+ selectedProvider: snapshot.driver,
+ selectedInstanceId: instanceId,
+ threadModelSelection: null,
+ projectModelSelection: null,
+ settings,
+ }).selectedModel,
+ ).toBe("");
+ });
+
it("preserves server-provided legacy model metadata", () => {
const baseProvider = provider({
instanceId: "claudeAgent",
@@ -350,6 +379,7 @@ describe("instance-scoped model selection", () => {
availableModel: "gemini-3.1-pro",
missingModel: "gemini-3.1-pro-high",
},
+ { driverName: "devin", availableModel: "swe-2-medium", missingModel: "swe-2-high" },
])("$driverName catalog gaps", ({ driverName, availableModel, missingModel }) => {
it("preserves a selected model when a catalog refresh no longer contains it", () => {
const providers = [
diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts
index 1a39f06098ac..9170968b2dba 100644
--- a/apps/web/src/modelSelection.ts
+++ b/apps/web/src/modelSelection.ts
@@ -1,4 +1,5 @@
import {
+ resolveProviderModelPolicy,
ANTIGRAVITY_DEFAULT_MODEL,
DEFAULT_TEXT_GENERATION_MODEL,
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
@@ -79,6 +80,7 @@ function readInstanceCustomModels(
}
export interface AppModelOption {
+ fusion?: ServerProvider["models"][number]["fusion"];
slug: string;
name: string;
shortName?: string;
@@ -97,8 +99,9 @@ function appendUnavailableDynamicModelSelection(
provider: ProviderDriverKind,
selectedModel: string | null | undefined,
hiddenModels: ReadonlyArray,
+ modelPolicy: ServerProvider["modelPolicy"],
): AppModelOption[] {
- if (provider !== "opencode" && provider !== "antigravity") return options;
+ if (!modelPolicy?.preserveUnavailableModels) return options;
const slug = normalizeCustomModelSlug(selectedModel);
if (!slug) return options;
if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options;
@@ -119,6 +122,7 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti
isCustom: model.isCustom,
};
if (model.shortName) option.shortName = model.shortName;
+ if (model.fusion) option.fusion = model.fusion;
if (model.subProvider) option.subProvider = model.subProvider;
if (model.aliases) option.aliases = model.aliases;
if (model.badge) option.badge = model.badge;
@@ -221,6 +225,9 @@ function getAppModelOptions(
provider,
selectedModel,
preferences.hiddenModels,
+ resolveProviderModelPolicy(
+ providers.find((entry) => entry.instanceId === defaultInstanceId) ?? { driver: provider },
+ ),
);
}
@@ -269,6 +276,7 @@ export function getAppModelOptionsForInstance(
entry.driverKind,
selectedModel,
preferences.hiddenModels,
+ resolveProviderModelPolicy(entry.snapshot),
);
}
@@ -308,7 +316,7 @@ export function resolveAppModelSelectionForInstance(
}
if (
resolutionOptions?.preserveUnavailableSelection &&
- (entry.driverKind === "opencode" || entry.driverKind === "antigravity")
+ resolveProviderModelPolicy(entry.snapshot).preserveUnavailableModels
) {
const unavailableSelection = normalizeCustomModelSlug(selectedModel);
const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels;
@@ -432,6 +440,7 @@ export function resolveAppModelSelectionState(
provider,
model,
models: entry.models,
+ modelPolicy: resolveProviderModelPolicy(entry.snapshot),
modelOptions: selectedEntry ? selection.options : undefined,
planModeEnabled: settings.planModeEnabled,
});
diff --git a/docs/README.md b/docs/README.md
index 4691e6f83c8e..3f6d423da330 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -19,7 +19,7 @@
- [Remote access](./user/remote-access.md)
- [Running in the background](./user/background-service.md)
- [Updating T3 Code](./user/updating.md)
-- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md)
+- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Devin](./user/providers-devin.md)
---
diff --git a/docs/user/install.md b/docs/user/install.md
index a4ed171bd986..92b95cd39420 100644
--- a/docs/user/install.md
+++ b/docs/user/install.md
@@ -98,6 +98,7 @@ computer.
| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. |
| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. |
| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. |
+| Devin | Install [Devin CLI](https://docs.devin.ai/cli), then run `devin auth login`. |
| Antigravity | Install and sign in with Google from T3 Code's provider settings. |
Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its
@@ -118,7 +119,7 @@ base URL. Mark secret values as sensitive; after saving, T3 Code does not displa
their original values.
For provider-specific setup and accounts, see [Codex](./providers-codex.md),
-[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), and
+[Claude](./providers-claude.md), [Devin](./providers-devin.md), [OpenCode](./providers-opencode.md), and
[Antigravity](./providers-antigravity.md).
## Next steps
diff --git a/docs/user/providers-devin.md b/docs/user/providers-devin.md
new file mode 100644
index 000000000000..9c5161b9c8e0
--- /dev/null
+++ b/docs/user/providers-devin.md
@@ -0,0 +1,23 @@
+# Devin
+
+Devin runs locally on your environment through its CLI. Install [Devin CLI](https://docs.devin.ai/cli), run `devin auth login` on the machine running T3 Code, then enable **Devin** in **Settings → Providers**.
+
+If `devin` launches Devin Desktop, set **Binary path** to the actual CLI executable. The Desktop launcher does not support ACP.
+
+Available models load from `devin models list --format json` when provider status refreshes, before you start a chat. Refresh **Settings → Providers** after changing your Devin subscription. Choose a model family, then set its thinking level and, where available, Fast mode or context window. Workspace commands load when the session opens.
+
+For [Fusion](https://docs.devin.ai/cli/fusion), use Devin CLI 3000.10.20 or newer with a paid plan, then refresh provider status. Choose **Fusion** in the model picker, select a lead and sidekick, then choose **Use Fusion**. Set the lead's thinking level and Fast mode with the composer controls. Switch to a regular model to leave Fusion in the same thread.
+
+Select a model from your account's catalog. Saved threads resume their Devin session, and model changes apply within the same thread. A temporary refresh failure keeps the last available model list; signing out clears it.
+
+T3 forwards permission requests and supports Plan mode, cancellation, images, file attachments, and `/compact`. Auto mode uses Devin's Smart mode when available and asks for approval otherwise. Conversation rewind is not supported by Devin ACP.
+
+Type `$` in the composer to select a skill discovered by Devin CLI for your workspace. T3 invokes the selected skill with the rest of your message as its arguments. Devin supports one explicit skill invocation per message; native `/skill-name` commands work too. Skills can use T3's browser and device tools when those capabilities are available on your connected environment.
+
+For remote connections, installation and sign-in happen on the server machine. Provider instances can use separate environments, including `WINDSURF_API_KEY` for API-key authentication.
+
+If the CLI is signed in in your terminal but T3 reports **Not authenticated**, compare the environment variables used by each. On Linux and macOS, Devin stores credentials under `$XDG_DATA_HOME/devin` when set, otherwise `~/.local/share/devin`; on Windows it uses `%APPDATA%\devin`. See [Devin's credential locations](https://docs.devin.ai/cli/enterprise/devin-auth#credentials-file-location).
+
+Use the same credential directory in the provider instance's **Environment variables**, or run `devin auth login` with that instance's environment, then refresh provider status. Custom launchers should isolate T3 with `T3CODE_HOME` without redirecting `XDG_DATA_HOME` for provider processes.
+
+For generated titles and source-control text, choose another provider under **Settings → General → Text generation model**.
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 0882aef51d05..5f7d5930228e 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -221,5 +221,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial>
[CLAUDE_DRIVER_KIND]: "Claude",
[CURSOR_DRIVER_KIND]: "Cursor",
[GROK_DRIVER_KIND]: "Grok",
+ [ProviderDriverKind.make("devin")]: "Devin",
[OPENCODE_DRIVER_KIND]: "OpenCode",
};
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index c2fc8524ece2..764e4c825949 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -76,6 +76,13 @@ export const ServerProviderModel = Schema.Struct({
isCustom: Schema.Boolean,
isDefault: Schema.optional(Schema.Boolean),
isLegacy: Schema.optional(Schema.Boolean),
+ /** Lets clients configure a Fusion pairing without parsing its display label or native ID. */
+ fusion: Schema.optional(
+ Schema.Struct({
+ lead: Schema.Struct({ id: TrimmedNonEmptyString, name: TrimmedNonEmptyString }),
+ sidekick: Schema.Struct({ id: TrimmedNonEmptyString, name: TrimmedNonEmptyString }),
+ }),
+ ),
capabilities: Schema.NullOr(ModelCapabilities),
});
export type ServerProviderModel = typeof ServerProviderModel.Type;
@@ -203,6 +210,15 @@ export const ServerProvider = Schema.Struct({
requiresNewThreadForModelChange: Schema.optional(Schema.Boolean),
supportsConversationRollback: Schema.optional(Schema.Boolean),
supportsTextGeneration: Schema.optional(Schema.Boolean),
+ // Instance catalogs cannot borrow models from another account or static defaults.
+ modelPolicy: Schema.optional(
+ Schema.Struct({
+ catalogScope: Schema.optional(Schema.Literal("instance")),
+ preserveUnavailableModels: Schema.optional(Schema.Boolean),
+ // Exact options select native variants; never coerce a saved choice or inject defaults.
+ optionSelection: Schema.optional(Schema.Literal("exact")),
+ }),
+ ),
setup: Schema.optional(
Schema.Struct({
canAuthenticate: Schema.Boolean,
@@ -238,6 +254,28 @@ export const ServerProvider = Schema.Struct({
});
export type ServerProvider = typeof ServerProvider.Type;
+const LEGACY_MODEL_POLICIES = new Map>([
+ ["antigravity", { catalogScope: "instance", preserveUnavailableModels: true }],
+ ["opencode", { preserveUnavailableModels: true }],
+ [
+ "devin",
+ { catalogScope: "instance", preserveUnavailableModels: true, optionSelection: "exact" },
+ ],
+]);
+
+/** Older environments lack policy metadata. Keep their behavior at this compatibility boundary. */
+export function resolveProviderModelPolicy(
+ provider:
+ | {
+ readonly driver?: string;
+ readonly modelPolicy?: ServerProvider["modelPolicy"];
+ }
+ | null
+ | undefined,
+): NonNullable {
+ return provider?.modelPolicy ?? LEGACY_MODEL_POLICIES.get(provider?.driver ?? "") ?? {};
+}
+
// Provider status kinds grow over time (ServerProviderState,
// ServerProviderAuthStatus, ServerProviderVersionAdvisoryStatus,
// ServerProviderUpdateStatus); an older client must not fail the whole config
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 7b3715d704be..6a67cda2a6bf 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -730,6 +730,32 @@ export const GrokSettings = makeProviderSettingsSchema(
);
export type GrokSettings = typeof GrokSettings.Type;
+export const DevinSettings = makeProviderSettingsSchema(
+ {
+ // Enable explicitly after installing and signing in to Devin CLI.
+ enabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(false)),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ binaryPath: makeBinaryPathSetting("devin").pipe(
+ Schema.annotateKey({
+ title: "Binary path",
+ description:
+ "Path to Devin CLI (not the Devin Desktop launcher). Sign in with devin auth login on this environment.",
+ providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "omit" },
+ }),
+ ),
+ customModels: Schema.Array(CustomModelSetting).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ },
+ {
+ order: ["binaryPath"],
+ },
+);
+export type DevinSettings = typeof DevinSettings.Type;
+
/**
* Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in
* in the browser. The API key and Agent Platform methods take credentials from
@@ -1168,6 +1194,7 @@ export const ServerSettings = Schema.Struct({
claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ devin: DevinSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
}).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
@@ -1325,6 +1352,12 @@ const GrokSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
});
+const DevinSettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(TrimmedString),
+ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
+});
+
const AntigravitySettingsPatch = Schema.Struct({
enabled: Schema.optionalKey(Schema.Boolean),
authMethod: Schema.optionalKey(AntigravityAuthMethod),
@@ -1415,6 +1448,7 @@ export const ServerSettingsPatch = Schema.Struct({
claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
cursor: Schema.optionalKey(CursorSettingsPatch),
grok: Schema.optionalKey(GrokSettingsPatch),
+ devin: Schema.optionalKey(DevinSettingsPatch),
opencode: Schema.optionalKey(OpenCodeSettingsPatch),
antigravity: Schema.optionalKey(AntigravitySettingsPatch),
}),
diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts
index b3bcf3cd2433..108ae8ef3187 100644
--- a/packages/effect-acp/src/client.ts
+++ b/packages/effect-acp/src/client.ts
@@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
import * as AcpError from "./errors.ts";
import * as AcpProtocol from "./protocol.ts";
import * as AcpRpcs from "./rpc.ts";
-import * as AcpSchema from "./_generated/schema.gen.ts";
+import * as AcpSchema from "./schema.ts";
import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts";
import {
callRpc,
diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts
index 5026645374eb..6a93d2d15c29 100644
--- a/packages/effect-acp/src/rpc.ts
+++ b/packages/effect-acp/src/rpc.ts
@@ -1,7 +1,7 @@
import * as Rpc from "effect/unstable/rpc/Rpc";
import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
-import * as AcpSchema from "./_generated/schema.gen.ts";
+import * as AcpSchema from "./schema.ts";
import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts";
const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, {
diff --git a/packages/effect-acp/src/schema.ts b/packages/effect-acp/src/schema.ts
index 8e354aca3701..da249114c8d3 100644
--- a/packages/effect-acp/src/schema.ts
+++ b/packages/effect-acp/src/schema.ts
@@ -1,2 +1,23 @@
+import * as Schema from "effect/Schema";
+import * as Generated from "./_generated/schema.gen.ts";
+
export * from "./_generated/schema.gen.ts";
export * from "./_generated/meta.gen.ts";
+
+// Agents already use additionalDirectories; preserve it until the generated ACP schema includes it.
+const additionalDirectories = Schema.optionalKey(Schema.Array(Schema.String));
+export const NewSessionRequest = Schema.Struct({
+ ...Generated.NewSessionRequest.fields,
+ additionalDirectories,
+});
+export type NewSessionRequest = typeof NewSessionRequest.Type;
+export const LoadSessionRequest = Schema.Struct({
+ ...Generated.LoadSessionRequest.fields,
+ additionalDirectories,
+});
+export type LoadSessionRequest = typeof LoadSessionRequest.Type;
+export const ResumeSessionRequest = Schema.Struct({
+ ...Generated.ResumeSessionRequest.fields,
+ additionalDirectories,
+});
+export type ResumeSessionRequest = typeof ResumeSessionRequest.Type;
diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts
index b3c278a1ac36..b6eae0be4141 100644
--- a/packages/shared/src/model.ts
+++ b/packages/shared/src/model.ts
@@ -141,10 +141,50 @@ function withDescriptorCurrentValue(
export function getProviderOptionDescriptors(input: {
caps: ModelCapabilities;
selections?: ReadonlyArray | null | undefined;
+ preserveUnavailableSelections?: boolean;
}): ReadonlyArray {
const { caps, selections } = input;
const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor);
+ // Account catalogs can lose choices. Keep explicit selections visible and let
+ // the provider reject them instead of silently dispatching a different variant.
+ if (input.preserveUnavailableSelections) {
+ for (const selection of selections ?? []) {
+ const index = baseDescriptors.findIndex((descriptor) => descriptor.id === selection.id);
+ const descriptor = baseDescriptors[index];
+ if (!descriptor) {
+ baseDescriptors.push(
+ typeof selection.value === "boolean"
+ ? {
+ id: selection.id,
+ label: `${selection.id} (Unavailable)`,
+ type: "boolean",
+ currentValue: selection.value,
+ }
+ : {
+ id: selection.id,
+ label: selection.id,
+ type: "select",
+ currentValue: selection.value,
+ options: [{ id: selection.value, label: `${selection.value} (Unavailable)` }],
+ },
+ );
+ } else if (
+ descriptor.type === "select" &&
+ typeof selection.value === "string" &&
+ !descriptor.options.some((option) => option.id === selection.value)
+ ) {
+ baseDescriptors[index] = {
+ ...descriptor,
+ options: [
+ ...descriptor.options,
+ { id: selection.value, label: `${selection.value} (Unavailable)` },
+ ],
+ };
+ }
+ }
+ }
+
return baseDescriptors.map((descriptor) =>
withDescriptorCurrentValue(
descriptor,
diff --git a/patches/react-native-shiki-engine@0.3.12.patch b/patches/react-native-shiki-engine@0.3.12.patch
new file mode 100644
index 000000000000..0ccd7e62afd8
--- /dev/null
+++ b/patches/react-native-shiki-engine@0.3.12.patch
@@ -0,0 +1,11 @@
+diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt
+--- a/android/CMakeLists.txt
++++ b/android/CMakeLists.txt
+@@ -8,5 +8,7 @@ find_library(LOG_LIB log)
+ find_library(ONIG_LIB onig
+ PATHS ${CMAKE_CURRENT_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI}
++ # Never link a host library into the Android binary.
++ NO_DEFAULT_PATH
+ NO_CMAKE_FIND_ROOT_PATH
+ REQUIRED
+ )
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5a37d9bf6fa4..a2173428649f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -104,6 +104,7 @@ patchedDependencies:
react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
react-native-reanimated@4.5.1: a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c
react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d
+ react-native-shiki-engine@0.3.12: c56bf1178f00be1dc733db0315d41c4db69a10a67639ec37fd0775b5cdb0b249
uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90
importers:
@@ -450,7 +451,7 @@ importers:
version: 4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
react-native-shiki-engine:
specifier: ^0.3.12
- version: 0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
+ version: 0.3.12(patch_hash=c56bf1178f00be1dc733db0315d41c4db69a10a67639ec37fd0775b5cdb0b249)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
react-native-svg:
specifier: 15.15.4
version: 15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
@@ -20987,7 +20988,7 @@ snapshots:
react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
warn-once: 0.1.1
- react-native-shiki-engine@0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
+ react-native-shiki-engine@0.3.12(patch_hash=c56bf1178f00be1dc733db0315d41c4db69a10a67639ec37fd0775b5cdb0b249)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
dependencies:
'@shikijs/types': 4.3.0
'@shikijs/vscode-textmate': 10.0.2
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index f121121251d8..5a94daba1154 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -177,6 +177,7 @@ patchedDependencies:
# Preserve the final layout frame. Backport of [#10171](https://github.com/software-mansion/react-native-reanimated/pull/10171).
react-native-reanimated@4.5.1: patches/react-native-reanimated@4.5.1.patch
react-native-screens@4.26.2: patches/react-native-screens@4.26.2.patch
+ react-native-shiki-engine@0.3.12: patches/react-native-shiki-engine@0.3.12.patch
uniwind@1.11.0: patches/uniwind@1.11.0.patch
peerDependencyRules: