diff --git a/AGENTS.md b/AGENTS.md
index 784b37cc47b2..ba3f8126f974 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# T3 Code
-T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients.
+T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, Kilo Code, OpenCode) and serves web, desktop, and mobile clients.
You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor.
@@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste
- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature.
- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`
-- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
+- **Providers.** Codex, Claude, Cursor, Grok, Kilo Code, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug.
- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real.
diff --git a/README.md b/README.md
index 8ec101387f67..5e49c046cd9b 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).
-Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
+Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, Kilo Code, and OpenCode. If they're set up on your computer, T3 Code can control them.
## "Wait, what are you selling me?"
@@ -13,12 +13,13 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation
> [!WARNING]
-> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
+> T3 Code currently supports Codex, Claude, Cursor, Grok Build, Kilo Code, and OpenCode. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
+> - Kilo Code: install [Kilo CLI](https://kilo.ai/docs/code-with-ai/platforms/cli) and run `kilo auth login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
### Try it out (install-free)
diff --git a/apps/marketing/public/harnesses/kilo-dark.svg b/apps/marketing/public/harnesses/kilo-dark.svg
new file mode 100644
index 000000000000..3af8094b5b4f
--- /dev/null
+++ b/apps/marketing/public/harnesses/kilo-dark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro
index e45cb7602873..433b7ca40389 100644
--- a/apps/marketing/src/pages/index.astro
+++ b/apps/marketing/src/pages/index.astro
@@ -49,7 +49,7 @@ const mobileEndorsementRows = [
- Orchestrate Claude Code, Codex, OpenCode, Cursor, and Grok from one surface.
+ Orchestrate Claude Code, Codex, OpenCode, Cursor, Grok, and Kilo Code from one surface.
Bring your own subscription. Fork the whole thing.
@@ -187,7 +187,7 @@ const mobileEndorsementRows = [
Bring your own sub
T3 Code doesn't resell tokens. Plug in Claude Code, Codex, OpenCode,
- Cursor, or Grok with the credentials you already have — we orchestrate
+ Cursor, Grok, or Kilo Code with the credentials you already have — we orchestrate
them, you keep your plan.
@@ -228,6 +228,13 @@ const mobileEndorsementRows = [
grok login
+
@@ -376,29 +379,33 @@ export function ProviderModelsSection({
})}
-
-
{
- setInput(event.target.value);
- if (error) setError(null);
- }}
- onKeyDown={(event) => {
- if (event.key !== "Enter") return;
- event.preventDefault();
- handleAdd();
- }}
- placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"}
- spellCheck={false}
- />
-
-
- Add
-
-
+ {supportsCustomModels ? (
+ <>
+
+
{
+ setInput(event.target.value);
+ if (error) setError(null);
+ }}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter") return;
+ event.preventDefault();
+ handleAdd();
+ }}
+ placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"}
+ spellCheck={false}
+ />
+
+
+ Add
+
+
- {error ? {error}
: null}
+ {error ? {error}
: null}
+ >
+ ) : null}
);
}
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
index 3a38a91e2265..21f73f274c90 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
@@ -370,8 +370,8 @@ export function EnvironmentProviderSettings({
}) {
const settings = useEnvironmentSettings(environmentId);
const updateSettings = useUpdateEnvironmentSettings(environmentId);
- const serverProviders =
- useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
+ const serverProvidersValue = useAtomValue(serverEnvironment.providersValueAtom(environmentId));
+ const serverProviders = serverProvidersValue ?? EMPTY_SERVER_PROVIDERS;
const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
});
@@ -403,7 +403,9 @@ export function EnvironmentProviderSettings({
provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")),
),
);
- const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
+ const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders, {
+ providersLoaded: serverProvidersValue !== null,
+ });
const textGenInstanceId = textGenerationModelSelection.instanceId;
const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
const providerHealthPreset = getBackgroundActivityPresetSettings(
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index e77c05549265..0b78b2c6ce2f 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -49,6 +49,7 @@ import {
resolveDesktopUpdateButtonAction,
} from "../../components/desktopUpdate.logic";
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
+import { ComposerControl } from "../chat/ComposerControl";
import { TraitsPicker } from "../chat/TraitsPicker";
import {
resolveEnvironmentIdentificationPillLabel,
@@ -73,13 +74,18 @@ import {
withoutPlanAgentSelection,
} from "../../modelSelection";
import {
- applyProviderInstanceSettings,
+ applyProviderInstanceSettingsToSnapshots,
deriveProviderInstanceEntries,
+ hasSelectableTextGenerationProviderSelection,
sortProviderInstanceEntries,
} from "../../providerInstances";
import { ensureLocalApi, readLocalApi } from "../../localApi";
import { isMacPlatform } from "../../lib/utils";
-import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server";
+import {
+ primaryServerConfigAtom,
+ primaryServerObservabilityAtom,
+ primaryServerProvidersAtom,
+} from "../../state/server";
import { useProjects } from "../../state/entities";
import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
import { formatRelativeTimeLabel } from "../../timestampFormat";
@@ -1861,7 +1867,13 @@ export function GeneralSettingsPanel() {
readLastEnabledProjectGroupingMode(),
);
const observability = useAtomValue(primaryServerObservabilityAtom);
+ const serverConfig = useAtomValue(primaryServerConfigAtom);
const serverProviders = useAtomValue(primaryServerProvidersAtom);
+ const providerSnapshotsLoaded = serverConfig !== null;
+ const settingsAwareServerProviders = applyProviderInstanceSettingsToSnapshots(
+ serverProviders,
+ settings,
+ );
const diagnosticsDescription = formatDiagnosticsDescription({
localTracingEnabled: observability?.localTracingEnabled ?? false,
otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
@@ -1870,12 +1882,20 @@ export function GeneralSettingsPanel() {
otlpMetricsUrl: observability?.otlpMetricsUrl,
});
- const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
+ const textGenerationModelSelection = resolveAppModelSelectionState(
+ settings,
+ settingsAwareServerProviders,
+ { providersLoaded: providerSnapshotsLoaded },
+ );
const textGenInstanceId = textGenerationModelSelection.instanceId;
const textGenModel = textGenerationModelSelection.model;
const textGenModelOptions = textGenerationModelSelection.options;
const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
- applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
+ deriveProviderInstanceEntries(settingsAwareServerProviders),
+ ).filter((entry) => entry.supportsTextGeneration);
+ const hasTextGenerationProvider = hasSelectableTextGenerationProviderSelection(
+ textGenerationModelSelection,
+ textGenerationModelInstanceEntries,
);
const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
(entry) => entry.instanceId === textGenInstanceId,
@@ -1884,7 +1904,7 @@ export function GeneralSettingsPanel() {
textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
settings,
- serverProviders,
+ settingsAwareServerProviders,
textGenInstanceId,
textGenModel,
);
@@ -2414,59 +2434,73 @@ export function GeneralSettingsPanel() {
}
control={
-
{
- updateSettings({
- textGenerationModelSelection: resolveAppModelSelectionState(
- {
- ...settings,
- textGenerationModelSelection: createModelSelection(instanceId, model),
- },
- serverProviders,
- ),
- });
- }}
- />
- {}}
- modelOptions={textGenModelOptions}
- allowPromptInjectedEffort={false}
- planModeEnabled={settings.planModeEnabled}
- triggerVariant="outline"
- triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
- onModelOptionsChange={(nextOptions) => {
- updateSettings({
- textGenerationModelSelection: resolveAppModelSelectionState(
- {
- ...settings,
- textGenerationModelSelection: createModelSelection(
- textGenInstanceId,
- textGenModel,
- nextOptions,
+ {!providerSnapshotsLoaded ? (
+
+ Loading providers…
+
+ ) : hasTextGenerationProvider ? (
+ <>
+ {
+ updateSettings({
+ textGenerationModelSelection: resolveAppModelSelectionState(
+ {
+ ...settings,
+ textGenerationModelSelection: createModelSelection(instanceId, model),
+ },
+ settingsAwareServerProviders,
+ { providersLoaded: providerSnapshotsLoaded },
),
- },
- serverProviders,
- ),
- });
- }}
- />
+ });
+ }}
+ />
+ {}}
+ modelOptions={textGenModelOptions}
+ allowPromptInjectedEffort={false}
+ planModeEnabled={settings.planModeEnabled}
+ triggerVariant="outline"
+ triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
+ onModelOptionsChange={(nextOptions) => {
+ updateSettings({
+ textGenerationModelSelection: resolveAppModelSelectionState(
+ {
+ ...settings,
+ textGenerationModelSelection: createModelSelection(
+ textGenInstanceId,
+ textGenModel,
+ nextOptions,
+ ),
+ },
+ settingsAwareServerProviders,
+ { providersLoaded: providerSnapshotsLoaded },
+ ),
+ });
+ }}
+ />
+ >
+ ) : (
+
+ No provider available
+
+ )}
}
/>
diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts b/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts
new file mode 100644
index 000000000000..b802dda43914
--- /dev/null
+++ b/apps/web/src/components/settings/SourceControlWritingSettings.logic.test.ts
@@ -0,0 +1,144 @@
+import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
+import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";
+import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveAppModelSelectionState } from "../../modelSelection";
+import {
+ applyProviderInstanceSettingsToSnapshots,
+ NO_PROVIDER_MODEL_SELECTION,
+} from "../../providerInstances";
+import {
+ resolveActiveSourceControlWriterSelection,
+ resolveSourceControlWriterToggleSelection,
+} from "./SourceControlWritingSettings.logic";
+
+describe("resolveSourceControlWriterToggleSelection", () => {
+ it("copies an available default when enabling the override", () => {
+ expect(
+ resolveSourceControlWriterToggleSelection(
+ true,
+ {
+ instanceId: ProviderInstanceId.make("claudeAgent"),
+ model: "claude-sonnet-4-6",
+ },
+ true,
+ ),
+ ).toEqual({
+ instanceId: ProviderInstanceId.make("claudeAgent"),
+ model: "claude-sonnet-4-6",
+ });
+ });
+
+ it("does not persist the local no-provider sentinel", () => {
+ expect(
+ resolveSourceControlWriterToggleSelection(true, NO_PROVIDER_MODEL_SELECTION, false),
+ ).toBe(undefined);
+ });
+
+ it("clears the override when disabling it", () => {
+ expect(
+ resolveSourceControlWriterToggleSelection(false, NO_PROVIDER_MODEL_SELECTION, false),
+ ).toBeNull();
+ });
+});
+
+describe("resolveActiveSourceControlWriterSelection", () => {
+ it("keeps a valid dedicated selection by exact identity", () => {
+ const dedicatedSelection = {
+ instanceId: ProviderInstanceId.make("dedicated_writer"),
+ model: "anthropic/dedicated-model",
+ };
+ const defaultSelection = {
+ instanceId: ProviderInstanceId.make("general_writer"),
+ model: "openai/general-model",
+ };
+
+ expect(
+ resolveActiveSourceControlWriterSelection(
+ dedicatedSelection,
+ dedicatedSelection,
+ defaultSelection,
+ ),
+ ).toBe(dedicatedSelection);
+ });
+
+ it("uses the live general selection when settings-only fallbacks are unavailable", () => {
+ const globalInstanceId = ProviderInstanceId.make("disabled_global");
+ const dedicatedInstanceId = ProviderInstanceId.make("disabled_writer");
+ const missingFallbackInstanceId = ProviderInstanceId.make("missing_fallback");
+ const liveInstanceId = ProviderInstanceId.make("live_writer");
+ const codex = ProviderDriverKind.make("codex");
+ const claude = ProviderDriverKind.make("claudeAgent");
+ const cursor = ProviderDriverKind.make("cursor");
+ const grok = ProviderDriverKind.make("grok");
+ const settings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ providers: {
+ ...DEFAULT_UNIFIED_SETTINGS.providers,
+ codex: { ...DEFAULT_UNIFIED_SETTINGS.providers.codex, enabled: false },
+ claudeAgent: { ...DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent, enabled: false },
+ cursor: { ...DEFAULT_UNIFIED_SETTINGS.providers.cursor, enabled: false },
+ grok: { ...DEFAULT_UNIFIED_SETTINGS.providers.grok, enabled: false },
+ opencode: { ...DEFAULT_UNIFIED_SETTINGS.providers.opencode, enabled: false },
+ kilo: { ...DEFAULT_UNIFIED_SETTINGS.providers.kilo, enabled: false },
+ },
+ providerInstances: {
+ [globalInstanceId]: { driver: codex, enabled: false },
+ [dedicatedInstanceId]: { driver: claude, enabled: false },
+ [missingFallbackInstanceId]: { driver: cursor, enabled: true },
+ [liveInstanceId]: { driver: grok, enabled: true },
+ },
+ textGenerationModelSelection: {
+ instanceId: globalInstanceId,
+ model: "openai/disabled-global",
+ },
+ sourceControlWriterModelSelection: {
+ instanceId: dedicatedInstanceId,
+ model: "anthropic/disabled-writer",
+ },
+ } satisfies UnifiedSettings;
+ const providers = [
+ {
+ instanceId: liveInstanceId,
+ driver: grok,
+ enabled: true,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-08-23T00:00:00.000Z",
+ models: [
+ {
+ slug: "xai/live-model",
+ name: "Live model",
+ isCustom: false,
+ capabilities: {},
+ },
+ ],
+ slashCommands: [],
+ skills: [],
+ } satisfies ServerProvider,
+ ];
+ const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings);
+ const generalSelection = resolveAppModelSelectionState(settings, effectiveProviders);
+ const sourceControlSelection = resolveSourceControlWriterModelSelection(
+ settings,
+ effectiveProviders,
+ );
+
+ expect(sourceControlSelection).toMatchObject({ instanceId: missingFallbackInstanceId });
+ expect(sourceControlSelection).not.toBe(settings.sourceControlWriterModelSelection);
+ expect(generalSelection).toMatchObject({
+ instanceId: liveInstanceId,
+ model: "xai/live-model",
+ });
+ expect(
+ resolveActiveSourceControlWriterSelection(
+ sourceControlSelection,
+ settings.sourceControlWriterModelSelection,
+ generalSelection,
+ ),
+ ).toBe(generalSelection);
+ });
+});
diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts b/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts
new file mode 100644
index 000000000000..b09ae5af2c67
--- /dev/null
+++ b/apps/web/src/components/settings/SourceControlWritingSettings.logic.ts
@@ -0,0 +1,33 @@
+import type { ModelSelection } from "@t3tools/contracts";
+import { createModelSelection } from "@t3tools/shared/model";
+
+/**
+ * Resolves the source-control writer toggle without ever persisting the
+ * local-only no-provider sentinel. `undefined` means enabling is unavailable.
+ */
+export function resolveSourceControlWriterToggleSelection(
+ checked: boolean,
+ defaultSelection: ModelSelection,
+ hasTextGenerationProvider: boolean,
+): ModelSelection | null | undefined {
+ if (!checked) return null;
+ if (!hasTextGenerationProvider) return undefined;
+ return createModelSelection(
+ defaultSelection.instanceId,
+ defaultSelection.model,
+ defaultSelection.options,
+ );
+}
+
+/**
+ * Keep the dedicated selection only when the shared resolver returned the
+ * exact configured object. Any fallback it produced is settings-only, so the
+ * already live-resolved general selection remains authoritative in the UI.
+ */
+export function resolveActiveSourceControlWriterSelection(
+ resolvedSelection: ModelSelection,
+ dedicatedSelection: ModelSelection | null,
+ defaultSelection: ModelSelection,
+): ModelSelection {
+ return resolvedSelection === dedicatedSelection ? resolvedSelection : defaultSelection;
+}
diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx
index d7c094af372b..c8260909ef1f 100644
--- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx
+++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx
@@ -2,25 +2,30 @@ import { useAtomValue } from "@effect/atom-react";
import { useRef } from "react";
import type { SourceControlWritingStyleMode } from "@t3tools/contracts";
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
-import { createModelSelection } from "@t3tools/shared/model";
import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
import {
- applyProviderInstanceSettings,
+ applyProviderInstanceSettingsToSnapshots,
deriveProviderInstanceEntries,
+ hasSelectableTextGenerationProviderSelection,
sortProviderInstanceEntries,
} from "../../providerInstances";
import {
getCustomModelOptionsByInstance,
resolveAppModelSelectionState,
} from "../../modelSelection";
-import { primaryServerProvidersAtom } from "../../state/server";
+import { primaryServerConfigAtom, primaryServerProvidersAtom } from "../../state/server";
+import { ComposerControl } from "../chat/ComposerControl";
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { Switch } from "../ui/switch";
import { Textarea } from "../ui/textarea";
import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
+import {
+ resolveActiveSourceControlWriterSelection,
+ resolveSourceControlWriterToggleSelection,
+} from "./SourceControlWritingSettings.logic";
const MODE_OPTIONS: Record =
{
@@ -43,29 +48,44 @@ const MODE_OPTIONS: Record(null);
const style = settings.sourceControlWritingStyle;
const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
const isSourceControlWritingStyleDirty =
style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions;
- const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders);
+ const defaultModelSelection = resolveAppModelSelectionState(
+ settings,
+ settingsAwareServerProviders,
+ { providersLoaded: providerSnapshotsLoaded },
+ );
const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;
const resolvedSourceControlWriterSelection = resolveSourceControlWriterModelSelection(
settings,
- serverProviders,
+ settingsAwareServerProviders,
+ );
+ const activeSelection = resolveActiveSourceControlWriterSelection(
+ resolvedSourceControlWriterSelection,
+ settings.sourceControlWriterModelSelection,
+ defaultModelSelection,
);
- const activeSelection =
- resolvedSourceControlWriterSelection === settings.textGenerationModelSelection
- ? defaultModelSelection
- : resolvedSourceControlWriterSelection;
const instanceEntries = sortProviderInstanceEntries(
- applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
+ deriveProviderInstanceEntries(settingsAwareServerProviders),
+ ).filter((entry) => entry.supportsTextGeneration);
+ const hasTextGenerationProvider = hasSelectableTextGenerationProviderSelection(
+ defaultModelSelection,
+ instanceEntries,
);
const modelOptionsByInstance = getCustomModelOptionsByInstance(
settings,
- serverProviders,
+ settingsAwareServerProviders,
activeSelection.instanceId,
activeSelection.model,
);
@@ -173,7 +193,11 @@ export function SourceControlWritingSettingsSection() {
description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."
control={
- {usesDedicatedModel ? (
+ {!providerSnapshotsLoaded ? (
+
+ Loading providers…
+
+ ) : usesDedicatedModel && hasTextGenerationProvider ? (
{
updateSettings({
- sourceControlWriterModelSelection: createModelSelection(instanceId, model),
+ sourceControlWriterModelSelection: { instanceId, model },
});
}}
/>
+ ) : !hasTextGenerationProvider ? (
+
+ No provider available
+
) : null}
- updateSettings({
- sourceControlWriterModelSelection: checked
- ? createModelSelection(
- defaultModelSelection.instanceId,
- defaultModelSelection.model,
- defaultModelSelection.options,
- )
- : null,
- })
+ disabled={
+ !usesDedicatedModel && (!providerSnapshotsLoaded || !hasTextGenerationProvider)
}
+ onCheckedChange={(checked) => {
+ const nextSelection = resolveSourceControlWriterToggleSelection(
+ Boolean(checked),
+ defaultModelSelection,
+ hasTextGenerationProvider,
+ );
+ if (nextSelection === undefined) return;
+ updateSettings({ sourceControlWriterModelSelection: nextSelection });
+ }}
aria-label="Use a separate source control writer model"
/>
diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts
index bfee6a8d6807..8eb4314d3d85 100644
--- a/apps/web/src/components/settings/providerDriverMeta.ts
+++ b/apps/web/src/components/settings/providerDriverMeta.ts
@@ -3,11 +3,20 @@ import {
CodexSettings,
CursorSettings,
GrokSettings,
+ KiloSettings,
OpenCodeSettings,
ProviderDriverKind,
} from "@t3tools/contracts";
import type * as Schema from "effect/Schema";
-import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
+import {
+ ClaudeAI,
+ CursorIcon,
+ GrokIcon,
+ KiloIcon,
+ type Icon,
+ OpenAI,
+ OpenCodeIcon,
+} from "../Icons";
type ProviderSettingsSchema = {
readonly fields: Readonly>;
@@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
badgeLabel: "Early Access",
settingsSchema: GrokSettings,
},
+ {
+ value: ProviderDriverKind.make("kilo"),
+ label: "Kilo Code",
+ icon: KiloIcon,
+ badgeLabel: "Early Access",
+ settingsSchema: KiloSettings,
+ },
{
value: ProviderDriverKind.make("opencode"),
label: "OpenCode",
diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts
index 80f7d31cf2f9..cc247de23f74 100644
--- a/apps/web/src/lib/contextWindow.ts
+++ b/apps/web/src/lib/contextWindow.ts
@@ -38,6 +38,8 @@ export function formatProviderDisplayName(provider: string | null | undefined):
return "Cursor";
case "opencode":
return "OpenCode";
+ case "kilo":
+ return "Kilo Code";
default: {
// Title-case unknown driver kinds so they read reasonably.
const trimmed = provider.replace(/Agent$/i, "").trim();
diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts
index 405366d9fcbe..2ea31e4382cf 100644
--- a/apps/web/src/modelSelection.test.ts
+++ b/apps/web/src/modelSelection.test.ts
@@ -15,6 +15,7 @@ function provider(input: {
provider?: ProviderDriverKind;
instanceId: string;
models?: ReadonlyArray;
+ supportsTextGeneration?: boolean;
}): ServerProvider {
const driver =
input.provider ??
@@ -30,6 +31,9 @@ function provider(input: {
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-01-01T00:00:00.000Z",
+ ...(input.supportsTextGeneration === undefined
+ ? {}
+ : { supportsTextGeneration: input.supportsTextGeneration }),
models: (input.models ?? []).map((slug) => ({
slug,
name: slug,
@@ -176,6 +180,39 @@ describe("instance-scoped model selection", () => {
);
});
+ it("ignores persisted Kilo custom models outside the authoritative command catalog", () => {
+ const providers = [
+ provider({
+ provider: ProviderDriverKind.make("kilo"),
+ instanceId: "kilo",
+ models: ["kilo/live"],
+ }),
+ ];
+ const settings: UnifiedSettings = {
+ ...settingsWithProviderInstances(),
+ providerInstances: {
+ ...settingsWithProviderInstances().providerInstances,
+ [ProviderInstanceId.make("kilo")]: {
+ driver: ProviderDriverKind.make("kilo"),
+ config: { customModels: ["kilo/stale"] },
+ },
+ },
+ };
+ const kilo = deriveProviderInstanceEntries(providers)[0]!;
+
+ expect(getAppModelOptionsForInstance(settings, kilo).map((option) => option.slug)).toEqual([
+ "kilo/live",
+ ]);
+ expect(
+ resolveAppModelSelectionForInstance(
+ ProviderInstanceId.make("kilo"),
+ settings,
+ providers,
+ "kilo/stale",
+ ),
+ ).toBe("kilo/live");
+ });
+
it("does not inject an unknown selected slug into the stock instance list", () => {
const providers = [
provider({
@@ -323,6 +360,85 @@ describe("instance-scoped model selection", () => {
model: "openai/gpt-5.5",
});
});
+
+ it("heals Kilo background selection to a safe custom provider instance", () => {
+ const providers = [
+ provider({
+ provider: ProviderDriverKind.make("kilo"),
+ instanceId: "kilo",
+ models: ["__t3_provider_default__", "kilo/openrouter/free"],
+ supportsTextGeneration: false,
+ }),
+ provider({
+ provider: ProviderDriverKind.make("claudeAgent"),
+ instanceId: "claude_openrouter",
+ models: ["claude-sonnet-4-6"],
+ }),
+ ];
+ const settings: UnifiedSettings = {
+ ...settingsWithProviderInstances(),
+ textGenerationModelSelection: createModelSelection(
+ ProviderInstanceId.make("kilo"),
+ "__t3_provider_default__",
+ ),
+ };
+
+ expect(resolveAppModelSelectionState(settings, providers)).toEqual({
+ instanceId: ProviderInstanceId.make("claude_openrouter"),
+ model: "claude-sonnet-4-6",
+ });
+ });
+
+ it("preserves the stored selection while provider snapshots are still loading", () => {
+ const selection = createModelSelection(
+ ProviderInstanceId.make("claude_openrouter"),
+ "openai/gpt-5.5",
+ );
+ const settings: UnifiedSettings = {
+ ...settingsWithProviderInstances(),
+ textGenerationModelSelection: selection,
+ };
+
+ expect(resolveAppModelSelectionState(settings, [], { providersLoaded: false })).toBe(selection);
+ });
+
+ it("returns the no-provider sentinel for an authoritative empty snapshot", () => {
+ const settings: UnifiedSettings = {
+ ...settingsWithProviderInstances(),
+ textGenerationModelSelection: createModelSelection(
+ ProviderInstanceId.make("claude_openrouter"),
+ "openai/gpt-5.5",
+ ),
+ };
+
+ expect(resolveAppModelSelectionState(settings, [], { providersLoaded: true })).toEqual({
+ instanceId: ProviderInstanceId.make("t3code_no_provider"),
+ model: "",
+ });
+ });
+
+ it("returns the no-provider sentinel when only Kilo can run interactive prompts", () => {
+ const providers = [
+ provider({
+ provider: ProviderDriverKind.make("kilo"),
+ instanceId: "kilo",
+ models: ["__t3_provider_default__", "kilo/openrouter/free"],
+ supportsTextGeneration: false,
+ }),
+ ];
+ const settings: UnifiedSettings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ textGenerationModelSelection: createModelSelection(
+ ProviderInstanceId.make("kilo"),
+ "__t3_provider_default__",
+ ),
+ };
+
+ expect(resolveAppModelSelectionState(settings, providers)).toEqual({
+ instanceId: ProviderInstanceId.make("t3code_no_provider"),
+ model: "",
+ });
+ });
});
describe("withoutPlanAgentSelection", () => {
diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts
index ccdffdda1004..f25a3841d183 100644
--- a/apps/web/src/modelSelection.ts
+++ b/apps/web/src/modelSelection.ts
@@ -23,7 +23,11 @@ import {
resolveSelectableProvider,
} from "./providerModels";
import { ModelEsque } from "./components/chat/providerIconUtils";
-import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances";
+import {
+ NO_PROVIDER_MODEL_SELECTION,
+ type ProviderInstanceEntry,
+ deriveProviderInstanceEntries,
+} from "./providerInstances";
import { sortModelsForProviderInstance } from "./modelOrdering";
const MAX_CUSTOM_MODEL_COUNT = 32;
@@ -166,7 +170,8 @@ export function getAppModelOptions(
// settings and the initial render before the first write both still
// see the user's authored custom models.
const defaultInstanceId = defaultInstanceIdForDriver(provider);
- const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider);
+ const customModels =
+ provider === "kilo" ? [] : readInstanceCustomModels(settings, defaultInstanceId, provider);
for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {
if (seen.has(slug)) {
continue;
@@ -209,7 +214,10 @@ export function getAppModelOptionsForInstance(
),
);
- const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind);
+ const customModels =
+ entry.driverKind === "kilo"
+ ? []
+ : readInstanceCustomModels(settings, entry.instanceId, entry.driverKind);
for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {
if (seen.has(slug)) {
continue;
@@ -326,17 +334,31 @@ export function resolvePlanAgentHealPatch(input: {
export function resolveAppModelSelectionState(
settings: UnifiedSettings,
providers: ReadonlyArray,
+ options?: { readonly providersLoaded?: boolean },
): ModelSelection {
const selection = settings.textGenerationModelSelection ?? {
instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,
model: DEFAULT_TEXT_GENERATION_MODEL,
};
+ // Provider snapshots arrive after settings during bootstrap/reconnect.
+ // Preserve routing identity only while that snapshot is actually absent;
+ // an authoritative empty snapshot means no background provider exists.
+ if (providers.length === 0 && options?.providersLoaded === false) {
+ return selection;
+ }
const entries = deriveProviderInstanceEntries(providers);
const selectedEntry = entries.find(
- (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable,
+ (entry) =>
+ entry.instanceId === selection.instanceId &&
+ entry.enabled &&
+ entry.isAvailable &&
+ entry.supportsTextGeneration,
);
const entry =
- selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable);
+ selectedEntry ??
+ entries.find(
+ (candidate) => candidate.enabled && candidate.isAvailable && candidate.supportsTextGeneration,
+ );
if (entry) {
// When the instance changed due to fallback (e.g. selected instance was disabled),
// don't carry over the old instance's model — use the fallback instance's default.
@@ -360,20 +382,5 @@ export function resolveAppModelSelectionState(
return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);
}
- const provider = resolveSelectableProvider(providers, null);
- const keptSelectedProvider = false;
-
- // When the provider changed due to fallback (e.g. selected provider was disabled),
- // don't carry over the old provider's model — use the fallback provider's default.
- const selectedModel = keptSelectedProvider ? selection.model : null;
- const model = resolveAppModelSelection(provider, settings, providers, selectedModel);
- const { modelOptionsForDispatch } = getComposerProviderState({
- provider,
- model,
- models: getProviderModels(providers, provider),
- modelOptions: keptSelectedProvider ? selection.options : undefined,
- planModeEnabled: settings.planModeEnabled,
- });
-
- return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch);
+ return NO_PROVIDER_MODEL_SELECTION;
}
diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts
index b64a5e25d508..68a2bf8173c3 100644
--- a/apps/web/src/providerInstances.test.ts
+++ b/apps/web/src/providerInstances.test.ts
@@ -1,16 +1,40 @@
import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
+import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import { describe, expect, it } from "vite-plus/test";
import {
applyProviderInstanceSettings,
+ applyProviderInstanceSettingsToSnapshots,
deriveProviderEntriesByEnvironment,
deriveProviderInstanceEntries,
getDefaultProviderInstanceModel,
+ hasSelectableTextGenerationProviderSelection,
isProviderInstancePickerReady,
isProviderInstancePickerVisible,
+ NO_PROVIDER_MODEL_SELECTION,
resolveDefaultProviderModelSelection,
resolveSelectableProviderInstance,
resolveProviderDriverKindForInstanceSelection,
} from "./providerInstances";
+import { resolveAppModelSelectionState } from "./modelSelection";
+
+describe("hasSelectableTextGenerationProviderSelection", () => {
+ it("uses catalog presence so a legitimate empty-catalog instance cannot collide", () => {
+ const entries = deriveProviderInstanceEntries([
+ provider({
+ provider: ProviderDriverKind.make("codex"),
+ instanceId: "t3code_no_provider",
+ models: [],
+ }),
+ ]);
+
+ expect(hasSelectableTextGenerationProviderSelection(NO_PROVIDER_MODEL_SELECTION, [])).toBe(
+ false,
+ );
+ expect(hasSelectableTextGenerationProviderSelection(NO_PROVIDER_MODEL_SELECTION, entries)).toBe(
+ true,
+ );
+ });
+});
function provider(input: {
provider: ProviderDriverKind;
@@ -48,6 +72,137 @@ const model = (slug: string, isCustom = false, isDefault = false) => ({
capabilities: {},
});
+describe("applyProviderInstanceSettingsToSnapshots", () => {
+ it("uses an enabled setting while the streamed snapshot still reports disabled", () => {
+ const instanceId = ProviderInstanceId.make("t3code_no_provider");
+ const driver = ProviderDriverKind.make("codex");
+ const settings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ providerInstances: {
+ ...DEFAULT_UNIFIED_SETTINGS.providerInstances,
+ [instanceId]: { driver, enabled: true },
+ },
+ textGenerationModelSelection: { instanceId, model: "openai/gpt-5.5" },
+ };
+ const providers = [
+ provider({
+ provider: driver,
+ instanceId,
+ enabled: false,
+ models: [model("openai/gpt-5.5")],
+ }),
+ ];
+
+ const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings);
+ expect(effectiveProviders[0]?.enabled).toBe(true);
+ expect(resolveAppModelSelectionState(settings, effectiveProviders)).toMatchObject({
+ instanceId,
+ model: "openai/gpt-5.5",
+ });
+ });
+
+ it("uses a disabled setting while the streamed snapshot still reports enabled", () => {
+ const instanceId = ProviderInstanceId.make("codex");
+ const driver = ProviderDriverKind.make("codex");
+ const settings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ providerInstances: {
+ ...DEFAULT_UNIFIED_SETTINGS.providerInstances,
+ [instanceId]: { driver, enabled: false },
+ },
+ textGenerationModelSelection: { instanceId, model: "openai/gpt-5.5" },
+ };
+ const providers = [
+ provider({
+ provider: driver,
+ instanceId,
+ enabled: true,
+ models: [model("openai/gpt-5.5")],
+ }),
+ ];
+
+ const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings);
+ expect(effectiveProviders[0]?.enabled).toBe(false);
+ expect(resolveAppModelSelectionState(settings, effectiveProviders)).toEqual(
+ NO_PROVIDER_MODEL_SELECTION,
+ );
+ });
+
+ it("disables a stale snapshot when the same instance id was recreated under another driver", () => {
+ const instanceId = ProviderInstanceId.make("shared_work");
+ const oldDriver = ProviderDriverKind.make("codex");
+ const replacementDriver = ProviderDriverKind.make("claudeAgent");
+ const oldSelection = { instanceId, model: "openai/old-catalog-model" };
+ const settings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ providerInstances: {
+ ...DEFAULT_UNIFIED_SETTINGS.providerInstances,
+ [instanceId]: { driver: replacementDriver, enabled: true },
+ },
+ textGenerationModelSelection: oldSelection,
+ };
+ const providers = [
+ provider({
+ provider: oldDriver,
+ instanceId,
+ enabled: true,
+ models: [model(oldSelection.model)],
+ }),
+ ];
+
+ const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings);
+ expect(effectiveProviders[0]).toMatchObject({ driver: oldDriver, enabled: false });
+ expect(resolveAppModelSelectionState(settings, effectiveProviders)).toEqual(
+ NO_PROVIDER_MODEL_SELECTION,
+ );
+ expect(resolveDefaultProviderModelSelection(effectiveProviders, oldSelection)).toBeNull();
+ });
+
+ it("keeps an unavailable snapshot disabled when settings enable it", () => {
+ const unavailableInstanceId = ProviderInstanceId.make("unavailable_writer");
+ const liveInstanceId = ProviderInstanceId.make("live_writer");
+ const codex = ProviderDriverKind.make("codex");
+ const claude = ProviderDriverKind.make("claudeAgent");
+ const unavailableSelection = {
+ instanceId: unavailableInstanceId,
+ model: "openai/unavailable-model",
+ };
+ const settings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ providerInstances: {
+ [unavailableInstanceId]: { driver: codex, enabled: true },
+ [liveInstanceId]: { driver: claude, enabled: true },
+ },
+ textGenerationModelSelection: unavailableSelection,
+ };
+ const providers = [
+ provider({
+ provider: codex,
+ instanceId: unavailableInstanceId,
+ enabled: false,
+ availability: "unavailable",
+ models: [model(unavailableSelection.model)],
+ }),
+ provider({
+ provider: claude,
+ instanceId: liveInstanceId,
+ models: [model("anthropic/live-model")],
+ }),
+ ];
+
+ const effectiveProviders = applyProviderInstanceSettingsToSnapshots(providers, settings);
+ const entries = deriveProviderInstanceEntries(effectiveProviders);
+ const unavailableEntry = entries.find((entry) => entry.instanceId === unavailableInstanceId);
+
+ expect(effectiveProviders[0]).toMatchObject({ availability: "unavailable", enabled: false });
+ expect(unavailableEntry && isProviderInstancePickerVisible(unavailableEntry)).toBe(false);
+ expect(resolveAppModelSelectionState(settings, effectiveProviders)).toMatchObject({
+ instanceId: liveInstanceId,
+ model: "anthropic/live-model",
+ });
+ });
+});
+
describe("isProviderInstancePickerReady", () => {
it("rejects a disabled instance even while its last probe status is ready", () => {
const [entry] = deriveProviderInstanceEntries([
@@ -420,6 +575,41 @@ describe("resolveDefaultProviderModelSelection", () => {
expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored);
});
+ it("heals a stale Kilo model to the live command catalog default", () => {
+ const providers = [
+ provider({
+ provider: ProviderDriverKind.make("kilo"),
+ instanceId: "kilo",
+ models: [model("kilo/live", false, true), model("kilo/other")],
+ }),
+ ];
+
+ expect(
+ resolveDefaultProviderModelSelection(providers, {
+ instanceId: ProviderInstanceId.make("kilo"),
+ model: "kilo/stale",
+ options: [{ id: "reasoning", value: "high" }],
+ }),
+ ).toEqual({ instanceId: "kilo", model: "kilo/live" });
+ });
+
+ it("preserves a Kilo selection while its live catalog is unavailable", () => {
+ const providers = [
+ provider({
+ provider: ProviderDriverKind.make("kilo"),
+ instanceId: "kilo",
+ status: "warning",
+ models: [],
+ }),
+ ];
+ const stored = {
+ instanceId: ProviderInstanceId.make("kilo"),
+ model: "kilo/offline",
+ };
+
+ expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored);
+ });
+
it("replaces a stale stored instance with the first ready instance and its model", () => {
const providers = [
provider({
diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts
index ef60d554dd78..2a75e9702637 100644
--- a/apps/web/src/providerInstances.ts
+++ b/apps/web/src/providerInstances.ts
@@ -16,6 +16,7 @@ import {
DEFAULT_MODEL_BY_PROVIDER,
defaultInstanceIdForDriver,
PROVIDER_DISPLAY_NAMES,
+ providerSupportsTextGeneration,
resolveProviderInstanceEnabled,
type ModelSelection,
type ProviderDriverKind,
@@ -61,10 +62,29 @@ export interface ProviderInstanceEntry {
readonly isDefault: boolean;
/** True when `availability === "unavailable"` is absent or "available". */
readonly isAvailable: boolean;
+ readonly supportsTextGeneration: boolean;
readonly snapshot: ServerProvider;
readonly models: ReadonlyArray;
}
+/**
+ * Whether a resolved selection names a real enabled text-generation instance.
+ * Local empty-state values are intentionally identified through catalog
+ * absence, not a reserved string pair that a configured instance could use.
+ */
+export function hasSelectableTextGenerationProviderSelection(
+ selection: ModelSelection,
+ entries: ReadonlyArray,
+): boolean {
+ return entries.some(
+ (entry) =>
+ entry.instanceId === selection.instanceId &&
+ entry.enabled &&
+ entry.isAvailable &&
+ entry.supportsTextGeneration,
+ );
+}
+
/**
* Whether an instance can currently contribute models to an interactive picker.
*
@@ -194,6 +214,7 @@ export function deriveProviderInstanceEntries(
status: snapshot.status,
isDefault,
isAvailable: snapshot.availability !== "unavailable",
+ supportsTextGeneration: providerSupportsTextGeneration(snapshot),
snapshot,
models: snapshot.models,
} satisfies ProviderInstanceEntry;
@@ -247,15 +268,34 @@ export function applyProviderInstanceSettings(
return entries.map((entry) => {
const explicitInstance = settings.providerInstances?.[entry.instanceId];
- const enabled = explicitInstance
- ? resolveProviderInstanceEnabled(explicitInstance)
+ const configuredEnabled = explicitInstance
+ ? explicitInstance.driver === entry.driverKind &&
+ resolveProviderInstanceEnabled(explicitInstance)
: entry.isDefault
? (legacyProviders[entry.driverKind]?.enabled ?? entry.enabled)
: false;
+ const enabled = entry.isAvailable && configuredEnabled;
return enabled === entry.enabled ? entry : { ...entry, enabled };
});
}
+/**
+ * Apply settings-owned enablement to provider snapshots before selection logic
+ * consumes them. This keeps routing resolution and picker visibility on the
+ * same state while streamed probes reconcile an enable/disable settings write.
+ */
+export function applyProviderInstanceSettingsToSnapshots(
+ providers: ReadonlyArray,
+ settings: Pick,
+): ReadonlyArray {
+ return applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings).map(
+ (entry) =>
+ entry.enabled === entry.snapshot.enabled
+ ? entry.snapshot
+ : { ...entry.snapshot, enabled: entry.enabled },
+ );
+}
+
/**
* Sort instance entries so the default instance of each driver kind appears
* before any custom instances of the same kind. Within a kind, custom
@@ -383,7 +423,18 @@ export function resolveDefaultProviderModelSelection(
): ModelSelection | null {
const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId);
if (instanceId === undefined) return null;
- if (selection?.instanceId === instanceId) return selection;
+ if (selection?.instanceId === instanceId) {
+ const provider = providers.find((candidate) => candidate.instanceId === instanceId);
+ if (
+ provider?.driver === "kilo" &&
+ provider.models.length > 0 &&
+ !provider.models.some((model) => model.slug === selection.model)
+ ) {
+ const model = getDefaultProviderInstanceModel(providers, instanceId);
+ return model ? { instanceId, model } : null;
+ }
+ return selection;
+ }
const model = getDefaultProviderInstanceModel(providers, instanceId);
return model ? { instanceId, model } : null;
}
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 4824258422fb..3124319cf62c 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{
available: true,
pickerSidebarBadge: "new",
},
+ {
+ value: ProviderDriverKind.make("kilo"),
+ label: "Kilo Code",
+ available: true,
+ pickerSidebarBadge: "new",
+ },
];
export type WorkLogToolLifecycleStatus =
diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md
index da16f74d339f..64fc2ead1ff8 100644
--- a/docs/internals/glossary.md
+++ b/docs/internals/glossary.md
@@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is
#### Provider
-The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter.
+The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, Kilo Code, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter.
#### Session
diff --git a/docs/internals/overview.md b/docs/internals/overview.md
index b9454f7b58d0..61b94d953c1f 100644
--- a/docs/internals/overview.md
+++ b/docs/internals/overview.md
@@ -18,13 +18,13 @@ there, never in the client.
┌──────────────────▼─────────────────────────────┐
│ apps/server │
│ orchestration engine (event-sourced) │
-│ provider driver registry (5 built-in drivers) │
+│ provider driver registry (6 built-in drivers) │
│ checkpointing, VCS, terminals, filesystem │
└──────────────────┬─────────────────────────────┘
│ per-driver transport
┌──────────────────▼─────────────────────────────┐
│ Agent CLIs: Codex, Claude, Cursor, Grok, │
-│ OpenCode │
+│ Kilo Code, OpenCode │
└────────────────────────────────────────────────┘
```
@@ -106,8 +106,8 @@ build production behavior on receipts.
## Provider drivers
-Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:
-Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a
+Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:
+Codex, Claude, Cursor, Grok, Kilo Code, and OpenCode. A driver declares its kind and config schema and creates a
scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves
an instance to its adapter, so `ProviderService` routes session and turn operations without knowing
which agent is behind them. See [providers.md](./providers.md).
diff --git a/docs/internals/providers.md b/docs/internals/providers.md
index a309d70f03de..5c020d782a0c 100644
--- a/docs/internals/providers.md
+++ b/docs/internals/providers.md
@@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread.
## Built-in drivers
-[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries:
+[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries:
| Driver kind | Driver source |
| ------------- | --------------------------------------- |
@@ -15,6 +15,7 @@ orchestration layer does not know which one is behind a thread.
| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
| `grok` | [`Drivers/GrokDriver.ts`][grok] |
+| `kilo` | [`Drivers/KiloDriver.ts`][kilo] |
| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an
@@ -23,6 +24,39 @@ adapter in a child scope. Adapter implementations live beside them in
[`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's
transport, config, and event shapes are mapped.
+### Kilo Code ACP constraints
+
+Kilo Code uses `kilo acp`. Kilo 7.4 starts an internal HTTP listener for each ACP child and tries
+port 4096 before falling back to an OS-assigned port. The port fallback supports concurrent warm
+children, but Kilo's first-run database bootstrap does not exclude another process from the same
+one-time migration. The Kilo runtime therefore serializes startup within one T3 server and retries
+typed initialization exits; already-started Kilo sessions remain concurrent. Separate T3 server
+processes cannot share that in-memory gate, so simultaneous first starts against the same fresh Kilo
+data directory can still fail or wait for T3 Code's bounded startup deadline.
+
+The current Kilo ACP bridge exposes session load/resume, model and mode configuration, images, MCP,
+and permissions. It does not forward Kilo question events or expose provider-side rollback. The
+adapter therefore leaves follow-up questions unsupported and rejects rollback rather than changing
+only T3 Code's local history.
+
+Kilo plugins and project-declared local MCP processes run outside ACP tool-permission requests. The
+adapter forces Kilo pure mode and disables project Kilo config for Supervised, Auto-accept edits,
+and Auto sessions so those controls cannot be bypassed during child startup. Full-access sessions
+preserve the user's Kilo plugins and project config.
+
+Provider status and model refreshes run `kilo --version` followed by the official newline-delimited
+`kilo models` command. The model command runs with pure mode, project config, external skills, and
+skill-shell execution disabled; it does not start ACP, MCP servers, or a durable conversation. The
+catalog includes a T3-only “Kilo provider default” sentinel, which the interactive adapter resolves
+to the model Kilo reports as current instead of sending the sentinel upstream. Kilo is deliberately
+excluded from automatic text generation (thread titles and source-control writing), because those
+hidden prompts cannot safely inherit a user's full provider environment. Background-model pickers
+therefore offer only provider instances whose snapshot advertises that capability.
+
+Prompting and the permission/config behavior above are verified against Kilo 7.4.23, which is the
+minimum version accepted by the provider status check and the adapter's cached pre-spawn guard.
+Kilo's issue tracker records headless ACP prompt hangs in 7.2.24 and 7.3.16.[^kilo-acp-prompt]
+
## Registry and routing
Two registries separate configuration from live processes:
@@ -80,6 +114,7 @@ when a request opens (approval) or user input is requested, via
[claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts
[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts
[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts
+[kilo]: ../../apps/server/src/provider/Drivers/KiloDriver.ts
[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts
[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
[instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts
@@ -90,3 +125,5 @@ when a request opens (approval) or user input is requested, via
[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts
+
+[^kilo-acp-prompt]: [Kilo issue #10768](https://github.com/Kilo-Org/kilocode/issues/10768)
diff --git a/docs/user/install.md b/docs/user/install.md
index 15f96e00d4f3..b095fc910d27 100644
--- a/docs/user/install.md
+++ b/docs/user/install.md
@@ -54,16 +54,26 @@ yay -S t3code-nightly-bin
T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want
to use, then authenticate it.
-| Provider | CLI | Default binary | Log in with |
-| ---------- | ----------------------------------------------------- | -------------- | --------------------- |
-| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` |
-| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` |
-| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` |
-| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
-| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |
-
-Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn
-them on in **Settings** → the provider's card when you want to use them.
+| Provider | CLI | Default binary | Log in with |
+| ---------- | ----------------------------------------------------------- | -------------- | --------------------- |
+| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` |
+| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` |
+| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` |
+| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
+| Kilo Code | [Kilo CLI](https://kilo.ai/docs/code-with-ai/platforms/cli) | `kilo` | `kilo auth login` |
+| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |
+
+Codex, Claude, and Cursor are on by default. Grok Build, Kilo Code, and OpenCode are off by
+default; turn them on in **Settings** → the provider's card when you want to use them.
+
+Kilo Code runs through its ACP bridge and requires Kilo 7.4.23 or newer; use `kilo upgrade` if
+Settings reports an older version. Kilo 7.4 does not forward its question tool through ACP, so
+follow-up questions are not currently shown in T3 Code. Provider-side conversation rollback is
+also unavailable; T3 Code reports rollback as unsupported instead of desynchronizing the two
+histories. Settings can verify the Kilo CLI and model catalog with `kilo models` but cannot confirm
+login status, so run `kilo auth login` before starting paid-model turns. Kilo is available for
+interactive threads but not for automatic text generation such as thread titles or source-control
+writing; those settings use another enabled provider instance.
Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that
T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`.
diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md
index 0648bafc8b77..cd4a09918e89 100644
--- a/docs/user/permission-modes.md
+++ b/docs/user/permission-modes.md
@@ -44,4 +44,10 @@ with prompting enabled and a restricted workspace while **Full access** disables
labels above describe what you get; the exact per-provider translation is internal and may
change.
+For Kilo Code, **Supervised**, **Auto-accept edits**, and **Auto** disable Kilo plugins and
+project-local Kilo configuration for that session. Kilo can otherwise start repository-declared
+plugins or local MCP commands before an approval reaches T3 Code. This also means project-local
+Kilo instructions and settings are unavailable in those modes. **Full access** keeps the normal
+Kilo extension and project-configuration behavior.
+
Mobile offers the same four modes with the same labels and descriptions.
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 9fcd0d266dd6..3862588352db 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
+const KILO_DRIVER_KIND = ProviderDriverKind.make("kilo");
export const DEFAULT_MODEL = "gpt-5.6-sol";
@@ -222,4 +223,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial>
[CURSOR_DRIVER_KIND]: "Cursor",
[GROK_DRIVER_KIND]: "Grok",
[OPENCODE_DRIVER_KIND]: "OpenCode",
+ [KILO_DRIVER_KIND]: "Kilo Code",
};
diff --git a/packages/contracts/src/providerInstance.ts b/packages/contracts/src/providerInstance.ts
index 2a9fc9ed0d1b..1a377af06728 100644
--- a/packages/contracts/src/providerInstance.ts
+++ b/packages/contracts/src/providerInstance.ts
@@ -74,6 +74,19 @@ const isProviderDriverKindValue = Schema.is(ProviderDriverKind);
export const isProviderDriverKind = (value: unknown): value is ProviderDriverKind =>
isProviderDriverKindValue(value);
+const PROVIDER_DRIVERS_WITHOUT_TEXT_GENERATION = new Set([
+ ProviderDriverKind.make("kilo"),
+]);
+
+/**
+ * Whether a driver may be used for automatic text generation such as thread
+ * titles and source-control writing. Unknown/fork drivers remain supported by
+ * default; a driver is listed here only when its implementation explicitly
+ * declines the capability.
+ */
+export const providerDriverSupportsTextGeneration = (driver: ProviderDriverKind): boolean =>
+ !PROVIDER_DRIVERS_WITHOUT_TEXT_GENERATION.has(driver);
+
/**
* `ProviderInstanceId` — user-defined routing key for a configured provider
* instance. Same slug rules as `ProviderDriverKind`; branded separately so the
diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts
index 23e4a43bf5c4..6532c6d6e318 100644
--- a/packages/contracts/src/server.test.ts
+++ b/packages/contracts/src/server.test.ts
@@ -6,6 +6,7 @@ import {
ServerProvider,
ServerProviders,
ServerUpsertKeybindingResult,
+ providerSupportsTextGeneration,
} from "./server.ts";
const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
@@ -45,6 +46,18 @@ describe("ServerProvider", () => {
expect(parsed.skills).toEqual([]);
expect(parsed.versionAdvisory).toBeUndefined();
expect(parsed.updateState).toBeUndefined();
+ expect(providerSupportsTextGeneration(parsed)).toBe(true);
+ });
+
+ it("decodes an explicit automatic text-generation opt-out", () => {
+ const parsed = decodeServerProvider({
+ ...baseProviderSnapshot,
+ driver: "kilo",
+ instanceId: "kilo",
+ supportsTextGeneration: false,
+ });
+
+ expect(providerSupportsTextGeneration(parsed)).toBe(false);
});
it("defaults one-click update support when decoding older advisory snapshots", () => {
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index 9791a4f62185..dbaefd4b0452 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -171,6 +171,9 @@ export const ServerProvider = Schema.Struct({
continuation: Schema.optional(ServerProviderContinuation),
showInteractionModeToggle: Schema.optional(Schema.Boolean),
requiresNewThreadForModelChange: Schema.optional(Schema.Boolean),
+ // Optional for wire compatibility. Absence means supported; drivers that
+ // cannot safely run automatic background prompts set this to false.
+ supportsTextGeneration: Schema.optional(Schema.Boolean),
enabled: Schema.Boolean,
installed: Schema.Boolean,
version: Schema.NullOr(TrimmedNonEmptyString),
@@ -213,6 +216,10 @@ export type ServerProviders = typeof ServerProviders.Type;
export const isProviderAvailable = (snapshot: ServerProvider): boolean =>
snapshot.availability !== "unavailable";
+/** Legacy snapshots predate this capability flag and remain supported. */
+export const providerSupportsTextGeneration = (snapshot: ServerProvider): boolean =>
+ snapshot.supportsTextGeneration !== false;
+
export const ServerObservability = Schema.Struct({
logsDirectoryPath: TrimmedNonEmptyString,
localTracingEnabled: Schema.Boolean,
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 55023bcc48e7..6718fceb229f 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -202,6 +202,7 @@ describe("provider enabled defaults", () => {
expect(decoded.providers.claudeAgent.enabled).toBe(true);
expect(decoded.providers.cursor.enabled).toBe(true);
expect(decoded.providers.grok.enabled).toBe(false);
+ expect(decoded.providers.kilo.enabled).toBe(false);
expect(decoded.providers.opencode.enabled).toBe(false);
});
@@ -209,6 +210,7 @@ describe("provider enabled defaults", () => {
expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true);
expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true);
expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false);
+ expect(defaultEnabledForDriver(ProviderDriverKind.make("kilo"))).toBe(false);
// Unknown fork drivers stay enabled; their own build decides otherwise.
expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true);
});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 80e03b8c879e..78024886eb85 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -455,7 +455,7 @@ export type CursorSettings = typeof CursorSettings.Type;
export const GrokSettings = makeProviderSettingsSchema(
{
- // Off by default (like Cursor and OpenCode): the binding is not yet
+ // Off by default (like Kilo and OpenCode): the binding is not yet
// stable enough to probe on every install. Users opt in from Settings.
enabled: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
@@ -481,7 +481,7 @@ export type GrokSettings = typeof GrokSettings.Type;
export const OpenCodeSettings = makeProviderSettingsSchema(
{
- // Off by default (like Cursor and Grok): the binding is not yet stable
+ // Off by default (like Grok and Kilo): the binding is not yet stable
// enough to probe on every install. Users opt in from Settings.
enabled: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
@@ -531,6 +531,32 @@ export const OpenCodeSettings = makeProviderSettingsSchema(
);
export type OpenCodeSettings = typeof OpenCodeSettings.Type;
+export const KiloSettings = makeProviderSettingsSchema(
+ {
+ // Off by default (like Grok and OpenCode): the binding is not yet stable
+ // enough to probe on every install. Users opt in from Settings.
+ enabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(false)),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ binaryPath: makeBinaryPathSetting("kilo").pipe(
+ Schema.annotateKey({
+ title: "Binary path",
+ description: "Path to the Kilo CLI binary.",
+ providerSettingsForm: { placeholder: "kilo", clearWhenEmpty: "omit" },
+ }),
+ ),
+ customModels: Schema.Array(Schema.String).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ },
+ {
+ order: ["binaryPath"],
+ },
+);
+export type KiloSettings = typeof KiloSettings.Type;
+
export const ObservabilitySettings = Schema.Struct({
otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
@@ -673,6 +699,7 @@ export const ServerSettings = Schema.Struct({
cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ kilo: KiloSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
}).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
// New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
// are `ProviderInstanceConfig` envelopes. The driver-specific config blob
@@ -820,6 +847,12 @@ const OpenCodeSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
});
+const KiloSettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(TrimmedString),
+ customModels: Schema.optionalKey(Schema.Array(Schema.String)),
+});
+
export const ServerSettingsPatch = Schema.Struct({
// Server settings
enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
@@ -861,6 +894,7 @@ export const ServerSettingsPatch = Schema.Struct({
cursor: Schema.optionalKey(CursorSettingsPatch),
grok: Schema.optionalKey(GrokSettingsPatch),
opencode: Schema.optionalKey(OpenCodeSettingsPatch),
+ kilo: Schema.optionalKey(KiloSettingsPatch),
}),
),
// Whole-map replacement for the new instance config. Patching individual
diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts
index baa84a4e1aa8..c28cfb56fdc1 100644
--- a/packages/shared/src/serverSettings.test.ts
+++ b/packages/shared/src/serverSettings.test.ts
@@ -14,6 +14,7 @@ import {
isModelSelectionProviderEnabled,
normalizePersistedServerSettingString,
parsePersistedServerObservabilitySettings,
+ resolveTextGenerationModelSelection,
resolveSourceControlWriterModelSelection,
} from "./serverSettings.ts";
@@ -264,6 +265,62 @@ describe("serverSettings helpers", () => {
expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
});
+ it("heals unsupported Kilo background selection to an enabled custom instance", () => {
+ const safeInstanceId = ProviderInstanceId.make("claude_personal");
+ const kiloSelection = createModelSelection(ProviderInstanceId.make("kilo"), "provider/model");
+ const settings = {
+ ...DEFAULT_SERVER_SETTINGS,
+ providers: {
+ ...DEFAULT_SERVER_SETTINGS.providers,
+ codex: { ...DEFAULT_SERVER_SETTINGS.providers.codex, enabled: false },
+ claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, enabled: false },
+ cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: false },
+ grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: false },
+ opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: false },
+ kilo: { ...DEFAULT_SERVER_SETTINGS.providers.kilo, enabled: true },
+ },
+ providerInstances: {
+ [safeInstanceId]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ enabled: true,
+ config: {},
+ },
+ },
+ textGenerationModelSelection: kiloSelection,
+ };
+
+ expect(resolveTextGenerationModelSelection(settings)).toEqual({
+ instanceId: safeInstanceId,
+ model: "claude-haiku-4-5",
+ });
+ });
+
+ it("does not resurrect a disabled explicit default instance from its legacy mirror", () => {
+ const kiloSelection = createModelSelection(ProviderInstanceId.make("kilo"), "provider/model");
+ const settings = {
+ ...DEFAULT_SERVER_SETTINGS,
+ providers: {
+ ...DEFAULT_SERVER_SETTINGS.providers,
+ codex: { ...DEFAULT_SERVER_SETTINGS.providers.codex, enabled: true },
+ claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, enabled: false },
+ cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: false },
+ grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: false },
+ opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: false },
+ kilo: { ...DEFAULT_SERVER_SETTINGS.providers.kilo, enabled: true },
+ },
+ providerInstances: {
+ codex: {
+ driver: ProviderDriverKind.make("codex"),
+ enabled: false,
+ config: {},
+ },
+ },
+ textGenerationModelSelection: kiloSelection,
+ };
+
+ expect(resolveTextGenerationModelSelection(settings)).toBe(kiloSelection);
+ });
+
it("replaces providerInstances maps so omitted instance fields are cleared", () => {
const codexId = ProviderInstanceId.make("codex");
const current = {
diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts
index 69fc9eaacbcc..49cdcd7dc3a2 100644
--- a/packages/shared/src/serverSettings.ts
+++ b/packages/shared/src/serverSettings.ts
@@ -1,9 +1,15 @@
import {
+ DEFAULT_MODEL_BY_PROVIDER,
+ DEFAULT_TEXT_GENERATION_MODEL,
+ DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
isProviderDriverKind,
isProviderAvailable,
+ providerDriverSupportsTextGeneration,
+ providerSupportsTextGeneration,
resolveProviderInstanceEnabled,
type ModelSelection,
- type ProviderDriverKind,
+ ProviderDriverKind,
+ ProviderInstanceId,
type ServerProvider,
ServerSettings,
type ServerSettingsPatch,
@@ -46,22 +52,103 @@ export function isModelSelectionProviderEnabled(
);
}
+function resolveModelSelectionDriver(
+ settings: ServerSettings,
+ selection: ModelSelection,
+): ProviderDriverKind | undefined {
+ const instance = settings.providerInstances[selection.instanceId];
+ if (instance !== undefined) return instance.driver;
+ return isProviderDriverKind(selection.instanceId) &&
+ getLegacyProviderSettings(settings, selection.instanceId) !== undefined
+ ? selection.instanceId
+ : undefined;
+}
+
+export function isModelSelectionTextGenerationSupported(
+ settings: ServerSettings,
+ selection: ModelSelection,
+): boolean {
+ const driver = resolveModelSelectionDriver(settings, selection);
+ return driver !== undefined && providerDriverSupportsTextGeneration(driver);
+}
+
+function defaultTextGenerationSelection(
+ instanceId: ModelSelection["instanceId"],
+ driver: ProviderDriverKind,
+): ModelSelection {
+ return createModelSelection(
+ instanceId,
+ DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ??
+ DEFAULT_MODEL_BY_PROVIDER[driver] ??
+ DEFAULT_TEXT_GENERATION_MODEL,
+ );
+}
+
+/**
+ * Find an actually enabled text-generation-capable instance. Explicit default
+ * instances shadow their legacy mirror even when disabled, while enabled
+ * custom instances remain eligible.
+ */
+export function findEnabledTextGenerationFallback(
+ settings: ServerSettings,
+): ModelSelection | undefined {
+ for (const [rawInstanceId, instance] of Object.entries(settings.providerInstances)) {
+ if (
+ resolveProviderInstanceEnabled(instance) &&
+ providerDriverSupportsTextGeneration(instance.driver)
+ ) {
+ return defaultTextGenerationSelection(
+ ProviderInstanceId.make(rawInstanceId),
+ instance.driver,
+ );
+ }
+ }
+
+ for (const [rawDriver, provider] of Object.entries(settings.providers)) {
+ const driver = ProviderDriverKind.make(rawDriver);
+ if (
+ settings.providerInstances[ProviderInstanceId.make(rawDriver)] !== undefined ||
+ !provider.enabled ||
+ !providerDriverSupportsTextGeneration(driver)
+ ) {
+ continue;
+ }
+ return defaultTextGenerationSelection(ProviderInstanceId.make(rawDriver), driver);
+ }
+ return undefined;
+}
+
+export function resolveTextGenerationModelSelection(settings: ServerSettings): ModelSelection {
+ const selection = settings.textGenerationModelSelection;
+ return isModelSelectionProviderEnabled(settings, selection) &&
+ isModelSelectionTextGenerationSupported(settings, selection)
+ ? selection
+ : (findEnabledTextGenerationFallback(settings) ?? selection);
+}
+
export function resolveSourceControlWriterModelSelection(
settings: ServerSettings,
providers?: ReadonlyArray,
): ModelSelection {
+ const defaultSelection = resolveTextGenerationModelSelection(settings);
const selection = settings.sourceControlWriterModelSelection;
- if (!selection || !isModelSelectionProviderEnabled(settings, selection)) {
- return settings.textGenerationModelSelection;
+ if (
+ !selection ||
+ !isModelSelectionProviderEnabled(settings, selection) ||
+ !isModelSelectionTextGenerationSupported(settings, selection)
+ ) {
+ return defaultSelection;
}
if (providers === undefined) {
return selection;
}
const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId);
- return provider?.enabled === true && isProviderAvailable(provider)
+ return provider?.enabled === true &&
+ isProviderAvailable(provider) &&
+ providerSupportsTextGeneration(provider)
? selection
- : settings.textGenerationModelSelection;
+ : defaultSelection;
}
export interface PersistedServerObservabilitySettings {